#!/usr/bin/env python3
from __future__ import annotations

import csv
import json
import base64
import hashlib
import hmac
import importlib
import os
import queue
import re
import shutil
import subprocess
import sys
import threading
import time
import traceback
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
import tkinter as tk
import tkinter.font as tkfont
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from tkinter import BOTH, END, LEFT, RIGHT, X, Y, BooleanVar, StringVar, Text, Tk, Toplevel
from tkinter import ttk
from tkinter.scrolledtext import ScrolledText
from typing import Any, Callable, Optional, Sequence

from common.log_utils import RUN_INSTANCE_ID, create_task_log_path, credential_scope_label, export_log_to_desktop, safe_log_name, sanitize_log_text, task_runtime_dir
from common.process_utils import close_channel, terminate_processes
from common.realtime_log_filter import (
    RealtimeLogDisplayFilter,
    filter_realtime_log_text as _base_filter_realtime_log_text,
    format_realtime_log_text_without_filter,
)
from common.runtime_paths import cloud_data_dir, cloud_log_dir, cloud_runtime_dir
from common.xray_asset import ensure_remote_xray
from common.task_guards import TaskGuard, TaskGuardError
from common.task_runtime import READ_RESOURCE_RISK_GROUP, WRITE_RESOURCE_RISK_GROUP
from common.connection_command import configured_default_script_command, shell_env_assignments
from common.credential_mark_cache import credential_history_for_cloud, delete_credential_history_record, remember_mark_for_credentials
from common.credential_parser import append_mark_to_output, extract_ak_sk, extract_mark
from common.customer_ownership import CHECKING_MESSAGE, NON_PARTNER_MESSAGE, READY_MESSAGE, SERVER_ERROR_MESSAGE, WELCOME_MESSAGE, classify_auth_exception
from common.debug_billing import limited_vendor_features_enabled, set_on_demand_debug_enabled
from common.cloud_resources import display_expire_time_for_billing
from common.ip_utils import is_public_ip
from common.output_formatting import append_result_footer, extract_plain_socks5_output, read_excel_table_rows, write_excel_view_file, write_latest_result_files
from common.user_messages import friendly_popup_text
from common.ui_helpers import (
    ACTION_BUTTON_WIDTH,
    LONG_ACTION_BUTTON_WIDTH,
    LOG_PANEL_WIDTH,
    bind_canvas_mousewheel,
    cloud_dialog_title,
    maximize_window,
    PAGE_CONTENT_GAP,
    PANEL_PADDING,
    PURCHASE_BUILD_BUTTON_WIDTH,
    RESOURCE_ACTION_BUTTON_WIDTH,
    SECTION_GAP,
    apply_dialog_icon,
    ask_app_confirm_dialog,
    autosize_treeview_columns,
    build_cloud_page_layout,
    build_realtime_log_panel,
    build_result_output_panel,
    create_fixed_tab_pages,
    play_task_completion_sound,
    show_app_message_dialog,
    treeview_display_column_name,
)

CTYUN_VENDOR_KEY = "ctyun"
CTYUN_CREDENTIAL_HISTORY_PLACEHOLDER = "选择历史账号（点击输入搜索）"


class DelayedActionPreconfirmAccepted(Exception):
    pass


class DelayedActionPreconfirmCancelled(Exception):
    pass


class DelayedActionPreconfirmCaptured(Exception):
    def __init__(self, message: str) -> None:
        super().__init__(message)
        self.message = message


_HIDDEN_REALTIME_LOG_LINES = {
    "===== 导出运行结果中 =====",
}
_HIDDEN_REALTIME_LOG_PREFIXES = (
    "当前目录:",
    "当前目录：",
    "开始时间:",
    "开始时间：",
    "结束时间:",
    "结束时间：",
    "日志文件:",
    "日志文件：",
    "已导出运行结果:",
    "已导出云服务器绑定 IP:",
    "已生成账号资源查询结果:",
    "已生成退订资源查询结果:",
)
_HIDDEN_REALTIME_LOG_PATTERNS = (
    r"接口连接中断或暂时失败，自动重试\s+\d+/\d+:\s*HTTP Error 404:\s*Not Found",
)
_INVALID_DISPLAY_VALUES = {"none", "null", "nil", "nan", "n/a", "na", "-", "--", "无", "暂无"}
USED_IP_CSV_HEADERS = ("日期时间", "天翼云用户名-简AK-SK", "地区", "IP")
USED_IP_TABLE_COLUMN_WIDTHS = (22.0, 36.0, 14.0, 18.0)
USED_IP_TABLE_NOTE = "可写入已使用的旧IP和地区，更换新IP时会过滤更换（注意：务必准确填写地区和IP，确保地区归属无误，否则换新失败！）"
FAILED_IP_WORKSHEET_NAME = "更换失败IP库"
FAILED_IP_TABLE_NOTE = "自动记录更换失败或尚未完成更换的公网IP；后续更换成功后会自动移除。"
NEW_IP_RELEASE_COOLDOWN_SECONDS = 2 * 60 * 60


def filter_realtime_log_text(text: str) -> str:
    """天翼云实时日志使用统一过滤规则：中途失败隐藏，最终失败显示原因和导出日志提醒。"""
    visible_lines: list[str] = []
    for line in text.splitlines():
        stripped = line.strip()
        if _is_hidden_ctyun_realtime_line(stripped):
            continue
        if stripped in _HIDDEN_REALTIME_LOG_LINES:
            continue
        if any(stripped.startswith(prefix) for prefix in _HIDDEN_REALTIME_LOG_PREFIXES):
            continue
        visible_lines.append(line)
    if not visible_lines:
        return ""
    filtered_text = "\n".join(visible_lines) + ("\n" if text.endswith("\n") else "")
    return _base_filter_realtime_log_text(filtered_text)


def _clean_display_text(value: Any) -> str:
    text = str(value or "").strip()
    if not text or text.lower() in _INVALID_DISPLAY_VALUES:
        return ""
    return text


def _is_hidden_ctyun_realtime_line(line: str) -> bool:
    stripped = str(line or "").strip()
    if not stripped:
        return False
    if stripped in _HIDDEN_REALTIME_LOG_LINES:
        return True
    if any(stripped.startswith(prefix) for prefix in _HIDDEN_REALTIME_LOG_PREFIXES):
        return True
    return any(re.search(pattern, stripped, re.IGNORECASE) for pattern in _HIDDEN_REALTIME_LOG_PATTERNS)


def _format_ctyun_realtime_text_without_filter(text: str) -> str:
    visible_lines = [line for line in str(text or "").splitlines() if not _is_hidden_ctyun_realtime_line(line)]
    if not visible_lines:
        return ""
    filtered_text = "\n".join(visible_lines) + ("\n" if str(text or "").endswith("\n") else "")
    return format_realtime_log_text_without_filter(filtered_text)


APP_TITLE = "天翼云管理"
REQUIRED_PARTNER_NAME = "指定合作伙伴"
PARTNER_REQUIRED_MESSAGE = f"请绑定{REQUIRED_PARTNER_NAME}后再进行使用"
CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE = NON_PARTNER_MESSAGE
CUSTOMER_OWNERSHIP_CHECKING_MESSAGE = CHECKING_MESSAGE
CUSTOMER_OWNERSHIP_WELCOME_MESSAGE = WELCOME_MESSAGE
CUSTOMER_OWNERSHIP_READY_MESSAGE = READY_MESSAGE
CUSTOMER_OWNERSHIP_CHECK_FAILED_MESSAGE = SERVER_ERROR_MESSAGE
CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE = "天翼云查询AK/SK中，正在校验合作伙伴归属，请稍等。"
CTYUN_CUSTOMER_GUARD_ENABLED = False
CTYUN_STANDALONE_LIMITED_ENV = "WAWA_CTYUN_STANDALONE_LIMITED"


def ctyun_standalone_limited_features() -> bool:
    if os.environ.get(CTYUN_STANDALONE_LIMITED_ENV, "").strip() == "1":
        return True
    legacy_vendor = os.environ.get("WAWA_SINGLE_VENDOR", "").strip().lower()
    return legacy_vendor in {"ctyun", "ct", "tianyi"}


def _customer_ownership_result_log_message(status: str, message: str) -> str:
    if status == "ok":
        return CUSTOMER_OWNERSHIP_WELCOME_MESSAGE
    return message or CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE


def _customer_identity_log_message(summary: dict[str, Any] | None) -> str:
    if not isinstance(summary, dict):
        return ""
    fields = [str(item).strip() for item in summary.get("fields") or [] if str(item).strip()]
    source = str(summary.get("source") or "").strip() or "未识别"
    attempts = summary.get("attempts")
    if fields:
        return f"天翼云AK/SK账号信息：来源={source}，字段={', '.join(fields)}。"
    if isinstance(attempts, int) and attempts > 0:
        return f"天翼云AK/SK账号信息：已尝试 {attempts} 个接口，未读取到可核对字段。"
    return "天翼云AK/SK账号信息：未读取到可核对字段。"


EIP_SOLD_OUT_POPUP_TEXT = "地区弹性公网IP已售罄，请更换地区。"
SSH_USERNAME = "root"
SSH_PASSWORD = "cao2002."
CTYUN_CUSTOM_CONNECTION_DEFAULT_USER = "user"
CTYUN_BUILD_MODE_DEFAULT = "default"
CTYUN_BUILD_MODE_RANDOM = "random"
CTYUN_DEFAULT_SCRIPT_URL = "https://www.wawaapi.com/static/scripts/ctyun/ctyun-default.sh"
CTYUN_DEFAULT_SSH_COMMAND = f"bash <(curl -fsSL {CTYUN_DEFAULT_SCRIPT_URL})"
CTYUN_RANDOM_SSH_COMMAND = "bash <(curl -fsSL https://www.wawaapi.com/static/scripts/ctyun/ctyun.sh)"
SSH_COMMAND = CTYUN_RANDOM_SSH_COMMAND
KEY_RE = re.compile(r"\b[a-fA-F0-9]{32}\b")
APP_BG = "#f3f3f3"
PANEL_BG = "#f3f3f3"
CONTENT_BG = "#f3f3f3"
TEXT_BG = "white"
TEXT_FG = "black"
MUTED_FG = "#666666"
BORDER_COLOR = "#d0d5dd"
TABLE_BORDER_COLOR = BORDER_COLOR
TABLE_ALT_BG = "#f8f8f8"
TABLE_GROUP_BG = "#eeeeee"
UI_FONT_FAMILY = "SimSun"
UI_FONT_SIZE = 10
UI_HEADING_FONT_SIZE = 10
UI_FIXED_FONT_FAMILY = "Consolas"
UI_FIXED_FONT_SIZE = 10
CHECKED = "☑"
UNCHECKED = "☐"
PARTIAL_CHECKED = "◩"
LEGACY_CHECKED_VALUES = {"✓", "✔", "☒", CHECKED}
LEGACY_UNCHECKED_VALUES = {"□", "☐", UNCHECKED, "", "0", "false", "False", "no", "off"}
TREE_CONTROL_SIZE = 15
TREE_CONTROL_GAP = 5
TREE_CONTROL_ROW_WIDTH = TREE_CONTROL_SIZE * 2 + TREE_CONTROL_GAP
CTYUN_CLEANUP_TREEVIEW_STYLE = "CTYUN.Cleanup.Grid.Treeview"
ACCENT_BG = "#f2f4f7"
BUTTON_BG = "#ffffff"
BUTTON_ACTIVE_BG = "#f2f4f7"
TAB_PIXEL_WIDTH = 121
TAB_PIXEL_HEIGHT = 55
DISCLAIMER_TEXT = """软件使用免责声明

一、严禁行为
- 严禁翻墙：禁止任何翻墙上网、访问境外违规平台行为
- 严禁涉诈：禁止发布、传播、实施各类诈骗相关活动
- 严禁违规用云：禁止滥用、转借云服务器资源从事违法违规行为

二、违规处罚
违反任一规定，立即全权限封禁账号；涉嫌违法的，固定证据并配合警方立案追责。"""


def _compact_name_list(items: Sequence[str], limit: int = 12) -> str:
    names = [str(item).strip() for item in items if str(item).strip()]
    if not names:
        return "无"
    shown = names[:limit]
    suffix = f" 等 {len(names)} 项" if len(names) > limit else ""
    return "、".join(shown) + suffix


def _action_confirm_message(
    title: str,
    *,
    regions: Sequence[str] = (),
    resources: Sequence[str] = (),
    details: Sequence[str] = (),
    warning: str = "",
    cost: bool = False,
    final_prompt: str = "",
) -> str:
    lines = [title.strip()]
    if regions:
        lines.extend(["", f"选择地区：{_compact_name_list(regions)}"])
    if resources:
        lines.extend(["", f"选择资源：{_compact_name_list(resources)}"])
    if details:
        lines.extend(["", *[str(detail).strip() for detail in details if str(detail).strip()]])
    if warning:
        lines.extend(["", warning.strip()])
    if final_prompt:
        if cost:
            lines.extend(["", "该操作会创建云资源并产生费用。"])
        lines.extend(["", final_prompt.strip()])
    elif cost:
        lines.extend(["", "该操作会创建云资源并产生费用。是否继续？"])
    else:
        lines.extend(["", "是否继续？"])
    return "\n".join(lines)

PHASES = [
    ("buy-eip", "读取到AK/SK、所需地区，购买弹性IP中"),
    ("has-active-regions", "检查可继续地区中"),
    ("discover", "检查已有云主机并读取规格镜像中"),
    ("buy-ecs", "购买弹性云主机中"),
    ("havip-bind", "创建并绑定子网虚拟IP中"),
    ("ecs-eip-security", "绑定云主机IP并完善安全组中"),
]

FIXED_60M_REGIONS = {"杭州2", "合肥2", "南京3", "福州25", "福州4", "西宁2"}
MAX_SELECTED_REGIONS = 10
PROVINCE_GROUPS = (
    ("A-G", ("安徽省", "北京市", "重庆市", "福建省", "港澳及海外", "甘肃省", "广东省", "广西壮族自治区", "贵州省")),
    ("H-K", ("海南省", "河北省", "黑龙江省", "河南省", "华北", "华东", "华南", "湖北省", "湖南省", "吉林省", "江苏省", "江西省")),
    ("L-S", ("辽宁省", "内蒙古自治区", "宁夏回族自治区", "青海省", "陕西省", "山东省", "上海市", "山西省", "四川省")),
    ("T-Z", ("天津市", "西南", "新疆维吾尔自治区", "西藏自治区", "云南省", "浙江省")),
)
PROVINCE_DISPLAY_NAMES = {
    "北京市": "北京",
    "重庆市": "重庆",
    "上海市": "上海",
    "天津市": "天津",
    "广西壮族自治区": "广西",
    "内蒙古自治区": "内蒙古",
    "宁夏回族自治区": "宁夏",
    "新疆维吾尔自治区": "新疆",
    "西藏自治区": "西藏",
}
FOREIGN_REGION_KEYWORDS = (
    "印度尼西亚",
    "新加坡",
    "菲律宾",
    "马来西亚",
    "泰国",
    "越南",
    "日本",
    "韩国",
    "美国",
    "德国",
    "英国",
    "印尼",
    "雅加达",
    "马尼拉",
    "吉隆坡",
    "曼谷",
    "singapore",
    "indonesia",
    "philippines",
)
CITY_PROVINCE_HINTS = (
    ("内蒙", "内蒙古自治区"),
    ("内蒙古", "内蒙古自治区"),
    ("乌鲁木齐", "新疆维吾尔自治区"),
    ("呼和浩特", "内蒙古自治区"),
    ("庆阳", "甘肃省"),
    ("郴州", "湖南省"),
    ("石家庄", "河北省"),
    ("哈尔滨", "黑龙江省"),
    ("浙江金华", "浙江省"),
    ("北京", "北京市"),
    ("上海", "上海市"),
    ("天津", "天津市"),
    ("重庆", "重庆市"),
    ("杭州", "浙江省"),
    ("金华", "浙江省"),
    ("合肥", "安徽省"),
    ("芜湖", "安徽省"),
    ("南京", "江苏省"),
    ("苏州", "江苏省"),
    ("扬州", "江苏省"),
    ("无锡", "江苏省"),
    ("福州", "福建省"),
    ("厦门", "福建省"),
    ("武汉", "湖北省"),
    ("襄阳", "湖北省"),
    ("兰州", "甘肃省"),
    ("西安", "陕西省"),
    ("西宁", "青海省"),
    ("贵阳", "贵州省"),
    ("贵州", "贵州省"),
    ("辽阳", "辽宁省"),
    ("沈阳", "辽宁省"),
    ("大连", "辽宁省"),
    ("太原", "山西省"),
    ("晋中", "山西省"),
    ("成都", "四川省"),
    ("广州", "广东省"),
    ("佛山", "广东省"),
    ("东莞", "广东省"),
    ("南宁", "广西壮族自治区"),
    ("南昌", "江西省"),
    ("九江", "江西省"),
    ("青岛", "山东省"),
    ("济南", "山东省"),
    ("海口", "海南省"),
    ("郑州", "河南省"),
    ("长沙", "湖南省"),
    ("昆明", "云南省"),
    ("拉萨", "西藏自治区"),
    ("中卫", "宁夏回族自治区"),
    ("银川", "宁夏回族自治区"),
    ("长春", "吉林省"),
    ("华北", "华北"),
    ("华东", "华东"),
    ("华南", "华南"),
    ("西南", "西南"),
    ("香港", "港澳及海外"),
    ("澳门", "港澳及海外"),
)
REGION_LABELS = {
    "杭州2": "浙江省 杭州市（杭州2）",
    "合肥2": "安徽省 合肥市（合肥2）",
    "芜湖2": "安徽省 芜湖市（芜湖2）",
    "南京3": "江苏省 南京市（南京3）",
    "上海7": "上海市 上海市（上海7）",
    "福州25": "福建省 福州市（福州25）",
    "福州4": "福建省 福州市（福州4）",
    "武汉4": "湖北省 武汉市（武汉4）",
    "兰州2": "甘肃省 兰州市（兰州2）",
    "西安5": "陕西省 西安市（西安5）",
    "西宁2": "青海省 西宁市（西宁2）",
    "贵州3": "贵州省 贵阳市（贵州3）",
    "辽阳1": "辽宁省 辽阳市（辽阳1）",
}


def builtin_region_group(name: str) -> str:
    if name == "福州25":
        return ""
    if name in FIXED_60M_REGIONS:
        return "60"
    if name in REGION_LABELS:
        return "300"
    return ""


def app_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent


def resource_dir() -> Path:
    if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
        bundled_ctyun = Path(sys._MEIPASS) / "CTYUN"
        if bundled_ctyun.exists():
            return bundled_ctyun
        return Path(sys._MEIPASS)
    return Path(__file__).resolve().parent


ROOT = cloud_runtime_dir("CTYUN")
RESOURCE_ROOT = resource_dir()
APP_DATA_DIR = cloud_data_dir("CTYUN")
APP_LOG_DIR = cloud_log_dir("CTYUN")


def resource_path(name: str) -> Path:
    candidates = [
        RESOURCE_ROOT / name,
        # 兼容 PyInstaller datas 放在 _MEIPASS/CTYUN 下、旧版代码却从 _MEIPASS 根目录读取的情况。
        RESOURCE_ROOT / "CTYUN" / name,
        Path(__file__).resolve().parent / name,
        Path(__file__).resolve().parents[1] / "assets" / name,
    ]
    if getattr(sys, "frozen", False):
        exe_dir = Path(sys.executable).resolve().parent
        candidates.extend([
            exe_dir / "source" / "CTYUN" / name,
            exe_dir / "source" / "assets" / name,
        ])
    for path in candidates:
        if path.exists():
            return path
    return RESOURCE_ROOT / name


def app_data_dir() -> Path:
    return APP_DATA_DIR / "config"


def runtime_regions_file() -> Path:
    return app_data_dir() / "regions_10ip.json"


def all_regions_catalog_file() -> Path:
    return app_data_dir() / "all_regions_catalog.json"


def network_config_file() -> Path:
    return app_data_dir() / "network_config.json"


def eip_release_record_file(account_scope: str, region: str) -> Path:
    return (
        app_data_dir()
        / "eip_release_times"
        / safe_log_name(account_scope)
        / f"{safe_log_name(region)}.json"
    )


def load_eip_release_timestamp(account_scope: str, region: str) -> float:
    path = eip_release_record_file(account_scope, region)
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
        released_at = float(data.get("releasedAt") or 0) if isinstance(data, dict) else 0.0
    except (OSError, ValueError, TypeError, json.JSONDecodeError):
        return 0.0
    return released_at if released_at > 0 else 0.0


def save_eip_release_timestamp(account_scope: str, region: str, released_at: float | None = None) -> float:
    timestamp = float(released_at if released_at is not None else time.time())
    path = eip_release_record_file(account_scope, region)
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "region": str(region or "").strip(),
        "releasedAt": timestamp,
        "releasedAtText": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)),
    }
    temp_path = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
    try:
        temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        os.replace(temp_path, path)
    finally:
        try:
            temp_path.unlink(missing_ok=True)
        except OSError:
            pass
    return timestamp


def format_release_elapsed(seconds: float) -> str:
    total_seconds = max(0, int(seconds))
    if total_seconds < 60:
        return "不足1分钟"
    total_minutes = total_seconds // 60
    hours, minutes = divmod(total_minutes, 60)
    if not hours:
        return f"{minutes}分钟"
    if not minutes:
        return f"{hours}小时"
    return f"{hours}小时{minutes}分钟"


def load_network_config() -> dict[str, Any]:
    path = network_config_file()
    if not path.exists() or path.stat().st_size <= 0:
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def save_network_config(data: dict[str, Any]) -> None:
    path = network_config_file()
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def normalize_proxy_url(value: str) -> str:
    text = str(value or "").strip()
    if not text:
        return ""
    if "://" not in text:
        text = "http://" + text
    return text


def load_all_regions_catalog_records() -> list[dict[str, Any]]:
    path = all_regions_catalog_file()
    if not path.exists() or path.stat().st_size <= 0:
        return []
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except Exception:
        return []
    records = data.get("regions", []) if isinstance(data, dict) else []
    return [item for item in records if isinstance(item, dict) and item.get("displayName")]


def save_all_regions_catalog_records(records: Sequence[dict[str, Any]]) -> None:
    cleaned = [dict(item) for item in records if isinstance(item, dict) and item.get("displayName") and item.get("regionID")]
    if not cleaned:
        return
    path = all_regions_catalog_file()
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
        "regions": cleaned,
    }
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def ensure_runtime_regions_file() -> Path:
    target = runtime_regions_file()
    source = resource_path("regions_10ip.json")
    if not target.exists() or target.stat().st_size <= 0:
        target.parent.mkdir(parents=True, exist_ok=True)
        if source.exists():
            target.write_bytes(source.read_bytes())
        return target
    if source.exists():
        try:
            target_doc = json.loads(target.read_text(encoding="utf-8-sig"))
            source_doc = json.loads(source.read_text(encoding="utf-8-sig"))
            target_regions = target_doc.get("regions", []) if isinstance(target_doc, dict) else []
            source_regions = source_doc.get("regions", []) if isinstance(source_doc, dict) else []
            if isinstance(target_regions, list) and isinstance(source_regions, list):
                existing = {str(item.get("displayName") or "") for item in target_regions if isinstance(item, dict)}
                added = False
                for item in source_regions:
                    if not isinstance(item, dict):
                        continue
                    name = str(item.get("displayName") or "")
                    if name and name not in existing:
                        target_regions.append(item)
                        existing.add(name)
                        added = True
                cleaned = False
                for item in target_regions:
                    if not isinstance(item, dict):
                        continue
                    name = str(item.get("displayName") or "")
                    if builtin_region_group(name) and item.get("_uiGroup"):
                        item.pop("_uiGroup", None)
                        cleaned = True
                if added or cleaned:
                    target.write_text(json.dumps(target_doc, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        except Exception:
            pass
    return target


def worker_main() -> int:
    os.environ.setdefault("CTYUN_WORKFLOW_ROOT", str(ROOT))
    default_regions_file = ensure_runtime_regions_file()
    os.environ.setdefault("CTYUN_WORKFLOW_REGIONS_FILE", str(default_regions_file))
    configured_regions_file = Path(os.environ["CTYUN_WORKFLOW_REGIONS_FILE"]).resolve()
    try:
        configured_regions_missing = (
            not configured_regions_file.exists()
            or configured_regions_file.stat().st_size <= 0
        )
    except OSError:
        configured_regions_missing = True
    if (
        configured_regions_missing
        and default_regions_file.exists()
        and configured_regions_file != default_regions_file.resolve()
    ):
        configured_regions_file.parent.mkdir(parents=True, exist_ok=True)
        shutil.copyfile(default_regions_file, configured_regions_file)
        print("天翼云任务地区配置缺失，已自动恢复后继续。", flush=True)
    os.environ.setdefault("CTYUN_ALL_REGIONS_CACHE_FILE", str(all_regions_catalog_file()))
    from CTYUN import workflow_2region

    sys.argv = [sys.argv[0], *sys.argv[2:]]
    try:
        return int(workflow_2region.main() or 0)
    except workflow_2region.CtyunSslCertificateError as exc:
        print(str(exc), flush=True)
        return 1
    except SystemExit as exc:
        if isinstance(exc.code, int):
            return int(exc.code or 0)
        if exc.code:
            print(str(exc.code), flush=True)
        return 1
    except Exception as exc:
        print(f"天翼云子任务异常，已拦截避免软件崩溃弹窗：{exc}", flush=True)
        print(traceback.format_exc(), flush=True)
        return 1


def load_regions() -> list[str]:
    records = load_region_records()
    regions = [item["displayName"] for item in records if item.get("displayName")]
    if not regions:
        raise ValueError("天翼云地区配置为空，请重新解压完整便携包。")
    return regions


def load_region_records() -> list[dict[str, Any]]:
    path = ensure_runtime_regions_file()
    if not path.exists() or path.stat().st_size <= 0:
        path = resource_path("regions_10ip.json")
    if not path.exists() or path.stat().st_size <= 0:
        raise FileNotFoundError("天翼云地区配置文件缺失，请确认 source 和 data 文件夹完整。")
    data = json.loads(path.read_text(encoding="utf-8-sig"))
    regions = [item for item in data.get("regions", []) if isinstance(item, dict) and item.get("displayName")]
    if not regions:
        raise ValueError("天翼云地区配置为空，请重新解压完整便携包。")
    return regions


class WorkflowGui:
    def __init__(self, root: Tk, parent: tk.Misc | None = None, *, embedded: bool = False) -> None:
        self.root = root
        self.parent = parent or root
        self.embedded = embedded
        self.standalone_limited_features = ctyun_standalone_limited_features()
        self.customer_cleanup_limited_features = limited_vendor_features_enabled("ctyun")
        self._scale_fonts()
        if not embedded:
            self.root.title(APP_TITLE)
            icon_path = resource_path("ctyun_logo.ico")
            if icon_path.exists():
                try:
                    self.root.iconbitmap(str(icon_path))
                except Exception:
                    pass
            self.root.geometry("1070x910")
            self.root.minsize(1000, 760)

        self.queue: queue.Queue[tuple[str, str]] = queue.Queue()
        self.region_vars: dict[str, BooleanVar] = {}
        self.region_checkbuttons: dict[str, ttk.Checkbutton] = {}
        self.region_columns: dict[str, ttk.Frame] = {}
        self.region_next_rows: dict[str, int] = {}
        self.region_option_group: dict[str, str] = {}
        self.dynamic_region_labels: dict[str, str] = {}
        self.all_regions_dialog: Toplevel | None = None
        self.cleanup_item_vars: dict[str, BooleanVar] = {}
        self.cleanup_region_vars: dict[str, BooleanVar] = {}
        self.cleanup_region_body_frames: dict[str, ttk.Frame] = {}
        self.cleanup_region_toggle_buttons: dict[str, ttk.Button] = {}
        self.account_resource_items: list[dict[str, Any]] = []
        self.cleanup_items: list[dict[str, str]] = []
        self.cleanup_tree: ttk.Treeview | None = None
        self.cleanup_tree_items: dict[str, dict[str, str]] = {}
        self.cleanup_last_clicked_iid = ""
        self.cleanup_clear_button: ttk.Button | None = None
        self.reinstall_execute_button: ttk.Button | None = None
        self.ak_var = StringVar()
        self.sk_var = StringVar()
        self.mark_var = StringVar()
        self.credential_history_controls: list[dict[str, Any]] = []
        self._credential_history_popup: Toplevel | None = None
        self.ctyun_proxy_var = StringVar(value=str(load_network_config().get("proxy") or ""))
        self.build_mode_var = StringVar(value=CTYUN_BUILD_MODE_RANDOM)
        set_on_demand_debug_enabled("ctyun", False)
        self.on_demand_var = BooleanVar(value=False)
        self.one_ecs_two_eip_var = BooleanVar(value=False)
        self.monthly_dedicated_eip_var = BooleanVar(value=False)
        self.private_192_168_100_var = BooleanVar(value=False)
        self.default_conn_user_var = StringVar(value=CTYUN_CUSTOM_CONNECTION_DEFAULT_USER)
        self.default_conn_password_var = StringVar(value="wawacloud")
        self.default_conn_socks_port_var = StringVar(value="23456")
        self.default_conn_custom_saved = False
        self.default_conn_once_var = BooleanVar(value=False)
        self.status_var = StringVar(value="就绪")
        self.selected_region_order: list[str] = []
        self.updating_regions = False
        self.running = False
        self.active_task_count = 0
        self.blocking_task_count = 0
        self.task_lock = threading.Lock()
        self.task_guards: list[TaskGuard] = []
        self.delayed_action_queue: list[tuple[str, Callable[[], Any]]] = []
        self.delayed_action_running_label = ""
        self.delayed_current_action_label = ""
        self.delayed_action_max_total = 6
        self.delayed_preconfirm_active = False
        self.delayed_skip_next_confirmation = False
        self.delayed_preconfirm_methods = {
            "_start_purchase_only",
            "_start_purchase_new_ips",
            "_start_build_only",
            "_start_purchase_and_build",
            "_execute_clear_cleanup_resources",
            "_execute_cleanup_resources",
            "_execute_reinstall_resources",
            "_execute_replace_ips",
        }
        self.delayed_queue_result_active = False
        self.delayed_queue_result_sequence = 0
        self.delayed_queue_result_current_token = ""
        self.delayed_queue_result_current_label = ""
        self.delayed_queue_result_actions: list[tuple[str, str]] = []
        self.delayed_queue_result_entries: list[dict[str, Any]] = []
        self.delayed_queue_result_suppress_dialog = False
        self.active_progress_text: Text | None = None
        self.active_log_name = "purchase"
        self.log_action_names: dict[str, str] = {}
        self.realtime_log_filter = RealtimeLogDisplayFilter()
        self.pending_log_chunks: dict[str, list[str]] = {"purchase": [], "cleanup": []}
        self.log_flush_after_id: str | None = None
        self.output_refresh_after_id: str | None = None
        self.deferred_popup_after_id: str | None = None
        self.l2tp_text: Text | None = None
        self.socks_text: Text | None = None
        self.result_ip_replacements: dict[str, str] = {}
        self.pending_after_done = None
        self.stop_requested = False
        self.stop_file = ROOT / ".workflow_stop"
        self.stop_buttons: list[ttk.Button] = []
        self.cleanup_live_refresh_job: str | None = None
        self.cleanup_live_refresh_active = False
        self.cleanup_live_refresh_path: Path | None = None
        self.cleanup_live_refresh_regions: list[str] | None = None
        self.cleanup_live_refresh_mtime = 0.0
        self.cleanup_live_refresh_count = -1
        self.cleanup_finalized_result_paths: set[str] = set()
        self.cleanup_completion_watch_paths: set[str] = set()
        self.ssh_channel = None
        self.ssh_channel_lock = threading.Lock()
        self.child_processes: set[subprocess.Popen] = set()
        self.log_lock = threading.Lock()
        self.thread_context = threading.local()
        self.current_workflow_keys: list[str] = []
        self.current_workflow_regions: list[str] = []
        self.current_workflow_mode = "all"
        self.current_workflow_new_ip_only = False
        self.result_log_start_index = "1.0"
        self.result_data_started_at = time.time()
        self.last_task_failure_reason = ""
        self.private_192_168_100_result_active = False
        self.invalid_result_regions: set[str] = set()
        self.current_account_scope = ""
        self.customer_guard_status = "idle"
        self.customer_guard_fingerprint = ""
        self.customer_guard_message = ""
        self.customer_guard_notice_fingerprint = ""
        self.customer_guard_log_fingerprint = ""
        self.customer_guard_pending_resource_query = False
        self.customer_guard_after_id: str | None = None
        self.customer_guard_token = 0
        self.suspend_customer_guard_trace = False
        self.customer_guard_dialog = None
        self.runtime_root = ROOT
        self.purchase_result_root = ROOT
        self.cleanup_result_path: Path | None = None
        self.eip_sold_out_popup_shown = False
        self.business_hot_reload_mtimes = self._collect_business_source_mtimes()
        self.business_hot_reload_dispatching = False
        self.ak_var.trace_add("write", self._on_customer_guard_credentials_changed)
        self.sk_var.trace_add("write", self._on_customer_guard_credentials_changed)

        self._build()
        self.root.after(100, self._drain_queue)

    def _standalone_limited_features(self) -> bool:
        return bool(getattr(self, "standalone_limited_features", False))

    def _customer_cleanup_limited_features(self) -> bool:
        return bool(getattr(self, "customer_cleanup_limited_features", False))

    def _cleanup_cloud_server_limited_features(self) -> bool:
        return self._standalone_limited_features() or self._customer_cleanup_limited_features()

    @staticmethod
    def _business_source_dirs() -> list[Path]:
        ctyun_dir = Path(__file__).resolve().parent
        return [ctyun_dir, ctyun_dir.parent / "common"]

    @classmethod
    def _collect_business_source_mtimes(cls) -> dict[str, float]:
        tracked: dict[str, float] = {}
        for source_dir in cls._business_source_dirs():
            if not source_dir.exists():
                continue
            for path in source_dir.rglob("*.py"):
                if "__pycache__" in path.parts:
                    continue
                try:
                    tracked[str(path.resolve())] = path.stat().st_mtime
                except OSError:
                    continue
        return tracked

    @staticmethod
    def _module_source_mtime(module: Any) -> float | None:
        filename = str(getattr(module, "__file__", "") or "")
        if not filename or filename.endswith((".pyc", ".pyo")):
            return None
        if not filename.endswith(".py") or not os.path.exists(filename):
            return None
        try:
            return os.path.getmtime(filename)
        except OSError:
            return None

    @staticmethod
    def _business_module_reload_priority(name: str) -> int:
        if name == "common.shared":
            return -100
        if name in {"CTYUN.ctyun_openapi", "CTYUN.workflow_2region"}:
            return -50
        if name == "CTYUN.app":
            return 1000
        return 0

    def _has_active_tasks(self) -> bool:
        with self.task_lock:
            return bool(self.active_task_count or self.running)

    def _set_status(self, text: str) -> None:
        status_var = getattr(self, "status_var", None)
        if status_var is None:
            self.status_var = StringVar(value=text)
            return
        status_var.set(text)

    def _reload_business_modules_if_changed(self) -> bool:
        current = self._collect_business_source_mtimes()
        previous = getattr(self, "business_hot_reload_mtimes", {}) or {}
        if current == previous:
            return False
        if self._has_active_tasks():
            self._set_status("检测到源码更新，任务结束后下次操作自动加载")
            return False
        modules: list[tuple[int, int, int, str, Any]] = []
        prefixes = ("common", "CTYUN")
        importlib.invalidate_caches()
        for name, module in list(sys.modules.items()):
            if not module or not any(name == prefix or name.startswith(prefix + ".") for prefix in prefixes):
                continue
            if self._module_source_mtime(module) is None:
                continue
            prefix_index = prefixes.index(name.split(".", 1)[0])
            modules.append((prefix_index, self._business_module_reload_priority(name), name.count("."), name, module))
        # 中文注释：只重载天翼云业务代码，不重建界面；按钮和控件变化仍由“界面更新”处理。
        for _prefix_index, _priority, _depth, _name, module in sorted(modules):
            importlib.reload(module)
        refreshed_module = sys.modules.get("CTYUN.app")
        refreshed_class = getattr(refreshed_module, "WorkflowGui", None)
        if isinstance(refreshed_class, type) and refreshed_class is not self.__class__:
            self.__class__ = refreshed_class
        self.business_hot_reload_mtimes = self._collect_business_source_mtimes()
        self._set_status("业务代码已自动加载最新源码")
        return True

    def _dispatch_after_business_hot_reload(self, method_name: str, *args: Any, **kwargs: Any) -> bool:
        if getattr(self, "business_hot_reload_dispatching", False):
            return False
        try:
            reloaded = self._reload_business_modules_if_changed()
        except Exception as exc:
            self._show_error(f"业务代码自动加载失败：{exc}")
            return False
        if not reloaded:
            return False
        method = getattr(self, method_name, None)
        if not callable(method):
            self._show_error(f"业务代码已更新，但未找到入口：{method_name}")
            return True
        self.business_hot_reload_dispatching = True
        try:
            method(*args, **kwargs)
        finally:
            self.business_hot_reload_dispatching = False
        return True

    def _hot_command(self, method_name: str, *args: Any, **kwargs: Any):
        def command() -> Any:
            label = self._delayed_action_label(method_name)

            def execute() -> Any:
                if self._dispatch_after_business_hot_reload(method_name, *args, **kwargs):
                    return None
                method = getattr(self, method_name)
                return method(*args, **kwargs)

            if method_name == "_query_cleanup_resources":
                return execute()
            return self._run_or_delay_action(
                label,
                execute,
                requires_preconfirm=method_name in self.delayed_preconfirm_methods,
            )

        return command

    def _delayed_action_label(self, method_name: str) -> str:
        labels = {
            "_start_purchase_only": "购买选择地区",
            "_start_purchase_new_ips": "购买选择地区新IP",
            "_start_build_only": "搭建选择地区",
            "_start_purchase_and_build": "一键购买并搭建选择地区",
            "_query_cleanup_resources": "读取账号资源",
            "_execute_clear_cleanup_resources": "一键清空账号资源",
            "_execute_cleanup_resources": "退订/删除选定资源",
            "_execute_reinstall_resources": "重装选定云服务器",
            "_execute_replace_ips": "更换新IP",
        }
        return labels.get(method_name, method_name)

    def _is_delayed_action_busy(self) -> bool:
        with self.task_lock:
            return bool(self.running)

    def _delayed_copy_command(self, label: str, action: Callable[[], Any]) -> Callable[[], Any]:
        return lambda: self._run_or_delay_action(label, action)

    def _preconfirmed_delayed_action(self, action: Callable[[], Any]) -> Callable[[], Any]:
        def execute() -> Any:
            self.delayed_skip_next_confirmation = True
            try:
                return action()
            finally:
                self.delayed_skip_next_confirmation = False

        return execute

    @staticmethod
    def _combined_delayed_queue_confirm_message(preconfirm_message: str, queued_summary: str) -> str:
        message = str(preconfirm_message or "").strip()
        queue_summary = str(queued_summary or "").strip()
        queue_tail = f"{queue_summary}\n\n确认后将加入延时执行队列，按顺序自动执行。"
        if not message:
            return f"当前已有任务正在执行。\n\n{queue_tail}"
        merged = re.sub(
            r"(?:请确认是否继续|是否继续)[？?]\s*$",
            "请确认是否继续，另外当前已有任务正在执行。",
            message,
        )
        if merged == message:
            merged = f"{message}\n\n另外当前已有任务正在执行。"
        return f"{merged}\n\n{queue_tail}"

    def _preconfirm_delayed_action_if_needed(
        self,
        action: Callable[[], Any],
        requires_preconfirm: bool,
    ) -> tuple[Callable[[], Any] | None, str]:
        if not requires_preconfirm:
            return action, ""
        self.delayed_preconfirm_active = True
        try:
            action()
        except DelayedActionPreconfirmCaptured as exc:
            return self._preconfirmed_delayed_action(action), str(exc.message or "")
        except DelayedActionPreconfirmAccepted:
            return self._preconfirmed_delayed_action(action), ""
        except DelayedActionPreconfirmCancelled:
            return None, ""
        finally:
            self.delayed_preconfirm_active = False
        return None, ""

    def _current_delayed_action_label(self) -> str:
        if self.delayed_current_action_label:
            return self.delayed_current_action_label
        if self.delayed_action_running_label:
            return self.delayed_action_running_label
        try:
            status = self.status_var.get().strip()
        except Exception:
            status = ""
        if status.startswith("当前任务："):
            status = status[len("当前任务：") :].split("，后续任务", 1)[0].strip()
        if status and status != "就绪":
            return status
        return "当前任务"

    def _delayed_queue_labels(self, extra_label: str = "") -> list[str]:
        labels = [queued_label for queued_label, _ in self.delayed_action_queue]
        if extra_label:
            labels.append(extra_label)
        return labels

    def _delayed_queue_summary(self, extra_label: str = "") -> str:
        pending = self._delayed_queue_labels(extra_label)
        following = "、".join(pending) if pending else "无"
        return f"当前任务：{self._current_delayed_action_label()}，后续任务：{following}"

    def _delayed_action_total(self) -> int:
        return (1 if self._is_delayed_action_busy() else 0) + len(self.delayed_action_queue)

    def _refresh_delayed_queue_status(self) -> None:
        if self._is_delayed_action_busy() or self.delayed_action_queue or self.delayed_action_running_label:
            self._set_status(self._delayed_queue_summary())

    def _begin_delayed_queue_result_batch(self, current_label: str = "") -> None:
        if not self.delayed_queue_result_active:
            self.delayed_queue_result_active = True
            self.delayed_queue_result_sequence = 0
            self.delayed_queue_result_current_token = ""
            self.delayed_queue_result_current_label = ""
            self.delayed_queue_result_actions = []
            self.delayed_queue_result_entries = []
        if current_label and not self.delayed_queue_result_current_token:
            self._start_delayed_queue_result_action(current_label)

    def _start_delayed_queue_result_action(self, label: str) -> None:
        if not self.delayed_queue_result_active:
            return
        label = str(label or "当前任务").strip() or "当前任务"
        self.delayed_queue_result_sequence += 1
        token = str(self.delayed_queue_result_sequence)
        self.delayed_queue_result_current_token = token
        self.delayed_queue_result_current_label = label
        self.delayed_queue_result_actions.append((token, label))

    def _record_delayed_queue_result_popup(self, message: str, *, is_error: bool = False) -> bool:
        if not self.delayed_queue_result_active or self.delayed_queue_result_suppress_dialog:
            return False
        if "队列已满" in str(message or ""):
            return False
        token = self.delayed_queue_result_current_token
        label = self.delayed_queue_result_current_label or self._current_delayed_action_label()
        if not token:
            self._start_delayed_queue_result_action(label)
            token = self.delayed_queue_result_current_token
            label = self.delayed_queue_result_current_label
        text = friendly_popup_text(message).strip()
        if not text:
            return True
        self.delayed_queue_result_entries.append(
            {
                "token": token,
                "label": str(label or "当前任务").strip() or "当前任务",
                "message": text,
                "is_error": bool(is_error),
            }
        )
        return True

    def _finish_delayed_queue_result_action(self, label: str = "") -> None:
        if not self.delayed_queue_result_active:
            return
        token = self.delayed_queue_result_current_token
        if not token:
            return
        if not any(entry.get("token") == token for entry in self.delayed_queue_result_entries):
            fallback_label = str(label or self.delayed_queue_result_current_label or "当前任务").strip()
            self.delayed_queue_result_entries.append(
                {
                    "token": token,
                    "label": fallback_label or "当前任务",
                    "message": "已执行完成。",
                    "is_error": False,
                }
            )
        self.delayed_queue_result_current_token = ""
        self.delayed_queue_result_current_label = ""

    def _show_delayed_queue_result_summary_if_done(self) -> None:
        if not self.delayed_queue_result_active:
            return
        if self._is_delayed_action_busy() or self.delayed_action_queue or self.delayed_action_running_label:
            return
        action_order = list(self.delayed_queue_result_actions)
        entries = list(self.delayed_queue_result_entries)
        self.delayed_queue_result_active = False
        self.delayed_queue_result_sequence = 0
        self.delayed_queue_result_current_token = ""
        self.delayed_queue_result_current_label = ""
        self.delayed_queue_result_actions = []
        self.delayed_queue_result_entries = []
        if not action_order and not entries:
            return
        lines = ["天翼云-队列任务执行完毕，任务结果："]
        seen_tokens: set[str] = set()
        for token, label in action_order:
            group = [entry for entry in entries if entry.get("token") == token]
            if not group:
                group = [{"message": "已执行完成。", "is_error": False}]
            seen_tokens.add(token)
            message = "\n".join(str(entry.get("message") or "").strip() for entry in group if str(entry.get("message") or "").strip())
            prefix = "[失败] " if any(entry.get("is_error") for entry in group) else ""
            lines.append(f"{prefix}{label}：{message or '已执行完成。'}")
        for entry in entries:
            token = str(entry.get("token") or "")
            if token in seen_tokens:
                continue
            label = str(entry.get("label") or "当前任务").strip() or "当前任务"
            prefix = "[失败] " if entry.get("is_error") else ""
            lines.append(f"{prefix}{label}：{str(entry.get('message') or '').strip() or '已执行完成。'}")
        self._show_info("\n\n".join(lines))

    def _run_or_delay_action(
        self,
        label: str,
        action: Callable[[], Any],
        *,
        requires_preconfirm: bool = False,
    ) -> Any:
        if self._is_delayed_action_busy():
            if self._delayed_action_total() >= self.delayed_action_max_total:
                summary = self._delayed_queue_summary()
                self._set_status(summary)
                self._show_info(f"当前云延时执行队列已满，最多保留 6 个按序任务。\n\n{summary}\n\n请等待前面的任务完成后再添加。")
                return None
            queued_action, preconfirm_message = self._preconfirm_delayed_action_if_needed(action, requires_preconfirm)
            if queued_action is None:
                return None
            queued_summary = self._delayed_queue_summary(label)
            confirm_message = self._combined_delayed_queue_confirm_message(preconfirm_message, queued_summary)
            if not ask_app_confirm_dialog(
                self._dialog_parent(),
                self._dialog_title(confirm_message, default_suffix="确认"),
                confirm_message,
                ok_text="确认并加入队列任务",
            ):
                return None
            self._begin_delayed_queue_result_batch(self._current_delayed_action_label())
            self.delayed_action_queue.append((label, queued_action))
            self._refresh_delayed_queue_status()
            queue_summary = self._delayed_queue_summary()
            self._emit(
                f"已加入延时执行队列。{queue_summary}\n队列会按顺序执行，前一个任务完成后自动开始下一个。\n",
                target_name=self.active_log_name if self.active_log_name in {"purchase", "cleanup"} else "purchase",
            )
            return None
        self.delayed_current_action_label = label
        try:
            result = action()
            if self._is_delayed_action_busy():
                self._refresh_delayed_queue_status()
            return result
        finally:
            if not self._is_delayed_action_busy():
                self.delayed_current_action_label = ""

    def _run_next_delayed_action(self) -> None:
        if self._is_delayed_action_busy() or self.delayed_action_running_label:
            return
        if not self.delayed_action_queue:
            return
        label, action = self.delayed_action_queue.pop(0)
        self.delayed_action_running_label = label
        self.delayed_current_action_label = label
        self._refresh_delayed_queue_status()
        self._start_delayed_queue_result_action(label)
        try:
            action()
        finally:
            if not self._is_delayed_action_busy():
                finished_label = self.delayed_action_running_label
                self.delayed_action_running_label = ""
                self.delayed_current_action_label = ""
                self._finish_delayed_queue_result_action(finished_label)
                if self.delayed_action_queue:
                    self.root.after(100, self._run_next_delayed_action)
                else:
                    self.root.after(100, self._show_delayed_queue_result_summary_if_done)
                return

    def _complete_delayed_action_if_idle(self) -> None:
        if self._is_delayed_action_busy():
            return
        finished_label = self.delayed_action_running_label
        self.delayed_action_running_label = ""
        self.delayed_current_action_label = ""
        self._finish_delayed_queue_result_action(finished_label)
        if self.delayed_action_queue:
            self._run_next_delayed_action()
        else:
            self._show_delayed_queue_result_summary_if_done()
        return

    def _scale_fonts(self) -> None:
        font_specs = {
            "TkDefaultFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkTextFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkFixedFont": (UI_FIXED_FONT_FAMILY, UI_FIXED_FONT_SIZE, "normal"),
            "TkMenuFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkHeadingFont": (UI_FONT_FAMILY, UI_HEADING_FONT_SIZE, "normal"),
            "TkCaptionFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkSmallCaptionFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkIconFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
            "TkTooltipFont": (UI_FONT_FAMILY, UI_FONT_SIZE, "normal"),
        }
        for name, (family, size, weight) in font_specs.items():
            try:
                font = tkfont.nametofont(name)
                font.configure(family=family, size=size, weight=weight)
            except Exception:
                pass

    def _build(self) -> None:
        try:
            self.parent.configure(bg=APP_BG)
        except Exception:
            pass
        style = ttk.Style()
        try:
            if "vista" in style.theme_names():
                style.theme_use("vista")
        except Exception:
            pass
        ui_font = tkfont.nametofont("TkDefaultFont")
        text_font = tkfont.nametofont("TkTextFont")
        heading_font = tkfont.nametofont("TkHeadingFont")
        style.configure("TFrame", background=CONTENT_BG)
        style.configure("TLabelframe", background=CONTENT_BG)
        style.configure("TLabelframe.Label", background=CONTENT_BG, font=heading_font)
        style.configure("TLabel", background=CONTENT_BG, foreground=TEXT_FG, font=ui_font)
        style.configure("TButton", font=ui_font, padding=(8, 5))
        style.configure("TRadiobutton", background=CONTENT_BG, foreground=TEXT_FG, font=ui_font)
        style.map("TRadiobutton", background=[("active", CONTENT_BG), ("selected", CONTENT_BG)], foreground=[("active", TEXT_FG), ("selected", TEXT_FG)])
        style.configure("TCheckbutton", background=CONTENT_BG, foreground=TEXT_FG, font=ui_font)
        style.map("TCheckbutton", background=[("active", CONTENT_BG), ("selected", CONTENT_BG)], foreground=[("active", TEXT_FG), ("selected", TEXT_FG)])
        style.configure("Grid.Treeview", background=TEXT_BG, fieldbackground=TEXT_BG, font=text_font, rowheight=max(26, int(text_font.metrics("linespace") + 8)))
        style.configure("Grid.Treeview.Heading", background=ACCENT_BG, foreground=TEXT_FG, font=heading_font)

        outer = ttk.Frame(self.parent, padding=0)
        outer.pack(fill=BOTH, expand=True)
        outer.columnconfigure(0, weight=1)

        if not self.embedded:
            outer.rowconfigure(1, weight=1)
            self._build_keys(outer)
            page_host = ttk.Frame(outer)
            page_host.grid(row=1, column=0, sticky="nsew")
        else:
            outer.rowconfigure(0, weight=1)
            page_host = outer

        pages = create_fixed_tab_pages(page_host, [("purchase", "购买搭建"), ("cleanup", "资源管理")])
        purchase_tab = pages["purchase"]
        cleanup_tab = pages["cleanup"]

        self._build_purchase_tab(purchase_tab)
        self._build_cleanup_tab(cleanup_tab)

    def _build_keys(self, parent: ttk.Frame) -> None:
        frame = ttk.LabelFrame(parent, text="请输入用户AK/SK", padding=10)
        frame.grid(row=0, column=0, sticky="ew", pady=(0, 10))
        key_row = ttk.Frame(frame)
        key_row.pack(fill=X)
        key_row.columnconfigure(1, weight=1, minsize=388)
        key_row.columnconfigure(2, weight=1, minsize=188)
        ttk.Label(key_row, text="AccessKey ID", width=14).grid(row=0, column=0, sticky="w", padx=(0, 8), pady=(0, 6))
        ttk.Entry(key_row, textvariable=self.ak_var, width=54).grid(row=0, column=1, columnspan=2, sticky="ew", pady=(0, 6))
        ttk.Label(key_row, text="SecurityKey", width=14).grid(row=1, column=0, sticky="w", padx=(0, 8))
        ttk.Entry(key_row, textvariable=self.sk_var, width=54).grid(row=1, column=1, columnspan=2, sticky="ew", pady=(0, 6))
        ttk.Label(key_row, text="User(可编辑)：", width=14).grid(row=2, column=0, sticky="w", padx=(0, 8))
        ttk.Entry(key_row, textvariable=self.mark_var, width=27).grid(row=2, column=1, sticky="ew", padx=(0, 6))
        self._build_credential_history_cell(key_row, row=2, column=2)
        action_row = ttk.Frame(frame)
        action_row.pack(fill=X, pady=(8, 0))
        action_row.columnconfigure(0, weight=1)
        ttk.Button(action_row, text="粘贴", command=self._paste_keys).grid(row=0, column=0, sticky="ew", padx=(0, 8))
        ttk.Button(action_row, text="代理设置", command=self._show_ctyun_proxy_dialog).grid(row=0, column=1, sticky="e")

    def _refresh_credential_history_cell(self, history_widget: Any, value_var: StringVar) -> None:
        records: dict[str, dict[str, str]] = {}
        try:
            for item in credential_history_for_cloud(CTYUN_VENDOR_KEY):
                mark = str(item.get("mark") or "").strip()
                ak = str(item.get("ak") or "").strip()
                sk = str(item.get("sk") or "").strip()
                if not mark or not ak or not sk or mark in records:
                    continue
                records[mark] = {"mark": mark, "ak": ak, "sk": sk}
        except Exception:
            records = {}
        try:
            setattr(history_widget, "_credential_history_records", records)
            if value_var.get().strip() not in records:
                value_var.set(CTYUN_CREDENTIAL_HISTORY_PLACEHOLDER)
        except Exception:
            pass

    def _refresh_credential_history_controls(self) -> None:
        live_controls: list[dict[str, Any]] = []
        for control in self.credential_history_controls:
            widget = control.get("widget")
            value_var = control.get("var")
            if not isinstance(value_var, StringVar):
                continue
            try:
                if widget is None or not widget.winfo_exists():
                    continue
            except Exception:
                continue
            live_controls.append(control)
            self._refresh_credential_history_cell(widget, value_var)
        self.credential_history_controls = live_controls

    def _build_credential_history_cell(self, parent: ttk.Frame, *, row: int, column: int) -> None:
        history_var = StringVar(value=CTYUN_CREDENTIAL_HISTORY_PLACEHOLDER)
        history_box = ttk.Frame(parent)
        history_box.grid(row=row, column=column, sticky="ew")
        history_box.columnconfigure(0, weight=1)
        history_entry = ttk.Entry(history_box, textvariable=history_var, width=27)
        history_entry.grid(row=0, column=0, sticky="ew")

        def open_history() -> None:
            self._open_credential_history_popup(history_box, history_entry, history_var)

        ttk.Button(history_box, text="v", width=3, command=open_history).grid(row=0, column=1, sticky="e", padx=(3, 0))
        history_entry.bind("<Button-1>", lambda _event: open_history())
        history_entry.bind("<Key>", lambda _event: "break")
        self.credential_history_controls.append({"widget": history_entry, "var": history_var})
        self._refresh_credential_history_cell(history_entry, history_var)

    def _close_credential_history_popup(self) -> None:
        popup = self._credential_history_popup
        self._credential_history_popup = None
        try:
            if popup is not None and popup.winfo_exists():
                popup.destroy()
        except Exception:
            pass

    def _open_credential_history_popup(self, anchor: tk.Misc, history_widget: Any, value_var: StringVar) -> None:
        self._refresh_credential_history_cell(history_widget, value_var)
        records = getattr(history_widget, "_credential_history_records", {})
        if not isinstance(records, dict):
            records = {}
        values = list(records.keys())
        self._close_credential_history_popup()

        popup = Toplevel(self.root)
        self._credential_history_popup = popup
        popup.withdraw()
        popup.overrideredirect(True)
        try:
            popup.transient(self.root)
        except Exception:
            pass

        frame = ttk.Frame(popup, padding=(6, 6, 6, 6))
        frame.pack(fill=BOTH, expand=True)
        search_row = ttk.Frame(frame)
        search_row.pack(fill=X)
        tk.Label(search_row, text="搜索：", bg=CONTENT_BG, fg=TEXT_FG, font=(UI_FONT_FAMILY, UI_FONT_SIZE)).pack(side=LEFT, padx=(0, 4))
        search_var = StringVar()
        search_entry = ttk.Entry(search_row, textvariable=search_var)
        search_entry.pack(side=LEFT, fill=X, expand=True)
        list_frame = ttk.Frame(frame)
        list_frame.pack(fill=BOTH, expand=True, pady=(6, 0))
        list_frame.columnconfigure(0, weight=1)
        list_frame.rowconfigure(0, weight=1)
        visible_rows = max(3, min(8, len(values) or 3))
        list_canvas = tk.Canvas(list_frame, height=visible_rows * 28, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground="#d8dee8")
        list_canvas.grid(row=0, column=0, sticky="nsew")
        scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=list_canvas.yview)
        scrollbar.grid(row=0, column=1, sticky="ns")
        list_canvas.configure(yscrollcommand=scrollbar.set)
        rows_frame = tk.Frame(list_canvas, bg="#ffffff")
        rows_window = list_canvas.create_window((0, 0), window=rows_frame, anchor="nw")
        filtered_values: list[str] = []

        def resize_rows(event: tk.Event) -> None:
            try:
                list_canvas.itemconfigure(rows_window, width=event.width)
            except Exception:
                pass

        def update_scroll_region(*_args: Any) -> None:
            try:
                list_canvas.configure(scrollregion=list_canvas.bbox("all"))
            except Exception:
                pass

        list_canvas.bind("<Configure>", resize_rows)
        rows_frame.bind("<Configure>", update_scroll_region)
        bind_canvas_mousewheel(list_canvas, popup, frame, search_row, search_entry, list_frame, rows_frame)

        def choose_value(selected_value: str) -> None:
            value_var.set(selected_value)
            self._apply_selected_credential_history(history_widget, value_var)
            self._close_credential_history_popup()

        def delete_value(selected_value: str) -> str:
            nonlocal records, values
            record = records.get(selected_value) if isinstance(records, dict) else None
            if not isinstance(record, dict):
                return "break"
            deleted = delete_credential_history_record(
                CTYUN_VENDOR_KEY,
                str(record.get("mark") or selected_value),
                str(record.get("ak") or ""),
                str(record.get("sk") or ""),
            )
            if not deleted:
                return "break"
            self._refresh_credential_history_cell(history_widget, value_var)
            records = getattr(history_widget, "_credential_history_records", {})
            if not isinstance(records, dict):
                records = {}
            values = list(records.keys())
            value_var.set(CTYUN_CREDENTIAL_HISTORY_PLACEHOLDER)
            self._refresh_credential_history_controls()
            refresh_list()
            return "break"

        def refresh_list(*_args: Any) -> None:
            nonlocal filtered_values
            keyword = search_var.get().strip().lower()
            filtered_values = [item for item in values if keyword in item.lower()]
            for child in rows_frame.winfo_children():
                child.destroy()
            if not filtered_values:
                empty_text = "无匹配账号" if keyword else "暂无历史账号"
                tk.Label(rows_frame, text=empty_text, anchor="w", bg="#ffffff", fg="#6b7280").pack(fill=X, padx=8, pady=6)
                update_scroll_region()
                return
            def draw_delete_icon(canvas: tk.Canvas, hovered: bool = False) -> None:
                bg_color = "#fff1f2" if hovered else "#ffffff"
                border_color = "#fb7185" if hovered else "#ef4444"
                line_color = "#b91c1c" if hovered else "#dc2626"
                canvas.configure(bg=bg_color)
                canvas.delete("all")
                image = tk.PhotoImage(width=19, height=19)
                image.put(bg_color, to=(0, 0, 19, 19))
                for pos in range(19):
                    image.put(border_color, to=(pos, 0, pos + 1, 1))
                    image.put(border_color, to=(pos, 18, pos + 1, 19))
                    image.put(border_color, to=(0, pos, 1, pos + 1))
                    image.put(border_color, to=(18, pos, 19, pos + 1))
                for pos in range(4, 15):
                    image.put(line_color, to=(pos, pos, pos + 1, pos + 1))
                    mirror_x = 18 - pos
                    image.put(line_color, to=(mirror_x, pos, mirror_x + 1, pos + 1))
                canvas.create_image(0, 0, anchor=tk.NW, image=image)
                setattr(canvas, "_delete_icon_image", image)

            def set_row_hover(row_widget: tk.Frame, label_widget: tk.Label, box_widget: tk.Frame, base_bg: str, hovered: bool = False) -> None:
                bg_color = "#eef6ff" if hovered else base_bg
                try:
                    row_widget.configure(bg=bg_color)
                    label_widget.configure(bg=bg_color, fg="#0f172a" if hovered else "#111827")
                    box_widget.configure(bg=bg_color)
                except Exception:
                    pass

            for index, item in enumerate(filtered_values):
                row_bg = "#ffffff" if index % 2 == 0 else "#f8fafc"
                row = tk.Frame(rows_frame, bg=row_bg)
                row.pack(fill=X)
                row.columnconfigure(0, weight=1)
                row.rowconfigure(0, weight=1)
                label = tk.Label(row, text=item, anchor="w", bg=row_bg, fg="#111827", padx=6, pady=4, cursor="hand2")
                label.grid(row=0, column=0, sticky="ew")
                delete_box = tk.Frame(row, width=19, height=19, bg=row_bg)
                delete_box.grid(row=0, column=1, sticky="e", padx=(4, 4), pady=0)
                delete_box.grid_propagate(False)
                delete_button = tk.Canvas(
                    delete_box,
                    width=19,
                    height=19,
                    bd=0,
                    highlightthickness=0,
                    bg=row_bg,
                    cursor="hand2",
                )
                draw_delete_icon(delete_button)
                delete_button.bind("<Button-1>", lambda _event, value=item: delete_value(value))
                delete_button.bind("<Enter>", lambda _event, canvas=delete_button: draw_delete_icon(canvas, True))
                delete_button.bind("<Leave>", lambda _event, canvas=delete_button: draw_delete_icon(canvas, False))
                delete_button.place(x=0, y=0, width=19, height=19)
                bind_canvas_mousewheel(list_canvas, row, label, delete_box, delete_button)
                row.bind("<Button-1>", lambda _event, value=item: choose_value(value))
                label.bind("<Button-1>", lambda _event, value=item: choose_value(value))
                row.bind("<Enter>", lambda _event, row_widget=row, label_widget=label, box_widget=delete_box, base_bg=row_bg: set_row_hover(row_widget, label_widget, box_widget, base_bg, True))
                row.bind("<Leave>", lambda _event, row_widget=row, label_widget=label, box_widget=delete_box, base_bg=row_bg: set_row_hover(row_widget, label_widget, box_widget, base_bg, False))
                label.bind("<Enter>", lambda _event, row_widget=row, label_widget=label, box_widget=delete_box, base_bg=row_bg: set_row_hover(row_widget, label_widget, box_widget, base_bg, True))
                label.bind("<Leave>", lambda _event, row_widget=row, label_widget=label, box_widget=delete_box, base_bg=row_bg: set_row_hover(row_widget, label_widget, box_widget, base_bg, False))
            update_scroll_region()

        def choose_selected(*_args: Any) -> None:
            if not filtered_values:
                return
            choose_value(filtered_values[0])

        search_var.trace_add("write", refresh_list)
        search_entry.bind("<Return>", lambda _event: choose_selected())
        search_entry.bind("<Down>", lambda _event: "break")
        popup.bind("<Escape>", lambda _event: self._close_credential_history_popup())
        refresh_list()
        try:
            anchor.update_idletasks()
            width = max(anchor.winfo_width(), 260)
            height = 36 + 28 * visible_rows + 16
            x = anchor.winfo_rootx()
            y = anchor.winfo_rooty() + anchor.winfo_height()
            popup.geometry(f"{width}x{height}+{x}+{y}")
            popup.deiconify()
            popup.lift()
            search_entry.focus_force()
        except Exception:
            self._close_credential_history_popup()

    def _apply_selected_credential_history(self, history_widget: Any, value_var: StringVar) -> None:
        selected = value_var.get().strip()
        records = getattr(history_widget, "_credential_history_records", {})
        record = records.get(selected) if isinstance(records, dict) else None
        if not isinstance(record, dict):
            self._refresh_credential_history_cell(history_widget, value_var)
            return
        ak = str(record.get("ak") or "").strip()
        sk = str(record.get("sk") or "").strip()
        mark = str(record.get("mark") or selected).strip()
        if not ak or not sk or not mark:
            return
        old_fingerprint = self._customer_guard_fingerprint(self.ak_var.get(), self.sk_var.get())
        new_fingerprint = self._customer_guard_fingerprint(ak, sk)
        self.ak_var.set(ak)
        self.sk_var.set(sk)
        self.mark_var.set(mark)
        if new_fingerprint and new_fingerprint != old_fingerprint:
            self.prepare_for_new_credentials()
        self._remember_mark_for_credentials(ak, sk, mark)

    def _build_purchase_tab(self, parent: ttk.Frame) -> None:
        left, right = build_cloud_page_layout(parent)
        left.rowconfigure(0, weight=0)
        left.rowconfigure(1, weight=0)
        left.rowconfigure(2, weight=1)

        self._build_task_options(left)
        self._build_regions(left, row=1)
        self._build_output_boxes(left, row=2)
        self.purchase_progress_text = self._build_progress(right, "实时日志", include_result_buttons=True, log_context="purchase")
        self.active_progress_text = self.purchase_progress_text

    def _build_cleanup_tab(self, parent: ttk.Frame) -> None:
        left, right = build_cloud_page_layout(parent)
        left.rowconfigure(0, weight=1)

        self._build_cleanup_controls(left)
        self.cleanup_progress_text = self._build_progress(right, "实时日志", include_result_buttons=False, log_context="manage")

    def _region_option_label(self, name: str, stats: dict[str, int] | None = None) -> str:
        label = self.dynamic_region_labels.get(name) or REGION_LABELS.get(name, name)
        stats = stats or {}
        parts: list[str] = []
        ecs_count = int(stats.get("ECS") or 0)
        eip_count = int(stats.get("EIP") or 0)
        if ecs_count and not self._standalone_limited_features():
            parts.append(f"ECS:{ecs_count}")
        if eip_count:
            parts.append(f"EIP:{eip_count}")
        return f"{label}丨{' '.join(parts)}" if parts else label

    def _account_resources_for_purchase(self) -> Sequence[dict[str, Any]]:
        items = getattr(self, "account_resource_items", None)
        if items is not None:
            return items
        return getattr(self, "cleanup_items", [])

    def _record_value(self, record: dict[str, Any], *keys: str) -> str:
        for key in keys:
            value = record.get(key)
            if value not in (None, ""):
                return str(value).strip()
        return ""

    def _format_region_record_label(self, record: dict[str, Any]) -> str:
        name = self._record_value(record, "displayName", "regionName", "resourcePoolName", "name")
        province = self._region_record_province(record)
        city = self._record_value(record, "city", "cityName", "regionCity", "areaName")
        if province and city:
            return f"{province} {city}（{name}）"
        if province:
            return f"{province}（{name}）"
        return name

    def _normalize_province_name(self, value: str) -> str:
        province = value.strip()
        if not province:
            return ""
        reverse = {short: full for full, short in PROVINCE_DISPLAY_NAMES.items()}
        if province in reverse:
            return reverse[province]
        if province in {"北京", "上海", "天津", "重庆"}:
            return f"{province}市"
        if province in {"广西", "内蒙古", "宁夏", "新疆", "西藏"}:
            return reverse.get(province, province)
        if province in {"华北", "华东", "华南", "西南", "港澳及海外"}:
            return province
        if not province.endswith(("省", "市", "自治区")):
            province = f"{province}省"
        return province

    def _region_lookup_text(self, record: dict[str, Any]) -> str:
        return " ".join(
            self._record_value(record, key)
            for key in (
                "displayName",
                "regionName",
                "resourcePoolName",
                "name",
                "cnName",
                "officialName",
                "province",
                "provinceName",
                "city",
                "cityName",
                "areaName",
            )
        )

    def _is_foreign_region_record(self, record: dict[str, Any]) -> bool:
        text = self._region_lookup_text(record).lower()
        return any(keyword.lower() in text for keyword in FOREIGN_REGION_KEYWORDS)

    def _infer_province_from_region_record(self, record: dict[str, Any]) -> str:
        text = self._region_lookup_text(record)
        compact = re.sub(r"\d+$", "", text).replace("-", "")
        for hint, province in CITY_PROVINCE_HINTS:
            if hint in compact:
                return province
        return ""

    def _short_province_name(self, province: str) -> str:
        province = self._normalize_province_name(province)
        if not province:
            return "其他"
        if province in PROVINCE_DISPLAY_NAMES:
            return PROVINCE_DISPLAY_NAMES[province]
        return province.removesuffix("省").removesuffix("市")

    def _region_record_province(self, record: dict[str, Any]) -> str:
        province = self._record_value(
            record,
            "province",
            "provinceName",
            "province_name",
            "regionProvince",
            "resourcePoolProvince",
            "area",
        )
        if province and len(province.strip()) > 1 and province.strip() != "其他":
            return self._normalize_province_name(province)
        name = self._record_value(record, "displayName", "regionName", "resourcePoolName", "name")
        label = self.dynamic_region_labels.get(name) or REGION_LABELS.get(name, "")
        if label:
            return self._normalize_province_name(label.split()[0])
        inferred = self._infer_province_from_region_record(record)
        if inferred:
            return self._normalize_province_name(inferred)
        return "其他"

    def _region_record_kind(self, record: dict[str, Any]) -> str:
        truthy = {"1", "true", "yes", "y", "是"}
        for key in ("isJoint", "isCooperation", "isCooperate", "jointOperate"):
            value = self._record_value(record, key).lower()
            if value in truthy:
                return "joint"
        for key in ("isSelf", "isSelfOperated", "isSelfOperate", "selfOperate"):
            value = self._record_value(record, key).lower()
            if value in truthy:
                return "self"
        values = " ".join(
            self._record_value(record, key)
            for key in (
                "regionType",
                "resourcePoolType",
                "poolType",
                "poolTypeName",
                "operationType",
                "operateType",
                "operateMode",
                "supplierType",
                "providerType",
                "description",
            )
        ).lower()
        if any(word in values for word in ("合营", "合作", "联营", "joint", "partner", "cooper")):
            return "joint"
        if any(word in values for word in ("自营", "直营", "self")):
            return "self"
        return "self"

    def _normalize_all_region_record(self, record: dict[str, Any]) -> dict[str, Any] | None:
        name = self._record_value(record, "displayName", "regionName", "resourcePoolName", "name", "cnName")
        region_id = self._record_value(record, "regionID", "regionId", "resourcePoolID", "resourcePoolId", "poolID", "id", "ID")
        if not name or not region_id:
            return None
        if self._is_foreign_region_record(record):
            return None
        normalized = dict(record)
        normalized["displayName"] = name
        normalized["regionID"] = region_id
        normalized["province"] = self._region_record_province(normalized)
        normalized["city"] = self._record_value(normalized, "city", "cityName", "regionCity", "areaName") or normalized["province"]
        normalized["azName"] = self._record_value(normalized, "azName", "az", "availabilityZone") or "default"
        normalized["_regionKind"] = self._region_record_kind(normalized)
        return normalized

    def _load_local_all_region_records(self) -> list[dict[str, Any]]:
        records: list[dict[str, Any]] = []
        try:
            source = load_region_records()
        except Exception:
            source = []
        source = [*source, *load_all_regions_catalog_records()]
        for item in source:
            record = self._normalize_all_region_record(item)
            if record:
                records.append(record)
        return self._merge_all_region_records(records)

    def _merge_all_region_records(self, *groups: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
        merged: list[dict[str, Any]] = []
        by_key: dict[str, dict[str, Any]] = {}
        for group in groups:
            for item in group:
                record = self._normalize_all_region_record(item)
                if not record:
                    continue
                key = record.get("regionID") or record.get("displayName")
                existing = by_key.get(str(key))
                if existing:
                    ui_group = existing.get("_uiGroup")
                    existing.update({k: v for k, v in record.items() if v not in (None, "")})
                    if ui_group:
                        existing["_uiGroup"] = ui_group
                    continue
                by_key[str(key)] = record
                merged.append(record)
        return merged

    def _query_cloud_all_region_records(self) -> list[dict[str, Any]]:
        ak = self.ak_var.get().strip()
        sk = self.sk_var.get().strip()
        if not self._valid_keys(ak, sk):
            return []
        keys = ("CTYUN_ACCESS_KEY", "CTYUN_SECRET_KEY", "CTYUN_WORKFLOW_REGIONS_FILE", "CTYUN_ALL_REGIONS_CACHE_FILE")
        old_env = {key: os.environ.get(key) for key in keys}
        os.environ["CTYUN_ACCESS_KEY"] = ak
        os.environ["CTYUN_SECRET_KEY"] = sk
        os.environ["CTYUN_WORKFLOW_REGIONS_FILE"] = str(ensure_runtime_regions_file())
        os.environ["CTYUN_ALL_REGIONS_CACHE_FILE"] = str(all_regions_catalog_file())
        try:
            try:
                module = importlib.import_module("CTYUN.workflow_2region")
            except Exception:
                module = importlib.import_module("workflow_2region")
            records = module.query_region_catalog(True)
        finally:
            for key, value in old_env.items():
                if value is None:
                    os.environ.pop(key, None)
                else:
                    os.environ[key] = value
        normalized = [record for record in (self._normalize_all_region_record(item) for item in records) if record]
        save_all_regions_catalog_records(normalized)
        return normalized

    def _filtered_all_region_records(self, state: dict[str, Any]) -> list[dict[str, Any]]:
        filters: set[str] = set()
        if state["self_var"].get():
            filters.add("self")
        if state["joint_var"].get():
            filters.add("joint")
        records = list(state.get("records") or [])
        if not filters:
            return records
        return [record for record in records if record.get("_regionKind") in filters]

    def _province_sort_key(self, province: str) -> tuple[int, int, str]:
        normalized = self._normalize_province_name(province)
        for group_index, (_group, provinces) in enumerate(PROVINCE_GROUPS):
            if normalized in provinces:
                return (group_index, provinces.index(normalized), normalized)
        return (len(PROVINCE_GROUPS), 999, normalized)

    def _region_sort_key(self, record: dict[str, Any]) -> tuple[str, int, str]:
        name = str(record.get("displayName") or "")
        match = re.search(r"(\d+)$", name)
        prefix = name[: match.start()] if match else name
        number = int(match.group(1)) if match else 0
        return (prefix, number, name)

    def _show_all_regions_dialog(self) -> None:
        if self.all_regions_dialog and self.all_regions_dialog.winfo_exists():
            self.all_regions_dialog.lift()
            return
        dialog = Toplevel(self._dialog_parent())
        self.all_regions_dialog = dialog
        dialog.title(self._dialog_title("全部地区", default_suffix="全部地区"))
        dialog.geometry("900x650")
        dialog.minsize(860, 600)
        dialog.transient(self._dialog_parent())
        apply_dialog_icon(dialog)
        dialog.columnconfigure(0, weight=1)
        dialog.rowconfigure(1, weight=1)

        header = ttk.Frame(dialog, padding=(12, 10, 12, 6))
        header.grid(row=0, column=0, sticky="ew")
        header.columnconfigure(1, weight=1)
        back_button = ttk.Button(header, text="返回", width=6)
        back_button.grid(row=0, column=0, sticky="w", padx=(0, 6))
        title_label = ttk.Label(header, text="全部地区")
        title_label.grid(row=0, column=1, sticky="w")
        joint_var = BooleanVar(value=True)
        self_var = BooleanVar(value=True)
        search_var = StringVar()
        ttk.Label(header, text="搜索地区").grid(row=0, column=2, sticky="e", padx=(8, 4))
        ttk.Entry(header, textvariable=search_var, width=20).grid(row=0, column=3, sticky="e", padx=(0, 8))
        ttk.Checkbutton(header, text="自营地区", variable=self_var).grid(row=0, column=4, sticky="e", padx=(8, 0))
        ttk.Checkbutton(header, text="合营地区", variable=joint_var).grid(row=0, column=5, sticky="e", padx=(8, 0))

        body_shell = ttk.Frame(dialog, padding=(12, 0, 12, 8))
        body_shell.grid(row=1, column=0, sticky="nsew")
        body_shell.columnconfigure(0, weight=1)
        body_shell.rowconfigure(0, weight=1)
        canvas = tk.Canvas(body_shell, highlightthickness=0)
        scrollbar = ttk.Scrollbar(body_shell, orient="vertical", command=canvas.yview)
        body = ttk.Frame(canvas)
        canvas.configure(yscrollcommand=scrollbar.set)
        canvas.grid(row=0, column=0, sticky="nsew")
        scrollbar.grid(row=0, column=1, sticky="ns")
        window_id = canvas.create_window((0, 0), window=body, anchor="nw")
        body.bind("<Configure>", lambda _event: canvas.configure(scrollregion=canvas.bbox("all")))
        canvas.bind("<Configure>", lambda event: canvas.itemconfigure(window_id, width=event.width))

        configured_count = len(load_region_records())
        local_records = self._load_local_all_region_records()
        catalog_count = max(0, len(local_records) - configured_count)
        can_query_cloud_regions = self._valid_keys(self.ak_var.get().strip(), self.sk_var.get().strip())
        status_var = StringVar(
            value=(
                f"当前显示本地全部地区缓存：{len(local_records)} 个，正在刷新云端地区。"
                if can_query_cloud_regions
                else (
                    f"当前显示本地全部地区缓存：{len(local_records)} 个。"
                    if catalog_count > 0
                    else f"当前显示本地地区配置：{len(local_records)} 个；完整全部地区尚未缓存。"
                )
            )
        )
        ttk.Label(dialog, textvariable=status_var, padding=(12, 0, 12, 10)).grid(row=2, column=0, sticky="w")
        state: dict[str, Any] = {
            "dialog": dialog,
            "body": body,
            "title": title_label,
            "back": back_button,
            "status": status_var,
            "self_var": self_var,
            "joint_var": joint_var,
            "search_var": search_var,
            "records": local_records,
            "local_records": local_records,
            "cloud_loading": False,
            "province": "",
        }

        def refresh_current() -> None:
            self._render_all_region_current(state)

        for var in (self_var, joint_var, search_var):
            var.trace_add("write", lambda *_args: refresh_current())
        self._render_all_region_provinces(state)
        self._refresh_all_regions_catalog_async(state)

    def _refresh_all_regions_catalog_async(self, state: dict[str, Any]) -> None:
        if not self._valid_keys(self.ak_var.get().strip(), self.sk_var.get().strip()):
            state["records"] = list(state.get("local_records") or state.get("records") or [])
            state["status"].set(f"当前显示本地地区：{len(state['records'])} 个。")
            self._render_all_region_current(state)
            return

        def worker() -> None:
            try:
                cloud_records = self._query_cloud_all_region_records()
                error = ""
            except Exception as exc:
                cloud_records = []
                error = str(exc)

            def apply_result() -> None:
                dialog = state.get("dialog")
                if not dialog or not dialog.winfo_exists():
                    return
                local_records = list(state.get("local_records") or state.get("records") or [])
                state["cloud_loading"] = False
                if cloud_records:
                    state["records"] = self._merge_all_region_records(local_records, cloud_records)
                    save_all_regions_catalog_records(state["records"])
                    state["status"].set(f"已刷新云端全部地区：{len(cloud_records)} 个，可继续选择添加。")
                elif error:
                    state["records"] = local_records
                    reason = " ".join(str(error).split())
                    if len(reason) > 120:
                        reason = f"{reason[:120]}..."
                    state["status"].set(f"云端全部地区刷新失败：{reason}，当前继续显示本地地区。")
                else:
                    state["records"] = local_records
                    state["status"].set("云端全部地区未返回可用地区，当前继续显示本地地区。")
                self._render_all_region_current(state)

            self.root.after(0, apply_result)

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

    def _clear_all_region_body(self, state: dict[str, Any]) -> ttk.Frame:
        body = state["body"]
        for child in body.winfo_children():
            child.destroy()
        return body

    def _reset_all_region_body_columns(self, body: ttk.Frame) -> None:
        for column in range(6):
            body.columnconfigure(column, weight=0, uniform="")

    def _all_region_search_query(self, state: dict[str, Any]) -> str:
        search_var = state.get("search_var")
        if isinstance(search_var, StringVar):
            return search_var.get().strip().lower()
        return ""

    def _render_all_region_current(self, state: dict[str, Any]) -> None:
        if self._all_region_search_query(state):
            self._render_all_region_search_results(state)
        elif state.get("province"):
            self._render_all_region_region_page(state, str(state["province"]))
        else:
            self._render_all_region_provinces(state)

    def _search_all_region_records(self, state: dict[str, Any]) -> list[dict[str, Any]]:
        query = self._all_region_search_query(state)
        if not query:
            return []
        return [
            record
            for record in self._filtered_all_region_records(state)
            if query in self._region_lookup_text(record).lower()
        ]

    def _render_all_region_records_grid(self, body: ttk.Frame, records: list[dict[str, Any]], state: dict[str, Any]) -> None:
        records.sort(key=self._region_sort_key)
        for column in range(4):
            body.columnconfigure(column, weight=1, uniform="ctyun_all_region_region")
        for index, record in enumerate(records):
            name = str(record.get("displayName") or "")
            selected = bool(self.region_vars.get(name) and self.region_vars[name].get())
            text = f"{name} ✓" if selected else name
            button = ttk.Button(body, text=text, command=lambda item=record: self._select_all_region(item, state))
            button.grid(row=index // 4, column=index % 4, sticky="ew", padx=3, pady=3)

    def _render_all_region_search_results(self, state: dict[str, Any]) -> None:
        state["title"].configure(text="搜索地区")
        state["back"].configure(state="normal", command=lambda: state["search_var"].set(""))
        body = self._clear_all_region_body(state)
        self._reset_all_region_body_columns(body)
        records = self._search_all_region_records(state)
        self._render_all_region_records_grid(body, records, state)
        if not records:
            ttk.Label(body, text="没有匹配的地区。").grid(row=0, column=0, sticky="w", pady=12)

    def _render_all_region_provinces(self, state: dict[str, Any]) -> None:
        state["province"] = ""
        state["title"].configure(text="全部地区")
        state["back"].configure(state="disabled", command=lambda: None)
        body = self._clear_all_region_body(state)
        self._reset_all_region_body_columns(body)
        body.columnconfigure(0, weight=0)
        body.columnconfigure(1, weight=1)
        records = self._filtered_all_region_records(state)
        provinces = sorted({self._region_record_province(record) for record in records}, key=self._province_sort_key)
        grouped: dict[str, list[str]] = {label: [] for label, _ in PROVINCE_GROUPS}
        extra: list[str] = []
        for province in provinces:
            placed = False
            for label, group_provinces in PROVINCE_GROUPS:
                if self._normalize_province_name(province) in group_provinces:
                    grouped[label].append(province)
                    placed = True
                    break
            if not placed:
                extra.append(province)

        row = 0
        for group_label, _group_provinces in PROVINCE_GROUPS:
            if not grouped[group_label]:
                continue
            self._render_all_region_province_row(body, row, group_label, grouped[group_label], state)
            row += 1
        if extra:
            self._render_all_region_province_row(body, row, "其他", extra, state)
        if not provinces:
            message = "正在读取云端全部地区..." if state.get("cloud_loading") else "当前筛选下没有可选地区。"
            ttk.Label(body, text=message).grid(row=0, column=0, sticky="w", pady=12)

    def _render_all_region_province_row(self, body: ttk.Frame, row: int, group_label: str, provinces: list[str], state: dict[str, Any]) -> None:
        ttk.Label(body, text=group_label, width=8).grid(row=row, column=0, sticky="nw", pady=(4, 18))
        grid = ttk.Frame(body)
        grid.grid(row=row, column=1, sticky="ew", pady=(0, 12))
        for column in range(4):
            grid.columnconfigure(column, weight=1, uniform="ctyun_all_region_province")
        for index, province in enumerate(provinces):
            button = ttk.Button(
                grid,
                text=self._short_province_name(province),
                width=14,
                command=lambda selected=province: self._render_all_region_region_page(state, selected),
            )
            button.grid(row=index // 4, column=index % 4, sticky="ew", padx=2, pady=2)

    def _render_all_region_region_page(self, state: dict[str, Any], province: str) -> None:
        state["province"] = province
        short_name = self._short_province_name(province)
        state["title"].configure(text=short_name)
        state["back"].configure(state="normal", command=lambda: self._render_all_region_provinces(state))
        body = self._clear_all_region_body(state)
        self._reset_all_region_body_columns(body)
        records = [
            record
            for record in self._filtered_all_region_records(state)
            if self._normalize_province_name(self._region_record_province(record)) == self._normalize_province_name(province)
        ]
        self._render_all_region_records_grid(body, records, state)
        if not records:
            ttk.Label(body, text="当前筛选下该省份没有可选地区。").grid(row=0, column=0, sticky="w", pady=12)

    def _select_all_region(self, record: dict[str, Any], state: dict[str, Any]) -> None:
        record = self._normalize_all_region_record(record) or {}
        name = str(record.get("displayName") or "")
        if not name:
            return
        already_selected = bool(self.region_vars.get(name) and self.region_vars[name].get())
        if not already_selected and len(self._selected_regions()) >= MAX_SELECTED_REGIONS:
            self._show_warning(f"最多可选择 {MAX_SELECTED_REGIONS} 个地区。")
            return
        self._upsert_region_record(record)
        self._ensure_region_checkbutton(name, record, checked=True)
        state["status"].set(f"已添加地区：{self._format_region_record_label(record)}")
        self._render_all_region_current(state)

    def _reset_all_region_dynamic_selection(self) -> None:
        dynamic_names = [name for name, group in self.region_option_group.items() if group == "all"]
        if not dynamic_names:
            return
        for name in dynamic_names:
            var = self.region_vars.get(name)
            if var is not None:
                try:
                    var.set(False)
                except tk.TclError:
                    pass
            while name in self.selected_region_order:
                self.selected_region_order.remove(name)
            checkbutton = self.region_checkbuttons.pop(name, None)
            if checkbutton is not None:
                try:
                    checkbutton.destroy()
                except tk.TclError:
                    pass
            self.region_vars.pop(name, None)
            self.region_option_group.pop(name, None)
            self.dynamic_region_labels.pop(name, None)
        if "all" in self.region_next_rows:
            self.region_next_rows["all"] = 1

    def _ensure_region_checkbutton(self, name: str, record: dict[str, Any], *, checked: bool) -> None:
        self.dynamic_region_labels[name] = self._format_region_record_label(record)
        if name in self.region_vars:
            try:
                self.region_checkbuttons[name].configure(text=self._region_option_label(name))
            except tk.TclError:
                pass
            if checked:
                self.region_vars[name].set(True)
            return
        group = builtin_region_group(name) or "all"
        parent_col = self.region_columns.get(group)
        if parent_col is None:
            return
        var = BooleanVar(value=False)
        var.trace_add("write", lambda *_args, region=name: self._on_region_toggle(region))
        self.region_vars[name] = var
        checkbutton = ttk.Checkbutton(parent_col, text=self._region_option_label(name), variable=var)
        row = self.region_next_rows.get(group, 1)
        checkbutton.grid(row=row, column=0, sticky="w", pady=2)
        self.region_next_rows[group] = row + 1
        self.region_checkbuttons[name] = checkbutton
        self.region_option_group[name] = group
        if checked:
            var.set(True)

    def _upsert_region_record(self, record: dict[str, Any]) -> None:
        path = ensure_runtime_regions_file()
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            data = {"regions": []}
        regions = data.get("regions")
        if not isinstance(regions, list):
            regions = []
            data["regions"] = regions
        name = str(record.get("displayName") or "")
        region_id = str(record.get("regionID") or "")
        existing = None
        for item in regions:
            if not isinstance(item, dict):
                continue
            if name and str(item.get("displayName") or "") == name:
                existing = item
                break
            if region_id and str(item.get("regionID") or "") == region_id:
                existing = item
                break
        clean = {
            "province": record.get("province") or self._region_record_province(record),
            "city": record.get("city") or self._region_record_province(record),
            "displayName": name,
            "officialName": record.get("officialName") or record.get("name") or name,
            "regionID": region_id,
            "azName": record.get("azName") or "default",
            "vpcID": record.get("vpcID") or "",
            "subnetID": record.get("subnetID") or "",
            "securityGroupID": record.get("securityGroupID") or "",
            "flavorID": record.get("flavorID") or "",
            "imageID": record.get("imageID") or "",
            "maxEipBandwidth": record.get("maxEipBandwidth"),
            "_regionKind": record.get("_regionKind") or self._region_record_kind(record),
        }
        group = builtin_region_group(name)
        if existing:
            existing.update({key: value for key, value in clean.items() if value not in (None, "")})
            if group:
                existing.pop("_uiGroup", None)
            else:
                existing["_uiGroup"] = "all"
        else:
            max_index = max((int(item.get("index") or 0) for item in regions if isinstance(item, dict)), default=0)
            clean["index"] = max_index + 1
            if not group:
                clean["_uiGroup"] = "all"
            regions.append(clean)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    def _update_region_resource_labels(self, items: Sequence[dict[str, Any]] | None = None) -> None:
        stats_by_region: dict[str, dict[str, int]] = {}
        for item in items or []:
            if not isinstance(item, dict):
                continue
            region = self._cleanup_region_name(item)
            if not region:
                continue
            region_stats = stats_by_region.setdefault(region, {"ECS": 0, "EIP": 0})
            item_type = str(item.get("type") or "")
            if item_type == "ECS":
                if self._is_active_cleanup_ecs_item(item):
                    region_stats["ECS"] += 1
            elif item_type in {"EIP", "HAVIP_EIP"}:
                region_stats["EIP"] += 1
        for name, checkbutton in self.region_checkbuttons.items():
            try:
                checkbutton.configure(text=self._region_option_label(name, stats_by_region.get(name)))
            except tk.TclError:
                pass

    def _build_task_options(self, parent: ttk.Frame) -> None:
        panel = ttk.LabelFrame(parent, text="任务选项", padding=PANEL_PADDING)
        panel.grid(row=0, column=0, sticky="ew", pady=(0, SECTION_GAP))
        panel.columnconfigure(0, weight=1)
        panel.columnconfigure(1, weight=0)
        panel.columnconfigure(2, weight=0)
        panel.columnconfigure(3, weight=0)
        panel.columnconfigure(4, weight=0)
        panel.rowconfigure(0, weight=1)

        region_section = ttk.Frame(panel)
        col300 = ttk.Frame(region_section)
        col60 = ttk.Frame(region_section)
        col_all = ttk.Frame(region_section)
        purchase_separator = ttk.Separator(panel, orient="vertical")
        purchase_section = ttk.Frame(panel)
        build_separator = ttk.Separator(panel, orient="vertical")
        build_section = ttk.Frame(panel)

        region_section.grid(row=0, column=0, sticky="nw", padx=(0, 14))
        purchase_separator.grid(row=0, column=1, sticky="ns", padx=(0, 14))
        purchase_section.grid(row=0, column=2, sticky="nw", padx=(0, 14))
        build_separator.grid(row=0, column=3, sticky="ns", padx=(0, 14))
        build_section.grid(row=0, column=4, sticky="nw", padx=(0, 24))
        region_section.columnconfigure(0, weight=1, uniform="ctyun_region_options")
        region_section.columnconfigure(1, weight=1, uniform="ctyun_region_options")
        region_section.columnconfigure(2, weight=1, uniform="ctyun_region_options")
        col300.columnconfigure(0, weight=1)
        col60.columnconfigure(0, weight=1)
        col_all.columnconfigure(0, weight=1)
        purchase_section.columnconfigure(0, weight=1)

        ttk.Label(region_section, text="地区选项").grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 4))
        col300.grid(row=1, column=0, sticky="nw", padx=(0, 18))
        col60.grid(row=1, column=1, sticky="nw", padx=(0, 18))
        col_all.grid(row=1, column=2, sticky="nw")
        ttk.Label(col300, text="300M地区").grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Label(col60, text="60M地区").grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Button(col_all, text="查看全部地区", command=self._show_all_regions_dialog).grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Label(purchase_section, text="购买选项").grid(row=0, column=0, sticky="w", pady=(0, 4))
        ttk.Label(build_section, text="搭建选项").grid(row=0, column=0, sticky="w", pady=(0, 4))

        self.region_columns = {"300": col300, "60": col60, "all": col_all}
        self.region_next_rows = {"300": 1, "60": 1, "all": 1}
        for record in load_region_records():
            name = str(record.get("displayName") or "")
            if not name:
                continue
            builtin_group = builtin_region_group(name)
            if not builtin_group:
                continue
            var = BooleanVar(value=False)
            var.trace_add("write", lambda *_args, region=name: self._on_region_toggle(region))
            self.region_vars[name] = var
            group = builtin_group
            parent_col = self.region_columns[group]
            checkbutton = ttk.Checkbutton(parent_col, text=self._region_option_label(name), variable=var)
            checkbutton.grid(row=self.region_next_rows[group], column=0, sticky="w", pady=2)
            self.region_next_rows[group] += 1
            self.region_checkbuttons[name] = checkbutton
            self.region_option_group[name] = group

        purchase_option_row = 1
        if not self._standalone_limited_features():
            ttk.Checkbutton(
                purchase_section,
                text="按需",
                variable=self.on_demand_var,
                command=self._sync_on_demand_billing,
            ).grid(row=purchase_option_row, column=0, sticky="w", pady=(0, 6))
            purchase_option_row += 1
        ttk.Checkbutton(
            purchase_section,
            text="1ECS拖2EIP(未选则1ECS拖10EIP)",
            variable=self.one_ecs_two_eip_var,
        ).grid(row=purchase_option_row, column=0, sticky="w", pady=(0, 6))
        purchase_option_row += 1
        ttk.Checkbutton(
            purchase_section,
            text="包月独享带宽1M*10",
            variable=self.monthly_dedicated_eip_var,
        ).grid(row=purchase_option_row, column=0, sticky="w", pady=(0, 6))
        purchase_option_row += 1
        ttk.Checkbutton(
            purchase_section,
            text="VPC:192.168.100.101~110",
            variable=self.private_192_168_100_var,
        ).grid(row=purchase_option_row, column=0, sticky="w")

        ttk.Radiobutton(build_section, text="随机连接账密", variable=self.build_mode_var, value=CTYUN_BUILD_MODE_RANDOM).grid(row=1, column=0, sticky="w", pady=(0, 6))
        default_radio = ttk.Radiobutton(
            build_section,
            text="默认连接账密(右键自定义)",
            variable=self.build_mode_var,
            value=CTYUN_BUILD_MODE_DEFAULT,
        )
        default_radio.grid(row=2, column=0, sticky="w")
        default_radio.bind("<Button-3>", self._show_default_connection_dialog)

    def _on_demand_billing_enabled(self) -> bool:
        return not self._standalone_limited_features() and bool(self.on_demand_var.get())

    def _sync_on_demand_billing(self) -> None:
        enabled = self._on_demand_billing_enabled()
        if not enabled and self._standalone_limited_features():
            self.on_demand_var.set(False)
        set_on_demand_debug_enabled("ctyun", enabled)

    def _allow_sensitive_config_dialog(self) -> bool:
        ak = self.ak_var.get().strip()
        sk = self.sk_var.get().strip()
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            if self._credentials_ready_for_customer_guard(ak, sk):
                return True
            self._show_error(self._connection_config_block_message())
            return False
        fingerprint = self._customer_guard_fingerprint(ak, sk) if ak and sk else ""
        if fingerprint and self.customer_guard_status == "ok" and self.customer_guard_fingerprint == fingerprint:
            return True
        message = self._customer_guard_block_message(ak, sk)
        self._show_error(self._connection_config_block_message(message))
        return False

    @staticmethod
    def _connection_config_block_message(message: str = "") -> str:
        return "请先填写并通过K/SK合作伙伴校验后再打开配置。"

    def _show_ctyun_proxy_dialog(self) -> None:
        win = Toplevel(self._dialog_parent())
        win.title(self._dialog_title(default_suffix="代理设置"))
        win.resizable(False, False)
        win.transient(self._dialog_parent())
        win.grab_set()
        apply_dialog_icon(win)

        body = ttk.Frame(win, padding=12)
        body.grid(row=0, column=0, sticky="nsew")
        body.columnconfigure(1, weight=1)
        proxy_var = StringVar(value=self.ctyun_proxy_var.get().strip())

        ttk.Label(body, text="代理地址").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=4)
        ttk.Entry(body, textvariable=proxy_var, width=38).grid(row=0, column=1, sticky="ew", pady=4)
        ttk.Label(body, text="示例：http://127.0.0.1:7890，留空则不使用").grid(row=1, column=0, columnspan=2, sticky="w", pady=(2, 8))

        button_bar = ttk.Frame(body)
        button_bar.grid(row=2, column=0, columnspan=2, sticky="e", pady=(4, 0))

        def save() -> None:
            proxy = normalize_proxy_url(proxy_var.get())
            if proxy and not proxy.lower().startswith(("http://", "https://")):
                self._show_info("代理地址仅支持 HTTP/HTTPS，例如 http://127.0.0.1:7890。")
                return
            self.ctyun_proxy_var.set(proxy)
            save_network_config({"proxy": proxy})
            win.destroy()

        ttk.Button(button_bar, text="应用", width=10, command=save).pack(side=LEFT, padx=(0, 8))
        ttk.Button(button_bar, text="取消", width=10, command=win.destroy).pack(side=LEFT)
        win.update_idletasks()
        parent = self._dialog_parent()
        x = parent.winfo_rootx() + max(0, (parent.winfo_width() - win.winfo_reqwidth()) // 2)
        y = parent.winfo_rooty() + max(0, (parent.winfo_height() - win.winfo_reqheight()) // 2)
        win.geometry(f"+{x}+{y}")

    def _show_default_connection_dialog(self, _event: tk.Event | None = None) -> str:
        if not self._allow_sensitive_config_dialog():
            return "break"
        win = Toplevel(self._dialog_parent())
        win.title(f"{APP_TITLE}-自定义连接配置")
        win.resizable(False, False)
        win.transient(self._dialog_parent())
        win.grab_set()
        apply_dialog_icon(win)

        body = ttk.Frame(win, padding=12)
        body.grid(row=0, column=0, sticky="nsew")
        body.columnconfigure(1, weight=1)

        username_var = StringVar(value=self.default_conn_user_var.get().strip() or CTYUN_CUSTOM_CONNECTION_DEFAULT_USER)
        password_var = StringVar(value=self.default_conn_password_var.get().strip() or "wawacloud")
        port_var = StringVar(value=self.default_conn_socks_port_var.get().strip() or "23456")
        once_var = BooleanVar(value=bool(self.default_conn_once_var.get()))
        rows = (
            ("用户名", username_var),
            ("密码", password_var),
            ("Socks5端口", port_var),
        )
        for row, (label, variable) in enumerate(rows):
            ttk.Label(body, text=label).grid(row=row, column=0, sticky="w", padx=(0, 8), pady=4)
            ttk.Entry(body, textvariable=variable, width=28).grid(row=row, column=1, sticky="ew", pady=4)

        actions = ttk.Frame(body)
        actions.grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(10, 0))
        actions.columnconfigure(0, weight=1)

        def save() -> None:
            username = username_var.get().strip() or CTYUN_CUSTOM_CONNECTION_DEFAULT_USER
            password = password_var.get().strip() or "wawacloud"
            port_text = port_var.get().strip() or "23456"
            if not port_text.isdigit() or not (1 <= int(port_text) <= 65535):
                self._show_info("Socks5端口请输入 1-65535 之间的数字。")
                return
            self.default_conn_user_var.set(username)
            self.default_conn_password_var.set(password)
            self.default_conn_socks_port_var.set(port_text)
            self.default_conn_custom_saved = True
            self.default_conn_once_var.set(bool(once_var.get()))
            win.destroy()

        ttk.Checkbutton(actions, text="生效1次", variable=once_var).grid(row=0, column=0, sticky="w")
        button_bar = ttk.Frame(actions)
        button_bar.grid(row=0, column=1, sticky="e")
        ttk.Button(button_bar, text="应用", width=10, command=save).pack(side=LEFT, padx=(0, 8))
        ttk.Button(button_bar, text="取消", width=10, command=win.destroy).pack(side=LEFT)
        win.update_idletasks()
        parent = self._dialog_parent()
        x = parent.winfo_rootx() + max(0, (parent.winfo_width() - win.winfo_reqwidth()) // 2)
        y = parent.winfo_rooty() + max(0, (parent.winfo_height() - win.winfo_reqheight()) // 2)
        win.geometry(f"+{x}+{y}")
        return "break"

    def _used_ip_table_account_label(self) -> str:
        mark = self._clean_account_mark(self.mark_var.get())
        account_scope = re.sub(r"^账号-", "", self._account_log_scope())
        if mark and account_scope:
            return f"{mark}-{account_scope}"
        return mark or account_scope or "ctyun"

    def _used_ip_table_path(self) -> Path:
        self._used_ip_table_migration_error = ""
        linked = self._linked_used_ip_table_path()
        if linked:
            if linked.suffix.lower() == ".csv":
                try:
                    return self._migrate_used_ip_csv_to_xlsx(linked, update_link=True)
                except Exception as exc:
                    self._used_ip_table_migration_error = str(exc)
            return linked
        default_path = APP_DATA_DIR / "used_ip_tables" / f"{safe_log_name(self._used_ip_table_account_label())}.xlsx"
        if default_path.exists():
            return default_path
        legacy_csv_path = default_path.with_suffix(".csv")
        if legacy_csv_path.exists():
            try:
                return self._migrate_used_ip_csv_to_xlsx(legacy_csv_path)
            except Exception as exc:
                self._used_ip_table_migration_error = str(exc)
                return legacy_csv_path
        return default_path

    def _migrate_used_ip_csv_to_xlsx(self, csv_path: Path, *, update_link: bool = False) -> Path:
        source_path = Path(csv_path).expanduser()
        if source_path.suffix.lower() != ".csv":
            return source_path
        target_path = source_path.with_suffix(".xlsx")
        source_rows = self._read_used_ip_csv_rows(source_path)
        target_rows = self._read_used_ip_csv_rows(target_path)
        failed_rows = self._read_failed_ip_rows(target_path)
        self._write_used_ip_csv_rows(
            target_path,
            [*target_rows, *source_rows],
            failed_rows=failed_rows,
        )
        if update_link:
            self._set_linked_used_ip_table_path(target_path)
        self._used_ip_table_migration_error = ""
        return target_path

    def _used_ip_table_link_config_path(self) -> Path:
        return APP_DATA_DIR / "used_ip_tables" / f"{safe_log_name(self._used_ip_table_account_label())}.link"

    def _linked_used_ip_table_path(self) -> Path | None:
        config_path = self._used_ip_table_link_config_path()
        try:
            text = config_path.read_text(encoding="utf-8-sig").strip()
        except Exception:
            return None
        if not text:
            return None
        path = Path(text)
        return path if path.suffix.lower() in {".csv", ".xlsx"} else None

    def _set_linked_used_ip_table_path(self, path: Path) -> None:
        target = path.resolve()
        if target.suffix.lower() not in {".csv", ".xlsx"}:
            raise ValueError("旧IP库必须为 .csv 或 .xlsx 文件。")
        config_path = self._used_ip_table_link_config_path()
        config_path.parent.mkdir(parents=True, exist_ok=True)
        config_path.write_text(str(target), encoding="utf-8")

    @staticmethod
    def _normalize_used_ip_value(value: Any) -> str:
        text = str(value or "").strip()
        parts = text.split(".")
        if len(parts) != 4:
            return ""
        try:
            numbers = [int(part) for part in parts]
        except ValueError:
            return ""
        if any(number < 0 or number > 255 for number in numbers):
            return ""
        return ".".join(str(number) for number in numbers)

    @staticmethod
    def _used_ip_csv_row(timestamp: Any, account_value: Any, region_label: Any, ip: Any) -> dict[str, str]:
        return {
            USED_IP_CSV_HEADERS[0]: str(timestamp or "").strip(),
            USED_IP_CSV_HEADERS[1]: str(account_value or "").strip(),
            USED_IP_CSV_HEADERS[2]: str(region_label or "").strip(),
            USED_IP_CSV_HEADERS[3]: str(ip or "").strip(),
        }

    def _read_used_ip_csv_rows(self, path: Path) -> list[dict[str, str]]:
        rows: list[dict[str, str]] = []
        if not path.exists():
            return rows
        if path.suffix.lower() == ".xlsx":
            for row in read_excel_table_rows(path, USED_IP_CSV_HEADERS):
                value = self._normalize_used_ip_value(row.get("ip") or row.get("IP") or row.get(USED_IP_CSV_HEADERS[3]))
                if value:
                    rows.append(
                        self._used_ip_csv_row(
                            row.get(USED_IP_CSV_HEADERS[0]) or row.get("time"),
                            row.get(USED_IP_CSV_HEADERS[1]) or row.get("account"),
                            row.get(USED_IP_CSV_HEADERS[2]) or row.get("region"),
                            value,
                        )
                    )
            return rows
        try:
            with path.open("r", encoding="utf-8-sig", newline="") as handle:
                reader = csv.DictReader(handle)
                if reader.fieldnames:
                    for row in reader:
                        value = self._normalize_used_ip_value(row.get("ip") or row.get("IP") or row.get(USED_IP_CSV_HEADERS[3]))
                        if value:
                            rows.append(
                                self._used_ip_csv_row(
                                    row.get(USED_IP_CSV_HEADERS[0]) or row.get("time"),
                                    row.get(USED_IP_CSV_HEADERS[1]) or row.get("account"),
                                    row.get(USED_IP_CSV_HEADERS[2]) or row.get("region"),
                                    value,
                                )
                            )
                handle.seek(0)
                for raw_row in csv.reader(handle):
                    for cell in raw_row:
                        value = self._normalize_used_ip_value(cell)
                        if value:
                            rows.append(self._used_ip_csv_row("", "", "", value))
        except Exception:
            return rows
        deduped: list[dict[str, str]] = []
        seen: set[str] = set()
        for row in rows:
            ip = self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]) or row.get("ip"))
            if not ip or ip in seen:
                continue
            seen.add(ip)
            deduped.append(row)
        return deduped

    def _read_failed_ip_rows(self, path: Path) -> list[dict[str, str]]:
        if not path.exists() or path.suffix.lower() != ".xlsx":
            return []
        rows: list[dict[str, str]] = []
        for row in read_excel_table_rows(path, USED_IP_CSV_HEADERS, worksheet_name=FAILED_IP_WORKSHEET_NAME):
            value = self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]) or row.get("IP") or row.get("ip"))
            if not value:
                continue
            rows.append(
                self._used_ip_csv_row(
                    row.get(USED_IP_CSV_HEADERS[0]) or row.get("time"),
                    row.get(USED_IP_CSV_HEADERS[1]) or row.get("account"),
                    row.get(USED_IP_CSV_HEADERS[2]) or row.get("region"),
                    value,
                )
            )
        return self._dedupe_used_ip_csv_rows(rows)

    def _dedupe_used_ip_csv_rows(self, rows: Sequence[dict[str, str]]) -> list[dict[str, str]]:
        normalized: list[dict[str, str]] = []
        rows_by_ip: dict[str, dict[str, str]] = {}
        for row in rows:
            ip = self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]) or row.get("ip"))
            if not ip:
                continue
            normalized_row = self._used_ip_csv_row(
                row.get(USED_IP_CSV_HEADERS[0]),
                row.get(USED_IP_CSV_HEADERS[1]),
                row.get(USED_IP_CSV_HEADERS[2]),
                ip,
            )
            existing_row = rows_by_ip.get(ip)
            if existing_row is None:
                rows_by_ip[ip] = normalized_row
                normalized.append(normalized_row)
                continue
            for header in USED_IP_CSV_HEADERS[:3]:
                if not existing_row.get(header) and normalized_row.get(header):
                    existing_row[header] = normalized_row[header]
        return normalized

    def _write_used_ip_csv_rows(
        self,
        path: Path,
        rows: Sequence[dict[str, str]],
        *,
        failed_rows: Optional[Sequence[dict[str, str]]] = None,
    ) -> None:
        normalized = self._dedupe_used_ip_csv_rows(rows)
        path.parent.mkdir(parents=True, exist_ok=True)
        if path.suffix.lower() == ".xlsx":
            normalized_failed = self._dedupe_used_ip_csv_rows(
                self._read_failed_ip_rows(path) if failed_rows is None else failed_rows
            )
            written = write_excel_view_file(
                path,
                USED_IP_CSV_HEADERS,
                normalized,
                column_widths=USED_IP_TABLE_COLUMN_WIDTHS,
                target_path=path,
                side_note=USED_IP_TABLE_NOTE,
                extra_sheets=[
                    {
                        "name": FAILED_IP_WORKSHEET_NAME,
                        "headers": USED_IP_CSV_HEADERS,
                        "rows": normalized_failed,
                        "column_widths": USED_IP_TABLE_COLUMN_WIDTHS,
                        "side_note": FAILED_IP_TABLE_NOTE,
                    }
                ],
            )
            if written is None:
                raise OSError("旧IP库 Excel 文件写入失败。")
            return
        with path.open("w", encoding="utf-8-sig", newline="") as handle:
            writer = csv.DictWriter(handle, fieldnames=USED_IP_CSV_HEADERS)
            writer.writeheader()
            writer.writerows(normalized)
        try:
            path.with_suffix(".xlsx").unlink(missing_ok=True)
        except OSError:
            pass
    def _append_used_ip_table(self, ips: Sequence[str], *, region_label: str) -> tuple[Path, int]:
        path = self._used_ip_table_path()
        rows = self._read_used_ip_csv_rows(path)
        existing = {row[USED_IP_CSV_HEADERS[3]] for row in rows}
        new_ips: list[str] = []
        for ip in ips:
            value = self._normalize_used_ip_value(ip)
            if value and value not in existing:
                existing.add(value)
                new_ips.append(value)
        if new_ips:
            timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
            account_value = self._used_ip_table_account_label()
            rows.extend(self._used_ip_csv_row(timestamp, account_value, region_label, ip) for ip in new_ips)
        self._write_used_ip_csv_rows(path, rows)
        return path, len(new_ips)

    @staticmethod
    def _used_ip_import_failure_message(path: Path, exc: Exception) -> str:
        reason = str(exc).strip() or type(exc).__name__
        winerror = getattr(exc, "winerror", None)
        error_number = getattr(exc, "errno", None)
        if isinstance(exc, PermissionError) or winerror in {5, 32, 33} or error_number == 13:
            solution = (
                "请先关闭正在打开该文件的 Excel/WPS（包括预览窗口），确认文件及所在目录可写，"
                "然后重新选择资源再试。"
            )
        elif isinstance(exc, FileNotFoundError) or winerror in {2, 3, 53, 67}:
            solution = (
                "请恢复网络盘或移动磁盘连接；若原链接已失效，请在“更换新IP”右键配置中，"
                "重新链接到本机可写的 CSV/XLSX 文件。"
            )
        elif winerror in {39, 112} or error_number == 28:
            solution = "请清理目标磁盘空间，确认文件所在磁盘可正常写入后重新尝试。"
        elif path.suffix.lower() == ".xlsx":
            solution = (
                "请先关闭正在打开该文件的 Excel/WPS，并确认 XLSX 文件未损坏；"
                "仍失败时请重新链接到本机可写的 CSV 或 XLSX 文件。"
            )
        else:
            solution = (
                "请关闭正在打开的旧IP库表格，确认链接路径存在、文件及目录可写且磁盘空间充足；"
                "仍失败时请重新链接到本机可写的 CSV/XLSX 文件。"
            )
        return (
            "旧IP库合并导入失败，已取消本次资源释放。\n\n"
            f"失败文件：{path}\n"
            f"失败原因：{reason}\n"
            f"解决办法：{solution}"
        )

    def _current_account_public_ip_groups_for_used_table(self) -> dict[str, list[str]]:
        groups: dict[str, list[str]] = {}
        for item in self._account_resources_for_purchase():
            if bool(item.get("_displayOnly")):
                continue
            if str(item.get("type") or "").upper() not in {"ECS", "EIP", "HAVIP_EIP"}:
                continue
            region_label = self._cleanup_region_name(item) or "当前账号"
            raw_values = [
                item.get("address"),
                item.get("eipAddress"),
                item.get("publicIP"),
                item.get("publicIp"),
                item.get("ip"),
                item.get("name"),
            ]
            for raw_value in raw_values:
                for candidate in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", str(raw_value or "")):
                    ip = self._normalize_used_ip_value(candidate)
                    if not ip or not is_public_ip(ip):
                        continue
                    bucket = groups.setdefault(region_label, [])
                    if ip not in bucket:
                        bucket.append(ip)
        return groups

    def _sync_current_account_ips_to_used_ip_table(self) -> int:
        added_total = 0
        for region_label, ips in self._current_account_public_ip_groups_for_used_table().items():
            _path, added_count = self._append_used_ip_table(ips, region_label=region_label)
            added_total += added_count
        return added_total

    def _ask_used_ip_csv_path(self, parent: tk.Misc, *, mode: str, title: str, initialfile: str = "") -> str:
        command = "tk_getSaveFile" if mode == "save" else "tk_getOpenFile"
        options: list[Any] = [
            command,
            "-parent",
            str(parent),
            "-title",
            title,
            "-filetypes",
            (("Excel文件", "*.xlsx"), ("CSV文件", "*.csv"), ("所有文件", "*.*")),
        ]
        if mode == "save":
            options.extend(["-defaultextension", ".xlsx"])
            if initialfile:
                options.extend(["-initialfile", initialfile])
        try:
            return str(self.root.tk.call(*options) or "").strip()
        except Exception as exc:
            self._show_error(f"打开文件选择窗口失败：{exc}")
            return ""

    def _show_used_ip_table_config_dialog(self, _event: tk.Event | None = None) -> str:
        if not self._allow_sensitive_config_dialog():
            return "break"
        win = Toplevel(self._dialog_parent())
        win.title(self._dialog_title(default_suffix="更换新IP配置"))
        win.resizable(False, False)
        win.transient(self._dialog_parent())
        win.grab_set()
        apply_dialog_icon(win)

        body = ttk.Frame(win, padding=12)
        body.grid(row=0, column=0, sticky="nsew")
        body.columnconfigure(1, weight=1)
        path_var = StringVar(value=str(self._used_ip_table_path()))
        status_var = StringVar(value="")

        ttk.Label(body, text="当前旧IP库").grid(row=0, column=0, sticky="w", padx=(0, 8), pady=(0, 8))
        ttk.Entry(body, textvariable=path_var, width=68, state="readonly").grid(row=0, column=1, sticky="ew", pady=(0, 8))
        ttk.Label(
            body,
            text="旧IP库用于记录已释放公网IP，换新IP时会避开表内IP；请保持表格内容准确。",
            wraplength=620,
            justify="left",
        ).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 10))
        actions = ttk.Frame(body)
        actions.grid(row=2, column=0, columnspan=2, pady=(2, 0))

        def with_released_grab(callback: Callable[[], str]) -> str:
            try:
                win.grab_release()
            except tk.TclError:
                pass
            try:
                return callback()
            finally:
                try:
                    if win.winfo_exists():
                        win.deiconify()
                        win.lift(self._dialog_parent())
                        win.grab_set()
                except tk.TclError:
                    pass

        def save_current_template() -> None:
            target = with_released_grab(
                lambda: self._ask_used_ip_csv_path(
                    win,
                    mode="save",
                    title="保存当前旧IP库(模板)",
                    initialfile=f"{safe_log_name(self._used_ip_table_account_label())}.xlsx",
                )
            )
            if not target:
                return
            path = Path(target)
            if path.suffix.lower() not in {".csv", ".xlsx"}:
                status_var.set("保存失败：旧IP库必须为 .csv 或 .xlsx 文件。")
                return
            try:
                self._sync_current_account_ips_to_used_ip_table()
                source_path = self._used_ip_table_path()
                source_rows = self._read_used_ip_csv_rows(source_path)
                source_failed_rows = self._read_failed_ip_rows(source_path)
                target_rows = self._read_used_ip_csv_rows(path)
                target_failed_rows = self._read_failed_ip_rows(path)
                if path.suffix.lower() == ".csv":
                    companion_xlsx = path.with_suffix(".xlsx")
                    target_rows = self._dedupe_used_ip_csv_rows(
                        [*target_rows, *self._read_used_ip_csv_rows(companion_xlsx)]
                    )
                    target_failed_rows = self._dedupe_used_ip_csv_rows(
                        [*target_failed_rows, *self._read_failed_ip_rows(companion_xlsx)]
                    )
                target_rows = self._dedupe_used_ip_csv_rows(target_rows)
                rows = self._dedupe_used_ip_csv_rows([*target_rows, *source_rows])
                failed_rows = self._dedupe_used_ip_csv_rows([*target_failed_rows, *source_failed_rows])
                original_count = len(target_rows)
                merged_count = max(0, len(rows) - original_count)
                self._write_used_ip_csv_rows(path, rows, failed_rows=failed_rows)
                if path.suffix.lower() == ".csv":
                    path = self._migrate_used_ip_csv_to_xlsx(path, update_link=True)
                    self._write_used_ip_csv_rows(path, rows, failed_rows=failed_rows)
                else:
                    self._set_linked_used_ip_table_path(path)
                if path.suffix.lower() == ".xlsx":
                    for companion in (path.with_suffix(".csv"), path.with_name(f"{path.with_suffix('.csv').name}.lock")):
                        try:
                            if companion != Path(target):
                                companion.unlink(missing_ok=True)
                        except OSError:
                            pass
                else:
                    try:
                        path.with_name(f"{path.name}.lock").unlink(missing_ok=True)
                    except OSError:
                        pass
            except Exception as exc:
                status_var.set(f"保存失败：{exc}")
                return
            path_var.set(str(path))
            status_var.set(
                f"已合并保存并链接：{path}，原有 {original_count} 条，新增 {merged_count} 条，共 {len(rows)} 条。"
            )

        def link_existing_table() -> None:
            target = with_released_grab(lambda: self._ask_used_ip_csv_path(win, mode="open", title="链接旧IP库"))
            if not target:
                return
            path = Path(target)
            if path.suffix.lower() not in {".csv", ".xlsx"}:
                status_var.set("链接失败：旧IP库必须为 .csv 或 .xlsx 文件。")
                return
            try:
                if not path.exists():
                    raise FileNotFoundError(f"未找到旧IP库：{path}")
                active_path = self._migrate_used_ip_csv_to_xlsx(path, update_link=True) if path.suffix.lower() == ".csv" else path
                rows = self._read_used_ip_csv_rows(active_path)
                self._write_used_ip_csv_rows(active_path, rows)
                self._set_linked_used_ip_table_path(active_path)
            except Exception as exc:
                status_var.set(f"链接失败：{exc}")
                return
            path_var.set(str(active_path))
            migrated_text = "，CSV已自动升级为Excel并保留原文件" if path.suffix.lower() == ".csv" else ""
            status_var.set(f"已链接旧IP库，读取到 {len(rows)} 条IP{migrated_text}。")

        ttk.Button(actions, text="保存当前旧IP库(模板)", width=28, command=save_current_template).pack(side=LEFT, padx=(0, 12))
        ttk.Button(actions, text="链接旧IP库", width=14, command=link_existing_table).pack(side=LEFT)
        ttk.Label(body, textvariable=status_var).grid(row=3, column=0, columnspan=2, sticky="w", pady=(10, 0))

        win.update_idletasks()
        parent = self._dialog_parent()
        x = parent.winfo_rootx() + max(0, (parent.winfo_width() - win.winfo_reqwidth()) // 2)
        y = parent.winfo_rooty() + max(0, (parent.winfo_height() - win.winfo_reqheight()) // 2)
        win.geometry(f"+{x}+{y}")
        return "break"

    def _build_regions(self, parent: ttk.Frame, *, row: int = 0) -> None:
        panel = ttk.Frame(parent, padding=(0, 0, 0, SECTION_GAP))
        panel.grid(row=row, column=0, sticky="ew")
        panel.columnconfigure(0, weight=1)
        panel.rowconfigure(0, weight=0)

        button_row = ttk.Frame(panel)
        button_row.grid(row=0, column=0, sticky="ew", pady=(0, 0))
        button_row.columnconfigure(5, weight=1)
        self.purchase_button = ttk.Button(button_row, text="购买选择地区", width=ACTION_BUTTON_WIDTH, command=self._hot_command("_start_purchase_only"))
        self.purchase_new_ip_button = ttk.Button(
            button_row,
            text="购买选择地区新IP",
            width=LONG_ACTION_BUTTON_WIDTH,
            command=self._hot_command("_start_purchase_new_ips"),
        )
        self.build_button = ttk.Button(button_row, text="搭建选择地区", width=ACTION_BUTTON_WIDTH, command=self._hot_command("_start_build_only"))
        self.reinstall_button = ttk.Button(
            button_row,
            text="重装选定云服务器",
            width=RESOURCE_ACTION_BUTTON_WIDTH,
            command=self._hot_command("_execute_reinstall_resources", "purchase"),
        )
        self.start_button = ttk.Button(button_row, text="一键购买并搭建选择地区", width=PURCHASE_BUILD_BUTTON_WIDTH, command=self._hot_command("_start_purchase_and_build"))
        self.purchase_button.grid(row=0, column=0, sticky="w", padx=(0, 6))
        self.purchase_new_ip_button.grid(row=0, column=1, sticky="w", padx=6)
        self.build_button.grid(row=0, column=2, sticky="w", padx=6)
        self.reinstall_button.grid(row=0, column=3, sticky="w", padx=6)
        self.start_button.grid(row=0, column=6, sticky="e", padx=(6, 0))

    def _build_output_boxes(self, parent: ttk.Frame, *, row: int = 1) -> None:
        output = ttk.Frame(parent)
        output.grid(row=row, column=0, sticky="nsew")
        self.l2tp_text, self.socks_text = build_result_output_panel(
            output,
            copy_l2tp_command=self._delayed_copy_command("复制L2TP结果", self._copy_l2tp_format),
            copy_plain_socks_command=self._delayed_copy_command("复制纯Socks5结果", self._copy_plain_socks5_format),
            copy_socks_command=self._delayed_copy_command("复制Socks5结果", self._copy_socks5_format),
            text_factory=Text,
            text_kwargs={"font": tkfont.nametofont("TkTextFont")},
        )

    def _build_cleanup_controls(self, parent: ttk.Frame) -> None:
        frame = ttk.Frame(parent)
        frame.grid(row=0, column=0, sticky="nsew")
        frame.columnconfigure(0, weight=1)
        frame.rowconfigure(1, weight=1)

        top_actions = ttk.Frame(frame)
        top_actions.grid(row=0, column=0, sticky="ew", pady=(0, SECTION_GAP))
        top_actions.columnconfigure(2, weight=1)
        self.cleanup_query_button = ttk.Button(
            top_actions,
            text="一键读取账号资源",
            width=RESOURCE_ACTION_BUTTON_WIDTH,
            command=self._hot_command("_query_cleanup_resources"),
        )
        self.cleanup_query_button.grid(row=0, column=0, padx=(0, 6), pady=4)
        ttk.Label(top_actions, text="(支持Shift键连选)").grid(row=0, column=1, padx=(8, 0), pady=6)
        if not self._cleanup_cloud_server_limited_features():
            self.cleanup_clear_button = ttk.Button(
                top_actions,
                text="一键清空账号资源",
                width=RESOURCE_ACTION_BUTTON_WIDTH,
                command=self._hot_command("_execute_clear_cleanup_resources"),
            )
            self.cleanup_clear_button.grid(row=0, column=3, sticky="e", padx=(6, 0), pady=4)
            self.reinstall_execute_button = ttk.Button(
                top_actions,
                text="重装选定云服务器",
                width=RESOURCE_ACTION_BUTTON_WIDTH,
                command=self._hot_command("_execute_reinstall_resources"),
            )
            self.reinstall_execute_button.grid(row=0, column=4, sticky="e", padx=(6, 0), pady=4)

        table_frame = tk.Frame(
            frame,
            bg=TABLE_BORDER_COLOR,
            highlightthickness=1,
            highlightbackground=TABLE_BORDER_COLOR,
            highlightcolor=TABLE_BORDER_COLOR,
        )
        table_frame.grid(row=1, column=0, sticky="nsew", pady=(0, SECTION_GAP))
        table_frame.columnconfigure(0, weight=1)
        table_frame.rowconfigure(0, weight=1)
        cleanup_tree_style = self._ensure_cleanup_tree_control_style()
        self.cleanup_tree = ttk.Treeview(
            table_frame,
            columns=("checked", "region_label", "kind", "name", "status", "expire_time", "resource_spec"),
            show="tree headings",
            height=18,
            style=cleanup_tree_style,
        )
        self.cleanup_tree.grid(row=0, column=0, sticky="nsew", padx=1, pady=1)
        self.cleanup_tree.heading("#0", text="选择", anchor="center")
        self.cleanup_tree.column("#0", width=60, minwidth=60, stretch=False, anchor="center")
        self.cleanup_tree.configure(displaycolumns=("region_label", "kind", "name", "status", "expire_time", "resource_spec"))
        cleanup_columns = [
            ("checked", "✓", 25, "center"),
            ("region_label", "地区", 75, "center"),
            ("kind", "类型", 90, "center"),
            ("name", "名称/IP", 190, "center"),
            ("status", "状态", 230, "center"),
            ("expire_time", "到期时间", 170, "center"),
            ("resource_spec", "规格", 125, "center"),
        ]
        for column, heading, width, anchor in cleanup_columns:
            self.cleanup_tree.heading(column, text=heading, anchor=anchor)
            self.cleanup_tree.column(column, width=width, minwidth=(25 if column == "checked" else 50), stretch=column != "checked", anchor=anchor)
        self.cleanup_tree.tag_configure("oddrow", background=TABLE_ALT_BG)
        self.cleanup_tree.tag_configure("evenrow", background=TEXT_BG)
        self.cleanup_tree.tag_configure("regiongroup", background=TABLE_GROUP_BG)
        y_scroll = ttk.Scrollbar(table_frame, command=self.cleanup_tree.yview, style="App.Vertical.TScrollbar")
        y_scroll.grid(row=0, column=1, sticky="ns")
        x_scroll = ttk.Scrollbar(table_frame, orient=tk.HORIZONTAL, command=self.cleanup_tree.xview, style="App.Horizontal.TScrollbar")
        x_scroll.grid(row=1, column=0, sticky="ew")
        self.cleanup_tree.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set)
        self.cleanup_tree.bind("<ButtonRelease-1>", self._on_cleanup_tree_click)
        self.cleanup_tree.bind("<Double-1>", self._on_cleanup_tree_double_click)
        self.cleanup_tree.bind("<<TreeviewOpen>>", self._on_cleanup_tree_open_close)
        self.cleanup_tree.bind("<<TreeviewClose>>", self._on_cleanup_tree_open_close)

        self._clear_cleanup_tree()

        select_buttons = ttk.Frame(frame)
        select_buttons.grid(row=2, column=0, sticky="ew")
        next_col = 0
        ttk.Button(select_buttons, text="全选资源", width=ACTION_BUTTON_WIDTH, command=lambda: self._set_all_cleanup_items(True)).grid(row=0, column=next_col, padx=(0, 6), pady=4)
        next_col += 1
        if not self._cleanup_cloud_server_limited_features():
            ttk.Button(select_buttons, text="全选云服务器", width=ACTION_BUTTON_WIDTH, command=self._select_cleanup_servers).grid(row=0, column=next_col, padx=6, pady=4)
            next_col += 1
        ttk.Button(select_buttons, text="全选IP", width=ACTION_BUTTON_WIDTH, command=self._select_cleanup_ips).grid(row=0, column=next_col, padx=6, pady=4)
        next_col += 1
        ttk.Button(select_buttons, text="选中失败IP", width=ACTION_BUTTON_WIDTH, command=self._select_failed_cleanup_ips).grid(row=0, column=next_col, padx=6, pady=4)
        next_col += 1
        ttk.Button(select_buttons, text="全选网络资源", width=LONG_ACTION_BUTTON_WIDTH, command=self._select_cleanup_network_resources).grid(row=0, column=next_col, padx=6, pady=4)
        next_col += 1
        ttk.Button(select_buttons, text="取消选择", width=ACTION_BUTTON_WIDTH, command=lambda: self._set_all_cleanup_items(False)).grid(row=0, column=next_col, padx=6, pady=4)
        next_col += 1
        select_buttons.columnconfigure(next_col, weight=1)
        next_col += 1
        replace_ip_button = ttk.Button(
            select_buttons,
            text="更换新IP(右键配置IP库)",
            width=24,
            command=self._hot_command("_execute_replace_ips"),
        )
        replace_ip_button.grid(row=0, column=next_col, sticky="e", padx=6, pady=4)
        replace_ip_button.bind("<Button-3>", self._show_used_ip_table_config_dialog)
        next_col += 1
        self.cleanup_execute_button = ttk.Button(select_buttons, text="退订/删除选定资源", width=RESOURCE_ACTION_BUTTON_WIDTH, command=self._hot_command("_execute_cleanup_resources"))
        self.cleanup_execute_button.grid(row=0, column=next_col, sticky="e", padx=(6, 0), pady=4)

    def _build_progress(self, parent: ttk.Frame, title: str, *, include_result_buttons: bool, log_context: str) -> Text:
        progress: Text
        progress, _export_button, stop_button = build_realtime_log_panel(
            parent,
            title=title,
            export_command=lambda ctx=log_context: self._export_log_text(progress, ctx),
            stop_command=self._request_stop,
            text_factory=Text,
            text_kwargs={"font": tkfont.nametofont("TkTextFont")},
            stop_state="disabled",
        )
        self.stop_buttons.append(stop_button)
        return progress

    def _paste_keys(self) -> None:
        try:
            text = self.root.clipboard_get()
        except Exception:
            return
        ak, sk = self._extract_keys_from_text(text)
        mark = extract_mark(text, "ctyun")
        old_fingerprint = self._customer_guard_fingerprint(self.ak_var.get(), self.sk_var.get())
        new_fingerprint = self._customer_guard_fingerprint(ak, sk) if ak and sk else ""
        if ak:
            self.ak_var.set(ak)
        if sk:
            self.sk_var.set(sk)
        if not ak or not sk:
            self._show_warning("未识别到完整 AK/SK，请确认粘贴内容包含 AccessKey ID 和 SecurityKey。")
            return
        if mark:
            self.mark_var.set(mark)
            self._remember_mark_for_credentials(ak, sk, mark)
        else:
            self.mark_var.set("")
        if new_fingerprint and new_fingerprint != old_fingerprint:
            self.prepare_for_new_credentials()
        if CTYUN_CUSTOMER_GUARD_ENABLED:
            self._schedule_customer_guard_check(delay_ms=100)
        else:
            self._reset_customer_guard_state()
            self._set_status("正在读取账号资源")
            self._append_cleanup_realtime_text("天翼云查询AK/SK中，请稍等。\n")
        self.root.after(300, self._query_cleanup_resources)

    def _remember_mark_for_credentials(self, ak: str, sk: str, mark: str) -> None:
        cleaned = self._clean_account_mark(mark)
        if not cleaned:
            return
        try:
            remember_mark_for_credentials(CTYUN_VENDOR_KEY, ak, sk, cleaned)
            self._refresh_credential_history_controls()
        except Exception:
            pass

    @staticmethod
    def _clean_account_mark(value: Any) -> str:
        # 中文注释：天翼云用户名可能包含中文，清理时只去掉空白和外层标点。
        text = "".join(str(value or "").split()).strip(" ,;:/\\|，；：、\"'<>[](){}")
        if not text or text.lower() in {"success", "ok", "true", "false", "none", "null"}:
            return ""
        if re.fullmatch(r"[0-9a-fA-F-]{24,}", text):
            return ""
        return text[:80]

    @classmethod
    def _first_account_mark_from_payload(cls, data: Any) -> str:
        preferred_keys = {
            "loginname",
            "login_name",
            "username",
            "user_name",
            "useraccount",
            "user_account",
            "accountname",
            "account_name",
            "customername",
            "customer_name",
            "custname",
            "cust_name",
            "tenantname",
            "tenant_name",
            "membername",
            "member_name",
        }
        fallback_keys = {"name", "displayname", "display_name", "realname", "real_name"}

        def walk(value: Any, *, allow_fallback: bool) -> str:
            if isinstance(value, dict):
                for key, item in value.items():
                    normalized = str(key).replace("-", "_").replace(" ", "_").lower()
                    if normalized in preferred_keys and not isinstance(item, (dict, list)):
                        mark = cls._clean_account_mark(item)
                        if mark:
                            return mark
                if allow_fallback:
                    for key, item in value.items():
                        normalized = str(key).replace("-", "_").replace(" ", "_").lower()
                        if normalized in fallback_keys and not isinstance(item, (dict, list)):
                            mark = cls._clean_account_mark(item)
                            if mark:
                                return mark
                for item in value.values():
                    mark = walk(item, allow_fallback=allow_fallback)
                    if mark:
                        return mark
            elif isinstance(value, list):
                for item in value:
                    mark = walk(item, allow_fallback=allow_fallback)
                    if mark:
                        return mark
            return ""

        return walk(data, allow_fallback=False) or walk(data, allow_fallback=True)

    @staticmethod
    def _ctyun_request_json(client: Any, endpoint: str, method: str, path: str, *, body: dict[str, Any] | None = None, query: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> dict[str, Any]:
        from urllib import parse, request
        from CTYUN.ctyun_openapi import ctyun_urlopen

        body = body or {}
        query = query or {}
        method_upper = method.upper()
        url = endpoint.rstrip("/") + path
        if query:
            query_text = "&".join(f"{key}={parse.quote(str(value), safe='')}" for key, value in sorted(query.items()))
            url = f"{url}?{query_text}"
        payload = None
        sign_body: dict[str, Any] | None = None
        sign_query = query if method_upper == "GET" else {}
        if method_upper != "GET":
            sign_body = body
            payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
        headers = client.signed_headers(query=sign_query, body=sign_body)
        headers.update(extra_headers or {})
        req = request.Request(url, data=payload, headers=headers, method=method_upper)
        with ctyun_urlopen(req, timeout=8) as resp:
            text = resp.read().decode("utf-8", errors="replace")
        try:
            data = json.loads(text)
        except json.JSONDecodeError:
            data = {"raw": text}
        return data if isinstance(data, dict) else {"returnObj": data}

    @staticmethod
    def _ctyun_v4_signing_key(sk: str, date: str, region: str, service: str) -> bytes:
        # 中文注释：天翼云 OOS/IAM 使用 AWS4-HMAC-SHA256 派生签名密钥。
        key = ("AWS4" + sk).encode("utf-8")
        for value in (date, region, service, "aws4_request"):
            key = hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest()
        return key

    @classmethod
    def _ctyun_oos_iam_request(cls, ak: str, sk: str, action: str, *, service: str = "sts", **params: Any) -> str:
        host = "oos-cn-iam.ctyunapi.cn"
        region = "cn"
        content_type = "application/octet-stream"
        payload_hash = "UNSIGNED-PAYLOAD"
        request_params: dict[str, Any] = {"Action": action, "Version": "2010-05-08"}
        request_params.update({key: value for key, value in params.items() if value not in (None, "")})
        body_text = urllib.parse.urlencode(sorted(request_params.items()))
        payload = body_text.encode("utf-8")

        amz_date = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
        date_stamp = amz_date[:8]
        signed_headers = "content-type;host;x-amz-content-sha256;x-amz-date"
        canonical_headers = (
            f"content-type:{content_type}\n"
            f"host:{host}\n"
            f"x-amz-content-sha256:{payload_hash}\n"
            f"x-amz-date:{amz_date}\n"
        )
        canonical_request = "\n".join(
            [
                "POST",
                "/",
                "",
                canonical_headers,
                signed_headers,
                payload_hash,
            ]
        )
        credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
        string_to_sign = "\n".join(
            [
                "AWS4-HMAC-SHA256",
                amz_date,
                credential_scope,
                hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
            ]
        )
        signature = hmac.new(
            cls._ctyun_v4_signing_key(sk, date_stamp, region, service),
            string_to_sign.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()
        headers = {
            "Content-Type": content_type,
            "Host": host,
            "X-Amz-Content-Sha256": payload_hash,
            "X-Amz-Date": amz_date,
            "Authorization": (
                "AWS4-HMAC-SHA256 "
                f"Credential={ak}/{credential_scope}, "
                f"SignedHeaders={signed_headers}, "
                f"Signature={signature}"
            ),
            "Connection": "close",
        }
        req = urllib.request.Request(f"https://{host}/", data=payload, headers=headers, method="POST")
        from CTYUN.ctyun_openapi import ctyun_urlopen

        with ctyun_urlopen(req, timeout=8) as resp:
            return resp.read().decode("utf-8", errors="replace")

    @staticmethod
    def _xml_local_name(tag: str) -> str:
        return str(tag or "").rsplit("}", 1)[-1]

    @classmethod
    def _ctyun_xml_text(cls, node: ET.Element, tag_name: str) -> str:
        wanted = tag_name.lower()
        for child in node.iter():
            if cls._xml_local_name(child.tag).lower() == wanted and child.text:
                return child.text.strip()
        return ""

    @classmethod
    def _ctyun_mark_from_oos_xml(cls, xml_text: str, ak: str = "") -> str:
        try:
            root = ET.fromstring(xml_text)
        except ET.ParseError:
            return ""
        ak_value = (ak or "").strip()
        for node in root.iter():
            if cls._xml_local_name(node.tag).lower() != "member":
                continue
            member_ak = cls._ctyun_xml_text(node, "AccessKeyId")
            if ak_value and member_ak and member_ak != ak_value:
                continue
            mark = cls._clean_account_mark(cls._ctyun_xml_text(node, "UserName"))
            if mark:
                return mark
        return cls._clean_account_mark(cls._ctyun_xml_text(root, "UserName"))

    def _ctyun_mark_from_oos_iam(self, ak: str, sk: str) -> str:
        # 只粘贴 AK/SK 时，优先通过 AK 定位所属用户名；失败时不打扰用户，继续走旧兜底。
        oos_attempts = (
            ("GetAccessKeyLastUsed", {"AccessKeyId": ak}),
            ("ListAccessKeys", {"AccessKeyId": ak}),
            ("GetUser", {}),
        )
        for action, params in oos_attempts:
            for service in ("iam", "sts", "s3"):
                try:
                    xml_text = self._ctyun_oos_iam_request(ak, sk, action, service=service, **params)
                except Exception:
                    continue
                mark = self._ctyun_mark_from_oos_xml(xml_text, ak)
                if mark:
                    return mark
        return ""

    def resolve_mark_from_credentials(self, ak: str, sk: str) -> str:
        """根据天翼云 AK/SK 尝试读取账号用户名，用于自动填充 User(可编辑)。"""
        ak_value = (ak or "").strip()
        sk_value = (sk or "").strip()
        if not ak_value or not sk_value:
            return ""
        try:
            from CTYUN import customer_guard

            mark, _detail = customer_guard.resolve_login_name_for_credentials(ak_value, sk_value)
            if mark:
                return mark
        except Exception:
            pass
        iam_mark = self._ctyun_mark_from_oos_iam(ak_value, sk_value)
        if iam_mark:
            return iam_mark
        from CTYUN.ctyun_openapi import CtyunClient
        from CTYUN.workflow_config import ENDPOINT_BILLING

        client = CtyunClient(ak_value, sk_value, ENDPOINT_BILLING, dry_run=False, timeout=8)
        billing_headers = {"regionid": "200000001852", "urlType": "CTAPI"}
        candidates = [
            (ENDPOINT_BILLING, "POST", "/v1/bill/queryAccountBalance", {}, {}, billing_headers),
            (ENDPOINT_BILLING, "POST", "/v1/account/queryAccountInfo", {}, {}, billing_headers),
            (ENDPOINT_BILLING, "POST", "/v1/account/getAccountInfo", {}, {}, billing_headers),
            (ENDPOINT_BILLING, "POST", "/v1/user/queryUserInfo", {}, {}, billing_headers),
            ("https://iam-global.ctapi.ctyun.cn", "GET", "/v4/iam/user", {}, {}, {}),
        ]
        for endpoint, method, path, body, query, headers in candidates:
            try:
                data = self._ctyun_request_json(client, endpoint, method, path, body=body, query=query, extra_headers=headers)
            except Exception:
                continue
            mark = self._first_account_mark_from_payload(data)
            if mark:
                return mark
        return ""

    def prepare_for_new_credentials(self) -> None:
        self.current_workflow_keys = []
        self.private_192_168_100_result_active = False
        self.invalid_result_regions.clear()
        self._reset_customer_guard_state()
        self._clear_output_boxes()
        self._clear_cleanup_tree()
        if self.purchase_progress_text is not None:
            self.purchase_progress_text.delete("1.0", END)
        if self.cleanup_progress_text is not None:
            self.cleanup_progress_text.delete("1.0", END)

    def set_credentials_silently(self, ak: str, sk: str, mark: str = "") -> None:
        self.suspend_customer_guard_trace = True
        try:
            if self.ak_var.get() != (ak or ""):
                self.ak_var.set(ak or "")
            if self.sk_var.get() != (sk or ""):
                self.sk_var.set(sk or "")
            if self.mark_var.get() != (mark or ""):
                self.mark_var.set(mark or "")
        finally:
            self.suspend_customer_guard_trace = False

    @staticmethod
    def _customer_guard_fingerprint(ak: str, sk: str) -> str:
        ak_value = (ak or "").strip()
        sk_value = (sk or "").strip()
        if not ak_value or not sk_value:
            return ""
        return f"{ak_value}\n{sk_value}"

    @staticmethod
    def _credentials_ready_for_customer_guard(ak: str, sk: str) -> bool:
        return bool(KEY_RE.fullmatch((ak or "").strip()) and KEY_RE.fullmatch((sk or "").strip()))

    def _set_customer_guard_controls_enabled(self, enabled: bool) -> None:
        excluded_texts = {"粘贴", "保存", "取消", "确认"}
        state_value = "normal" if enabled else "disabled"
        ttk_state = ["!disabled"] if enabled else ["disabled"]

        def walk(widget: tk.Misc) -> None:
            for child in widget.winfo_children():
                class_name = str(child.winfo_class())
                if class_name in {"TButton", "TRadiobutton", "TCheckbutton", "Button", "Radiobutton", "Checkbutton"}:
                    try:
                        text = str(child.cget("text") or "")
                    except Exception:
                        text = ""
                    if text not in excluded_texts and "注册绑定链接" not in text and "获取Ak/Sk教程" not in text and "获取AK/SK教程" not in text:
                        try:
                            child.state(ttk_state)  # type: ignore[attr-defined]
                        except Exception:
                            try:
                                child.configure(state=state_value)
                            except Exception:
                                pass
                walk(child)

        walk(self.parent)

    def _append_customer_guard_log_widget(self, widget: Optional[Text], text: str) -> None:
        if widget is None:
            return
        try:
            previous = widget.get("end-2l", "end-1c").strip()
        except Exception:
            previous = ""
        if previous == text:
            return
        line = text if text.endswith("\n") else text + "\n"
        widget.insert(END, line)
        try:
            line_count = int(str(widget.index("end-1c")).split(".")[0])
            if line_count > 2500:
                widget.delete("1.0", f"{line_count - 2200}.0")
        except Exception:
            pass
        widget.see(END)

    def _log_customer_guard_message(self, message: str) -> None:
        text = str(message or "").strip()
        if not text:
            return
        for widget in (self.purchase_progress_text, self.cleanup_progress_text):
            self._append_customer_guard_log_widget(widget, text)

    def _mark_customer_resource_query_ready(self) -> None:
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            return
        if not self._customer_guard_current_ok():
            return
        self._set_status(CUSTOMER_OWNERSHIP_READY_MESSAGE)
        self._log_customer_guard_message(CUSTOMER_OWNERSHIP_READY_MESSAGE)

    def _customer_guard_current_ok(self) -> bool:
        if self.customer_guard_status != "ok":
            return False
        return self.customer_guard_fingerprint == self._customer_guard_fingerprint(self.ak_var.get(), self.sk_var.get())

    def _replay_customer_guard_success_for_widget(self, widget: Optional[Text]) -> None:
        if not self._customer_guard_current_ok():
            return
        self._append_customer_guard_log_widget(widget, CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE)
        self._append_customer_guard_log_widget(widget, CUSTOMER_OWNERSHIP_WELCOME_MESSAGE)
        self._append_customer_guard_log_widget(widget, CUSTOMER_OWNERSHIP_READY_MESSAGE)

    def _reset_customer_guard_state(self) -> None:
        old_job = self.customer_guard_after_id
        self.customer_guard_after_id = None
        if old_job:
            try:
                self.root.after_cancel(old_job)
            except tk.TclError:
                pass
        self.customer_guard_status = "idle"
        self.customer_guard_fingerprint = ""
        self.customer_guard_message = ""
        self.customer_guard_notice_fingerprint = ""
        self.customer_guard_log_fingerprint = ""
        self.customer_guard_pending_resource_query = False
        self._close_customer_guard_dialog()
        self._set_customer_guard_controls_enabled(True)

    def _on_customer_guard_credentials_changed(self, *_args: Any) -> None:
        if self.suspend_customer_guard_trace:
            return
        self._reset_all_region_dynamic_selection()
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            self._reset_customer_guard_state()
            return
        self._schedule_customer_guard_check()

    def _schedule_customer_guard_check(self, delay_ms: int = 900, force: bool = False) -> None:
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            self._reset_customer_guard_state()
            return
        ak = self.ak_var.get().strip()
        sk = self.sk_var.get().strip()
        fingerprint = self._customer_guard_fingerprint(ak, sk)
        if not fingerprint or not self._credentials_ready_for_customer_guard(ak, sk):
            self._reset_customer_guard_state()
            return
        if (
            not force
            and self.customer_guard_status in {"ok", "blocked", "checking"}
            and self.customer_guard_fingerprint == fingerprint
        ):
            return
        old_job = self.customer_guard_after_id
        self.customer_guard_after_id = None
        if old_job:
            try:
                self.root.after_cancel(old_job)
            except tk.TclError:
                pass
        self.customer_guard_token += 1
        token = self.customer_guard_token
        self.customer_guard_status = "checking"
        self.customer_guard_fingerprint = fingerprint
        self.customer_guard_message = CUSTOMER_OWNERSHIP_CHECKING_MESSAGE
        if not self._has_active_tasks():
            self._set_status(CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE)
        if self.customer_guard_log_fingerprint != fingerprint:
            self.customer_guard_log_fingerprint = fingerprint
            self._log_customer_guard_message(CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE)
        def start_check() -> None:
            self.customer_guard_after_id = None

            def worker() -> None:
                status = "blocked"
                message = CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE
                resolved_mark = ""
                identity_summary: dict[str, Any] = {}
                try:
                    from CTYUN import customer_guard

                    ok, guard_message, detail = customer_guard.validate_customer_ownership(ak, sk)
                    resolved_mark = str(detail.get("mark") or "").strip()
                    identity_summary = detail.get("identity_summary") if isinstance(detail, dict) else {}
                    if not isinstance(identity_summary, dict):
                        identity_summary = {}
                    if ok:
                        status = "ok"
                    message = guard_message or CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE
                except Exception as exc:
                    status = "blocked"
                    message = classify_auth_exception(exc)
                self.root.after(
                    0,
                    lambda: self._apply_customer_guard_result(
                        token,
                        fingerprint,
                        status,
                        message,
                        resolved_mark,
                        identity_summary,
                    ),
                )

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

        self.customer_guard_after_id = self.root.after(delay_ms, start_check)

    def _apply_customer_guard_result(
        self,
        token: int,
        fingerprint: str,
        status: str,
        message: str,
        resolved_mark: str = "",
        identity_summary: dict[str, Any] | None = None,
    ) -> None:
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            self._reset_customer_guard_state()
            return
        if token != self.customer_guard_token:
            return
        if self._customer_guard_fingerprint(self.ak_var.get(), self.sk_var.get()) != fingerprint:
            return
        if resolved_mark and self.mark_var.get().strip() != resolved_mark:
            self.mark_var.set(resolved_mark)
        if resolved_mark:
            self._remember_mark_for_credentials(self.ak_var.get(), self.sk_var.get(), resolved_mark)
        self.customer_guard_status = status
        self.customer_guard_fingerprint = fingerprint
        self.customer_guard_message = message or CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE
        if status == "ok":
            self._set_status(CUSTOMER_OWNERSHIP_WELCOME_MESSAGE)
            self._log_customer_guard_message(_customer_ownership_result_log_message(status, message))
            self._set_customer_guard_controls_enabled(True)
            self._close_customer_guard_dialog()
            pending = self.customer_guard_pending_resource_query
            self.customer_guard_pending_resource_query = False
            if pending:
                self.root.after(100, self._query_cleanup_resources)
            return
        failure_message = message or CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE
        self._set_status(failure_message)
        self._log_customer_guard_message(_customer_ownership_result_log_message(status, failure_message))
        self._set_customer_guard_controls_enabled(False)
        if self.customer_guard_notice_fingerprint != fingerprint:
            self.customer_guard_notice_fingerprint = fingerprint
            self._show_customer_guard_dialog(failure_message)

    def _customer_guard_block_message(self, ak: str = "", sk: str = "") -> str:
        if not CTYUN_CUSTOMER_GUARD_ENABLED:
            return ""
        ak_value = (ak or self.ak_var.get()).strip()
        sk_value = (sk or self.sk_var.get()).strip()
        if not ak_value or not sk_value:
            return "请先填写或粘贴天翼云 AK/SK。"
        if not self._credentials_ready_for_customer_guard(ak_value, sk_value):
            return "请填写完整天翼云 AK/SK。"
        fingerprint = self._customer_guard_fingerprint(ak_value, sk_value)
        if self.customer_guard_status == "ok" and self.customer_guard_fingerprint == fingerprint:
            return ""
        if self.customer_guard_status == "checking" and self.customer_guard_fingerprint == fingerprint:
            return CUSTOMER_OWNERSHIP_CHECKING_MESSAGE
        if self.customer_guard_fingerprint == fingerprint and self.customer_guard_message:
            return self.customer_guard_message
        self._schedule_customer_guard_check(delay_ms=100)
        return CUSTOMER_OWNERSHIP_CHECKING_MESSAGE

    def _show_customer_guard_block(self, message: str) -> None:
        if not message:
            return
        log_message = CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE if message == CUSTOMER_OWNERSHIP_CHECKING_MESSAGE else message
        self._log_customer_guard_message(log_message)
        if message == CUSTOMER_OWNERSHIP_CHECKING_MESSAGE:
            self._set_status(CUSTOMER_OWNERSHIP_CHECKING_LOG_MESSAGE)
            return
        failure_message = message or CUSTOMER_OWNERSHIP_REQUIRED_MESSAGE
        self._set_status(failure_message)
        self._set_customer_guard_controls_enabled(False)
        self._show_customer_guard_dialog(failure_message)

    def _show_customer_guard_dialog(self, message: str) -> None:
        text = friendly_popup_text(message)
        self.customer_guard_dialog = show_app_message_dialog(
            self._dialog_parent(),
            self._dialog_title(text),
            text,
            existing_dialog=getattr(self, "customer_guard_dialog", None),
        )

    def _close_customer_guard_dialog(self) -> None:
        dialog = getattr(self, "customer_guard_dialog", None)
        self.customer_guard_dialog = None
        if dialog is None:
            return
        try:
            if dialog.winfo_exists():
                dialog.destroy()
        except tk.TclError:
            pass

    def _extract_keys_from_text(self, text: str) -> tuple[str, str]:
        parsed_ak, parsed_sk = extract_ak_sk(text)
        if parsed_ak and parsed_sk:
            return parsed_ak, parsed_sk

        lines = [line.strip() for line in text.splitlines() if line.strip()]

        def after_label(label: str) -> str:
            for index, line in enumerate(lines):
                if label.lower() not in line.lower():
                    continue
                same_line = line.split(label, 1)[-1] if label in line else ""
                match = KEY_RE.search(same_line)
                if match:
                    return match.group(0)
                for next_line in lines[index + 1 : index + 6]:
                    match = KEY_RE.search(next_line)
                    if match:
                        return match.group(0)
            return ""

        ak = after_label("AccessKey ID")
        sk = after_label("SecurityKey")
        if ak and sk:
            return ak, sk

        candidates = KEY_RE.findall(text)
        if len(candidates) >= 2:
            return candidates[-2], candidates[-1]
        return ak, sk

    def _valid_keys(self, ak: str, sk: str) -> bool:
        return bool(KEY_RE.fullmatch(ak) and KEY_RE.fullmatch(sk))

    def _dialog_parent(self) -> tk.Misc:
        """让多窗口下的弹窗挂到当前天翼云窗口，而不是跑到主窗口或其他窗口。"""
        try:
            parent = self.parent.winfo_toplevel()
            if parent is not None and parent.winfo_exists():
                return parent
        except Exception:
            pass
        return self.root

    def _dialog_window_mark(self) -> str:
        try:
            title = str(self._dialog_parent().title() or "").strip()
        except Exception:
            title = ""
        if title.startswith(APP_TITLE):
            mark = title[len(APP_TITLE) :].strip()
            if mark.startswith("-"):
                mark = mark[1:].strip()
            if mark:
                return re.sub(r"\s+", " ", mark).strip()
        try:
            mark = self.mark_var.get().strip()
        except Exception:
            mark = ""
        return re.sub(r"\s+", " ", mark).strip()

    def _dialog_title(self, message: str = "", *, default_suffix: str = "提示") -> str:
        if not str(message or "").strip() and str(default_suffix or "").strip() not in {"提示", "提醒", "确认"}:
            return f"{APP_TITLE}-{str(default_suffix).strip()}"
        return cloud_dialog_title(APP_TITLE, message, default_suffix=default_suffix)

    def _show_info(self, text: str) -> None:
        message = friendly_popup_text(text)
        if self._record_delayed_queue_result_popup(message, is_error=False):
            return
        if "队列任务执行完毕" in message:
            play_task_completion_sound()
        show_app_message_dialog(self._dialog_parent(), self._dialog_title(message), message)

    def _show_warning(self, text: str) -> None:
        message = friendly_popup_text(text)
        if self._record_delayed_queue_result_popup(message, is_error=True):
            return
        show_app_message_dialog(self._dialog_parent(), self._dialog_title(message), message)

    def _show_error(self, text: str) -> None:
        message = friendly_popup_text(text)
        if self._record_delayed_queue_result_popup(message, is_error=True):
            return
        show_app_message_dialog(self._dialog_parent(), self._dialog_title(message), message)

    @staticmethod
    def _completion_message_requires_popup(text: str, final_status: str = "") -> bool:
        value = f"{text}\n{final_status}"
        return any(token in value for token in ("失败", "未完成", "已停止", "异常", "错误", "售罄", "退出码"))

    @staticmethod
    def _failure_reason_from_log_line(line: str) -> str:
        text = re.sub(r"\s+", " ", str(line or "")).strip()
        if not text:
            return ""
        if "当前网络证书校验失败" in text:
            return text
        lowered = text.lower()
        if (
            "certificate_verify_failed" in lowered
            or "certificate verify failed" in lowered
            or "unable to get local issuer certificate" in lowered
        ):
            return "当前网络证书校验失败，可能为网吧/代理/杀软 HTTPS 拦截，请更换网络/手机热点/自有代理后重试。"
        return ""

    def _remember_failure_reason_from_line(self, line: str, *, region: str = "") -> None:
        reason = self._failure_reason_from_log_line(line)
        if not reason:
            return
        if region and not reason.startswith(f"{region}:"):
            reason = f"{region}: {reason}"
        current = str(getattr(self, "last_task_failure_reason", "") or "")
        if not current or "当前网络证书校验失败" in reason:
            self.last_task_failure_reason = reason

    def _show_completion_message(self, text: str, *, final_status: str = "") -> None:
        message = friendly_popup_text(text)
        if not message:
            return
        if final_status:
            self._set_status(final_status)

        def show() -> None:
            self.deferred_popup_after_id = None
            if self.delayed_queue_result_active and not self.delayed_queue_result_suppress_dialog:
                self._show_info(message)
                return
            play_task_completion_sound()
            show_app_message_dialog(self._dialog_parent(), self._dialog_title(message), message)

        try:
            self.deferred_popup_after_id = self.root.after(250, show)
        except tk.TclError:
            if self.delayed_queue_result_active and not self.delayed_queue_result_suppress_dialog:
                self._show_info(message)
            else:
                play_task_completion_sound()
                show_app_message_dialog(self._dialog_parent(), self._dialog_title(message), message)

    def _ask_yes_no(self, text: str) -> bool:
        if self.delayed_skip_next_confirmation:
            self.delayed_skip_next_confirmation = False
            return True
        if self.delayed_preconfirm_active:
            raise DelayedActionPreconfirmCaptured(str(text or ""))
        confirmed = ask_app_confirm_dialog(self._dialog_parent(), self._dialog_title(text, default_suffix="确认"), text)
        if self.delayed_preconfirm_active:
            if confirmed:
                raise DelayedActionPreconfirmAccepted()
            raise DelayedActionPreconfirmCancelled()
        return confirmed

    def _ask_cleanup_release_action(self, text: str, *, allow_used_ip_import: bool) -> str:
        if not allow_used_ip_import or self.delayed_skip_next_confirmation or self.delayed_preconfirm_active:
            return "confirm" if self._ask_yes_no(text) else "cancel"
        result = {"action": "cancel"}

        def choose(action: str) -> None:
            result["action"] = action

        show_app_message_dialog(
            self._dialog_parent(),
            self._dialog_title(text, default_suffix="确认"),
            text,
            modal=True,
            actions=(
                ("确认", lambda: choose("confirm")),
                ("确认并导入旧IP库", lambda: choose("confirm_and_import")),
                ("取消", lambda: choose("cancel")),
            ),
        )
        return result["action"]

    def _runtime_path(self, name: str, run_root: Path | None = None) -> Path:
        return (run_root or self.runtime_root) / name

    def _is_hidden_cleanup_item(self, item: dict[str, str]) -> bool:
        if str(item.get("type") or "") == "ECS" and self._is_unsubscribe_in_progress_cleanup_status(str(item.get("status") or "")):
            return True
        return False

    def _cleanup_display_items(self, items: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
        if self._customer_cleanup_limited_features():
            return [
                dict(item)
                for item in items
                if str(item.get("type") or "").upper() in {"EIP", "HAVIP_EIP"}
            ]
        if not self._cleanup_cloud_server_limited_features():
            return [dict(item) for item in items]
        return [dict(item) for item in items if str(item.get("type") or "").upper() != "ECS"]

    @staticmethod
    def _cleanup_region_name(item: dict[str, Any]) -> str:
        return str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip()

    @staticmethod
    def _cleanup_havip_vip(item: dict[str, Any]) -> str:
        value = str(
            item.get("vip")
            or item.get("privateIP")
            or item.get("privateIp")
            or item.get("private_ip")
            or ""
        ).strip()
        if value:
            return value
        for key in ("name", "address"):
            match = re.search(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", str(item.get(key) or ""))
            if match:
                return match.group(0)
        return ""

    @classmethod
    def _cleanup_bound_havip_key(cls, item: dict[str, Any]) -> tuple[str, str]:
        return cls._cleanup_region_name(item), cls._cleanup_havip_vip(item)

    def _dedupe_cleanup_display_items(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
        bound_havips = {
            self._cleanup_bound_havip_key(item)
            for item in items
            if str(item.get("type") or "") == "HAVIP_EIP"
        }
        bound_havips.discard(("", ""))
        deduped: list[dict[str, Any]] = []
        seen: set[tuple[str, str, str, str, str]] = set()
        for item in items:
            item_type = str(item.get("type") or "")
            if item_type == "HAVIP" and self._cleanup_bound_havip_key(item) in bound_havips:
                continue
            identity = (
                item_type,
                self._cleanup_region_name(item),
                str(item.get("eipID") or item.get("instanceID") or item.get("vpcID") or item.get("havipID") or item.get("bandwidthID") or ""),
                self._cleanup_havip_vip(item),
                str(item.get("address") or item.get("name") or ""),
            )
            if identity in seen:
                continue
            seen.add(identity)
            deduped.append(item)
        return deduped

    @staticmethod
    def _is_unsubscribed_cleanup_status(status: str) -> bool:
        value = str(status or "").strip().lower()
        if WorkflowGui._is_unsubscribe_in_progress_cleanup_status(value):
            return False
        return "包周期已退订" in value or "已退订" in value or "unsubscribed" in value or ("cycle" in value and "退订" in value)

    @staticmethod
    def _is_unsubscribe_in_progress_cleanup_status(status: str) -> bool:
        value = str(status or "").strip().lower()
        return any(
            token in value
            for token in (
                "unsubscribing",
                "unsubscribe processing",
                "unsubscription in progress",
                "退订中",
                "正在退订",
                "退订处理中",
            )
        )

    @staticmethod
    def _is_active_cleanup_ecs_item(item: dict[str, Any]) -> bool:
        if str(item.get("type") or "") != "ECS":
            return False
        if item.get("releaseOnly") is True:
            return False
        return not WorkflowGui._is_unsubscribed_cleanup_status(str(item.get("status") or ""))

    def _export_log_entries(self) -> list[tuple[str, str, str]]:
        entries: list[tuple[str, str, str]] = []
        for context_key, target in (("purchase", self.purchase_progress_text), ("manage", self.cleanup_progress_text)):
            if target is None:
                continue
            text = target.get("1.0", END).strip()
            if not text:
                continue
            section = "资源管理" if context_key == "manage" else "购买搭建"
            entries.append((section, self.log_action_names.get(context_key, "界面导出日志"), text))
        return entries

    def _export_log_text(self, widget: Text, context: str) -> None:
        entries = self._export_log_entries()
        if not entries:
            text = widget.get("1.0", END).strip() if widget is not None else ""
            if text:
                context_key = self._export_context_key(context)
                section = "资源管理" if context_key == "manage" else "购买搭建"
                entries.append((section, self.log_action_names.get(context_key, "界面导出日志"), text))
        if not entries:
            self._show_info("当前没有实时日志可导出")
            return
        path = export_log_to_desktop(
            "CTYUN",
            "全部日志",
            "导出日志",
            "",
            account_key=self._account_log_scope(),
            current_logs=entries,
            recent_sections=["购买搭建", "资源管理"],
        )
        self._show_info(f"日志压缩包已导出到桌面：\n{path}\n\n请把日志压缩包发给维护人员，简要说明情况即可。")

    def _copy_text(self, widget: Text) -> None:
        self._export_log_text(widget, self.active_log_name or "purchase")

    def _latest_result_dir(self) -> Path:
        return APP_DATA_DIR / "latest_results" / self._account_log_scope()

    def _read_latest_result_file(self, filename: str) -> str:
        try:
            return (self._latest_result_dir() / filename).read_text(encoding="utf-8").strip()
        except Exception:
            return ""

    def _save_latest_result_outputs(self, l2tp_output: str = "", socks_output: str = "") -> None:
        write_latest_result_files(self._latest_result_dir(), l2tp=l2tp_output, socks5=socks_output)

    def _parse_cached_result_blocks(self, text: str, section: str) -> dict[str, list[str]]:
        header = "入口IP/账号/密码/密钥/对应出口IP" if section == "l2tp" else "IP/端口/用户名/密码"
        lines = [str(line or "").strip() for line in str(text or "").replace("\r", "\n").splitlines()]
        records: dict[str, list[str]] = {}
        current_region = ""
        for index, value in enumerate(lines):
            if not value:
                continue
            if index + 1 < len(lines) and lines[index + 1] == header:
                current_region = value
                records.setdefault(current_region, [])
                continue
            row = self._ctyun_log_result_row(value, section)
            if row and current_region:
                rows = records.setdefault(current_region, [])
                if row not in rows:
                    rows.append(row)
        return {region: rows for region, rows in records.items() if rows}

    def _format_cached_result_blocks(self, records: dict[str, list[str]], section: str) -> str:
        header = "入口IP/账号/密码/密钥/对应出口IP" if section == "l2tp" else "IP/端口/用户名/密码"
        blocks = [
            "\n".join([region, header, *rows])
            for region, rows in records.items()
            if region and rows
        ]
        if not blocks:
            return ""
        return append_result_footer(
            "\n\n".join(blocks),
            "L2TP" if section == "l2tp" else "Socks5",
            self.mark_var.get(),
        )

    def _cached_result_region_key(self, records: dict[str, list[str]], region: str) -> str:
        tokens = self._result_region_tokens(region)
        return next(
            (
                name
                for name in records
                if tokens & self._result_region_tokens(name)
            ),
            "",
        )

    def _commit_cached_result_blocks(
        self,
        cached_l2tp: dict[str, list[str]],
        cached_socks: dict[str, list[str]],
    ) -> None:
        persisted_l2tp = self._format_cached_result_blocks(cached_l2tp, "l2tp")
        persisted_socks = self._format_cached_result_blocks(cached_socks, "socks")
        display_l2tp = {
            region: rows
            for region, rows in cached_l2tp.items()
            if self._is_result_region_allowed(region)
        }
        display_socks = {
            region: rows
            for region, rows in cached_socks.items()
            if self._is_result_region_allowed(region)
        }
        self._set_text_widget(self.l2tp_text, self._format_cached_result_blocks(display_l2tp, "l2tp"))
        self._set_text_widget(self.socks_text, self._format_cached_result_blocks(display_socks, "socks"))
        self._save_latest_result_outputs(persisted_l2tp, persisted_socks)

    @staticmethod
    def _retarget_cached_result_rows(rows: Sequence[str], section: str, ips: Sequence[str]) -> list[str]:
        target_ips = [str(ip or "").strip() for ip in ips if str(ip or "").strip()]
        if not target_ips or len(rows) < len(target_ips):
            return []
        rebuilt: list[str] = []
        entry_ip = target_ips[0]
        for index, new_ip in enumerate(target_ips):
            parts = [part.strip() for part in str(rows[index] or "").split("/")]
            if section == "l2tp":
                if len(parts) < 5:
                    return []
                rebuilt.append("/".join([entry_ip, parts[1], parts[2], parts[3], new_ip]))
            else:
                if len(parts) < 4:
                    return []
                rebuilt.append("/".join([new_ip, parts[1], parts[2], parts[3]]))
        return rebuilt

    def _copy_l2tp_format(self) -> None:
        text = append_mark_to_output(self._build_l2tp_format(), self.mark_var.get())
        if not text:
            self._show_info("当前进度里没有可复制的 L2TP 结果。")
            return
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self._show_info("L2TP 结果已复制。")

    def _copy_socks5_format(self) -> None:
        text = append_mark_to_output(self._build_socks5_format(), self.mark_var.get())
        if not text:
            self._show_info("当前进度里没有可复制的 Socks5 结果。")
            return
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self._show_info("Socks5 结果已复制。")

    def _copy_plain_socks5_format(self) -> None:
        current_output = self.socks_text.get("1.0", END) if self.socks_text is not None else ""
        text = extract_plain_socks5_output(current_output).strip()
        if not text:
            text = self._build_plain_socks5_format().strip()
        if not text:
            self._show_info("当前进度里没有可复制的纯 Socks5 结果。")
            return
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self._show_info("纯 Socks5 结果已复制。")

    def _set_text_widget(self, widget: Text | None, text: str) -> None:
        if widget is None:
            return
        widget.configure(state="normal")
        widget.delete("1.0", END)
        if text:
            widget.insert("1.0", text)
        widget.see("1.0")

    def _clear_output_boxes(self, *, persist: bool = False) -> None:
        self._set_text_widget(self.l2tp_text, "")
        self._set_text_widget(self.socks_text, "")
        if persist:
            self._save_latest_result_outputs("", "")

    def _refresh_output_boxes(self) -> None:
        self.output_refresh_after_id = None
        replacements = dict(getattr(self, "result_ip_replacements", {}) or {})
        candidate_l2tp_text = self._build_l2tp_format()
        candidate_socks_text = self._build_socks5_format()
        cached_l2tp_text = self._read_latest_result_file("last_l2tp.txt")
        cached_socks_text = self._read_latest_result_file("last_socks5.txt")
        if replacements:
            candidate_l2tp_text = self._replace_public_ips_in_text(candidate_l2tp_text, replacements)
            candidate_socks_text = self._replace_public_ips_in_text(candidate_socks_text, replacements)
            cached_l2tp_text = self._replace_public_ips_in_text(cached_l2tp_text, replacements)
            cached_socks_text = self._replace_public_ips_in_text(cached_socks_text, replacements)
        candidate_l2tp = self._parse_cached_result_blocks(candidate_l2tp_text, "l2tp")
        candidate_socks = self._parse_cached_result_blocks(candidate_socks_text, "socks")
        cached_l2tp = self._parse_cached_result_blocks(
            cached_l2tp_text,
            "l2tp",
        )
        cached_socks = self._parse_cached_result_blocks(
            cached_socks_text,
            "socks",
        )

        expected_rows = max(1, self._ctyun_eip_target_count())
        promote_candidate_results = getattr(self, "current_workflow_mode", "") != "purchase"
        complete_regions = {
            region
            for region in set(candidate_l2tp) & set(candidate_socks)
            if promote_candidate_results
            and not self._is_result_region_invalid(region)
            and len(candidate_l2tp.get(region, [])) >= expected_rows
            and len(candidate_socks.get(region, [])) >= expected_rows
        }
        for region in complete_regions:
            cached_l2tp[region] = list(candidate_l2tp[region])
            cached_socks[region] = list(candidate_socks[region])

        invalid_regions = {
            region
            for region in set(cached_l2tp) | set(cached_socks)
            if self._is_result_region_invalid(region)
        }
        for region in invalid_regions:
            cached_l2tp.pop(region, None)
            cached_socks.pop(region, None)

        self._commit_cached_result_blocks(cached_l2tp, cached_socks)

    def _schedule_output_refresh(self, delay_ms: int = 250, *, force: bool = False) -> None:
        if self.output_refresh_after_id:
            if not force:
                return
            try:
                self.root.after_cancel(self.output_refresh_after_id)
            except tk.TclError:
                pass
            self.output_refresh_after_id = None
        try:
            self.output_refresh_after_id = self.root.after(delay_ms, self._refresh_output_boxes)
        except tk.TclError:
            self.output_refresh_after_id = None

    def _is_build_log_target(self, target_name: str) -> bool:
        if target_name != "purchase":
            return False
        return getattr(self, "current_workflow_mode", "") in {"build", "all"}

    def _realtime_display_text(
        self,
        text: str,
        target_name: str,
        *,
        shared_log: bool = False,
        compact_account_query: bool = False,
    ) -> str:
        if compact_account_query:
            failure_lines = [
                line
                for line in str(text or "").splitlines()
                if any(
                    keyword in line
                    for keyword in ("失败", "错误", "异常", "超时", "证书", "拒绝", "无法", "已停止", "退出码")
                )
            ]
            if not failure_lines:
                return ""
            return _format_ctyun_realtime_text_without_filter("\n".join(failure_lines) + "\n")
        if shared_log:
            return _format_ctyun_realtime_text_without_filter(text)
        if target_name == "cleanup":
            return _format_ctyun_realtime_text_without_filter(text)
        if target_name == "purchase" and self.log_action_names.get("purchase") == "重装系统":
            return _format_ctyun_realtime_text_without_filter(text)
        if self._is_build_log_target(target_name):
            return _format_ctyun_realtime_text_without_filter(text)
        return self.realtime_log_filter.limit_notice(filter_realtime_log_text(text), target_name)

    @staticmethod
    def _append_realtime_text(widget: Text | None, text: str) -> None:
        if widget is None or not text:
            return
        try:
            previous_state = str(widget.cget("state"))
        except tk.TclError:
            return
        try:
            if previous_state == "disabled":
                widget.configure(state="normal")
            widget.insert(END, text)
            widget.see(END)
        except tk.TclError:
            return
        finally:
            if previous_state == "disabled":
                try:
                    widget.configure(state="disabled")
                except tk.TclError:
                    pass

    def _queue_realtime_text(self, target_name: str, text: str) -> None:
        if not text:
            return
        target = "cleanup" if target_name == "cleanup" else "purchase"
        self.pending_log_chunks.setdefault(target, []).append(text)
        if self.log_flush_after_id:
            return
        try:
            self.log_flush_after_id = self.root.after(100, self._flush_realtime_text)
        except tk.TclError:
            self.log_flush_after_id = None

    def _flush_realtime_text(self) -> None:
        self.log_flush_after_id = None
        for target_name in ("purchase", "cleanup"):
            chunks = self.pending_log_chunks.get(target_name) or []
            if not chunks:
                continue
            self.pending_log_chunks[target_name] = []
            widget = self.cleanup_progress_text if target_name == "cleanup" else self.purchase_progress_text
            self._append_realtime_text(widget, "".join(chunks))

    def _append_cleanup_realtime_text(self, text: str, *, mirror_purchase: bool = True) -> None:
        if not text:
            return
        self._queue_realtime_text("cleanup", text)
        if mirror_purchase:
            self._queue_realtime_text("purchase", text)

    @staticmethod
    def _path_key(path: Path) -> str:
        try:
            return str(path.resolve())
        except Exception:
            return str(path)

    def _purchase_progress_since_result_start(self) -> str:
        start_index = str(getattr(self, "result_log_start_index", "1.0") or "1.0")
        progress = ""
        if self.purchase_progress_text is not None:
            try:
                progress = self.purchase_progress_text.get(start_index, END)
            except tk.TclError:
                try:
                    progress = self.purchase_progress_text.get("1.0", END)
                except tk.TclError:
                    progress = ""
        pending = "".join(self.pending_log_chunks.get("purchase") or [])
        return f"{progress}{pending}"

    def _latest_result_data(self) -> dict[str, object]:
        progress = self._purchase_progress_since_result_start()
        data_path = self._runtime_path("run_result.json", self.purchase_result_root)
        if data_path.exists():
            try:
                if data_path.stat().st_mtime >= float(getattr(self, "result_data_started_at", time.time())) - 2.0:
                    data = json.loads(data_path.read_text(encoding="utf-8-sig"))
                    if isinstance(data, dict):
                        return data
            except Exception:
                pass
        retained: dict[str, dict[str, str]] = {}
        for match in re.finditer(r"^(.+?): 云服务器已绑定弹性 IP[:：]\s*([0-9.]+)", progress, re.M):
            retained[match.group(1).strip()] = {"eip": match.group(2).strip()}
        return {"virtualIps": {}, "ecsIp": retained, "summary": {}}

    def _region_title(self, name: str) -> str:
        return re.sub(r"\d+$", "", name).strip() or name

    def _result_region_tokens(self, name: str) -> set[str]:
        value = str(name or "").strip()
        title = self._region_title(value)
        return {token for token in (value, title) if token}

    @staticmethod
    def _ctyun_private_192_province_label(value: str) -> str:
        text = str(value or "").strip()
        for suffix in ("壮族自治区", "回族自治区", "维吾尔自治区", "特别行政区", "自治区", "省", "市"):
            if text.endswith(suffix):
                text = text[: -len(suffix)]
                break
        return text

    def _ctyun_private_192_region_label(self, name: str) -> str:
        display_name = str(name or "").strip()
        if not display_name:
            return ""
        for record in load_region_records():
            if str(record.get("displayName") or "") != display_name:
                continue
            province = self._ctyun_private_192_province_label(
                str(record.get("province") or self._region_record_province(record) or "")
            )
            if province:
                return f"{province} {display_name}"
            return display_name
        return display_name

    @staticmethod
    def _ctyun_private_192_ecs_eip(info: object, fallback: object = None) -> str:
        candidates: list[object] = []
        if isinstance(info, dict):
            candidates.append(info.get("ecsEip"))
        candidates.append(fallback)
        for candidate in candidates:
            if not isinstance(candidate, dict):
                continue
            for key in ("eip", "ip", "address", "eipAddress", "publicIp", "publicIP"):
                value = str(candidate.get(key) or "").strip()
                if value:
                    return value
        return ""

    def _build_private_192_168_100_purchase_l2tp_format(self) -> str:
        if not getattr(self, "private_192_168_100_result_active", False):
            return ""
        if getattr(self, "current_workflow_mode", "") != "purchase":
            return ""
        data = self._latest_result_data()
        summary = data.get("summary") if isinstance(data, dict) else {}
        ecs_map = data.get("ecsIp") if isinstance(data, dict) else {}
        if not isinstance(summary, dict):
            summary = {}
        if not isinstance(ecs_map, dict):
            ecs_map = {}
        region_names = list(self.current_workflow_regions or [])
        for name in summary.keys():
            if str(name) not in region_names:
                region_names.append(str(name))
        lines: list[str] = []
        for name in region_names:
            if not self._is_result_region_allowed(name):
                continue
            info = summary.get(name)
            if isinstance(info, dict) and str(info.get("status") or "").strip() not in {"completed", ""}:
                continue
            eip = self._ctyun_private_192_ecs_eip(info, ecs_map.get(name))
            if not eip:
                continue
            label = self._ctyun_private_192_region_label(name)
            if label:
                lines.append(f"{label}丨{eip}")
        return "\n".join(lines)

    def _is_result_region_invalid(self, name: str) -> bool:
        if not self.invalid_result_regions:
            return False
        return bool(self._result_region_tokens(name) & self.invalid_result_regions)

    def _active_result_region_tokens(self) -> set[str] | None:
        account_items = self._account_resources_for_purchase()
        if not account_items:
            return set()
        stats: dict[str, dict[str, int]] = {}
        for item in account_items:
            if bool(item.get("_displayOnly")):
                continue
            region = self._cleanup_region_name(item)
            if not region:
                continue
            item_type = str(item.get("type") or "")
            region_stats = stats.setdefault(region, {"ecs": 0, "ip": 0})
            if self._is_active_cleanup_ecs_item(item):
                region_stats["ecs"] += 1
            elif item_type in {"EIP", "HAVIP_EIP"}:
                region_stats["ip"] += 1
        allowed: set[str] = set()
        for region, region_stats in stats.items():
            if region_stats["ecs"] > 0:
                allowed.update(self._result_region_tokens(region))
        return allowed

    def _is_result_region_allowed(self, name: str) -> bool:
        if self._is_result_region_invalid(name):
            return False
        current_allowed: set[str] = set()
        for region in self.current_workflow_regions or []:
            current_allowed.update(self._result_region_tokens(str(region or "")))
        if current_allowed and self._result_region_tokens(name) & current_allowed:
            return True
        if not self._account_resources_for_purchase():
            return True
        allowed = self._active_result_region_tokens()
        if allowed is None:
            return True
        return bool(self._result_region_tokens(name) & allowed)

    @staticmethod
    def _looks_like_ipv4(value: str) -> bool:
        parts = str(value or "").split(".")
        if len(parts) != 4:
            return False
        try:
            return all(0 <= int(part) <= 255 for part in parts)
        except ValueError:
            return False

    @classmethod
    def _ctyun_log_result_row(cls, line: str, section: str) -> str:
        value, _ = cls._strip_ctyun_log_prefixes(line)
        if not value:
            return ""
        value = value.replace("对应出口IP:", "").replace("对应出口IP：", "")
        parts = [part.strip() for part in value.split("/")]
        if section == "l2tp" and len(parts) >= 5 and cls._looks_like_ipv4(parts[0]) and parts[1].lower().startswith("user"):
            return "/".join(parts[:5])
        if section == "socks" and len(parts) >= 4 and cls._looks_like_ipv4(parts[0]) and parts[1].isdigit():
            return "/".join(parts[:4])
        return ""

    @classmethod
    def _strip_ctyun_log_prefixes(cls, line: str) -> tuple[str, str]:
        value = str(line or "").strip()
        last_region = ""
        while True:
            match = re.match(r"^\[([^\]]+)\]\s*(.*)$", value)
            if not match:
                break
            bracket_value = match.group(1).strip()
            value = match.group(2).strip()
            if bracket_value and not cls._looks_like_ipv4(bracket_value):
                last_region = bracket_value
        return value, last_region

    def _ctyun_log_entry_ip_regions(self, progress: str) -> dict[str, str]:
        regions_by_ip: dict[str, str] = {}

        def remember(region: object, ip: object, *, overwrite: bool = False) -> None:
            region_name = str(region or "").strip()
            ip_value = str(ip or "").strip()
            if region_name and self._looks_like_ipv4(ip_value):
                if overwrite:
                    regions_by_ip[ip_value] = region_name
                else:
                    regions_by_ip.setdefault(ip_value, region_name)

        data = self._latest_result_data()
        if isinstance(data, dict):
            ecs_map = data.get("ecsIp")
            if isinstance(ecs_map, dict):
                for region, info in ecs_map.items():
                    if isinstance(info, dict):
                        for key in ("eip", "ip", "address", "publicIp", "publicIP"):
                            remember(region, info.get(key))
            summary_map = data.get("summary")
            if isinstance(summary_map, dict):
                for region, info in summary_map.items():
                    if not isinstance(info, dict):
                        continue
                    ecs_eip = info.get("ecsEip")
                    if isinstance(ecs_eip, dict):
                        for key in ("eip", "ip", "address", "publicIp", "publicIP"):
                            remember(region, ecs_eip.get(key))

        current_region = ""
        for raw_line in str(progress or "").replace("\r", "\n").splitlines():
            parse_line, bracket_region = self._strip_ctyun_log_prefixes(raw_line)
            if self._is_ctyun_log_region_marker(bracket_region):
                current_region = bracket_region
            name_match = re.match(r"^名称[:：]\s*(.+)$", parse_line)
            if name_match:
                current_region = name_match.group(1).strip()
                continue
            bound_match = re.match(r"^(.+?)[:：]\s*云服务器已绑定弹性\s*IP[:：]\s*((?:\d{1,3}\.){3}\d{1,3})", parse_line)
            if bound_match:
                remember(bound_match.group(1), bound_match.group(2), overwrite=True)
                continue
            host_match = re.search(r"(?:主机|服务器IP)[:：]\s*((?:\d{1,3}\.){3}\d{1,3})", parse_line)
            if host_match:
                remember(current_region, host_match.group(1), overwrite=True)
        return regions_by_ip

    def _is_ctyun_log_region_marker(self, value: str) -> bool:
        region = str(value or "").strip()
        if not region or self._looks_like_ipv4(region):
            return False
        if region in {"√", "×", "x", "X", "OK", "ok", "INFO", "WARN", "ERROR"}:
            return False
        current_allowed: set[str] = set()
        for workflow_region in self.current_workflow_regions or []:
            current_allowed.update(self._result_region_tokens(str(workflow_region or "")))
        if current_allowed:
            return bool(self._result_region_tokens(region) & current_allowed)
        return bool(re.search(r"\d+$", region))

    def _ctyun_log_result_blocks(self, section: str) -> list[str]:
        progress = self._purchase_progress_since_result_start()
        current_region = ""
        active_result_region = ""
        grouped: dict[str, list[str]] = {}
        display_names: dict[str, str] = {}
        entry_ip_regions = self._ctyun_log_entry_ip_regions(progress)
        limit = self._ctyun_eip_target_count()
        for raw_line in str(progress or "").replace("\r", "\n").splitlines():
            line = raw_line.strip()
            if not line:
                continue
            parse_line, bracket_region = self._strip_ctyun_log_prefixes(line)
            if self._is_ctyun_log_region_marker(bracket_region):
                current_region = bracket_region
            upper_line = parse_line.upper()
            if "L2TP" in upper_line and "信息" in parse_line:
                active_result_region = current_region if section == "l2tp" else ""
                continue
            if "SOCKS" in upper_line and "信息" in parse_line:
                active_result_region = current_region if section == "socks" else ""
                continue
            name_match = re.match(r"^名称[:：]\s*(.+)$", parse_line)
            if name_match:
                current_region = name_match.group(1).strip()
                active_result_region = ""
                if current_region:
                    grouped[current_region] = []
                    display_names[current_region] = self._region_title(current_region)
                continue
            parallel_match = re.match(r"^=+\s*(.+?)\s*已完成购买绑定", parse_line)
            if parallel_match:
                current_region = parallel_match.group(1).strip()
                active_result_region = ""
                continue
            row = self._ctyun_log_result_row(parse_line, section)
            if not row:
                continue
            entry_ip = row.split("/", 1)[0].strip()
            row_region = entry_ip_regions.get(entry_ip) or active_result_region or current_region
            if not row_region or not self._is_result_region_allowed(row_region):
                continue
            active_result_region = row_region
            rows = grouped.setdefault(row_region, [])
            display_names.setdefault(row_region, self._region_title(row_region))
            if row not in rows and len(rows) < limit:
                rows.append(row)
        header = "入口IP/账号/密码/密钥/对应出口IP" if section == "l2tp" else "IP/端口/用户名/密码"
        return [f"{display_names.get(region, self._region_title(region))}\n{header}\n" + "\n".join(rows) for region, rows in grouped.items() if rows]

    def _ctyun_log_text_has_result_row(self, text: str) -> bool:
        for raw_line in str(text or "").replace("\r", "\n").splitlines():
            line = raw_line.strip()
            if self._ctyun_log_result_row(line, "l2tp") or self._ctyun_log_result_row(line, "socks"):
                return True
        return False

    def _invalidate_result_regions(self, regions: Sequence[str], *, reason: str = "") -> None:
        added: set[str] = set()
        for region in regions:
            added.update(self._result_region_tokens(str(region or "")))
        if not added:
            return
        before = set(self.invalid_result_regions)
        self.invalid_result_regions.update(added)
        self._schedule_output_refresh()
        if reason and self.invalid_result_regions != before:
            self._emit(reason)

    def _restore_result_regions(self, regions: Sequence[str]) -> None:
        restored: set[str] = set()
        for region in regions:
            restored.update(self._result_region_tokens(str(region or "")))
        if restored:
            self.invalid_result_regions.difference_update(restored)

    def _hide_result_regions_after_reinstall(self, regions: Sequence[str]) -> None:
        self._invalidate_result_regions(regions, reason="已清除重装地区的旧搭建结果，需重新搭建后生成新结果。\n")

    def _successful_reinstall_regions(self, run_root: Path, requested_regions: Sequence[str]) -> list[str]:
        requested = {str(region or "").strip() for region in requested_regions if str(region or "").strip()}
        try:
            data = json.loads((run_root / "reinstall_result.json").read_text(encoding="utf-8-sig"))
        except Exception:
            return []
        successful = {
            str(region or "").strip()
            for region in (data.get("successfulRegions") if isinstance(data, dict) else []) or []
            if str(region or "").strip()
        }
        return sorted(successful & requested) if requested else sorted(successful)

    def _after_reinstall_task(
        self,
        regions: Sequence[str],
        run_root: Path,
        popup_message: str,
    ) -> None:
        successful_regions = self._successful_reinstall_regions(run_root, regions)
        if successful_regions:
            self._hide_result_regions_after_reinstall(successful_regions)
        self._schedule_cleanup_resources_after_task(
            list(regions),
            final_popup_message=popup_message,
        )

    def _ordered_ips_for_region(self, name: str, data: dict[str, object]) -> list[str]:
        ecs = data.get("ecsIp", {})
        virtual = data.get("virtualIps", {})
        entry_ip = ""
        if isinstance(ecs, dict) and isinstance(ecs.get(name), dict):
            entry_ip = str(ecs[name].get("eip") or "").strip()
        rows = []
        if isinstance(virtual, dict) and isinstance(virtual.get(name), list):
            rows = [row for row in virtual[name] if isinstance(row, dict)]
        rows.sort(key=lambda row: tuple(int(part) for part in str(row.get("vip", "0.0.0.0")).split(".") if part.isdigit()))
        ips = [entry_ip] if entry_ip else []
        ips.extend(str(row.get("eip") or "").strip() for row in rows if row.get("eip"))
        return [ip for ip in ips if ip]

    def _build_l2tp_format(self) -> str:
        private_192_output = self._build_private_192_168_100_purchase_l2tp_format()
        if getattr(self, "private_192_168_100_result_active", False) and getattr(self, "current_workflow_mode", "") == "purchase":
            return private_192_output
        limit = self._ctyun_eip_target_count()
        log_blocks = self._ctyun_log_result_blocks("l2tp")
        if log_blocks:
            return append_result_footer("\n\n".join(log_blocks), "L2TP", self.mark_var.get())
        if self.build_mode_var.get().strip() != CTYUN_BUILD_MODE_DEFAULT:
            return ""
        data = self._latest_result_data()
        regions = list((data.get("ecsIp") or {}).keys()) if isinstance(data.get("ecsIp"), dict) else []
        regions = [name for name in regions if self._is_result_region_allowed(str(name))]
        user_prefix = "user"
        if self.default_conn_custom_saved:
            password = self.default_conn_password_var.get().strip() or "wawacloud"
            psk = password
        else:
            password = "wawacloud"
            psk = "wawacloud"
        lines: list[str] = []
        has_rows = False
        for name in regions:
            ips = self._ordered_ips_for_region(name, data)
            if not ips:
                continue
            if has_rows:
                lines.append("")
            has_rows = True
            entry_ip = ips[0]
            title = self._region_title(name)
            lines.extend([title, "入口IP/账号/密码/密钥/对应出口IP"])
            lines.extend(f"{entry_ip}/{user_prefix}{i}/{password}/{psk}/{ip}" for i, ip in enumerate(ips[:limit], start=1))
        if not has_rows:
            return ""
        return append_result_footer("\n".join(lines), "L2TP", self.mark_var.get())

    def _build_socks5_format(self) -> str:
        if getattr(self, "private_192_168_100_result_active", False) and getattr(self, "current_workflow_mode", "") == "purchase":
            return ""
        limit = self._ctyun_eip_target_count()
        log_blocks = self._ctyun_log_result_blocks("socks")
        if log_blocks:
            return append_result_footer("\n\n".join(log_blocks), "Socks5", self.mark_var.get())
        if self.build_mode_var.get().strip() != CTYUN_BUILD_MODE_DEFAULT:
            return ""
        data = self._latest_result_data()
        regions = list((data.get("ecsIp") or {}).keys()) if isinstance(data.get("ecsIp"), dict) else []
        regions = [name for name in regions if self._is_result_region_allowed(str(name))]
        if self.default_conn_custom_saved:
            socks_port = self.default_conn_socks_port_var.get().strip() or "23456"
            username = self.default_conn_user_var.get().strip() or CTYUN_CUSTOM_CONNECTION_DEFAULT_USER
            password = self.default_conn_password_var.get().strip() or "wawacloud"
        else:
            socks_port = "23456"
            username = "root"
            password = "wawacloud"
        lines: list[str] = []
        has_rows = False
        for name in regions:
            ips = self._ordered_ips_for_region(name, data)
            if not ips:
                continue
            if has_rows:
                lines.append("")
            has_rows = True
            title = self._region_title(name)
            lines.extend([title, "IP/端口/用户名/密码"])
            lines.extend(f"{ip}/{socks_port}/{username}/{password}" for ip in ips[:limit])
        if not has_rows:
            return ""
        return append_result_footer("\n".join(lines), "Socks5", self.mark_var.get())

    def _build_plain_socks5_format(self) -> str:
        if getattr(self, "private_192_168_100_result_active", False) and getattr(self, "current_workflow_mode", "") == "purchase":
            return ""
        log_blocks = self._ctyun_log_result_blocks("socks")
        if log_blocks:
            rows: list[str] = []
            for block in log_blocks:
                for line in block.splitlines()[2:]:
                    if line.strip():
                        rows.append(line.strip())
            return "\n".join(rows)
        if self.build_mode_var.get().strip() != CTYUN_BUILD_MODE_DEFAULT:
            return ""
        data = self._latest_result_data()
        regions = list((data.get("ecsIp") or {}).keys()) if isinstance(data.get("ecsIp"), dict) else []
        regions = [name for name in regions if self._is_result_region_allowed(str(name))]
        if self.default_conn_custom_saved:
            socks_port = self.default_conn_socks_port_var.get().strip() or "23456"
            username = self.default_conn_user_var.get().strip() or CTYUN_CUSTOM_CONNECTION_DEFAULT_USER
            password = self.default_conn_password_var.get().strip() or "wawacloud"
        else:
            socks_port = "23456"
            username = "root"
            password = "wawacloud"
        rows: list[str] = []
        limit = self._ctyun_eip_target_count()
        for name in regions:
            for ip in self._ordered_ips_for_region(name, data)[:limit]:
                rows.append(f"{ip}/{socks_port}/{username}/{password}")
        return "\n".join(rows)

    def _account_log_scope(self, keys: list[str] | None = None) -> str:
        """按当前 AK/SK 生成日志账号分组，避免多个软件实例写进同一日志目录。"""
        if keys and len(keys) >= 2:
            return credential_scope_label(keys[0], keys[1])
        return credential_scope_label(self.ak_var.get(), self.sk_var.get())

    def _recent_eip_release_warning(self, keys: list[str], regions: Sequence[str]) -> str:
        account_scope = self._account_log_scope(keys)
        now = time.time()
        warnings: list[str] = []
        for region in regions:
            released_at = load_eip_release_timestamp(account_scope, region)
            if not released_at:
                continue
            elapsed = max(0.0, now - released_at)
            if elapsed >= NEW_IP_RELEASE_COOLDOWN_SECONDS:
                continue
            warnings.append(
                f"{region}地区IP释放未超过2小时，距现在 {format_release_elapsed(elapsed)}，"
                "失败率将过高并造成无必要费用。"
            )
        if not warnings:
            return ""
        return "\n".join(["IP释放时间提醒：", *warnings])

    def _activate_task_runtime(self, context: str, action: str, keys: list[str]) -> None:
        """为本次任务创建独立运行目录，隔离 run_result、retained_ips 和停止文件。"""
        section = "资源管理" if context == "manage" else "购买搭建"
        account_scope = self._account_log_scope(keys)
        if self.current_account_scope and self.current_account_scope != account_scope:
            getattr(self, "result_ip_replacements", {}).clear()
        detail = f"ctyun-{time.strftime('%Y%m%d-%H%M%S')}-{RUN_INSTANCE_ID}"
        run_root = task_runtime_dir("CTYUN", section, action, detail, account_key=account_scope)
        self.runtime_root = run_root
        if context != "manage":
            self.purchase_result_root = run_root
        if context == "manage":
            self.cleanup_result_path = run_root / "cleanup_resources.json"
        self.current_account_scope = account_scope
        self.stop_file = run_root / ".workflow_stop"

    def _on_region_toggle(self, name: str) -> None:
        if self.updating_regions:
            return
        checked = self.region_vars[name].get()
        if checked:
            if name in self.selected_region_order:
                self.selected_region_order.remove(name)
                self.selected_region_order.append(name)
            elif len(self.selected_region_order) >= MAX_SELECTED_REGIONS:
                self.updating_regions = True
                try:
                    self.region_vars[name].set(False)
                finally:
                    self.updating_regions = False
                self._show_warning(f"最多可选择 {MAX_SELECTED_REGIONS} 个地区。")
            else:
                self.selected_region_order.append(name)
        elif name in self.selected_region_order:
            self.selected_region_order.remove(name)

    def _selected_regions(self) -> list[str]:
        return [name for name in self.selected_region_order if self.region_vars[name].get()]

    def _begin_async_task(
        self,
        title: str,
        *,
        affects_global_busy: bool = True,
        regions: Optional[Sequence[str]] = None,
        risk_group: str = WRITE_RESOURCE_RISK_GROUP,
        log_target: str | None = None,
    ) -> bool:
        try:
            task_guard = TaskGuard(
                cloud="CTYUN",
                ak=self.ak_var.get(),
                sk=self.sk_var.get(),
                action=title,
                regions=list(regions or ["global"]),
                risk_group=risk_group,
            ).acquire()
        except TaskGuardError as exc:
            self._set_status("任务被并发保护拦截")
            self._show_info(str(exc))
            return False
        with self.task_lock:
            self.active_task_count += 1
            if affects_global_busy:
                self.blocking_task_count += 1
            self.running = self.blocking_task_count > 0
            count = self.active_task_count
            self.task_guards.append(task_guard)
        self._set_buttons_state("normal")
        self._set_status(self._task_running_status(title))
        self._emit(f"\n{title}已异步启动；当前并发任务数 {count}\n", target_name=log_target)
        return True

    @staticmethod
    def _task_running_status(title: str) -> str:
        if "读取账号资源" in title or "查询资源" in title:
            return "正在读取账号资源"
        if title.endswith("中"):
            return title
        return f"{title}中"

    @staticmethod
    def _task_final_status(action_name: str, *, stopped: bool = False, failed: bool = False) -> str:
        if action_name in {"查询资源", "读取账号资源"}:
            if stopped:
                return "账号资源读取已停止"
            if failed:
                return "账号资源读取失败"
            return "账号资源读取完成"
        if stopped:
            return f"{action_name}已停止"
        if failed:
            return f"{action_name}失败"
        return f"{action_name}完成"

    def _finish_async_task(self, affects_global_busy: bool = True, final_status: str = "") -> None:
        task_guard = None
        with self.task_lock:
            self.active_task_count = max(0, self.active_task_count - 1)
            if affects_global_busy:
                self.blocking_task_count = max(0, self.blocking_task_count - 1)
            self.running = self.blocking_task_count > 0
            remaining = self.active_task_count
            if self.task_guards:
                task_guard = self.task_guards.pop()
        if task_guard is not None:
            task_guard.release()
        self._set_status("任务运行中" if remaining else (final_status or "就绪"))

    def _start_purchase_only(self) -> None:
        self._start("purchase", new_ip_only=False)

    def _precheck_purchase_new_ip_regions(self, regions: Sequence[str]) -> list[str]:
        account_items = self._account_resources_for_purchase()
        if not account_items:
            message = (
                "没有最新账号资源信息，无法确认可复用云服务器；"
                "请等待粘贴 AK/SK 后的资源读取完成，或点击“一键读取账号资源”后重试。"
            )
            self._emit(message + "\n", target_name="purchase")
            self._show_warning(message)
            return []
        try:
            used_ip_rows = self._read_used_ip_csv_rows(self._used_ip_table_path())
            used_ips = {
                self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]))
                for row in used_ip_rows
                if self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]))
            }
        except Exception as exc:
            message = f"读取旧IP库失败，已停止购买选择地区新IP：{exc}"
            self._emit(message + "\n", target_name="purchase")
            self._show_warning(message)
            return []
        if not used_ips:
            message = "旧IP库为空，无法过滤已使用IP；请先右键“更换新IP”配置并保存或链接旧IP库。"
            self._emit(message + "\n", target_name="purchase")
            self._show_warning(message)
            return []

        items_by_region: dict[str, list[dict[str, Any]]] = {}
        for item in account_items:
            if bool(item.get("_displayOnly")):
                continue
            region = self._cleanup_region_name(item)
            if region:
                items_by_region.setdefault(region, []).append(item)

        eligible: list[str] = []
        missing_ecs: list[str] = []
        full_eip: list[str] = []
        target_count = self._ctyun_eip_target_count()
        for region in regions:
            region_items = items_by_region.get(region, [])
            if not any(self._is_active_cleanup_ecs_item(item) for item in region_items):
                missing_ecs.append(region)
                continue
            eip_keys = {
                str(
                    item.get("eipID")
                    or item.get("ID")
                    or item.get("id")
                    or self._cleanup_eip_address(item)
                    or item.get("key")
                    or ""
                ).strip()
                for item in region_items
                if str(item.get("type") or "") in {"EIP", "HAVIP_EIP"}
            }
            eip_count = len({key for key in eip_keys if key})
            if eip_count >= target_count:
                full_eip.append(region)
                continue
            eligible.append(region)

        cancelled = set(missing_ecs + full_eip)
        if cancelled:
            self.updating_regions = True
            try:
                for region in cancelled:
                    var = self.region_vars.get(region)
                    if var is not None:
                        var.set(False)
                    if region in self.selected_region_order:
                        self.selected_region_order.remove(region)
            finally:
                self.updating_regions = False

        notices: list[str] = []
        if missing_ecs:
            notices.append(
                "以下地区未发现可复用的云服务器，已取消："
                f"{'、'.join(missing_ecs)}。此功能只为已有云服务器购买并绑定新弹性IP，不会创建云服务器。"
            )
        if full_eip:
            notices.append(
                f"以下地区已达到当前购买选项的 {target_count} 条弹性IP上限，已取消："
                f"{'、'.join(full_eip)}。请先释放对应旧IP，等待超过2小时后再购买新IP，可降低再次获得旧IP的概率。"
            )
        if notices:
            notices.append("其他符合条件的已选地区将继续执行。" if eligible else "本次没有符合条件的地区，任务已取消。")
            message = "\n\n".join(notices)
            self._emit(message + "\n", target_name="purchase")
            self._show_info(message)
        return eligible

    def _start_purchase_new_ips(self) -> None:
        self._start("purchase", new_ip_only=True)

    def _start_build_only(self) -> None:
        self._start("build", new_ip_only=False)

    def _start_purchase_and_build(self) -> None:
        self._start("all", new_ip_only=False)

    def _start(
        self,
        mode: str = "all",
        *,
        new_ip_only: bool = False,
    ) -> None:
        keys = [self.ak_var.get().strip(), self.sk_var.get().strip()]
        regions = self._selected_regions()
        action_title = {
            "purchase": "购买选择地区",
            "build": "搭建选择地区",
            "all": "一键购买并搭建选择地区",
        }.get(mode, "一键购买并搭建选择地区")
        if new_ip_only:
            action_title = "购买选择地区新IP"
        if not keys[0] or not keys[1]:
            self._show_warning("请粘贴两行 AK/SK。")
            return
        if not self._valid_keys(keys[0], keys[1]):
            self._show_warning("AK/SK 格式不正确。请粘贴完整内容，系统会自动识别 32 位 AccessKey ID 和 SecurityKey。")
            return
        guard_message = self._customer_guard_block_message(keys[0], keys[1])
        if guard_message:
            self._show_customer_guard_block(guard_message)
            return
        if not regions:
            self._show_warning(f"请至少选择一个地区，最多 {MAX_SELECTED_REGIONS} 个。")
            return
        if new_ip_only:
            regions = self._precheck_purchase_new_ip_regions(regions)
            if not regions:
                return
        if bool(self.private_192_168_100_var.get()) and mode in {"build", "all"}:
            self._show_warning("已选择 VPC:192.168.100.101~110 购买选项，该选项仅支持购买，不支持搭建。请取消该购买选项后再执行搭建，或仅点击购买选择地区。")
            return
        release_warning = self._recent_eip_release_warning(keys, regions) if new_ip_only else ""
        if not self._ask_yes_no(
            _action_confirm_message(
                f"确认开始天翼云{action_title} {len(regions)} 个地区",
                regions=regions,
                details=self._ctyun_workflow_confirm_details(mode, regions, new_ip_only=new_ip_only),
                warning=release_warning,
                cost=(mode != "build"),
                final_prompt="确认继续购买选择地区新IP吗？" if release_warning else "",
            ),
        ):
            return

        getattr(self, "result_ip_replacements", {}).clear()
        self._activate_task_runtime("purchase", action_title, keys)
        self._write_input(keys[0], keys[1], regions)
        self.current_workflow_keys = keys
        self.current_workflow_regions = regions
        self.current_workflow_mode = mode
        self.current_workflow_new_ip_only = new_ip_only
        self.result_data_started_at = time.time()
        self.last_task_failure_reason = ""
        self.private_192_168_100_result_active = mode == "purchase" and bool(self.private_192_168_100_var.get())
        if mode in {"build", "all"}:
            self._restore_result_regions(regions)
        self.eip_sold_out_popup_shown = False
        self._reset_stop()
        self.active_log_name = "purchase"
        self.realtime_log_filter.reset("purchase")
        self.log_action_names["purchase"] = self._workflow_action_name(mode)
        self._replay_customer_guard_success_for_widget(self.purchase_progress_text)
        try:
            self.result_log_start_index = self.purchase_progress_text.index("end-1c")
        except tk.TclError:
            self.result_log_start_index = "1.0"
        self.active_progress_text = self.purchase_progress_text
        if not self._begin_async_task(f"天翼云{action_title}", regions=regions):
            return
        if mode == "build":
            self._emit("搭建模式：读取所选地区已有云主机绑定 IP 后执行 SSH 搭建。\n")
        elif mode == "purchase":
            if new_ip_only:
                self._emit(
                    f"购买选择地区新IP：旧IP库过滤，仅复用已有云服务器，{self._ctyun_eip_purchase_rule_label()}，完成后不执行 SSH。\n"
                )
            else:
                self._emit(f"购买模式：{self._ctyun_eip_purchase_rule_label()}，完成后不执行 SSH。\n")
        else:
            self._emit(f"自动购买映射: {self._ctyun_eip_purchase_rule_label()}，选择地区并发执行，地区购买完成即开始 SSH 搭建。\n")
        threading.Thread(target=self._run_workflow, daemon=True).start()

    def _query_cleanup_resources(
        self,
        regions_override: list[str] | None = None,
        *,
        merge_with_cache: bool = False,
        final_popup_message: str | None = None,
    ) -> None:
        if not self._prepare_keys_only(action_name="查询资源", context="manage", isolate_runtime=True):
            return
        self._reset_stop()
        query_regions = list(regions_override or load_regions())
        run_root = self.runtime_root
        self.cleanup_result_path = run_root / "cleanup_resources.json"
        self.cleanup_finalized_result_paths.discard(self._path_key(self.cleanup_result_path))
        self.cleanup_completion_watch_paths.discard(self._path_key(self.cleanup_result_path))
        for path in (run_root / "cleanup_resources.json", run_root / "cleanup_selection.json"):
            try:
                path.unlink(missing_ok=True)
            except Exception:
                pass
        self.active_log_name = "cleanup"
        self.realtime_log_filter.reset("cleanup")
        self.realtime_log_filter.reset("purchase")
        self.log_action_names["manage"] = "刷新任务地区资源" if merge_with_cache else "查询资源"
        self._replay_customer_guard_success_for_widget(self.cleanup_progress_text)
        self.active_progress_text = self.cleanup_progress_text
        query_log_target = "both" if merge_with_cache else "account-query"
        if merge_with_cache:
            self._emit(f"正在刷新所选地区资源：{'、'.join(query_regions)}。\n", target_name="both")
        else:
            self._clear_cleanup_tree()
        self.pending_after_done = None
        if not self._begin_async_task(
            "天翼云刷新任务地区资源" if merge_with_cache else "天翼云读取账号资源",
            affects_global_busy=False,
            regions=query_regions,
            risk_group=READ_RESOURCE_RISK_GROUP,
            log_target=query_log_target,
        ):
            return
        if merge_with_cache:
            self._append_cleanup_realtime_text("正在刷新任务地区资源：资源库会随查询结果实时更新。\n")
        else:
            self._append_cleanup_realtime_text("您好合作伙伴，欢迎使用！查询账号资源中。\n")
        self._start_cleanup_live_refresh(
            run_root,
            refreshed_regions=query_regions if merge_with_cache else None,
        )

        def load_final_results(root: Path = run_root, regions: list[str] = query_regions) -> None:
            self._finalize_cleanup_query_display(
                root,
                refreshed_regions=regions if merge_with_cache else None,
            )

        threading.Thread(
            target=lambda: self._run_single_phase(
                "cleanup-query",
                "查询账号资源中",
                regions_override=query_regions,
                cleanup_query_scope="regions" if merge_with_cache else "account",
                done_callback=load_final_results,
                affects_global_busy=False,
                popup_message_override=final_popup_message,
            ),
            daemon=True,
        ).start()

    def _refresh_cleanup_resources_after_task(
        self,
        regions: list[str] | None = None,
        *,
        final_popup_message: str | None = None,
        invalidate_results: bool = False,
    ) -> None:
        """任务结束后只重新读取本次涉及地区，未涉及地区保持缓存显示。"""
        if self.stop_requested:
            if final_popup_message:
                self._show_completion_message(final_popup_message)
            return
        refresh_regions = [str(region or "").strip() for region in (regions or []) if str(region or "").strip()]
        if not refresh_regions:
            self._emit("未识别到本次任务地区，已跳过自动刷新资源。\n")
            if final_popup_message:
                self._show_completion_message(final_popup_message)
            return
        try:
            if invalidate_results:
                self._invalidate_result_regions(refresh_regions)
            self._emit(f"任务结束后仅刷新本次任务地区资源：{'、'.join(refresh_regions)}。\n", target_name="both")
            self._query_cleanup_resources(
                regions_override=refresh_regions,
                merge_with_cache=True,
                final_popup_message=final_popup_message,
            )
        except Exception as exc:
            self._show_warning(f"任务结束后自动刷新资源失败：{exc}")
            if final_popup_message:
                self._show_completion_message(final_popup_message)

    def _schedule_cleanup_resources_after_task(
        self,
        regions: list[str] | None = None,
        *,
        final_popup_message: str | None = None,
        invalidate_results: bool = False,
        delay_ms: int = 600,
    ) -> None:
        try:
            self.root.after(
                delay_ms,
                lambda selected_regions=list(regions or []), popup_message=final_popup_message: self._refresh_cleanup_resources_after_task(
                    selected_regions,
                    final_popup_message=popup_message,
                    invalidate_results=invalidate_results,
                ),
            )
        except tk.TclError:
            self._refresh_cleanup_resources_after_task(
                regions,
                final_popup_message=final_popup_message,
                invalidate_results=invalidate_results,
            )

    def _execute_clear_cleanup_resources(self) -> None:
        if self._cleanup_cloud_server_limited_features():
            return
        if not self.cleanup_items:
            self._show_warning("请先读取账号资源，再执行一键清空账号资源。")
            return
        self._set_all_cleanup_items(True)
        self._execute_cleanup_resources(action_name="一键清空账号资源")

    @staticmethod
    def _cleanup_eip_address(item: dict[str, Any]) -> str:
        for key in ("address", "eipAddress", "publicIP", "publicIp", "name"):
            value = str(item.get(key) or "").strip()
            if re.fullmatch(r"(?:\d{1,3}\.){3}\d{1,3}", value):
                return value
        return ""

    @staticmethod
    def _cleanup_eip_id(item: dict[str, Any]) -> str:
        return str(item.get("eipID") or item.get("ID") or item.get("id") or "").strip()

    def _record_confirmed_eip_release_time(
        self,
        account_scope: str,
        region: str,
        selected_items: Sequence[dict[str, Any]],
        result_path: Path,
        log,
    ) -> int:
        selected_eips: dict[tuple[str, str], dict[str, Any]] = {}
        for item in selected_items:
            if str(item.get("type") or "").upper() not in {"EIP", "HAVIP_EIP"}:
                continue
            eip_id = self._cleanup_eip_id(item)
            address = self._cleanup_eip_address(item)
            if eip_id:
                selected_eips[("id", eip_id)] = item
            elif address:
                selected_eips[("address", address)] = item
        if not selected_eips:
            return 0

        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            self._emit(
                f"[{region}] 无法读取删除后云端复查结果，不记录IP释放时间：{exc}\n",
                log,
                target_name="cleanup",
            )
            return 0
        failed_regions = {
            str(value or "").strip()
            for value in ((data.get("failedRegions") if isinstance(data, dict) else []) or [])
            if str(value or "").strip()
        }
        if (
            not isinstance(data, dict)
            or data.get("preserveExisting")
            or data.get("completed") is not True
            or region in failed_regions
        ):
            self._emit(
                f"[{region}] 删除后云端复查未完整成功，不记录IP释放时间。\n",
                log,
                target_name="cleanup",
            )
            return 0

        current_items = [
            item
            for item in (data.get("items") or [])
            if isinstance(item, dict)
            and self._cleanup_region_name(item) == region
            and str(item.get("type") or "").upper() in {"EIP", "HAVIP_EIP"}
        ]
        current_ids = {self._cleanup_eip_id(item) for item in current_items if self._cleanup_eip_id(item)}
        current_addresses = {
            self._cleanup_eip_address(item)
            for item in current_items
            if self._cleanup_eip_address(item)
        }
        released_count = 0
        for identity_type, identity_value in selected_eips:
            if identity_type == "id":
                released = identity_value not in current_ids
            else:
                released = identity_value not in current_addresses
            if released:
                released_count += 1

        if not released_count:
            self._emit(
                f"[{region}] 云端复查未确认选中弹性IP已释放，不记录IP释放时间。\n",
                log,
                target_name="cleanup",
            )
            return 0
        try:
            released_at = save_eip_release_timestamp(account_scope, region)
        except Exception as exc:
            self._emit(
                f"[{region}] 已确认释放 {released_count} 条弹性IP，但保存释放时间失败：{exc}\n",
                log,
                target_name="cleanup",
            )
            return released_count
        self._emit(
            f"[{region}] 云端已确认释放 {released_count} 条弹性IP，释放时间已记录："
            f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(released_at))}。\n",
            log,
            target_name="cleanup",
        )
        return released_count

    def _update_failed_ip_table_from_replace_result(
        self,
        selection_path: Path,
        run_root: Path | None = None,
    ) -> tuple[int, int, bool]:
        table_path = self._used_ip_table_path()
        if table_path.suffix.lower() != ".xlsx":
            return 0, 0, False

        try:
            selected = json.loads(selection_path.read_text(encoding="utf-8-sig"))
        except Exception:
            selected = []
        if not isinstance(selected, list):
            selected = []

        result_path = (run_root or self.runtime_root) / "replace_ip_results.json"
        try:
            result_data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception:
            result_data = {}
        if not isinstance(result_data, dict):
            result_data = {}

        selected_by_ip: dict[str, str] = {}
        for item in selected:
            if not isinstance(item, dict):
                continue
            old_ip = self._cleanup_eip_address(item)
            if old_ip:
                selected_by_ip[old_ip] = self._cleanup_region_name(item) or "未识别地区"

        replacements = result_data.get("replacements")
        if not isinstance(replacements, dict):
            replacements = {}
        succeeded_old_ips: set[str] = set()
        remove_ips: set[str] = set()
        handled_old_ips: set[str] = set()
        for old_value, new_value in replacements.items():
            old_ip = self._normalize_used_ip_value(old_value)
            new_ip = self._normalize_used_ip_value(new_value)
            if old_ip:
                succeeded_old_ips.add(old_ip)
                handled_old_ips.add(old_ip)
                remove_ips.add(old_ip)
            if new_ip:
                remove_ips.add(new_ip)

        failed_by_ip: dict[str, str] = {}
        result_items = result_data.get("items")
        if not isinstance(result_items, list):
            result_items = []
        for record in result_items:
            if not isinstance(record, dict):
                continue
            old_ip = self._normalize_used_ip_value(record.get("oldAddress"))
            new_ip = self._normalize_used_ip_value(record.get("newAddress"))
            region = str(record.get("region") or selected_by_ip.get(old_ip) or "未识别地区").strip()
            if old_ip:
                handled_old_ips.add(old_ip)
                remove_ips.add(old_ip)
            if bool(record.get("changed")):
                if new_ip:
                    remove_ips.add(new_ip)
                continue
            failed_ip = new_ip or old_ip
            if failed_ip:
                failed_by_ip[failed_ip] = region

        for old_ip, region in selected_by_ip.items():
            if old_ip in succeeded_old_ips or old_ip in handled_old_ips:
                continue
            failed_by_ip[old_ip] = region

        remove_ips.difference_update(failed_by_ip)
        failed_rows = self._read_failed_ip_rows(table_path)
        before_ips = {row[USED_IP_CSV_HEADERS[3]] for row in failed_rows}
        if remove_ips:
            failed_rows = [
                row
                for row in failed_rows
                if row.get(USED_IP_CSV_HEADERS[3]) not in remove_ips
            ]
        timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
        account_value = self._used_ip_table_account_label()
        current_ips = {row[USED_IP_CSV_HEADERS[3]] for row in failed_rows}
        added_count = 0
        for ip, region in failed_by_ip.items():
            if ip in current_ips:
                continue
            failed_rows.append(self._used_ip_csv_row(timestamp, account_value, region, ip))
            current_ips.add(ip)
            added_count += 1
        removed_count = len(before_ips & remove_ips)
        main_rows = self._read_used_ip_csv_rows(table_path)
        self._write_used_ip_csv_rows(table_path, main_rows, failed_rows=failed_rows)
        return added_count, removed_count, True

    @staticmethod
    def _replace_public_ips_in_text(text: str, replacements: dict[str, str]) -> str:
        result = str(text or "")
        for old_ip, new_ip in sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True):
            if not old_ip or not new_ip or old_ip == new_ip:
                continue
            result = re.sub(rf"(?<!\d){re.escape(old_ip)}(?!\d)", new_ip, result)
        return result

    def _apply_replace_ip_results_to_outputs(self, run_root: Path | None = None) -> bool:
        path = (run_root or self.runtime_root) / "replace_ip_results.json"
        if not path.exists():
            return False
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            return False
        if not isinstance(data, dict):
            return False
        replacements = data.get("replacements")
        if not isinstance(replacements, dict):
            replacements = {}
        fallback_replacements = {
            str(old_ip).strip(): str(new_ip).strip()
            for old_ip, new_ip in replacements.items()
            if self._normalize_used_ip_value(old_ip) and self._normalize_used_ip_value(new_ip)
        }

        cached_l2tp = self._parse_cached_result_blocks(
            self._read_latest_result_file("last_l2tp.txt"),
            "l2tp",
        )
        cached_socks = self._parse_cached_result_blocks(
            self._read_latest_result_file("last_socks5.txt"),
            "socks",
        )
        region_replacements: dict[str, dict[str, str]] = {}
        assigned_old_ips: set[str] = set()
        result_items = data.get("items")
        if not isinstance(result_items, list):
            result_items = []
        for record in result_items:
            if not isinstance(record, dict):
                continue
            region = str(record.get("region") or "").strip()
            old_ip = self._normalize_used_ip_value(record.get("oldAddress"))
            new_ip = self._normalize_used_ip_value(record.get("newAddress"))
            if not region or not old_ip or not new_ip or old_ip == new_ip:
                continue
            region_replacements.setdefault(region, {})[old_ip] = new_ip
            assigned_old_ips.add(old_ip)

        for old_ip, new_ip in fallback_replacements.items():
            if old_ip in assigned_old_ips or old_ip == new_ip:
                continue
            pattern = re.compile(rf"(?<!\d){re.escape(old_ip)}(?!\d)")
            matching_regions = {
                region
                for region in set(cached_l2tp) | set(cached_socks)
                if pattern.search("\n".join(cached_l2tp.get(region, [])))
                or pattern.search("\n".join(cached_socks.get(region, [])))
            }
            if len(matching_regions) == 1:
                region = next(iter(matching_regions))
                region_replacements.setdefault(region, {})[old_ip] = new_ip

        if not region_replacements:
            return False

        changed = False
        updated_regions: list[str] = []
        applied_replacements: dict[str, str] = {}
        for region, region_mapping in region_replacements.items():
            l2tp_key = self._cached_result_region_key(cached_l2tp, region)
            socks_key = self._cached_result_region_key(cached_socks, region)
            if self._is_result_region_invalid(region):
                if l2tp_key:
                    cached_l2tp.pop(l2tp_key, None)
                    changed = True
                if socks_key:
                    cached_socks.pop(socks_key, None)
                    changed = True
                self._emit(
                    f"{region} 云服务器已重装，旧连接账号密码已失效，结果框不显示该地区。\n",
                    target_name="cleanup",
                )
                continue

            l2tp_rows = cached_l2tp.get(l2tp_key, [])
            socks_rows = cached_socks.get(socks_key, [])
            if not l2tp_key or not socks_key or not l2tp_rows or len(l2tp_rows) != len(socks_rows):
                if l2tp_key:
                    cached_l2tp.pop(l2tp_key, None)
                    changed = True
                if socks_key:
                    cached_socks.pop(socks_key, None)
                    changed = True
                self._emit(
                    f"{region} 未找到仍有效的完整搭建结果，结果框不显示该地区；请先重新搭建。\n",
                    target_name="cleanup",
                )
                continue

            l2tp_text = "\n".join(l2tp_rows)
            socks_text = "\n".join(socks_rows)
            effective_mapping = {
                old_ip: new_ip
                for old_ip, new_ip in region_mapping.items()
                if re.search(rf"(?<!\d){re.escape(old_ip)}(?!\d)", l2tp_text)
                and re.search(rf"(?<!\d){re.escape(old_ip)}(?!\d)", socks_text)
            }
            if not effective_mapping:
                self._emit(
                    f"{region} 更换的新IP不属于该地区现有结果，结果框保持原样。\n",
                    target_name="cleanup",
                )
                continue

            cached_l2tp[l2tp_key] = [
                self._replace_public_ips_in_text(row, effective_mapping)
                for row in l2tp_rows
            ]
            cached_socks[socks_key] = [
                self._replace_public_ips_in_text(row, effective_mapping)
                for row in socks_rows
            ]
            applied_replacements.update(effective_mapping)
            updated_regions.append(region)
            changed = True

        if changed:
            self._commit_cached_result_blocks(cached_l2tp, cached_socks)
        if not applied_replacements:
            return changed

        result_ip_replacements = getattr(self, "result_ip_replacements", None)
        if not isinstance(result_ip_replacements, dict):
            result_ip_replacements = {}
            self.result_ip_replacements = result_ip_replacements
        for known_old_ip, known_new_ip in list(result_ip_replacements.items()):
            if known_new_ip in applied_replacements:
                result_ip_replacements[known_old_ip] = applied_replacements[known_new_ip]
        result_ip_replacements.update(applied_replacements)
        pairs = ", ".join(f"{old}->{new}" for old, new in applied_replacements.items())
        self._emit(
            "更换新IP：已复用原有效连接账号密码并更新结果框："
            + "、".join(updated_regions)
            + f"（{pairs}）。\n",
            target_name="cleanup",
        )
        return True

    def _replace_ip_completion_message(self, fallback: str = "") -> str:
        result_path = self.runtime_root / "replace_ip_results.json"
        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception:
            return fallback or "天翼云更换新IP任务结束，请查看实时日志。"
        if not isinstance(data, dict) or not data.get("completed"):
            return fallback or "天翼云更换新IP任务结束，请查看实时日志。"

        success = int(data.get("success") or 0)
        failed = int(data.get("failed") or 0)
        if not failed:
            return f"天翼云更换新IP完成：成功 {success} 条，失败 0 条。"

        reasons = data.get("failureReasons")
        reason_lines = (
            [str(reason).strip() for reason in reasons if str(reason).strip()]
            if isinstance(reasons, list)
            else []
        )
        if not reason_lines:
            reason_lines = ["部分 IP 未完成换新，请查看实时日志。"]
        reason_text = "\n".join(f"- {reason}" for reason in reason_lines[:5])
        if len(reason_lines) > 5:
            reason_text += f"\n- 另有 {len(reason_lines) - 5} 条失败，请查看实时日志。"
        return (
            f"天翼云更换新IP任务结束：成功 {success} 条，失败 {failed} 条。"
            f"\n\n失败原因：\n{reason_text}"
        )

    def _after_replace_ips_done(self, regions: list[str], popup_message: str, selection_path: Path) -> None:
        completion_message = self._replace_ip_completion_message(popup_message)
        try:
            added_failed, removed_failed, failure_sheet_ready = self._update_failed_ip_table_from_replace_result(
                selection_path,
                self.runtime_root,
            )
            if failure_sheet_ready and (added_failed or removed_failed):
                self._append_cleanup_realtime_text(
                    f"更换失败IP库已更新：新增 {added_failed} 条，移除 {removed_failed} 条。\n",
                    mirror_purchase=False,
                )
            elif not failure_sheet_ready:
                self._append_cleanup_realtime_text(
                    "当前旧IP库不是Excel文件，未能更新更换失败IP工作表。\n",
                    mirror_purchase=False,
                )
        except Exception as exc:
            self._append_cleanup_realtime_text(
                f"更新更换失败IP工作表失败：{exc}\n",
                mirror_purchase=False,
            )
        self._apply_replace_ip_results_to_outputs(self.runtime_root)
        self._show_completion_message(
            completion_message,
            final_status=self.status_var.get(),
        )
        self._schedule_cleanup_resources_after_task(
            regions,
            final_popup_message=None,
            invalidate_results=False,
        )

    def _execute_replace_ips(self) -> None:
        if not self._prepare_keys_only(action_name="更换新IP", context="manage", isolate_runtime=False):
            return
        selected_raw = self._selected_cleanup_items()
        selected = [
            item
            for item in selected_raw
            if str(item.get("type") or "") in {"EIP", "HAVIP_EIP"}
            and str(item.get("eipID") or item.get("ID") or "").strip()
        ]
        replaceable = [
            item
            for item in selected
            if str(item.get("type") or "") == "HAVIP_EIP"
            or str(item.get("associationID") or item.get("associationName") or item.get("associationType") or "").strip()
        ]
        if not replaceable:
            self._show_warning("请先选择需要更换的已绑定弹性IP。")
            return
        selected_regions = sorted({
            self._cleanup_region_name(item)
            for item in replaceable
            if self._cleanup_region_name(item)
        })
        selected_resources = [self._cleanup_item_label(item, include_region=False) for item in replaceable]
        if not self._ask_yes_no(
            _action_confirm_message(
                f"确认更换 {len(replaceable)} 条弹性IP",
                regions=selected_regions,
                resources=selected_resources,
                warning=(
                    "会先记录旧IP到旧IP库，再释放旧EIP、申请新EIP并绑定回原资源。"
                    "\n该操作会创建云资源并产生费用，旧IP会断网。"
                ),
            ),
        ):
            return
        try:
            grouped_ips: dict[str, list[str]] = {}
            for item in replaceable:
                address = self._cleanup_eip_address(item)
                if not address:
                    continue
                grouped_ips.setdefault(self._cleanup_region_name(item) or "未识别地区", []).append(address)
            for region, ips in grouped_ips.items():
                path, added_count = self._append_used_ip_table(ips, region_label=region)
                self._append_cleanup_realtime_text(f"[{region}] 旧IP库已记录 {len(ips)} 条，本次新增 {added_count} 条: {path}\n", mirror_purchase=False)
        except Exception as exc:
            self._show_warning(f"旧IP库写入失败，本次仍可继续更换，但不会记录旧IP：{exc}")

        selection_path = self._runtime_path("replace_ip_selection.json")
        selection_path.write_text(json.dumps(replaceable, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        try:
            self._runtime_path("replace_ip_results.json").unlink(missing_ok=True)
        except OSError:
            pass
        self._reset_stop()
        self.active_log_name = "cleanup"
        self.realtime_log_filter.reset("cleanup")
        self.log_action_names["manage"] = "更换新IP"
        self.active_progress_text = self.cleanup_progress_text
        if not self._begin_async_task("天翼云更换新IP", regions=selected_regions or load_regions(), log_target="cleanup"):
            return
        threading.Thread(
            target=lambda regions=list(selected_regions), path=selection_path: self._run_single_phase(
                "replace-ip",
                "更换新IP中",
                regions_override=regions or load_regions(),
                cleanup_selection_file=path,
                done_callback=lambda popup_message, target_regions=regions, target_path=path: self._after_replace_ips_done(target_regions, popup_message, target_path),
                popup_after_callback=True,
            ),
            daemon=True,
        ).start()

    def _execute_cleanup_resources(self, *, action_name: str = "退订删除资源") -> None:
        if not self._prepare_keys_only(action_name=action_name, context="manage", isolate_runtime=False):
            return
        selected = self._selected_cleanup_items()
        if not selected:
            self._show_warning("请先选择需要退订的资源。")
            return
        selected_regions = sorted({str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip() for item in selected if str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip()})
        selected_resources = [
            self._cleanup_item_label(item, include_region=False)
            for item in selected
        ]
        confirm_message = _action_confirm_message(
            f"确认退订/删除 {len(selected)} 项资源",
            regions=selected_regions,
            resources=selected_resources,
            warning="确认执行退订/删除/释放/重装会影响资源且可能无法恢复",
        )
        release_ip_groups: dict[str, list[str]] = {}
        for item in selected:
            if str(item.get("type") or "").upper() not in {"EIP", "HAVIP_EIP"}:
                continue
            address = self._cleanup_eip_address(item)
            if not address or not is_public_ip(address):
                continue
            region = self._cleanup_region_name(item) or "未识别地区"
            bucket = release_ip_groups.setdefault(region, [])
            if address not in bucket:
                bucket.append(address)
        release_action = self._ask_cleanup_release_action(
            confirm_message,
            allow_used_ip_import=bool(release_ip_groups),
        )
        if release_action == "cancel":
            return
        if release_action == "confirm_and_import":
            target_path = self._used_ip_table_path()
            try:
                imported_count = 0
                for region, ips in release_ip_groups.items():
                    path, added_count = self._append_used_ip_table(ips, region_label=region)
                    imported_count += added_count
                    self._append_cleanup_realtime_text(
                        f"[{region}] 释放IP已合并导入旧IP库 {len(ips)} 条，本次新增 {added_count} 条：{path}\n",
                        mirror_purchase=False,
                    )
                self._append_cleanup_realtime_text(
                    f"确认并导入旧IP库完成：本次新增 {imported_count} 条，随后执行资源释放。\n",
                    mirror_purchase=False,
                )
            except Exception as exc:
                message = self._used_ip_import_failure_message(target_path, exc)
                self._append_cleanup_realtime_text(f"{message}\n", mirror_purchase=False)
                self._show_error(message)
                return

        self._reset_stop()
        self.active_log_name = "cleanup"
        self.realtime_log_filter.reset("cleanup")
        self.log_action_names["manage"] = action_name
        self.active_progress_text = self.cleanup_progress_text
        task_title = "天翼云一键清空账号资源" if action_name == "一键清空账号资源" else "天翼云退订/删除"
        if not self._begin_async_task(task_title, regions=selected_regions or load_regions(), log_target="cleanup"):
            return
        self._emit(
            ("正在一键清空账号资源：实时日志会显示每项处理进度。\n" if action_name == "一键清空账号资源" else "正在退订/删除选定资源：实时日志会显示每项处理进度。\n"),
            target_name="cleanup",
        )
        account_scope = self._account_log_scope()
        threading.Thread(
            target=lambda: self._run_cleanup_execute_by_region(
                action_name,
                selected,
                selected_regions,
                account_scope,
            ),
            daemon=True,
        ).start()

    def _refresh_cleanup_region_after_delete(self, run_root: Path, region: str, log) -> bool:
        self._emit(f"[{region}] 删除流程结束，正在实时刷新该地区资源库。\n", log, target_name="cleanup")
        code = self._run_phase(
            "cleanup-query",
            log,
            regions_override=[region],
            cleanup_query_scope="regions",
            run_root=run_root,
        )
        if code == 0 and not self.stop_requested:
            source_path = run_root / "cleanup_resources.json"
            safe_region = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", region).strip("_") or "region"
            snapshot_root = run_root / f"cleanup_refresh_{safe_region}"
            try:
                snapshot_root.mkdir(parents=True, exist_ok=True)
                if source_path.exists():
                    shutil.copyfile(source_path, snapshot_root / "cleanup_resources.json")
                else:
                    self._emit(f"[{region}] 未找到刷新结果文件，无法更新资源库。\n", log, target_name="cleanup")
                    return False
            except Exception as exc:
                self._emit(f"[{region}] 保存刷新结果快照失败：{exc}\n", log, target_name="cleanup")
                return False
            self.root.after(
                0,
                lambda root=snapshot_root, region_name=region: self._load_cleanup_resources(
                    root,
                    refreshed_regions=[region_name],
                    mark_ready=False,
                    show_missing=False,
                ),
            )
            self._emit(f"[{region}] 购买搭建和资源管理资源库已提交实时刷新。\n", log, target_name="cleanup")
            return True
        if code == 130 or self.stop_requested:
            self._emit(f"[{region}] 已停止，跳过该地区删除后刷新。\n", log, target_name="cleanup")
        else:
            self._emit(f"[{region}] 删除后刷新资源库失败，退出码 {code}。\n", log, target_name="cleanup")
        return False

    def _run_cleanup_execute_by_region(
        self,
        action_name: str,
        selected: Sequence[dict[str, Any]],
        selected_regions: Sequence[str],
        account_scope: str,
    ) -> None:
        self.thread_context.log_name = "cleanup"
        run_root = self.runtime_root
        grouped: dict[str, list[dict[str, Any]]] = {}
        for item in selected:
            region = self._cleanup_region_name(item)
            if not region:
                continue
            grouped.setdefault(region, []).append(dict(item))
        log_path = self._ctyun_log_path(context="manage", action=action_name, prefix="ctyun-run")
        failed_regions: list[str] = []
        refreshed_failed_regions: list[str] = []
        success_regions: list[str] = []
        stopped = False
        with log_path.open("w", encoding="utf-8") as log:
            self._emit(f"当前目录: {run_root}\n", target_name="cleanup")
            self._emit(f"开始时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n\n", target_name="cleanup")
            title = "一键清空账号资源中" if action_name == "一键清空账号资源" else "按需退订所选资源中"
            self._emit(f"===== {title} =====\n", log, target_name="cleanup")
            if not grouped:
                self._emit("未识别到可执行删除的地区，任务结束。\n", log, target_name="cleanup")
                failed_regions.extend(str(region) for region in selected_regions if str(region or "").strip())
            else:
                self._emit(
                    f"按地区并发执行删除：{'、'.join(grouped.keys())}。每个地区删除完毕后会实时刷新购买搭建和资源管理资源库。\n",
                    log,
                    target_name="cleanup",
                )
                jobs = {}
                with ThreadPoolExecutor(max_workers=max(1, len(grouped))) as pool:
                    for index, (region, region_items) in enumerate(grouped.items(), start=1):
                        selection_path = self._runtime_path(f"cleanup_selection_{index}.json", run_root)
                        selection_path.write_text(json.dumps(region_items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
                        self._emit(f"[{region}] 已进入删除队列，资源数 {len(region_items)}。\n", log, target_name="cleanup")
                        jobs[pool.submit(
                            self._run_phase,
                            "cleanup-execute",
                            log,
                            regions_override=[region],
                            cleanup_selection_file=selection_path,
                            run_root=run_root,
                        )] = region
                    for future in as_completed(jobs):
                        region = jobs[future]
                        try:
                            code = future.result()
                        except Exception as exc:
                            code = 1
                            self._emit(f"[{region}] 删除子任务异常：{exc}\n", log, target_name="cleanup")
                        if self.stop_requested or code == 130:
                            stopped = True
                            self._emit(f"[{region}] 删除任务已停止。\n", log, target_name="cleanup")
                            continue
                        if code == 0:
                            success_regions.append(region)
                            self._emit(f"[{region}] 删除子任务完成。\n", log, target_name="cleanup")
                            self.root.after(
                                0,
                                lambda region_name=region: self._invalidate_result_regions(
                                    [region_name],
                                    reason="已清除本次地区的旧搭建结果，需重新搭建后生成新结果。\n",
                                ),
                            )
                        else:
                            failed_regions.append(region)
                            self._emit(f"[{region}] 删除子任务结束，退出码 {code}。\n", log, target_name="cleanup")
                        refreshed = self._refresh_cleanup_region_after_delete(run_root, region, log)
                        if not refreshed:
                            refreshed_failed_regions.append(region)
                        else:
                            self._record_confirmed_eip_release_time(
                                account_scope,
                                region,
                                grouped.get(region, []),
                                run_root / "cleanup_resources.json",
                                log,
                            )
            self._emit("\n", log, target_name="cleanup")
            self._emit(f"结束时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n", log, target_name="cleanup")
            self._emit(f"日志文件: {log_path}\n", log, target_name="cleanup")
        self._emit("\n任务已结束，可手动关闭窗口。\n", target_name="cleanup")
        failed = bool(failed_regions or refreshed_failed_regions)
        if stopped:
            popup_message = f"天翼云{action_name}任务已停止。"
        elif failed:
            detail_regions = failed_regions or refreshed_failed_regions
            popup_message = f"天翼云{action_name}任务结束，失败 {len(detail_regions)} 个地区，请查看实时日志。"
        else:
            popup_message = f"天翼云{action_name}任务完成。"
        final_status = self._task_final_status(action_name, stopped=stopped, failed=failed)
        self.queue.put(("done", None, True, popup_message, final_status))

    def _execute_reinstall_resources(self, source: str = "cleanup") -> None:
        if str(source or "") == "purchase":
            self._execute_reinstall_selected_purchase_regions()
            return
        if self._cleanup_cloud_server_limited_features():
            return
        if not self._prepare_keys_only(action_name="重装系统", context="manage", isolate_runtime=False):
            return
        selected = [item for item in self._selected_cleanup_items() if str(item.get("type") or "") == "ECS"]
        if not selected:
            self._show_warning("请先选择需要重装系统的云服务器。")
            return
        selected_regions = sorted({str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip() for item in selected if str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip()})
        selected_resources = [
            f"{item.get('name') or item.get('id') or item.get('ip') or '云服务器'}"
            for item in selected
        ]
        if not self._ask_yes_no(
            _action_confirm_message(
                f"确认重装 {len(selected)} 台云服务器",
                regions=selected_regions,
                resources=selected_resources,
                warning="确认执行退订/删除/释放/重装会影响资源且可能无法恢复",
            ),
        ):
            return

        selection_path = self._runtime_path("cleanup_selection.json")
        selection_path.write_text(json.dumps(selected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        self._reset_stop()
        self.active_log_name = "cleanup"
        self.realtime_log_filter.reset("cleanup")
        self.log_action_names["manage"] = "重装系统"
        self.active_progress_text = self.cleanup_progress_text
        if not self._begin_async_task("天翼云重装", regions=selected_regions or load_regions()):
            return
        threading.Thread(
            target=lambda: self._run_single_phase(
                "reinstall-execute",
                "重装选定云服务器中",
                regions_override=load_regions(),
                cleanup_selection_file=selection_path,
                done_callback=lambda popup_message, regions=selected_regions, root=self.runtime_root: self._after_reinstall_task(
                    regions,
                    root,
                    popup_message,
                ),
                popup_after_callback=True,
            ),
            daemon=True,
        ).start()

    def _execute_reinstall_selected_purchase_regions(self) -> None:
        selected_regions = self._selected_regions()
        if not selected_regions:
            self._show_warning("请先勾选需要重装云服务器的地区。")
            return
        region_set = {str(region or "").strip() for region in selected_regions if str(region or "").strip()}
        selected = [
            dict(item)
            for item in self._account_resources_for_purchase()
            if str(item.get("type") or "") == "ECS"
            and self._cleanup_region_name(item) in region_set
            and self._is_active_cleanup_ecs_item(item)
            and not self._is_hidden_cleanup_item(item)
            and not bool(item.get("_displayOnly"))
        ]
        selected = self._dedupe_cleanup_display_items(selected)
        if not selected:
            self._show_warning("勾选地区未查询到账号内云服务器，请先等待账号资源读取完成或点击一键读取账号资源。")
            return
        keys = [self.ak_var.get().strip(), self.sk_var.get().strip()]
        if not keys[0] or not keys[1]:
            self._show_warning("请粘贴两行 AK/SK。")
            return
        if not self._valid_keys(keys[0], keys[1]):
            self._show_warning("AK/SK 格式不正确。请粘贴完整内容，系统会自动识别 32 位 AccessKey ID 和 SecurityKey。")
            return
        guard_message = self._customer_guard_block_message(keys[0], keys[1])
        if guard_message:
            self._show_customer_guard_block(guard_message)
            return
        selected_resources = [
            f"{item.get('name') or item.get('id') or item.get('ip') or '云服务器'}"
            for item in selected
        ]
        if not self._ask_yes_no(
            _action_confirm_message(
                f"确认重装 {len(selected)} 台云服务器",
                regions=selected_regions,
                resources=selected_resources,
                warning="确认执行重装系统会影响云服务器且可能无法恢复",
            ),
        ):
            return
        self._activate_task_runtime("purchase", "重装系统", keys)
        self._write_input(keys[0], keys[1], selected_regions)
        selection_path = self._runtime_path("cleanup_selection.json")
        selection_path.write_text(json.dumps(selected, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        self._reset_stop()
        self.active_log_name = "purchase"
        self.realtime_log_filter.reset("purchase")
        self.log_action_names["purchase"] = "重装系统"
        self.active_progress_text = self.purchase_progress_text
        if not self._begin_async_task("天翼云重装选定地区云服务器", regions=selected_regions):
            return
        threading.Thread(
            target=lambda regions=list(selected_regions), path=selection_path: self._run_purchase_region_reinstall(regions, path),
            daemon=True,
        ).start()

    def _run_purchase_region_reinstall(self, selected_regions: list[str], selection_path: Path) -> None:
        self.thread_context.log_name = "purchase"
        run_root = self.runtime_root
        log_path = self._ctyun_log_path(context="purchase", action="重装系统", prefix="ctyun-run")
        failed = False
        stopped = False
        popup_message = ""
        final_status = "重装系统完成"
        with log_path.open("w", encoding="utf-8") as log:
            self._emit(f"当前目录: {run_root}\n", log, target_name="purchase")
            self._emit(f"开始时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n\n", log, target_name="purchase")
            self._emit(f"===== 重装选定云服务器中 =====\n", log, target_name="purchase")
            self._emit(f"勾选地区：{'、'.join(selected_regions)}\n", log, target_name="purchase")
            code = self._run_phase(
                "reinstall-execute",
                log,
                regions_override=selected_regions,
                cleanup_selection_file=selection_path,
                run_root=run_root,
            )
            self._emit("\n", log, target_name="purchase")
            if self.stop_requested or code == 130:
                stopped = True
                self._emit("任务已停止。\n", log, target_name="purchase")
            elif code != 0:
                failed = True
                popup_message = f"天翼云重装系统任务结束，退出码 {code}，请查看实时日志。"
                self._emit(f"重装系统阶段结束，退出码 {code}。\n", log, target_name="purchase")
            self._emit(f"结束时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n", log, target_name="purchase")
            self._emit(f"日志文件: {log_path}\n", log, target_name="purchase")
        self._emit("\n任务已结束，可手动关闭窗口。\n", target_name="purchase")
        if stopped:
            popup_message = "天翼云重装系统任务已停止。"
            final_status = "重装系统已停止"
        elif failed:
            final_status = final_status if final_status == "重装系统未执行" else "重装系统失败"
        else:
            popup_message = "天翼云重装系统任务完成。"
        done_callback = None
        done_popup_message = popup_message
        if selected_regions and not stopped:
            def done_callback(
                regions: list[str] = list(selected_regions),
                message: str = popup_message,
                root: Path = run_root,
            ) -> None:
                self._after_reinstall_task(
                    regions,
                    root,
                    message,
                )
            done_popup_message = ""
        self.queue.put(("done", done_callback, True, done_popup_message, final_status))

    def _reset_stop(self) -> None:
        self.stop_requested = False
        try:
            self.stop_file.unlink(missing_ok=True)
        except Exception:
            pass

    def _request_stop(self) -> None:
        if not self.running:
            return
        self.stop_requested = True
        try:
            self.stop_file.write_text("stop\n", encoding="utf-8")
        except Exception:
            pass
        self._emit("\n已收到停止指令，当前接口调用结束后会停止。\n")
        for button in self.stop_buttons:
            button.configure(state="disabled")

    def shutdown(self) -> None:
        """主窗口关闭时释放子进程和 SSH 通道，避免打包临时目录被占用。"""
        self.stop_requested = True
        self.running = False
        self.blocking_task_count = 0
        self._stop_cleanup_live_refresh()
        try:
            self.stop_file.write_text("stop\n", encoding="utf-8")
        except Exception:
            pass
        try:
            with self.ssh_channel_lock:
                channel = self.ssh_channel
                self.ssh_channel = None
            close_channel(channel)
        except Exception:
            pass
        terminate_processes(self.child_processes)
        self.child_processes.clear()

    def _prepare_keys_only(self, *, action_name: str = "", context: str = "manage", isolate_runtime: bool = True) -> bool:
        keys = [self.ak_var.get().strip(), self.sk_var.get().strip()]
        if not keys[0] or not keys[1]:
            self._show_warning("请粘贴两行 AK/SK。")
            return False
        if not self._valid_keys(keys[0], keys[1]):
            self._show_warning("AK/SK 格式不正确。请粘贴完整内容，系统会自动识别 32 位 AccessKey ID 和 SecurityKey。")
            return False
        guard_message = self._customer_guard_block_message(keys[0], keys[1])
        if guard_message:
            if guard_message == CUSTOMER_OWNERSHIP_CHECKING_MESSAGE and action_name == "查询资源" and context == "manage":
                self.customer_guard_pending_resource_query = True
            self._show_customer_guard_block(guard_message)
            return False
        if isolate_runtime:
            self._activate_task_runtime(context, action_name or "资源管理", keys)
        self._write_input(keys[0], keys[1], [])
        return True

    def _write_input(self, ak: str, sk: str, regions: list[str]) -> None:
        content = [ak, sk, *regions]
        self._runtime_path("input.example.txt").write_text("\n".join(content) + "\n", encoding="utf-8")

    def _clear_cleanup_tree(self) -> None:
        self._update_region_resource_labels([])
        if self.cleanup_tree is not None:
            for iid in self.cleanup_tree.get_children():
                self.cleanup_tree.delete(iid)
        self.cleanup_item_vars.clear()
        self.cleanup_region_vars.clear()
        self.cleanup_region_body_frames.clear()
        self.cleanup_region_toggle_buttons.clear()
        if hasattr(self, "account_resource_items"):
            self.account_resource_items.clear()
        self.cleanup_items.clear()
        self.cleanup_tree_items.clear()
        self.cleanup_last_clicked_iid = ""

    def _set_cleanup_placeholder(self, text: str) -> None:
        self._clear_cleanup_tree()
        if self.cleanup_tree is not None:
            self.cleanup_tree.insert(
                "",
                tk.END,
                iid="cleanup-placeholder",
                text="",
                values=("", "", "提示", text, "", "", ""),
                tags=("evenrow",),
            )

    def _start_cleanup_live_refresh(
        self,
        run_root: Path,
        *,
        refreshed_regions: list[str] | None = None,
    ) -> None:
        self._stop_cleanup_live_refresh()
        self.cleanup_live_refresh_active = True
        self.cleanup_live_refresh_path = run_root / "cleanup_resources.json"
        self.cleanup_live_refresh_regions = list(refreshed_regions or [])
        self.cleanup_live_refresh_mtime = 0.0
        self.cleanup_live_refresh_count = -1
        self._poll_cleanup_live_refresh()

    def _stop_cleanup_live_refresh(self, path: Path | None = None) -> None:
        if path is not None and self.cleanup_live_refresh_path is not None:
            try:
                if self.cleanup_live_refresh_path.resolve() != path.resolve():
                    return
            except Exception:
                if self.cleanup_live_refresh_path != path:
                    return
        self.cleanup_live_refresh_active = False
        job = self.cleanup_live_refresh_job
        self.cleanup_live_refresh_job = None
        if job:
            try:
                self.root.after_cancel(job)
            except Exception:
                pass

    @staticmethod
    def _cleanup_result_completed(path: Path) -> bool:
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            return False
        return isinstance(data, dict) and data.get("completed") is True

    @staticmethod
    def _cleanup_result_error(path: Path) -> str:
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            return ""
        if not isinstance(data, dict):
            return ""
        return str(data.get("error") or "").strip()

    def _finalize_cleanup_query_display(
        self,
        run_root: Path,
        *,
        refreshed_regions: list[str] | None = None,
    ) -> int | None:
        path = run_root / "cleanup_resources.json"
        key = self._path_key(path)
        already_finalized = key in self.cleanup_finalized_result_paths
        self._stop_cleanup_live_refresh(path)
        count = self._load_cleanup_resources(
            run_root,
            refreshed_regions=refreshed_regions,
        )
        error_message = self._cleanup_result_error(path)
        if error_message:
            self._set_status("账号资源读取失败")
            if not already_finalized:
                self.cleanup_finalized_result_paths.add(key)
                self._append_cleanup_realtime_text(f"账号资源读取失败：{error_message}\n")
            return None
        self._set_status("账号资源读取完成")
        if not already_finalized:
            self.cleanup_finalized_result_paths.add(key)
            if refreshed_regions is None:
                self._append_cleanup_realtime_text("您好合作伙伴，欢迎使用！账号资源读取完毕。\n")
            elif count is not None:
                self._append_cleanup_realtime_text(f"账号资源读取完成：资源库已显示 {count} 项资源。\n")
            else:
                self._append_cleanup_realtime_text("账号资源读取完成。\n")
        return count

    def _schedule_cleanup_completed_watch(self, run_root: Path) -> None:
        path = run_root / "cleanup_resources.json"
        key = self._path_key(path)
        if key in self.cleanup_completion_watch_paths:
            return
        self.cleanup_completion_watch_paths.add(key)
        self.root.after(1200, lambda root=run_root, result_key=key: self._finish_completed_cleanup_query_if_stale(root, result_key))

    def _finish_completed_cleanup_query_if_stale(self, run_root: Path, result_key: str) -> None:
        self.cleanup_completion_watch_paths.discard(result_key)
        path = run_root / "cleanup_resources.json"
        if self.cleanup_live_refresh_path is not None and self._path_key(self.cleanup_live_refresh_path) != result_key:
            return
        if not path.exists() or not self._cleanup_result_completed(path):
            return
        if self.child_processes:
            self._schedule_cleanup_completed_watch(run_root)
            return
        self._finalize_cleanup_query_display(run_root)
        if self.active_task_count > 0:
            self._finish_async_task(affects_global_busy=False, final_status="账号资源读取完成")
            self._set_buttons_state("normal")
            self._reset_stop()

    def _poll_cleanup_live_refresh(self) -> None:
        if not self.cleanup_live_refresh_active:
            return
        path = self.cleanup_live_refresh_path
        if path is not None and path.exists():
            try:
                mtime = path.stat().st_mtime
            except OSError:
                mtime = 0.0
            if mtime and mtime > self.cleanup_live_refresh_mtime:
                self.cleanup_live_refresh_mtime = mtime
                try:
                    count = self._load_cleanup_resources(
                        path.parent,
                        refreshed_regions=self.cleanup_live_refresh_regions,
                        mark_ready=False,
                        show_missing=False,
                        partial=True,
                    )
                    if count is not None and count != self.cleanup_live_refresh_count:
                        self.cleanup_live_refresh_count = count
                        if count > 0 and self.cleanup_live_refresh_regions:
                            self._append_cleanup_realtime_text(
                                f"正在读取账号资源：资源库已实时更新，当前已显示 {count} 项资源。\n"
                            )
                    if self._cleanup_result_completed(path):
                        self._schedule_cleanup_completed_watch(path.parent)
                except Exception:
                    pass
        self.cleanup_live_refresh_job = self.root.after(700, self._poll_cleanup_live_refresh)

    def _load_cleanup_resources(
        self,
        run_root: Path | None = None,
        refreshed_regions: list[str] | None = None,
        *,
        mark_ready: bool = True,
        show_missing: bool = True,
        partial: bool = False,
    ) -> int | None:
        path = self.cleanup_result_path if run_root is None else run_root / "cleanup_resources.json"
        if path is None:
            path = self._runtime_path("cleanup_resources.json")
        if not path.exists():
            if show_missing:
                self._set_cleanup_placeholder("没有找到查询结果，请重新查询。")
            return None
        try:
            data = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            if show_missing:
                self._set_cleanup_placeholder(f"读取查询结果失败：{exc}")
            return None
        if isinstance(data, dict) and data.get("preserveExisting"):
            message = str(data.get("error") or "资源查询失败，已保留现有资源库显示。").strip()
            if show_missing:
                if self.cleanup_items:
                    self._append_cleanup_realtime_text(f"{message}\n", mirror_purchase=False)
                else:
                    self._set_cleanup_placeholder(message)
            return None
        account_items = data.get("items", []) if isinstance(data, dict) else []
        account_items = [item for item in account_items if isinstance(item, dict)]
        account_items = [item for item in account_items if not self._is_hidden_cleanup_item(item)]
        account_items = self._dedupe_cleanup_display_items(account_items)
        region_set = {str(region or "").strip() for region in (refreshed_regions or []) if str(region or "").strip()}
        failed_region_set = {
            str(region or "").strip()
            for region in ((data.get("failedRegions") if isinstance(data, dict) else []) or [])
            if str(region or "").strip()
        }
        if region_set and failed_region_set:
            region_set -= failed_region_set
            if show_missing and failed_region_set:
                self._append_cleanup_realtime_text(
                    f"以下地区查询失败，资源库保留原显示：{'、'.join(sorted(failed_region_set))}。\n",
                    mirror_purchase=False,
                )
        if partial and region_set:
            item_regions = {
                str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip()
                for item in account_items
                if str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip()
            }
            if item_regions:
                region_set = region_set & item_regions
            else:
                region_set = set()
        if region_set:
            kept_items = [
                dict(item)
                for item in self._account_resources_for_purchase()
                if str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip() not in region_set
            ]
            refreshed_items = [
                dict(item)
                for item in account_items
                if str(item.get("region") or item.get("region_name") or item.get("regionName") or "").strip() in region_set
            ]
            account_items = self._dedupe_cleanup_display_items([*kept_items, *refreshed_items])
        if not account_items:
            if not partial:
                self._set_cleanup_placeholder("没有查询到可退订资源。")
                self._clear_output_boxes(persist=True)
                if mark_ready:
                    self._mark_customer_resource_query_ready()
            return 0 if not partial else None
        items = self._cleanup_display_items(account_items)
        items = self._dedupe_cleanup_display_items(items)
        eip_only = self._customer_cleanup_limited_features()
        if not items and not eip_only:
            if not partial:
                self._set_cleanup_placeholder("没有查询到可退订资源。")
            self.account_resource_items = account_items
            self._update_region_resource_labels(account_items)
            self._schedule_output_refresh()
            if mark_ready:
                self._mark_customer_resource_query_ready()
            return 0 if not partial else None
        if self.cleanup_tree is not None:
            for iid in self.cleanup_tree.get_children():
                self.cleanup_tree.delete(iid)
        self.cleanup_item_vars.clear()
        self.cleanup_region_vars.clear()
        self.cleanup_region_body_frames.clear()
        self.cleanup_region_toggle_buttons.clear()
        self.cleanup_tree_items.clear()
        self.cleanup_last_clicked_iid = ""
        self.account_resource_items = account_items
        self.cleanup_items = items
        self._update_region_resource_labels(account_items)
        self._schedule_output_refresh()
        source_grouped: dict[str, list[dict[str, str]]] = {}
        for item in account_items:
            region = str(item.get("region") or "未识别地区")
            source_grouped.setdefault(region, []).append(item)
        grouped: dict[str, list[dict[str, str]]] = {}
        for item in items:
            region = str(item.get("region") or "未识别地区")
            grouped.setdefault(region, []).append(item)
        if self.cleanup_tree is None:
            if mark_ready:
                self._mark_customer_resource_query_ready()
            return len(items)
        row_index = 0
        region_names = source_grouped if eip_only else grouped
        for group_index, region in enumerate(region_names):
            region_items = grouped.get(region) or []
            region_items = sorted(region_items, key=self._cleanup_item_sort_key)
            group_iid = f"cleanup-group-{group_index}"
            group_values = (
                UNCHECKED,
                region,
                "地区汇总",
                "",
                self._cleanup_region_summary(region_items),
                "",
                "",
            )
            self.cleanup_tree.insert(
                "",
                tk.END,
                iid=group_iid,
                text="",
                image=self._cleanup_tree_control_image(UNCHECKED),
                values=group_values,
                open=False,
                tags=("regiongroup",),
            )
            self.cleanup_tree_items[group_iid] = {"_kind": "region_group", "region": region}
            for index, item in enumerate(region_items):
                row_index += 1
                key = str(item.get("key") or f"{region}-item-{index}")
                item["key"] = key
                iid = f"cleanup-{row_index}"
                item_values = self._cleanup_tree_values(item)
                self.cleanup_tree.insert(
                    group_iid,
                    tk.END,
                    iid=iid,
                    text="",
                    image=self._cleanup_tree_control_image(item_values[0]),
                    values=(item_values[0], "", *item_values[1:]),
                    tags=("evenrow" if row_index % 2 == 0 else "oddrow",),
                )
                self.cleanup_tree_items[iid] = item
        self._refresh_cleanup_tree_group_checks()
        self.after_idle(
            lambda: autosize_treeview_columns(
                self.cleanup_tree,
                max_widths={"#0": 60, "region_label": 110, "name": 260, "status": 300, "expire_time": 220, "resource_spec": 180},
            )
        )
        if mark_ready:
            self._mark_customer_resource_query_ready()
        return len(items)

    def _cleanup_region_summary(self, items: list[dict[str, str]]) -> str:
        ecs_count = sum(1 for item in items if self._is_active_cleanup_ecs_item(item))
        eip_count = sum(1 for item in items if str(item.get("type") or "") in {"EIP", "HAVIP_EIP"})
        if self._customer_cleanup_limited_features():
            return f"EIP:{eip_count}"
        shared_bandwidth_count = sum(1 for item in items if str(item.get("type") or "") in {"SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽"})
        vpc_count = sum(1 for item in items if str(item.get("type") or "") == "VPC")
        subnet_count = sum(len(item.get("subnets") or []) for item in items if str(item.get("type") or "") == "VPC" and isinstance(item.get("subnets"), list))
        security_group_count = sum(1 for item in items if str(item.get("type") or "") in {"SECURITY_GROUP", "SECURITYGROUP", "安全组"})
        parts = []
        if not self._cleanup_cloud_server_limited_features():
            parts.append(f"ECS:{ecs_count}")
        parts.append(f"EIP:{eip_count}")
        if shared_bandwidth_count:
            parts.append(f"共享带宽:{shared_bandwidth_count}")
        parts.extend([f"VPC:{vpc_count}", f"子网:{subnet_count}", f"安全组:{security_group_count}"])
        return "/".join(parts)

    @staticmethod
    def _cleanup_ipv4_sort_key(value: Any) -> tuple[int, int, int, int]:
        parts = str(value or "").strip().split(".")
        if len(parts) != 4:
            return (999, 999, 999, 999)
        try:
            numbers = tuple(int(part) for part in parts)
        except ValueError:
            return (999, 999, 999, 999)
        if any(part < 0 or part > 255 for part in numbers):
            return (999, 999, 999, 999)
        return numbers  # type: ignore[return-value]

    @staticmethod
    def _cleanup_eip_virtual_ip(item: dict[str, Any]) -> str:
        for key in ("associationPrivateIP", "vip", "privateIP", "privateIp", "private_ip"):
            value = str(item.get(key) or "").strip()
            if value:
                return value
        match = re.search(r"虚拟IP\s*((?:\d{1,3}\.){3}\d{1,3})", str(item.get("status") or ""))
        return match.group(1) if match else ""

    @staticmethod
    def _cleanup_item_sort_key(
        item: dict[str, Any],
    ) -> tuple[int, tuple[int, int, int, int], tuple[int, int, int, int], str]:
        item_type = str(item.get("type") or "")
        order = {
            "ECS": 0,
            "EIP": 1,
            "HAVIP_EIP": 1,
            "SHARED_BANDWIDTH": 2,
            "BANDWIDTH": 2,
            "共享带宽": 2,
            "带宽": 2,
            "VPC": 3,
            "SUBNET": 4,
            "SECURITY_GROUP": 5,
            "SECURITYGROUP": 5,
            "安全组": 5,
            "HAVIP": 6,
            "RESOURCE_COUNT": 9,
        }
        address = str(item.get("address") or item.get("publicIP") or item.get("publicIp") or item.get("vip") or "")
        primary_ip = fallback_ip = address
        if item_type in {"EIP", "HAVIP_EIP"}:
            primary_ip = WorkflowGui._cleanup_eip_virtual_ip(item)
        return (
            order.get(item_type, 99),
            WorkflowGui._cleanup_ipv4_sort_key(primary_ip),
            WorkflowGui._cleanup_ipv4_sort_key(fallback_ip),
            str(item.get("name") or item.get("key") or ""),
        )

    @staticmethod
    def _is_cleanup_checked_marker(value: Any) -> bool:
        text = str(value or "").strip()
        if text in LEGACY_CHECKED_VALUES:
            return True
        if text in LEGACY_UNCHECKED_VALUES:
            return False
        return False

    @staticmethod
    def _toggle_cleanup_checked_marker(value: Any) -> str:
        return UNCHECKED if WorkflowGui._is_cleanup_checked_marker(value) else CHECKED

    @staticmethod
    def _cleanup_is_on_demand_item(item: dict[str, Any]) -> bool:
        if item.get("onDemand") is True or item.get("isOnDemand") is True:
            return True
        text = WorkflowGui._cleanup_billing_text(item)
        if WorkflowGui._cleanup_is_prepaid_item(item, text=text):
            return False
        if any(token in text for token in ("on_demand", "ondemand", "on demand", "postpaid", "post_paid", "pay_as_you_go")):
            return True
        return any(token in text for token in ("按量", "按需", "后付费", "按小时", "小时计费"))

    @staticmethod
    def _cleanup_billing_text(item: dict[str, Any]) -> str:
        values = [
            item.get("billing"),
            item.get("chargeType"),
            item.get("chargeMode"),
            item.get("payType"),
            item.get("payMode"),
            item.get("billingType"),
            item.get("cycleType"),
            item.get("cycleTypeName"),
            item.get("paymentType"),
            item.get("orderType"),
            item.get("subscriptionType"),
        ]
        cleaned_values = [_clean_display_text(value) for value in values]
        return " ".join(value for value in cleaned_values if value).lower()

    @staticmethod
    def _cleanup_is_prepaid_item(item: dict[str, Any], *, text: str | None = None) -> bool:
        value = text if text is not None else WorkflowGui._cleanup_billing_text(item)
        return any(token in value for token in ("prepaid", "pre_paid", "包年", "包月", "包周期", "yearly", "monthly", "cycle"))

    @staticmethod
    def _cleanup_should_default_expire_to_on_demand(item: dict[str, Any]) -> bool:
        if item.get("releaseOnly") is True:
            return False
        status = str(item.get("status") or "").strip()
        if WorkflowGui._is_unsubscribed_cleanup_status(status):
            return False
        item_type = str(item.get("type") or "").strip()
        if item_type not in {"ECS", "EIP", "HAVIP_EIP"}:
            return False
        return not WorkflowGui._cleanup_is_prepaid_item(item)

    def _cleanup_expire_text(self, item: dict[str, Any]) -> str:
        expire_time = ""
        for key in ("expireTime", "expiredTime", "expiredAt", "expire_time", "expirationTime", "endTime", "validTo"):
            value = _clean_display_text(item.get(key))
            if value:
                expire_time = value
                break
        return display_expire_time_for_billing(
            item,
            billing_text=self._cleanup_billing_text(item),
            explicit_expire_time=expire_time,
            assume_on_demand=self._cleanup_is_on_demand_item(item) or self._cleanup_should_default_expire_to_on_demand(item),
        )

    @staticmethod
    def _cleanup_eip_bind_status(item: dict[str, Any]) -> str:
        private_ip = str(item.get("associationPrivateIP") or item.get("privateIP") or "").strip()
        association_name = str(item.get("associationName") or "").strip()
        association_id = str(item.get("associationID") or "").strip()
        if private_ip:
            return f"已绑定虚拟IP{private_ip}"
        if association_name:
            return f"已绑定{association_name}"
        if association_id:
            return "已绑定资源"
        return "未绑定"

    def _cleanup_tree_values(self, item: dict[str, str]) -> tuple[str, str, str, str, str, str]:
        item_type = str(item.get("type") or "")
        if item_type == "RESOURCE_COUNT":
            count_text = str(item.get("resourceCount") or item.get("status") or "0")
            if count_text.isdigit():
                count_text = f"{count_text} 项"
            return (
                UNCHECKED,
                "账号资源",
                str(item.get("name") or "账号资源"),
                count_text,
                "",
                "",
            )
        if item_type == "ECS":
            status = str(item.get("status") or "")
            return (
                UNCHECKED,
                "云服务器",
                str(item.get("name") or "云服务器"),
                status,
                self._cleanup_expire_text(item),
                str(item.get("flavorName") or item.get("flavorID") or ""),
            )
        if item_type == "HAVIP_EIP":
            vip = str(item.get("vip") or "").strip()
            return (
                UNCHECKED,
                "弹性IP",
                str(item.get("address") or "弹性IP"),
                str(item.get("status") or (f"已绑定虚拟IP{vip}" if vip else "已绑定虚拟IP")),
                self._cleanup_expire_text(item),
                "",
            )
        if item_type == "HAVIP":
            return (
                UNCHECKED,
                "子网虚拟IP",
                str(item.get("vip") or item.get("name") or "虚拟IP"),
                str(item.get("status") or ""),
                "",
                str(item.get("subnetID") or ""),
            )
        if item_type == "EIP":
            return (
                UNCHECKED,
                "弹性IP",
                str(item.get("address") or item.get("name") or "弹性IP"),
                self._cleanup_eip_bind_status(item),
                self._cleanup_expire_text(item),
                str(item.get("associationName") or ""),
            )
        if item_type in {"SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽"}:
            return (
                UNCHECKED,
                "共享带宽",
                str(item.get("name") or "共享带宽"),
                str(item.get("status") or ""),
                self._cleanup_expire_text(item),
                str(item.get("resourceSpec") or ""),
            )
        if item_type in {"VPC", "SECURITY_GROUP", "SECURITYGROUP", "安全组"}:
            kind = "VPC" if item_type == "VPC" else "安全组"
            return (
                UNCHECKED,
                kind,
                str(item.get("name") or kind),
                str(item.get("status") or ""),
                "",
                str(item.get("subnetSummary") or item.get("ruleSummary") or item.get("vpcID") or ""),
            )
        return (
            UNCHECKED,
            item_type or "资源",
            self._cleanup_item_label(item, include_region=False),
            str(item.get("status") or ""),
            self._cleanup_expire_text(item),
            str(item.get("flavorName") or item.get("flavorID") or ""),
        )

    def _toggle_cleanup_group(self, region: str) -> None:
        body = self.cleanup_region_body_frames.get(region)
        button = self.cleanup_region_toggle_buttons.get(region)
        if not body:
            return
        if body.winfo_ismapped():
            body.pack_forget()
            if button:
                button.configure(text="＋")
            return
        body.pack(fill=X, anchor="w", pady=(6, 0))
        if button:
            button.configure(text="－")

    def _cleanup_item_label(self, item: dict[str, str], *, include_region: bool = True) -> str:
        region = str(item.get("region") or "")
        item_type = str(item.get("type") or "")
        prefix = f"{region} | " if include_region and region else ""
        if item_type == "ECS":
            return f"{prefix}云服务器 | {item.get('name') or '云服务器'} | {item.get('status') or ''}".strip(" |")
        if item_type == "HAVIP_EIP":
            vip = item.get("vip") or ""
            return f"{prefix}弹性IP | {item.get('address') or '弹性IP'} | 已绑定虚拟IP{vip}".strip(" |")
        if item_type == "HAVIP":
            return f"{prefix}子网虚拟IP {item.get('vip') or item.get('name') or ''}".strip(" |")
        if item_type == "EIP":
            assoc = item.get("associationName") or ("已绑定" if item.get("associationID") else "未绑定")
            return f"{prefix}弹性IP | {item.get('address') or item.get('name') or '弹性IP'} | {item.get('status') or ''} | {assoc}".strip(" |")
        if item_type in {"SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽"}:
            return f"{prefix}共享带宽 | {item.get('name') or '共享带宽'} | {item.get('resourceSpec') or item.get('status') or ''}".strip(" |")
        if item_type == "RESOURCE_COUNT":
            return f"{prefix}账号资源 | {item.get('resourceCount') or item.get('status') or 0}项".strip(" |")
        name = item.get("name") or item.get("address") or item.get("publicIP") or item_type or "资源"
        return f"{prefix}{item_type or '资源'} | {name}".strip(" |")

    def _all_cleanup_tree_iids(self, parent: str = "") -> list[str]:
        tree = self.cleanup_tree
        if tree is None:
            return []
        ordered: list[str] = []
        for iid in tree.get_children(parent):
            ordered.append(iid)
            ordered.extend(self._all_cleanup_tree_iids(iid))
        return ordered

    def _cleanup_visible_iids(self, *, include_groups: bool = True) -> list[str]:
        tree = self.cleanup_tree
        if tree is None:
            return []
        ordered: list[str] = []

        def walk(parent: str = "") -> None:
            for iid in tree.get_children(parent):
                item = self.cleanup_tree_items.get(iid) or {}
                if include_groups or item.get("_kind") != "region_group":
                    ordered.append(iid)
                if tree.item(iid, "open"):
                    walk(iid)

        walk("")
        return ordered

    @staticmethod
    def _paint_cleanup_tree_control_box(
        image: tk.PhotoImage,
        *,
        x_offset: int = 0,
        symbol: str = "",
    ) -> None:
        size = TREE_CONTROL_SIZE
        border = "#555555"
        foreground = "#202020"
        image.put("#ffffff", to=(x_offset, 0, x_offset + size, size))
        image.put(border, to=(x_offset, 0, x_offset + size, 1))
        image.put(border, to=(x_offset, size - 1, x_offset + size, size))
        image.put(border, to=(x_offset, 0, x_offset + 1, size))
        image.put(border, to=(x_offset + size - 1, 0, x_offset + size, size))
        if symbol in {"+", "-", "partial"}:
            image.put(foreground, to=(x_offset + 3, 7, x_offset + 12, 8))
        if symbol == "+":
            image.put(foreground, to=(x_offset + 7, 3, x_offset + 8, 12))
        elif symbol == "check":
            for x, y in ((3, 7), (4, 8), (5, 9), (6, 8), (7, 7), (8, 6), (9, 5), (10, 4), (11, 4)):
                px = x_offset + x
                image.put(foreground, to=(px, y, px + 1, y + 1))
            for x, y in ((4, 7), (5, 8), (6, 7), (7, 6), (8, 5), (9, 4), (10, 3)):
                px = x_offset + x
                image.put(foreground, to=(px, y, px + 1, y + 1))

    def _ensure_cleanup_tree_control_style(self) -> str:
        owner = self.parent.winfo_toplevel()
        assets = getattr(owner, "_ctyun_cleanup_tree_control_assets", None)
        if not isinstance(assets, dict):
            assets = {}
            check_symbols = {
                "unchecked": "",
                "checked": "check",
                "partial": "partial",
            }
            for toggle_state, toggle_symbol in (("none", ""), ("closed", "+"), ("open", "-")):
                for check_state, check_symbol in check_symbols.items():
                    image = tk.PhotoImage(
                        master=owner,
                        width=TREE_CONTROL_ROW_WIDTH,
                        height=TREE_CONTROL_SIZE,
                    )
                    if toggle_symbol:
                        self._paint_cleanup_tree_control_box(image, symbol=toggle_symbol)
                    self._paint_cleanup_tree_control_box(
                        image,
                        x_offset=TREE_CONTROL_SIZE + TREE_CONTROL_GAP,
                        symbol=check_symbol,
                    )
                    assets[f"{toggle_state}_{check_state}"] = image
            setattr(owner, "_ctyun_cleanup_tree_control_assets", assets)

        style = ttk.Style(self.parent)
        base_config = style.configure("Grid.Treeview")
        if base_config:
            style.configure(CTYUN_CLEANUP_TREEVIEW_STYLE, **base_config)
        style.configure(CTYUN_CLEANUP_TREEVIEW_STYLE, foreground="#202020")
        base_map = style.map("Grid.Treeview")
        if base_map:
            style.map(CTYUN_CLEANUP_TREEVIEW_STYLE, **base_map)
        heading_config = style.configure("Grid.Treeview.Heading")
        if heading_config:
            style.configure(f"{CTYUN_CLEANUP_TREEVIEW_STYLE}.Heading", **heading_config)
        style.layout(
            f"{CTYUN_CLEANUP_TREEVIEW_STYLE}.Item",
            [
                (
                    "Treeitem.padding",
                    {
                        "sticky": "nswe",
                        "children": [
                            ("Treeitem.image", {"side": "left", "sticky": ""}),
                            ("Treeitem.text", {"sticky": "nswe"}),
                        ],
                    },
                )
            ],
        )
        return CTYUN_CLEANUP_TREEVIEW_STYLE

    def _cleanup_tree_control_image(
        self,
        value: Any,
        *,
        expandable: bool = False,
        opened: bool = False,
    ) -> tk.PhotoImage:
        owner = self.parent.winfo_toplevel()
        assets = getattr(owner, "_ctyun_cleanup_tree_control_assets", None)
        if not isinstance(assets, dict):
            self._ensure_cleanup_tree_control_style()
            assets = getattr(owner, "_ctyun_cleanup_tree_control_assets")
        marker = str(value or "")
        if marker == PARTIAL_CHECKED:
            check_state = "partial"
        elif self._is_cleanup_checked_marker(marker):
            check_state = "checked"
        else:
            check_state = "unchecked"
        toggle_state = "open" if expandable and opened else "closed" if expandable else "none"
        return assets[f"{toggle_state}_{check_state}"]

    def _refresh_cleanup_tree_row_control(
        self,
        tree: ttk.Treeview,
        iid: str,
        value: str | None = None,
    ) -> None:
        if not tree.exists(iid):
            return
        values = list(tree.item(iid, "values"))
        marker = str(value if value is not None else (values[0] if values else UNCHECKED))
        expandable = bool(tree.get_children(iid))
        tree.item(
            iid,
            text="",
            image=self._cleanup_tree_control_image(
                marker,
                expandable=expandable,
                opened=expandable and bool(tree.item(iid, "open")),
            ),
        )

    @staticmethod
    def _cleanup_tree_control_hit(tree: ttk.Treeview, row_id: str, event: tk.Event) -> str:
        bbox = tree.bbox(row_id, "#0")
        if not bbox:
            return ""
        cell_x, cell_y, cell_width, cell_height = bbox
        scan_y = cell_y + max(0, cell_height // 2)
        image_xs = [
            x
            for x in range(cell_x, cell_x + cell_width)
            if "image" in str(tree.identify("element", x, scan_y) or "").lower()
        ]
        if not image_xs:
            return ""
        offset = int(getattr(event, "x", 0)) - min(image_xs)
        if 0 <= offset < TREE_CONTROL_SIZE and tree.get_children(row_id):
            return "toggle"
        checkbox_x = TREE_CONTROL_SIZE + TREE_CONTROL_GAP
        if checkbox_x <= offset < checkbox_x + TREE_CONTROL_SIZE:
            return "check"
        return ""

    def _set_cleanup_tree_row_checked(self, iid: str, value: str) -> None:
        tree = self.cleanup_tree
        if tree is None:
            return
        values = list(tree.item(iid, "values"))
        if values:
            values[0] = value
            tree.item(iid, values=values)
            self._refresh_cleanup_tree_row_control(tree, iid, value)

    def _cleanup_tree_checked_value(self, iid: str) -> Any:
        tree = self.cleanup_tree
        if tree is None:
            return ""
        try:
            return tree.set(iid, "checked")
        except tk.TclError:
            values = tree.item(iid, "values")
            return values[0] if values else ""

    def _set_cleanup_tree_group_checked(self, group_iid: str, value: str) -> None:
        self._set_cleanup_tree_row_checked(group_iid, value)
        for child_iid in self._all_cleanup_tree_iids(group_iid):
            self._set_cleanup_tree_row_checked(child_iid, value)

    def _set_cleanup_tree_group_open(self, group_iid: str, opened: bool) -> None:
        if self.cleanup_tree is None:
            return
        self.cleanup_tree.item(group_iid, open=opened)
        self._sync_cleanup_tree_group_toggle_text(group_iid)

    def _toggle_cleanup_tree_group_open(self, group_iid: str) -> None:
        if self.cleanup_tree is None:
            return
        self._set_cleanup_tree_group_open(group_iid, not bool(self.cleanup_tree.item(group_iid, "open")))

    def _sync_cleanup_tree_group_toggle_text(self, group_iid: str) -> None:
        if self.cleanup_tree is not None:
            self._refresh_cleanup_tree_row_control(self.cleanup_tree, group_iid)

    def _refresh_cleanup_tree_group_checks(self) -> None:
        tree = self.cleanup_tree
        if tree is None:
            return
        for group_iid in tree.get_children(""):
            item = self.cleanup_tree_items.get(group_iid) or {}
            if item.get("_kind") != "region_group":
                continue
            children = [iid for iid in self._all_cleanup_tree_iids(group_iid) if (self.cleanup_tree_items.get(iid) or {}).get("_kind") != "region_group"]
            if not children:
                self._set_cleanup_tree_row_checked(group_iid, UNCHECKED)
                continue
            child_values = [tree.item(iid, "values") for iid in children]
            checked_count = sum(1 for values in child_values if values and self._is_cleanup_checked_marker(values[0]))
            if checked_count == len(children):
                marker = CHECKED
            elif checked_count:
                marker = PARTIAL_CHECKED
            else:
                marker = UNCHECKED
            self._set_cleanup_tree_row_checked(group_iid, marker)

    def _on_cleanup_tree_click(self, event: tk.Event) -> None:
        tree = self.cleanup_tree
        if tree is None:
            return
        region = tree.identify("region", event.x, event.y)
        if region not in {"cell", "tree"}:
            return
        row_id = tree.identify_row(event.y)
        if not row_id:
            return
        item = self.cleanup_tree_items.get(row_id) or {}
        column_ref = tree.identify_column(event.x)
        column_name = treeview_display_column_name(tree, column_ref)
        if column_name == "#0":
            if self._cleanup_tree_control_hit(tree, row_id, event) == "toggle":
                self._toggle_cleanup_tree_group_open(row_id)
                return
            column_name = "checked"
        if column_name != "checked":
            return
        try:
            tree.selection_set(row_id)
            tree.focus(row_id)
        except tk.TclError:
            pass
        values = list(tree.item(row_id, "values"))
        target = self._toggle_cleanup_checked_marker(values[0] if values else "")
        shift_pressed = bool(int(getattr(event, "state", 0)) & 0x0001)
        if shift_pressed and self.cleanup_last_clicked_iid:
            ordered = self._cleanup_visible_iids(include_groups=False)
            if row_id in ordered and self.cleanup_last_clicked_iid in ordered:
                start = ordered.index(self.cleanup_last_clicked_iid)
                end = ordered.index(row_id)
                if start > end:
                    start, end = end, start
                for iid in ordered[start : end + 1]:
                    self._set_cleanup_tree_row_checked(iid, target)
                self._refresh_cleanup_tree_group_checks()
                return
        if item.get("_kind") == "region_group":
            self._set_cleanup_tree_group_checked(row_id, target)
        else:
            self._set_cleanup_tree_row_checked(row_id, target)
            self.cleanup_last_clicked_iid = row_id
        self._refresh_cleanup_tree_group_checks()

    def _on_cleanup_tree_double_click(self, event: tk.Event) -> str:
        tree = self.cleanup_tree
        if tree is None:
            return "break"
        row_id = tree.identify_row(event.y)
        if row_id and (self.cleanup_tree_items.get(row_id) or {}).get("_kind") == "region_group":
            self._toggle_cleanup_tree_group_open(row_id)
        return "break"

    def _on_cleanup_tree_open_close(self, _event: tk.Event) -> None:
        tree = self.cleanup_tree
        if tree is None:
            return
        selected = tree.focus()
        item = self.cleanup_tree_items.get(selected) or {}
        if item.get("_kind") == "region_group":
            self._sync_cleanup_tree_group_toggle_text(selected)

    def _set_all_cleanup_items(self, value: bool) -> None:
        if self.cleanup_tree is not None:
            mark = CHECKED if value else UNCHECKED
            for iid in self._all_cleanup_tree_iids():
                self._set_cleanup_tree_row_checked(iid, mark)
            self._refresh_cleanup_tree_group_checks()
            return
        for var in self.cleanup_item_vars.values():
            var.set(value)
        for var in self.cleanup_region_vars.values():
            var.set(value)

    def _set_cleanup_items_by_predicate(self, predicate) -> None:
        if self.cleanup_tree is not None:
            for iid, item in self.cleanup_tree_items.items():
                if item.get("_kind") == "region_group":
                    continue
                value = CHECKED if bool(predicate(item)) else UNCHECKED
                self._set_cleanup_tree_row_checked(iid, value)
            self._refresh_cleanup_tree_group_checks()
            return
        for item in self.cleanup_items:
            key = str(item.get("key") or "")
            var = self.cleanup_item_vars.get(key)
            if var:
                var.set(bool(predicate(item)))
        for region in list(self.cleanup_region_vars.keys()):
            self._sync_cleanup_region(region)

    def _select_cleanup_servers(self) -> None:
        if self._cleanup_cloud_server_limited_features():
            return
        self._set_cleanup_items_by_predicate(lambda item: str(item.get("type") or "") == "ECS")

    def _select_cleanup_ips(self) -> None:
        self._set_cleanup_items_by_predicate(lambda item: str(item.get("type") or "") in {"EIP", "HAVIP_EIP"})

    def _select_failed_cleanup_ips(self) -> tuple[int, int, int]:
        path = self._used_ip_table_path()
        if path.suffix.lower() != ".xlsx":
            migration_error = str(getattr(self, "_used_ip_table_migration_error", "") or "").strip()
            detail = f"，CSV自动升级失败：{migration_error}" if migration_error else ""
            self._append_cleanup_realtime_text(
                f"更换失败IP库需要使用 .xlsx 旧IP库{detail}。请先在右键配置中保存或链接 Excel 文件。\n",
                mirror_purchase=False,
            )
            return 0, 0, 0
        failed_rows = self._read_failed_ip_rows(path)
        failed_ips = {
            self._normalize_used_ip_value(row.get(USED_IP_CSV_HEADERS[3]))
            for row in failed_rows
        }
        failed_ips.discard("")

        def is_failed_replaceable(item: dict[str, Any]) -> bool:
            item_type = str(item.get("type") or "")
            if item_type not in {"EIP", "HAVIP_EIP"} or bool(item.get("_displayOnly")):
                return False
            replaceable = item_type == "HAVIP_EIP" or bool(
                str(item.get("associationID") or item.get("associationName") or item.get("associationType") or "").strip()
            )
            return replaceable and self._cleanup_eip_address(item) in failed_ips

        matched_ips = {
            self._cleanup_eip_address(item)
            for item in self.cleanup_items
            if is_failed_replaceable(item)
        }
        matched_ips.discard("")
        self._set_cleanup_items_by_predicate(is_failed_replaceable)
        missing_count = len(failed_ips - matched_ips)
        if matched_ips:
            message = f"已选中更换失败IP {len(matched_ips)} 条"
            if missing_count:
                message += f"，另有 {missing_count} 条已不在当前资源中"
            message += "。\n"
        elif failed_ips:
            message = "更换失败IP库中的IP均不在当前已读取资源中。\n"
        else:
            message = "更换失败IP库为空。\n"
        self._append_cleanup_realtime_text(message, mirror_purchase=False)
        return len(matched_ips), missing_count, len(failed_ips)

    def _select_cleanup_network_resources(self) -> None:
        self._set_cleanup_items_by_predicate(
            lambda item: str(item.get("type") or "") in {"VPC", "HAVIP", "SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽"}
        )

    def _cleanup_region_item_keys(self, region: str) -> list[str]:
        keys: list[str] = []
        for item in self.cleanup_items:
            if str(item.get("region") or "未识别地区") == region:
                key = str(item.get("key") or "")
                if key:
                    keys.append(key)
        return keys

    def _toggle_cleanup_region(self, region: str) -> None:
        selected = self.cleanup_region_vars.get(region)
        value = bool(selected and selected.get())
        for key in self._cleanup_region_item_keys(region):
            var = self.cleanup_item_vars.get(key)
            if var:
                var.set(value)

    def _sync_cleanup_region(self, region: str) -> None:
        keys = self._cleanup_region_item_keys(region)
        if not keys:
            return
        group_var = self.cleanup_region_vars.get(region)
        if not group_var:
            return
        group_var.set(all(self.cleanup_item_vars.get(key) and self.cleanup_item_vars[key].get() for key in keys))

    def _selected_cleanup_items(self) -> list[dict[str, str]]:
        if self.cleanup_tree is not None:
            selected: list[dict[str, str]] = []
            for iid in self._all_cleanup_tree_iids():
                checked_value = self._cleanup_tree_checked_value(iid)
                item = self.cleanup_tree_items.get(iid)
                if (
                    self._is_cleanup_checked_marker(checked_value)
                    and item
                    and item.get("_kind") != "region_group"
                    and str(item.get("type") or "") != "RESOURCE_COUNT"
                    and not bool(item.get("_displayOnly"))
                ):
                    selected.append(item)
            return selected
        selected = []
        for item in self.cleanup_items:
            if str(item.get("type") or "") == "RESOURCE_COUNT":
                continue
            if bool(item.get("_displayOnly")):
                continue
            key = str(item.get("key") or "")
            var = self.cleanup_item_vars.get(key)
            if var and var.get():
                selected.append(item)
        return selected

    def _workflow_action_name(self, mode: str) -> str:
        if mode == "purchase":
            if getattr(self, "current_workflow_new_ip_only", False):
                return "购买选择地区新IP"
            return "购买地区"
        if mode == "build":
            return "搭建地区"
        return "一键购买搭建"

    def _ctyun_build_spec_label(self, mode: str) -> str:
        if mode == "purchase":
            return "未搭建"
        return "默认连接账密(右键自定义)" if self.build_mode_var.get().strip() == CTYUN_BUILD_MODE_DEFAULT else "随机连接账密"

    def _ctyun_purchase_spec_label(self, region: str, mode: str) -> str:
        if mode == "build":
            return "未购买，复用已有资源"
        target_count = self._ctyun_eip_target_count()
        topology = "1ECS拖2EIP" if target_count == 2 else "1ECS拖10EIP"
        if bool(self.monthly_dedicated_eip_var.get()):
            spec = f"{topology}，包月独享带宽1M*{target_count}"
        else:
            bandwidth = "60M地区" if region in FIXED_60M_REGIONS else "300M地区"
            spec = f"{topology}，{bandwidth}"
        if bool(self.private_192_168_100_var.get()):
            spec = f"{spec}，VPC:192.168.100.101~110"
        return spec

    def _ctyun_purchase_billing_label(self) -> str:
        if self._on_demand_billing_enabled():
            ecs_text = "按需"
        else:
            ecs_text = "ECS包年包月1个月，自动续费"
        return f"{ecs_text}；{self._ctyun_eip_purchase_rule_label()}"

    def _ctyun_eip_purchase_rule_label(self) -> str:
        target_count = self._ctyun_eip_target_count()
        if bool(self.monthly_dedicated_eip_var.get()):
            return f"EIP包年包月，按带宽计费，独享带宽1M，补足{target_count}条"
        return f"EIP按天翼云300M/60M各地区规则，补足{target_count}条"

    def _ctyun_eip_target_count(self) -> int:
        var = getattr(self, "one_ecs_two_eip_var", None)
        try:
            enabled = bool(var.get()) if var is not None else False
        except tk.TclError:
            enabled = False
        return 2 if enabled else 10

    def _ctyun_havip_target_count(self) -> int:
        return max(0, self._ctyun_eip_target_count() - 1)

    def _ctyun_workflow_confirm_details(
        self,
        mode: str,
        regions: Sequence[str],
        *,
        new_ip_only: bool = False,
    ) -> list[str]:
        details: list[str] = []
        if new_ip_only:
            details.append(
                "购买模式：旧IP库过滤，仅复用已有云服务器并补足弹性IP；"
                "不会创建新云服务器，不会释放已绑定IP；未绑定且命中旧IP库的EIP会先释放后重购。"
            )
        if mode == "build":
            details.append("购买信息：未购买，复用已有资源")
        else:
            details.append("购买信息：")
            details.append(f"计费方式：{self._ctyun_purchase_billing_label()}")
            details.extend(f"{region}：{self._ctyun_purchase_spec_label(region, mode)}" for region in regions)

        if mode == "purchase":
            details.append("搭建信息：未搭建")
        else:
            details.extend(
                [
                    "搭建信息：",
                    f"搭建选项：{self._ctyun_build_spec_label(mode)}",
                    *self._default_connection_confirm_details(),
                ]
            )

        details.append("明细:")
        details.extend(
            f"{region}|购买规格:{self._ctyun_purchase_spec_label(region, mode)}|搭建规格:{self._ctyun_build_spec_label(mode)}"
            for region in regions
        )
        return details

    def _ctyun_workflow_popup_details(self, mode: str) -> str:
        regions = list(self.current_workflow_regions or self._selected_regions())
        build_spec = self._ctyun_build_spec_label(mode)
        lines = [
            f"{region}|购买规格:{self._ctyun_purchase_spec_label(region, mode)}|搭建规格:{build_spec}"
            for region in regions
        ]
        return "\n\n明细:\n" + "\n".join(lines) if lines else ""

    def _workflow_done_popup_message(self, mode: str, *, failed: bool = False, failure_reason: str = "") -> str:
        action_name = self._workflow_action_name(mode)
        if self.stop_requested:
            return f"天翼云{action_name}任务已停止。" + self._ctyun_workflow_popup_details(mode)
        if failed:
            reason = re.sub(r"\s+", " ", failure_reason or "").strip(" 。；;")
            if reason:
                if len(reason) > 500:
                    reason = reason[:497] + "..."
                return (
                    f"天翼云{action_name}任务未完成。\n\n"
                    f"原因：\n{reason}\n\n"
                    "详细过程请查看实时日志。"
                    + self._ctyun_workflow_popup_details(mode)
                )
            return (
                f"天翼云{action_name}任务未完成。\n\n"
                "原因：\n存在失败阶段，请查看实时日志。"
                + self._ctyun_workflow_popup_details(mode)
            )
        if mode == "purchase":
            if getattr(self, "current_workflow_new_ip_only", False):
                return "天翼云购买选择地区新IP任务完成。" + self._ctyun_workflow_popup_details(mode)
            return "天翼云购买选择地区任务完成。" + self._ctyun_workflow_popup_details(mode)
        if mode == "build":
            return "天翼云搭建选择地区任务完成，请查看 L2TP/Socks5 结果。" + self._ctyun_workflow_popup_details(mode)
        return "天翼云一键购买并搭建任务完成，请查看 L2TP/Socks5 结果。" + self._ctyun_workflow_popup_details(mode)

    def _phase_action_name(self, phase: str, title: str) -> str:
        if phase == "cleanup-query":
            return "查询资源"
        if phase == "cleanup-execute":
            return "退订删除资源"
        if phase.startswith("reinstall"):
            return "重装系统"
        if phase == "replace-ip":
            return "更换新IP"
        return title or phase

    def _export_context_key(self, context: str) -> str:
        return "manage" if context in {"manage", "cleanup"} else "purchase"

    def _ctyun_log_path(self, *, context: str, action: str, prefix: str) -> Path:
        section = "资源管理" if context == "manage" else "购买搭建"
        return create_task_log_path("CTYUN", section, action, prefix=prefix, account_key=self._account_log_scope())

    def _run_workflow(self) -> None:
        self.thread_context.log_name = "purchase"
        run_root = self.purchase_result_root
        started = time.strftime("%Y%m%d-%H%M%S")
        mode = getattr(self, "current_workflow_mode", "all")
        action_name = self._workflow_action_name(mode)
        self.last_task_failure_reason = ""
        self.log_action_names["purchase"] = action_name
        log_path = self._ctyun_log_path(context="purchase", action=action_name, prefix="ctyun-run")
        self._emit(f"当前目录: {run_root}\n")
        self._emit(f"开始时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n")
        self._emit("本次运行会执行真实云资源操作。\n\n")
        regions = list(self.current_workflow_regions or self._selected_regions())
        keys = list(self.current_workflow_keys or [self.ak_var.get().strip(), self.sk_var.get().strip()])
        if len(regions) > 1:
            self._run_workflow_parallel_regions(started, log_path, keys, regions, mode=mode, target_root=run_root)
            return

        run_export_and_ssh = True
        failed = False
        failure_reason = ""
        successful_purchase_regions: list[str] = []
        with log_path.open("w", encoding="utf-8") as log:
            if mode != "build":
                for phase, title in PHASES:
                    if self.stop_requested:
                        self._emit("流程已停止。\n", log)
                        run_export_and_ssh = False
                        break
                    self._emit(f"===== {title} =====\n", log)
                    code = self._run_phase(phase, log, run_root=run_root)
                    self._emit("\n", log)
                    if self.stop_requested or code == 130:
                        self._emit("流程已停止。\n", log)
                        run_export_and_ssh = False
                        break
                    if phase == "has-active-regions" and code == 2:
                        break
                    if code not in (0, 2):
                        self._emit(f"阶段失败：{title}，退出码 {code}\n", log)
                        failed = True
                        failure_reason = self.last_task_failure_reason
                        if phase == "check-balance":
                            run_export_and_ssh = False
                        break
            else:
                self._emit("===== 读取已有绑定 IP 中 =====\n", log)
            if run_export_and_ssh:
                end_time = time.strftime("%m/%d/%Y %H:%M:%S")
                self._emit("===== 导出运行结果中 =====\n", log)
                self._run_phase("export", log, end_time=end_time, log_path=log_path, run_root=run_root)
                if mode != "purchase" or getattr(self, "private_192_168_100_result_active", False):
                    self.queue.put(("refresh_outputs",))
                if mode == "purchase":
                    region_name = regions[0] if regions else ""
                    purchase_ok, purchase_reason = self._check_region_purchase_completed(run_root, region_name)
                    if purchase_ok:
                        if region_name:
                            successful_purchase_regions.append(region_name)
                        self._emit("\n购买选择地区已完成。\n", log)
                    else:
                        failed = True
                        failure_reason = f"{region_name}: {purchase_reason}" if region_name else purchase_reason
                        self._emit(f"\n购买选择地区未完成：{failure_reason}\n", log)
                elif not self.stop_requested:
                    self._emit("\n===== SSH连接并搭建中 =====\n", log)
                    ssh_ok, ssh_reason = self._run_ssh_setup(log, run_root=run_root)
                    if not ssh_ok:
                        failed = True
                        failure_reason = ssh_reason
                    self.queue.put(("refresh_outputs",))

        self._emit("\n任务已结束，可手动关闭窗口。\n")
        popup_message = self._workflow_done_popup_message(mode, failed=failed, failure_reason=failure_reason or self.last_task_failure_reason)
        final_status = self._task_final_status(action_name, stopped=self.stop_requested, failed=failed)
        self.queue.put((
            "done",
            lambda regions=list(regions), message=popup_message, workflow_mode=mode, successful_regions=list(successful_purchase_regions): self._after_workflow_done(
                regions,
                message,
                workflow_mode,
                successful_purchase_regions=successful_regions,
            ),
            True,
            "",
            final_status,
        ))

    def _worker_command(self) -> list[str]:
        if getattr(sys, "frozen", False):
            return [sys.executable, "--ctyun-worker"]
        launcher = Path(__file__).resolve().parents[1] / "main.py"
        return [sys.executable, "-u", str(launcher), "--ctyun-worker"]

    def _prepare_region_run_root(self, started: str, region: str, keys: list[str]) -> tuple[Path, Path, Path]:
        safe_region = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff_-]+", "_", region)
        action_name = self._workflow_action_name(getattr(self, "current_workflow_mode", "all"))
        run_root = task_runtime_dir(
            "CTYUN",
            "购买搭建",
            action_name,
            f"ctyun-{started}-{safe_region}-{RUN_INSTANCE_ID}",
            account_key=self._account_log_scope(keys),
        )
        run_root.mkdir(parents=True, exist_ok=True)
        regions_file = run_root / "regions_10ip.json"
        source_regions = ensure_runtime_regions_file()
        # 任务运行文件必须使用当前时间戳，避免被运行目录保留策略误判为历史文件。
        shutil.copyfile(source_regions, regions_file)
        input_file = run_root / "input.example.txt"
        input_file.write_text("\n".join([keys[0], keys[1], region]) + "\n", encoding="utf-8")
        return run_root, regions_file, input_file

    @staticmethod
    def _decode_subprocess_line(raw: bytes) -> str:
        if not raw:
            return ""
        candidates = []
        for encoding in ("utf-8", "gb18030", "cp936", "mbcs"):
            try:
                text = raw.decode(encoding, errors="replace")
            except LookupError:
                continue
            score = text.count("\ufffd") + text.count("锟")
            candidates.append((score, text))
        if candidates:
            candidates.sort(key=lambda item: item[0])
            return candidates[0][1]
        return raw.decode("utf-8", errors="replace")

    def _run_worker_phase_subprocess(
        self,
        *,
        phase: str,
        title: str,
        region: str,
        run_root: Path,
        regions_file: Path,
        input_file: Path,
        log,
        end_time: str = "",
        log_path: Path | None = None,
    ) -> int:
        args = [
            *self._worker_command(),
            "--input-file",
            str(input_file),
            "--phase",
            phase,
            "--execute",
            "--regions",
            region,
        ]
        if end_time:
            args.extend(["--run-end-time", end_time])
        if log_path:
            args.extend(["--run-log-file", str(log_path)])
        env = os.environ.copy()
        env["CTYUN_WORKFLOW_ROOT"] = str(run_root)
        env["CTYUN_WORKFLOW_REGIONS_FILE"] = str(regions_file)
        env["CTYUN_ALL_REGIONS_CACHE_FILE"] = str(all_regions_catalog_file())
        env["CTYUN_WORKFLOW_STOP_FILE"] = str(self.stop_file)
        env["PYTHONUTF8"] = "1"
        env["PYTHONIOENCODING"] = "utf-8"
        env["PYTHONUNBUFFERED"] = "1"
        env["PYTHONLEGACYWINDOWSSTDIO"] = "0"
        env["CTYUN_REINSTALL_BOOT_ACTION"] = "start"
        env["CTYUN_EIP_MONTHLY_DEDICATED_1M"] = "1" if bool(self.monthly_dedicated_eip_var.get()) else "0"
        env["CTYUN_EIP_TARGET_COUNT"] = str(self._ctyun_eip_target_count())
        env["CTYUN_PRIVATE_IP_PLAN"] = "192_168_100" if bool(self.private_192_168_100_var.get()) else ""
        env["CTYUN_USED_IP_TABLE"] = str(self._used_ip_table_path())
        purchase_phase = phase == "export" or any(phase == phase_name for phase_name, _title in PHASES)
        env["CTYUN_NEW_IP_ONLY"] = (
            "1"
            if purchase_phase and bool(getattr(self, "current_workflow_new_ip_only", False))
            else "0"
        )
        self._apply_ctyun_network_env(env)
        kwargs: dict[str, object] = {}
        if os.name == "nt":
            kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
        self._emit(f"[{region}] ===== {title} =====\n", log)
        try:
            proc = subprocess.Popen(
                args,
                cwd=str(app_dir()),
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=False,
                **kwargs,
            )
        except Exception as exc:
            self._emit(f"[{region}] 启动子任务失败：{exc}\n", log)
            return 1
        self.child_processes.add(proc)
        try:
            if proc.stdout is not None:
                for raw_line in proc.stdout:
                    line = self._decode_subprocess_line(raw_line)
                    self._remember_failure_reason_from_line(line, region=region)
                    self._emit(f"[{region}] {line}", log)
                    if self.stop_requested:
                        try:
                            proc.terminate()
                        except Exception:
                            pass
            return int(proc.wait() or 0)
        finally:
            self.child_processes.discard(proc)

    def _merge_parallel_region_results(self, run_roots: dict[str, Path], target_root: Path | None = None) -> None:
        target_root = target_root or self.purchase_result_root
        merged_virtual: dict[str, list[dict[str, str]]] = {}
        merged_ecs: dict[str, dict[str, str]] = {}
        merged_summary: dict[str, dict[str, object]] = {}
        for region, run_root in run_roots.items():
            result_path = run_root / "run_result.json"
            if not result_path.exists():
                merged_summary[region] = {"status": "skipped", "reason": "未生成运行结果"}
                continue
            try:
                data = json.loads(result_path.read_text(encoding="utf-8-sig"))
            except Exception as exc:
                merged_summary[region] = {"status": "skipped", "reason": f"读取结果失败：{exc}"}
                continue
            virtual = data.get("virtualIps") if isinstance(data, dict) else {}
            ecs = data.get("ecsIp") if isinstance(data, dict) else {}
            summary = data.get("summary") if isinstance(data, dict) else {}
            if isinstance(virtual, dict):
                merged_virtual.update({k: v for k, v in virtual.items() if isinstance(v, list)})
            if isinstance(ecs, dict):
                merged_ecs.update({k: v for k, v in ecs.items() if isinstance(v, dict)})
            if isinstance(summary, dict):
                merged_summary.update({k: v for k, v in summary.items() if isinstance(v, dict)})
        payload = {"virtualIps": merged_virtual, "ecsIp": merged_ecs, "summary": merged_summary}
        (target_root / "run_result.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        (target_root / "export_bound_ips.json").write_text(json.dumps(merged_virtual, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        (target_root / "retained_ips.json").write_text(json.dumps(merged_ecs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        (target_root / "workflow_summary.json").write_text(json.dumps(merged_summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    def _run_one_region_workflow(
        self,
        *,
        region: str,
        run_root: Path,
        regions_file: Path,
        input_file: Path,
        log,
        mode: str = "all",
    ) -> int:
        self.thread_context.log_name = "purchase"
        if mode != "build":
            for phase, title in PHASES:
                if self.stop_requested:
                    return 130
                code = self._run_worker_phase_subprocess(
                    phase=phase,
                    title=title,
                    region=region,
                    run_root=run_root,
                    regions_file=regions_file,
                    input_file=input_file,
                    log=log,
                )
                self._emit("\n", log)
                if self.stop_requested or code == 130:
                    return 130
                if phase == "has-active-regions" and code == 2:
                    return 2
                if code not in (0, 2):
                    self._emit(f"[{region}] 阶段失败：{title}，退出码 {code}\n", log)
                    return code
        else:
            self._emit(f"[{region}] ===== 读取已有绑定 IP 中 =====\n", log)
        end_time = time.strftime("%m/%d/%Y %H:%M:%S")
        return self._run_worker_phase_subprocess(
            phase="export",
            title="导出运行结果中",
            region=region,
            run_root=run_root,
            regions_file=regions_file,
            input_file=input_file,
            log=log,
            end_time=end_time,
        )

    def _check_region_export_ready_for_ssh(self, run_root: Path, region: str) -> tuple[bool, str]:
        result_path = run_root / "run_result.json"
        if not result_path.exists():
            return False, "未生成运行结果，请查看该地区上方购买日志"
        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            return False, f"读取运行结果失败：{exc}"
        if not isinstance(data, dict):
            return False, "运行结果格式异常"

        ecs_map = data.get("ecsIp")
        if isinstance(ecs_map, dict):
            ecs_info = ecs_map.get(region)
            if isinstance(ecs_info, dict) and str(ecs_info.get("eip") or "").strip():
                return True, str(ecs_info.get("eip") or "").strip()

        # 一键购买并搭建必须依赖云服务器公网 IP；导出阶段可能返回 0，但资源仍在创建或绑定未完成。
        summary_map = data.get("summary")
        summary = summary_map.get(region) if isinstance(summary_map, dict) else None
        if isinstance(summary, dict):
            ecs_eip = summary.get("ecsEip")
            if isinstance(ecs_eip, dict) and str(ecs_eip.get("eip") or "").strip():
                return True, str(ecs_eip.get("eip") or "").strip()
            reason = str(summary.get("reason") or "").strip()
            status = str(summary.get("status") or "").strip()
            havip_count = summary.get("havipEipCount")
            usable_ecs = summary.get("usableEcs")
            if reason:
                return False, reason
            if status and status != "completed":
                return False, f"资源绑定未完成（状态：{status}，云服务器：{usable_ecs or 0}，虚拟 IP：{havip_count or 0}/{self._ctyun_havip_target_count()}）"
        return False, "云服务器还没有绑定公网 IP，资源可能仍在创建或同步"

    def _check_region_purchase_completed(self, run_root: Path, region: str) -> tuple[bool, str]:
        result_path = run_root / "run_result.json"
        if not result_path.exists():
            return False, "未生成运行结果，请查看该地区上方购买日志"
        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            return False, f"读取运行结果失败：{exc}"
        if not isinstance(data, dict):
            return False, "运行结果格式异常"
        summary_map = data.get("summary")
        summary = summary_map.get(region) if isinstance(summary_map, dict) else None
        if not isinstance(summary, dict):
            return False, "运行结果缺少地区汇总"
        status = str(summary.get("status") or "").strip()
        ecs_eip = summary.get("ecsEip")
        has_ecs_eip = isinstance(ecs_eip, dict) and str(ecs_eip.get("eip") or "").strip()
        havip_count = summary.get("havipEipCount")
        if status == "completed" and has_ecs_eip:
            return True, ""
        reason = str(summary.get("reason") or "").strip()
        if reason:
            return False, reason
        return False, f"资源绑定未完成（状态：{status or '未知'}，虚拟 IP：{havip_count or 0}/{self._ctyun_havip_target_count()}）"

    def _run_workflow_parallel_regions(self, started: str, log_path: Path, keys: list[str], regions: list[str], *, mode: str = "all", target_root: Path | None = None) -> None:
        self.thread_context.log_name = "purchase"
        target_root = target_root or self.purchase_result_root
        self.last_task_failure_reason = ""
        run_roots: dict[str, Path] = {}
        successful_purchase_regions: list[str] = []
        with log_path.open("w", encoding="utf-8") as log:
            self._emit(f"选择地区并发执行：{', '.join(regions)}\n", log)
            jobs = {}
            failure_reasons: list[str] = []
            with ThreadPoolExecutor(max_workers=max(1, len(regions))) as pool:
                for region in regions:
                    run_root, regions_file, input_file = self._prepare_region_run_root(started, region, keys)
                    run_roots[region] = run_root
                    jobs[pool.submit(
                        self._run_one_region_workflow,
                        region=region,
                        run_root=run_root,
                        regions_file=regions_file,
                        input_file=input_file,
                        log=log,
                        mode=mode,
                    )] = region
                failed = False
                for future in as_completed(jobs):
                    region = jobs[future]
                    try:
                        code = future.result()
                    except Exception as exc:
                        code = 1
                        self._emit(f"[{region}] 子任务异常：{exc}\n", log)
                        failure_reasons.append(f"{region}: 子任务异常 {exc}")
                    if code not in (0, 2):
                        failed = True
                        self._emit(f"[{region}] 子任务结束，退出码 {code}\n", log)
                        failure_reasons.append(self.last_task_failure_reason or f"{region}: 子任务退出码 {code}")
                    elif mode == "purchase":
                        purchase_ok, purchase_reason = self._check_region_purchase_completed(run_roots[region], region)
                        if purchase_ok:
                            successful_purchase_regions.append(region)
                            self._emit(f"[{region}] 购买选择地区已完成。\n", log)
                        else:
                            failed = True
                            failure_reasons.append(f"{region}: {purchase_reason}")
                            self._emit(f"[{region}] 购买选择地区未完成：{purchase_reason}\n", log)
                    elif not self.stop_requested:
                        ready, reason = self._check_region_export_ready_for_ssh(run_roots[region], region)
                        if not ready:
                            failed = True
                            failure_reasons.append(f"{region}: {reason}")
                            self._emit(f"[{region}] 购买绑定未完成，跳过 SSH 搭建：{reason}\n", log)
                            self._emit(f"[{region}] 可稍后点击“搭建选择地区”，程序会重新读取已绑定 IP 后继续搭建。\n", log)
                            continue
                        self._emit(f"\n===== {region} 已完成购买绑定，立即开始 SSH 搭建 =====\n", log)
                        ssh_ok, ssh_reason = self._run_ssh_setup_from_file(run_roots[region] / "retained_ips.json", log)
                        if not ssh_ok:
                            failed = True
                            failure_reasons.append(ssh_reason if ssh_reason.startswith(f"{region}:") else f"{region}: {ssh_reason}")
            if self.stop_requested:
                self._emit("流程已停止。\n", log)
            self._emit("===== 合并双地区运行结果 =====\n", log)
            self._merge_parallel_region_results(run_roots, target_root=target_root)
            if mode != "purchase" or getattr(self, "private_192_168_100_result_active", False):
                self.queue.put(("refresh_outputs",))
            if failed:
                self._emit("存在地区子任务失败，请查看上方日志。\n", log)
            self._emit(f"结束时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n", log)
            self._emit(f"日志文件: {log_path}\n", log)
        self._emit("\n任务已结束，可手动关闭窗口。\n")
        popup_message = self._workflow_done_popup_message(mode, failed=failed, failure_reason="；".join(failure_reasons[:3]))
        final_status = self._task_final_status(self._workflow_action_name(mode), stopped=self.stop_requested, failed=failed)
        self.queue.put((
            "done",
            lambda regions=list(regions), message=popup_message, workflow_mode=mode, successful_regions=list(successful_purchase_regions): self._after_workflow_done(
                regions,
                message,
                workflow_mode,
                successful_purchase_regions=successful_regions,
            ),
            True,
            "",
            final_status,
        ))

    def _run_single_phase(
        self,
        phase: str,
        title: str,
        *,
        regions_override: list[str] | None = None,
        cleanup_query_scope: str = "",
        cleanup_selection_file: Path | None = None,
        done_callback=None,
        affects_global_busy: bool = True,
        popup_after_callback: bool = False,
        popup_message_override: str | None = None,
    ) -> None:
        manage_phase = phase.startswith(("cleanup", "reinstall")) or phase == "replace-ip"
        if phase == "cleanup-query":
            log_target = "account-query" if cleanup_query_scope == "account" else "both"
        else:
            log_target = "cleanup" if manage_phase else "purchase"
        self.thread_context.log_name = log_target
        run_root = self.runtime_root
        context = "manage" if manage_phase else "purchase"
        action_name = self._phase_action_name(phase, title)
        self.last_task_failure_reason = ""
        self.log_action_names[context] = action_name
        log_path = self._ctyun_log_path(context=context, action=action_name, prefix="ctyun-run")
        self._emit(f"当前目录: {run_root}\n", target_name=log_target)
        self._emit(f"开始时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n\n", target_name=log_target)
        with log_path.open("w", encoding="utf-8") as log:
            self._emit(f"===== {title} =====\n", log, target_name=log_target)
            code = self._run_phase(
                phase,
                log,
                regions_override=regions_override,
                cleanup_query_scope=cleanup_query_scope,
                cleanup_selection_file=cleanup_selection_file,
                run_root=run_root,
            )
            self._emit("\n", log, target_name=log_target)
            self._emit(f"结束时间: {time.strftime('%m/%d/%Y %H:%M:%S')}\n", log, target_name=log_target)
            self._emit(f"日志文件: {log_path}\n", log, target_name=log_target)
            if self.stop_requested or code == 130:
                self._emit("任务已停止。\n", log, target_name=log_target)
            elif code != 0:
                self._emit(f"阶段结束，退出码 {code}。\n", log, target_name=log_target)
        self._emit("\n任务已结束，可手动关闭窗口。\n", target_name=log_target)
        if self.stop_requested or code == 130:
            popup_message = f"天翼云{action_name}任务已停止。"
        elif code != 0:
            reason = self.last_task_failure_reason
            popup_message = (
                f"天翼云{action_name}任务结束：{reason}。请查看实时日志。"
                if reason
                else f"天翼云{action_name}任务未完成，请查看实时日志。"
            )
        elif phase == "cleanup-query" and popup_message_override is None:
            popup_message = ""
        else:
            popup_message = f"天翼云{action_name}任务完成。"
        if popup_message_override is not None and code == 0:
            popup_message = popup_message_override
        final_status = self._task_final_status(action_name, stopped=self.stop_requested or code == 130, failed=code not in (0, 130))
        if popup_after_callback and done_callback:
            self.queue.put(("done", lambda message=popup_message: done_callback(message), affects_global_busy, "", final_status))
        else:
            self.queue.put(("done", done_callback, affects_global_busy, popup_message, final_status))

    def _run_ssh_setup(self, log, run_root: Path | None = None) -> tuple[bool, str]:
        return self._run_ssh_setup_from_file(self._runtime_path("retained_ips.json", run_root or self.purchase_result_root), log)

    def _run_ssh_setup_from_file(self, retained_path: Path, log) -> tuple[bool, str]:
        if self.stop_requested:
            self._emit("已停止，跳过 SSH 搭建。\n", log)
            return False, "已停止，跳过 SSH 搭建"
        if not retained_path.exists():
            self._emit("未找到云服务器绑定 IP，跳过 SSH 搭建。\n", log)
            return False, "未找到云服务器绑定 IP，已跳过 SSH 搭建"
        try:
            retained = json.loads(retained_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            self._emit(f"读取云服务器绑定 IP 失败，跳过 SSH 搭建：{exc}\n", log)
            return False, f"读取云服务器绑定 IP 失败，已跳过 SSH 搭建：{exc}"
        if not isinstance(retained, dict) or not retained:
            self._emit("本次没有云服务器绑定 IP，跳过 SSH 搭建。\n", log)
            return False, "本次没有云服务器绑定 IP，已跳过 SSH 搭建"
        try:
            import paramiko
        except Exception as exc:
            self._emit(f"SSH 模块不可用，无法连接云服务器：{exc}\n", log)
            return False, f"SSH 模块不可用，无法连接云服务器：{exc}"

        attempted = 0
        failures: list[str] = []
        for region, item in retained.items():
            if self.stop_requested:
                self._emit("SSH 搭建已停止。\n", log)
                return False, "SSH 搭建已停止"
            if not isinstance(item, dict):
                continue
            host = str(item.get("eip") or item.get("ip") or "").strip()
            if not host:
                self._emit(f"{region}: 没有云服务器公网 IP，跳过 SSH 搭建。\n", log)
                failures.append(f"{region}: 没有云服务器公网 IP")
                continue
            attempted += 1
            ok, reason = self._ssh_one_host(paramiko, region, host, log)
            if not ok:
                failures.append(reason or f"{region}: SSH 搭建未成功")
        if failures:
            return False, "；".join(failures[:3])
        if attempted == 0:
            return False, "没有可用云服务器公网 IP，已跳过 SSH 搭建"
        return True, ""

    @staticmethod
    def _shell_env_assignments(values: dict[str, str]) -> str:
        return shell_env_assignments(values)

    def _default_connection_env(self) -> dict[str, str]:
        username = self.default_conn_user_var.get().strip() or CTYUN_CUSTOM_CONNECTION_DEFAULT_USER
        password = self.default_conn_password_var.get().strip() or "wawacloud"
        socks_port = self.default_conn_socks_port_var.get().strip() or "23456"
        return {
            "VPN_USER_PREFIX": "user",
            "VPN_PASSWORD": password,
            "VPN_IPSEC_PSK": password,
            "SOCKS_USER": username,
            "SOCKS_PASSWORD": password,
            "SOCKS_PASS": password,
            "SOCKS_PORT": socks_port,
            "XRAY_USER": username,
            "XRAY_PASS": password,
            "XRAY_PORT": socks_port,
            "PSK_KEY": password,
            "VPN_PASS": password,
            "L2TP_PSK": password,
            "L2TP_PASS": password,
        }

    def _apply_ctyun_network_env(self, env: dict[str, str]) -> None:
        proxy = normalize_proxy_url(self.ctyun_proxy_var.get())
        if not proxy:
            env.pop("CTYUN_HTTPS_PROXY", None)
            env.pop("CTYUN_PROXY", None)
            return
        env["CTYUN_HTTPS_PROXY"] = proxy
        env["CTYUN_PROXY"] = proxy
        env["HTTPS_PROXY"] = proxy
        env["HTTP_PROXY"] = proxy

    def _default_connection_confirm_details(self) -> list[str]:
        if not self.default_conn_custom_saved or self.build_mode_var.get().strip() != CTYUN_BUILD_MODE_DEFAULT:
            return []
        env = self._default_connection_env()
        effect_label = "生效1次" if self.default_conn_once_var.get() else "本次软件运行期间保持"
        return [
            f"自定义连接配置：{effect_label}",
            f"用户名：{env['SOCKS_USER']}",
            f"密码：{env['SOCKS_PASSWORD']}（L2TP PSK密钥相同）",
            f"Socks5端口：{env['SOCKS_PORT']}",
        ]

    def _reset_default_connection_config(self) -> None:
        self.default_conn_user_var.set(CTYUN_CUSTOM_CONNECTION_DEFAULT_USER)
        self.default_conn_password_var.set("wawacloud")
        self.default_conn_socks_port_var.set("23456")
        self.default_conn_custom_saved = False
        self.default_conn_once_var.set(False)

    def _reset_one_time_build_config_after_workflow(self, mode: str) -> None:
        if mode not in {"build", "all"}:
            return
        if self.build_mode_var.get().strip() != CTYUN_BUILD_MODE_DEFAULT:
            return
        if self.default_conn_custom_saved and self.default_conn_once_var.get():
            self._reset_default_connection_config()

    def _record_new_ip_purchase_used_ips(self, regions: Sequence[str]) -> None:
        result_path = self.purchase_result_root / "run_result.json"
        if not result_path.exists():
            return
        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            self._emit(f"购买选择地区新IP已完成，但读取结果写入旧IP库失败：{exc}\n")
            return
        if not isinstance(data, dict):
            return
        total_added = 0
        for region in regions:
            ips = self._ordered_ips_for_region(str(region or ""), data)
            if not ips:
                continue
            try:
                path, added_count = self._append_used_ip_table(ips, region_label=str(region or ""))
            except Exception as exc:
                self._emit(f"[{region}] 新IP写入旧IP库失败：{exc}\n")
                continue
            total_added += added_count
            self._emit(f"[{region}] 当前已绑定IP写入旧IP库，本次新增 {added_count} 条：{path}\n")
        if total_added:
            self._emit(f"购买选择地区新IP：旧IP库共新增 {total_added} 条当前已绑定IP。\n")

    def _refresh_new_ip_purchase_result_outputs(self, regions: Sequence[str]) -> list[str]:
        result_path = self.purchase_result_root / "run_result.json"
        try:
            data = json.loads(result_path.read_text(encoding="utf-8-sig"))
        except Exception as exc:
            self._emit(f"购买选择地区新IP：读取新IP映射失败，结果框未更新：{exc}\n")
            return []
        if not isinstance(data, dict):
            self._emit("购买选择地区新IP：新IP映射格式无效，结果框未更新。\n")
            return []

        cached_l2tp_text = self._read_latest_result_file("last_l2tp.txt")
        cached_socks_text = self._read_latest_result_file("last_socks5.txt")
        cached_l2tp = self._parse_cached_result_blocks(cached_l2tp_text, "l2tp")
        cached_socks = self._parse_cached_result_blocks(cached_socks_text, "socks")
        changed = False
        updated_regions: list[str] = []

        for region_value in regions:
            region = str(region_value or "").strip()
            if not region:
                continue
            l2tp_key = self._cached_result_region_key(cached_l2tp, region)
            socks_key = self._cached_result_region_key(cached_socks, region)

            if self._is_result_region_invalid(region):
                if l2tp_key:
                    cached_l2tp.pop(l2tp_key, None)
                    changed = True
                if socks_key:
                    cached_socks.pop(socks_key, None)
                    changed = True
                self._emit(f"{region} 云服务器已重装，旧连接账号密码已失效，结果框不显示该地区。\n")
                continue

            new_ips = self._ordered_ips_for_region(region, data)
            new_ips = [ip for ip in new_ips if self._looks_like_ipv4(ip)]
            l2tp_rows = self._retarget_cached_result_rows(
                cached_l2tp.get(l2tp_key, []),
                "l2tp",
                new_ips,
            )
            socks_rows = self._retarget_cached_result_rows(
                cached_socks.get(socks_key, []),
                "socks",
                new_ips,
            )
            if not l2tp_key or not socks_key or not l2tp_rows or not socks_rows:
                if l2tp_key:
                    cached_l2tp.pop(l2tp_key, None)
                    changed = True
                if socks_key:
                    cached_socks.pop(socks_key, None)
                    changed = True
                self._emit(f"{region} 未找到仍有效的完整搭建结果，结果框不显示该地区；请先重新搭建。\n")
                continue

            cached_l2tp[l2tp_key] = l2tp_rows
            cached_socks[socks_key] = socks_rows
            changed = True
            updated_regions.append(region)

        if not changed:
            return updated_regions

        self._commit_cached_result_blocks(cached_l2tp, cached_socks)
        if updated_regions:
            self._emit(
                "购买选择地区新IP：已复用原有效连接账号密码并更新结果框："
                + "、".join(updated_regions)
                + "。\n"
            )
        return updated_regions

    def _after_workflow_done(
        self,
        regions: list[str],
        message: str,
        mode: str,
        *,
        successful_purchase_regions: Sequence[str] | None = None,
    ) -> None:
        self._reset_one_time_build_config_after_workflow(mode)
        new_ip_only = bool(getattr(self, "current_workflow_new_ip_only", False))
        if mode == "build":
            if message:
                self._show_completion_message(message, final_status=self.status_var.get())
            return
        if mode == "purchase" and new_ip_only:
            completed_regions = list(successful_purchase_regions or [])
            self._record_new_ip_purchase_used_ips(completed_regions)
            self._refresh_new_ip_purchase_result_outputs(completed_regions)
        if (
            mode == "purchase"
            and not new_ip_only
            and not getattr(self, "private_192_168_100_result_active", False)
        ):
            self._invalidate_result_regions(list(successful_purchase_regions or []))
        if message:
            self._show_completion_message(message, final_status=self.status_var.get())
        self._schedule_cleanup_resources_after_task(
            regions,
            final_popup_message=None,
            invalidate_results=False,
        )
        self.current_workflow_new_ip_only = False

    def _ssh_command(self) -> str:
        target_env = {"TOTAL_IPS": str(self._ctyun_eip_target_count())}
        if self.build_mode_var.get().strip() == CTYUN_BUILD_MODE_RANDOM:
            return f"{self._shell_env_assignments(target_env)} {CTYUN_RANDOM_SSH_COMMAND}"
        if not self.default_conn_custom_saved:
            return f"{self._shell_env_assignments(target_env)}; export TOTAL_IPS; {CTYUN_DEFAULT_SSH_COMMAND}"
        return f"{self._shell_env_assignments(target_env)}; export TOTAL_IPS; {configured_default_script_command(CTYUN_DEFAULT_SCRIPT_URL, self._default_connection_env())}"

    def _drain_ssh_banner(self, channel, log, seconds: float = 1.2) -> None:
        end_at = time.time() + seconds
        while time.time() < end_at:
            if channel.recv_ready():
                data = channel.recv(4096)
                if data:
                    self._emit(data.decode("utf-8", errors="replace"), log)
            time.sleep(0.1)

    def _is_retryable_ssh_error(self, exc: Exception) -> bool:
        """识别云主机刚开机时常见的临时 SSH 连接问题。"""
        detail = str(exc).lower()
        retry_tokens = (
            "error reading ssh protocol banner",
            "banner",
            "timed out",
            "timeout",
            "10060",
            "connection reset",
            "connection aborted",
            "connection refused",
        )
        return any(token in detail for token in retry_tokens)

    def _ssh_one_host(self, paramiko, region: str, host: str, log) -> tuple[bool, str]:
        self._emit(f"名称:{region}\n", log)
        self._emit(f"主机:{host}\n", log)
        self._emit("端口:22\n", log)
        self._emit("方法:密码\n", log)
        self._emit(f"用户名:{SSH_USERNAME}\n", log)
        self._emit("开始 SSH 连接...\n", log)

        max_attempts = 3
        public_ip_probe_failed = False
        for attempt in range(1, max_attempts + 1):
            client = paramiko.SSHClient()
            client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
            channel = None
            try:
                if attempt > 1:
                    self._emit(f"{region}: SSH 第 {attempt}/{max_attempts} 次重试连接...\n", log)
                client.connect(
                    hostname=host,
                    port=22,
                    username=SSH_USERNAME,
                    password=SSH_PASSWORD,
                    timeout=25,
                    banner_timeout=35,
                    auth_timeout=35,
                    look_for_keys=False,
                    allow_agent=False,
                )
                ensure_remote_xray(
                    client,
                    lambda message: self._emit(f"{message}\n", log),
                )
                self._emit("SSH 连接成功，开始执行搭建命令。\n", log)
                command = self._ssh_command()
                self._emit("搭建脚本已下发，正在执行。\n", log)

                transport = client.get_transport()
                if transport is None:
                    raise RuntimeError("SSH 传输通道未建立")
                channel = transport.open_session()
                channel.get_pty()
                channel.set_combine_stderr(True)
                with self.ssh_channel_lock:
                    self.ssh_channel = channel
                self._emit("SSH 通道已就绪，按原样发送命令执行。\n", log)
                channel.exec_command(command.rstrip())
                status = None

                while True:
                    if self.stop_requested:
                        channel.close()
                        self._emit("\nSSH 搭建已停止。\n", log)
                        return False, f"{region}: SSH 搭建已停止"
                    while channel.recv_ready():
                        data = channel.recv(4096)
                        if data:
                            text = data.decode("utf-8", errors="replace")
                            if "获取公网IP失败" in text or "获取失败" in text:
                                public_ip_probe_failed = True
                            self._emit(text, log)
                    if channel.exit_status_ready():
                        status = channel.recv_exit_status()
                        break
                    time.sleep(0.3)
                while channel.recv_ready():
                    data = channel.recv(4096)
                    if data:
                        text = data.decode("utf-8", errors="replace")
                        if "获取公网IP失败" in text or "获取失败" in text:
                            public_ip_probe_failed = True
                        self._emit(text, log)
                try:
                    channel.send("exit\n")
                except Exception:
                    pass
                if status == 0:
                    self._emit(f"\n{region}: SSH 搭建完成。\n", log)
                    self.queue.put(("refresh_outputs",))
                    if public_ip_probe_failed:
                        reason = f"{region}: SSH 搭建完成，但日志显示部分内网 IP 获取公网 IP 失败"
                        self._emit(f"{reason}，请核对 L2TP/Socks5 结果。\n", log)
                        return False, reason
                    return True, ""
                else:
                    reason = f"{region}: SSH 命令执行失败，退出码 {status if status is not None else '未知'}"
                    self._emit(f"\n{reason}。\n", log)
                    return False, reason
            except Exception as exc:
                if self.stop_requested or attempt >= max_attempts or not self._is_retryable_ssh_error(exc):
                    reason = f"{region}: SSH 连接或执行失败：{exc}"
                    self._emit(f"{reason}\n", log)
                    return False, reason
                wait_seconds = 8 * attempt
                self._emit(f"{region}: SSH 连接第 {attempt} 次失败：{exc}，等待 {wait_seconds} 秒后重试。\n", log)
                time.sleep(wait_seconds)
            finally:
                with self.ssh_channel_lock:
                    if channel is not None and self.ssh_channel is channel:
                        self.ssh_channel = None
                client.close()
        return False, f"{region}: SSH 搭建未成功"

    def _run_phase(
        self,
        phase: str,
        log,
        *,
        end_time: str = "",
        log_path: Path | None = None,
        regions_override: list[str] | None = None,
        cleanup_query_scope: str = "",
        cleanup_selection_file: Path | None = None,
        run_root: Path | None = None,
    ) -> int:
        run_root = run_root or self.runtime_root
        args = [
            *self._worker_command(),
            "--input-file",
            str(run_root / "input.example.txt"),
            "--phase",
            phase,
            "--execute",
        ]
        if regions_override:
            args.extend(["--regions", ",".join(regions_override)])
        if cleanup_selection_file:
            args.extend(["--cleanup-selection-file", str(cleanup_selection_file)])
        if end_time:
            args.extend(["--run-end-time", end_time])
        if log_path:
            args.extend(["--run-log-file", str(log_path)])

        runtime_regions_file = ensure_runtime_regions_file()
        env = os.environ.copy()
        # 中文注释：多窗口会同时运行天翼云任务，workflow_2region 内部仍使用 ROOT 全局；
        # 放到 worker 子进程里执行，给每个窗口独立环境变量，避免两个窗口读写串目录。
        env["CTYUN_WORKFLOW_ROOT"] = str(run_root)
        env["CTYUN_WORKFLOW_REGIONS_FILE"] = str(runtime_regions_file)
        env["CTYUN_ALL_REGIONS_CACHE_FILE"] = str(all_regions_catalog_file())
        env["CTYUN_WORKFLOW_STOP_FILE"] = str(self.stop_file)
        env["PYTHONUTF8"] = "1"
        env["PYTHONIOENCODING"] = "utf-8"
        env["PYTHONUNBUFFERED"] = "1"
        env["PYTHONLEGACYWINDOWSSTDIO"] = "0"
        env["CTYUN_REINSTALL_BOOT_ACTION"] = "start"
        env["CTYUN_EIP_MONTHLY_DEDICATED_1M"] = "1" if bool(self.monthly_dedicated_eip_var.get()) else "0"
        env["CTYUN_EIP_TARGET_COUNT"] = str(self._ctyun_eip_target_count())
        env["CTYUN_PRIVATE_IP_PLAN"] = "192_168_100" if bool(self.private_192_168_100_var.get()) else ""
        env["CTYUN_USED_IP_TABLE"] = str(self._used_ip_table_path())
        purchase_phase = phase == "export" or any(phase == phase_name for phase_name, _title in PHASES)
        env["CTYUN_NEW_IP_ONLY"] = (
            "1"
            if purchase_phase and bool(getattr(self, "current_workflow_new_ip_only", False))
            else "0"
        )
        self._apply_ctyun_network_env(env)
        if phase == "cleanup-query" and cleanup_query_scope:
            env["CTYUN_CLEANUP_QUERY_SCOPE"] = cleanup_query_scope
        kwargs: dict[str, object] = {}
        if os.name == "nt":
            kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
        try:
            proc = subprocess.Popen(
                args,
                cwd=str(app_dir()),
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=False,
                **kwargs,
            )
        except Exception as exc:
            self._emit(f"启动阶段子任务失败：{exc}\n", log)
            return 1
        self.child_processes.add(proc)
        try:
            if proc.stdout is not None:
                for raw_line in proc.stdout:
                    line = self._decode_subprocess_line(raw_line)
                    self._remember_failure_reason_from_line(line)
                    self._emit(line, log)
                    if self.stop_requested:
                        try:
                            proc.terminate()
                        except Exception:
                            pass
            code = int(proc.wait() or 0)
            if self.stop_requested:
                return 130
            return code
        finally:
            self.child_processes.discard(proc)

    def _emit(self, text: str, log=None, *, target_name: str | None = None) -> None:
        text = sanitize_log_text(text)
        with self.log_lock:
            if log is not None:
                log.write(text)
                log.flush()
            resolved_target = target_name or getattr(self.thread_context, "log_name", self.active_log_name)
            if resolved_target in {"both", "account-query"}:
                compact_account_query = resolved_target == "account-query"
                self.queue.put(("log", "purchase", text, True, compact_account_query))
                self.queue.put(("log", "cleanup", text, True, compact_account_query))
                return
            if resolved_target not in {"purchase", "cleanup"}:
                resolved_target = self.active_log_name if self.active_log_name in {"purchase", "cleanup"} else "purchase"
            self.queue.put(("log", resolved_target, text))

    def _maybe_show_eip_sold_out_popup(self, text: str) -> None:
        if self.eip_sold_out_popup_shown:
            return
        if "EIP 售罄" not in text and "弹性IP已售罄" not in text and "所选线路类型弹性IP已售罄" not in text:
            return
        self.eip_sold_out_popup_shown = True
        region_name = self._eip_sold_out_region_name(text)
        if region_name:
            selected_regions: list[str] = []
            for value in self.current_workflow_regions:
                selected_region = str(value or "").strip()
                if selected_region and selected_region not in selected_regions:
                    selected_regions.append(selected_region)
            remaining_regions = [value for value in selected_regions if value != region_name]
            if remaining_regions:
                remaining_text = "、".join(remaining_regions)
                message = f"{region_name}地区弹性公网IP已售罄，请更换地区，是否继续执行{remaining_text}地区任务？"
                if ask_app_confirm_dialog(
                    self._dialog_parent(),
                    self._dialog_title(message, default_suffix="提醒"),
                    message,
                    ok_text="继续",
                    cancel_text="取消",
                ):
                    self._emit(f"{region_name}地区弹性公网IP已售罄，继续执行{remaining_text}地区任务。\n", target_name="purchase")
                else:
                    self._emit(f"{region_name}地区弹性公网IP已售罄，用户取消其余地区任务。\n", target_name="purchase")
                    self._request_stop()
                return
            self._show_warning(f"{region_name}地区弹性公网IP已售罄，请更换地区。")
            return
        self._show_warning(EIP_SOLD_OUT_POPUP_TEXT)

    def _eip_sold_out_region_name(self, text: str) -> str:
        line = str(text or "").strip()
        for region in self.current_workflow_regions:
            region_name = str(region or "").strip()
            if region_name and region_name in line:
                return region_name
        for pattern in (
            r"^\s*\[([^\]]+)\]\s*EIP\s*售罄",
            r"^\s*([^\s:：，。]+)\s*[:：]\s*EIP\s*售罄",
            r"^\s*([^\s:：，。]+)\s+EIP\s*售罄",
            r"^\s*([^\s:：，。]+).*?(?:弹性IP已售罄|所选线路类型弹性IP已售罄)",
        ):
            match = re.search(pattern, line)
            if match:
                return match.group(1).strip()
        if len(self.current_workflow_regions) == 1:
            return str(self.current_workflow_regions[0] or "").strip()
        return ""

    def _drain_queue(self) -> None:
        try:
            processed = 0
            while processed < 80:
                item = self.queue.get_nowait()
                processed += 1
                try:
                    kind = item[0]
                    if kind == "log":
                        _kind, target_name, text = item[:3]
                        shared_log = bool(item[3]) if len(item) > 3 else False
                        compact_account_query = bool(item[4]) if len(item) > 4 else False
                        display_text = self._realtime_display_text(
                            text,
                            target_name,
                            shared_log=shared_log,
                            compact_account_query=compact_account_query,
                        )
                        self._queue_realtime_text(target_name, display_text)
                        if target_name == "purchase":
                            self._maybe_show_eip_sold_out_popup(text)
                            if self._ctyun_log_text_has_result_row(display_text):
                                self._schedule_output_refresh(50, force=True)
                    elif kind == "refresh_outputs":
                        self._schedule_output_refresh()
                    elif kind == "done":
                        stopped = self.stop_requested
                        affects_global_busy = item[2] if len(item) > 2 and isinstance(item[2], bool) else True
                        final_status = item[4] if len(item) > 4 and isinstance(item[4], str) else ""
                        self._finish_async_task(affects_global_busy=affects_global_busy, final_status=final_status)
                        still_running = self.running
                        self._set_buttons_state("normal")
                        callback = item[1] if len(item) > 1 and callable(item[1]) else self.pending_after_done
                        popup_message = item[3] if len(item) > 3 and isinstance(item[3], str) else ""
                        self.pending_after_done = None
                        self._schedule_output_refresh(50, force=True)
                        if not still_running:
                            self._reset_stop()
                        if callback:
                            callback()
                        if popup_message:
                            self._show_completion_message(popup_message, final_status=final_status)
                        if not still_running:
                            self.root.after(800, self._complete_delayed_action_if_idle)
                    elif kind == "partner_failed":
                        text = item[1] if len(item) > 1 else ""
                        self._show_error(text or PARTNER_REQUIRED_MESSAGE)
                        self.root.destroy()
                except Exception:
                    continue
        except queue.Empty:
            pass
        finally:
            try:
                self.root.after(10 if not self.queue.empty() else 100, self._drain_queue)
            except tk.TclError:
                pass

    def _set_buttons_state(self, state: str) -> None:
        action_state = "normal"
        for button in (self.purchase_button, self.build_button, self.reinstall_button, self.start_button):
            button.configure(state=action_state)
        self.cleanup_query_button.configure(state=action_state)
        self.cleanup_execute_button.configure(state=action_state)
        if self.cleanup_clear_button is not None:
            self.cleanup_clear_button.configure(state=action_state)
        if self.reinstall_execute_button is not None:
            self.reinstall_execute_button.configure(state=action_state)
        stop_state = "normal" if self.running else "disabled"
        for button in self.stop_buttons:
            button.configure(state=stop_state)


class QueueWriter:
    def __init__(self, emit):
        self.emit = emit
        self.buffer = ""

    def write(self, text: str) -> int:
        if not text:
            return 0
        self.buffer += text
        while "\n" in self.buffer:
            line, self.buffer = self.buffer.split("\n", 1)
            self.emit(line + "\n")
        return len(text)

    def flush(self) -> None:
        if self.buffer:
            self.emit(self.buffer)
            self.buffer = ""


def main() -> int:
    if len(sys.argv) > 1 and sys.argv[1] == "--worker":
        return worker_main()
    root = Tk()
    root.title(APP_TITLE)
    WorkflowGui(root)
    root.update_idletasks()
    maximize_window(root)
    def prompt_disclaimer() -> None:
        if not show_disclaimer(root):
            root.destroy()

    root.after(200, prompt_disclaimer)
    root.mainloop()
    return 0


def show_disclaimer(root: Tk) -> bool:
    result = {"agreed": False}
    dialog = Toplevel(root)
    dialog.withdraw()
    dialog.title("软件使用免责声明")
    dialog.resizable(False, False)
    dialog.transient(root)
    apply_dialog_icon(dialog)
    frame = ttk.Frame(dialog, padding=16)
    frame.pack(fill=BOTH, expand=True)
    text = Text(frame, width=72, height=16, wrap="word", borderwidth=0, highlightthickness=0)
    text.insert("1.0", DISCLAIMER_TEXT)
    text.configure(state="disabled", background=dialog.cget("bg"))
    text.pack(fill=BOTH, expand=True)
    buttons = ttk.Frame(frame)
    buttons.pack(fill=X, pady=(12, 0))
    buttons.columnconfigure(0, weight=1)
    buttons.columnconfigure(1, weight=1)

    def agree() -> None:
        result["agreed"] = True
        dialog.destroy()

    def cancel() -> None:
        dialog.destroy()

    ttk.Button(buttons, text="同意", command=agree).grid(row=0, column=0, sticky="ew", padx=(0, 8))
    ttk.Button(buttons, text="取消", command=cancel).grid(row=0, column=1, sticky="ew", padx=(8, 0))
    dialog.protocol("WM_DELETE_WINDOW", cancel)
    root.update_idletasks()
    dialog.update_idletasks()
    width = 780
    height = 430
    root_x = root.winfo_rootx()
    root_y = root.winfo_rooty()
    root_w = max(root.winfo_width(), width)
    root_h = max(root.winfo_height(), height)
    x = root_x + root_w // 2 - width // 2
    y = root_y + root_h // 2 - height // 2
    x = max(20, min(x, root.winfo_screenwidth() - width - 20))
    y = max(20, min(y, root.winfo_screenheight() - height - 60))
    dialog.geometry(f"{width}x{height}+{x}+{y}")
    dialog.deiconify()
    dialog.attributes("-topmost", True)
    dialog.lift()
    dialog.focus_force()
    dialog.grab_set()
    dialog.after(300, lambda: (dialog.lift(), dialog.focus_force()))
    dialog.after(1200, lambda: dialog.attributes("-topmost", False))
    root.wait_window(dialog)
    return result["agreed"]


if __name__ == "__main__":
    raise SystemExit(main())
