#!/usr/bin/env python3
"""Two-region Tianyi Cloud workflow.

Default mode is dry-run. Add --execute only when you want to send real requests.

Main order:
1. buy-eip
2. has-active-regions
3. discover existing ECS or specs/images
4. buy-ecs
5. havip-bind
6. ecs-eip-security
7. export
"""

from __future__ import annotations

import argparse
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
import os
import re
import subprocess
import sys
import time
import uuid
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import HTTPError, URLError


RESOURCE_ID_RE = re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b")
CLEANUP_INDEX_WORKERS = 8
CLEANUP_DETAIL_WORKERS = 6
CLEANUP_DETAIL_RETRY_WORKERS = 3

try:
    # 包内运行时使用相对导入，避免重构后从项目根目录导入失败。
    from .ctyun_openapi import CtyunClient, CtyunSslCertificateError, check_stop_requested as client_check_stop_requested, ctyun_ssl_failure_message, ctyun_urlopen, eip_create_body, ecs_batch_body, hidden_subprocess_kwargs, is_ctyun_ssl_certificate_error, load_credentials
    from .workflow_config import (
        BANDWIDTH_CANDIDATES,
        DEFAULT_ECS_SPEC,
        ECS_PRIVATE_IP,
        EIP_FALLBACK_WORKERS,
        EIP_MAX_NO_PROGRESS_BATCHES,
        EIP_PARALLEL_WORKERS,
        EIP_SYNC_CHECK_INTERVAL_SECONDS,
        EIP_SYNC_CHECK_TOTAL_SECONDS,
        ENDPOINT_BILLING,
        ENDPOINT_ECS,
        ENDPOINT_IMAGE,
        ENDPOINT_VPC,
        HAVIP_RANGE,
        MIN_ACCOUNT_BALANCE,
        MONTHLY_DEDICATED_EIP_BANDWIDTH,
        MONTHLY_DEDICATED_EIP_CYCLE_COUNT,
        PASSWORD,
        REGION_ALIASES,
        REGION_EIP_BANDWIDTHS,
        REGION_SLUGS,
        SECURITY_GROUP_RULES,
        TARGET_ECS_COUNT,
        TARGET_EIP_COUNT,
    )
except ImportError:
    # 兼容旧方式：直接在 CTYUN 目录里单独运行 workflow_2region.py。 
    from ctyun_openapi import CtyunClient, CtyunSslCertificateError, check_stop_requested as client_check_stop_requested, ctyun_ssl_failure_message, ctyun_urlopen, eip_create_body, ecs_batch_body, hidden_subprocess_kwargs, is_ctyun_ssl_certificate_error, load_credentials
    from workflow_config import (
        BANDWIDTH_CANDIDATES,
        DEFAULT_ECS_SPEC,
        ECS_PRIVATE_IP,
        EIP_FALLBACK_WORKERS,
        EIP_MAX_NO_PROGRESS_BATCHES,
        EIP_PARALLEL_WORKERS,
        EIP_SYNC_CHECK_INTERVAL_SECONDS,
        EIP_SYNC_CHECK_TOTAL_SECONDS,
        ENDPOINT_BILLING,
        ENDPOINT_ECS,
        ENDPOINT_IMAGE,
        ENDPOINT_VPC,
        HAVIP_RANGE,
        MIN_ACCOUNT_BALANCE,
        MONTHLY_DEDICATED_EIP_BANDWIDTH,
        MONTHLY_DEDICATED_EIP_CYCLE_COUNT,
        PASSWORD,
        REGION_ALIASES,
        REGION_EIP_BANDWIDTHS,
        REGION_SLUGS,
        SECURITY_GROUP_RULES,
        TARGET_ECS_COUNT,
        TARGET_EIP_COUNT,
    )

try:
    from common.debug_billing import is_on_demand_debug_enabled
except Exception:
    def is_on_demand_debug_enabled(_cloud: str) -> bool:
        return False

try:
    from common.output_formatting import read_excel_table_rows
except Exception:
    read_excel_table_rows = None


def is_monthly_dedicated_eip_enabled() -> bool:
    value = os.environ.get("CTYUN_EIP_MONTHLY_DEDICATED_1M", "").strip().lower()
    return value in {"1", "true", "yes", "on", "enabled"}


def is_private_192_168_100_plan_enabled() -> bool:
    value = os.environ.get("CTYUN_PRIVATE_IP_PLAN", "").strip().lower()
    return value in {"192_168_100", "192.168.100", "192", "1", "true", "yes", "on", "enabled"}


def is_new_ip_purchase_enabled() -> bool:
    value = os.environ.get("CTYUN_NEW_IP_ONLY", "").strip().lower()
    return value in {"1", "true", "yes", "on", "enabled"}


def target_eip_count() -> int:
    value = os.environ.get("CTYUN_EIP_TARGET_COUNT", "").strip().lower()
    if value in {"2", "1ecs_2eip", "1ecs-2eip", "two"}:
        return 2
    return TARGET_EIP_COUNT


def target_havip_count() -> int:
    return max(0, target_eip_count() - TARGET_ECS_COUNT)


def private_ip_plan_key() -> str:
    return "192_168_100" if is_private_192_168_100_plan_enabled() else "default_10_0_0"


def region_private_ip_plan(region: dict[str, Any]) -> str:
    return str(region.get("privateIPPlan") or "default_10_0_0")


def region_matches_private_ip_plan(region: dict[str, Any]) -> bool:
    return region_private_ip_plan(region) == private_ip_plan_key()


def ecs_private_ip() -> str:
    return "192.168.100.101" if is_private_192_168_100_plan_enabled() else ECS_PRIVATE_IP


def planned_havip_ips() -> list[str]:
    if is_private_192_168_100_plan_enabled():
        candidates = [f"192.168.100.{i}" for i in range(102, 111)]
    else:
        candidates = [f"10.0.0.{i}" for i in HAVIP_RANGE]
    return candidates[:target_havip_count()]


def planned_havip_label() -> str:
    ips = planned_havip_ips()
    if len(ips) == 1:
        return ips[0]
    return f"{ips[0]}~{ips[-1]}" if ips else ""


def private_ip_plan_label() -> str:
    return f"ECS={ecs_private_ip()}、HAVIP={planned_havip_label()}"


def vpc_cidr() -> str:
    return "192.168.0.0/16" if is_private_192_168_100_plan_enabled() else "10.0.0.0/8"


def subnet_cidr() -> str:
    return "192.168.100.0/24" if is_private_192_168_100_plan_enabled() else "10.0.0.0/24"


def preferred_vpc_cidrs() -> set[str]:
    if is_private_192_168_100_plan_enabled():
        return {"192.168.0.0/16", "192.168.100.0/24"}
    return {"10.0.0.0/8", "10.0.0.0/16"}


try:
    from common.cloud_resources import resource_expire_time
except Exception:
    def resource_expire_time(resource: dict[str, Any]) -> str:
        keys = {
            "expireTime",
            "expiredTime",
            "expiredAt",
            "expire_time",
            "expirationTime",
            "expireAt",
            "endTime",
            "validTo",
            "resourceExpireTime",
        }

        def walk(value: Any) -> str:
            if isinstance(value, dict):
                for key, nested in value.items():
                    if str(key) in keys and nested not in (None, ""):
                        return str(nested)
                    found = walk(nested)
                    if found:
                        return found
            elif isinstance(value, list):
                for nested in value:
                    found = walk(nested)
                    if found:
                        return found
            return ""

        return walk(resource)

INVALID_DISPLAY_VALUES = {"none", "null", "nil", "nan", "n/a", "na", "-", "--", "无", "暂无"}


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


if hasattr(sys.stdout, "reconfigure"):
    sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)
if hasattr(sys.stderr, "reconfigure"):
    sys.stderr.reconfigure(encoding="utf-8", errors="replace", line_buffering=True)


ROOT = Path(os.environ.get("CTYUN_WORKFLOW_ROOT", Path(__file__).resolve().parent)).resolve()
REGIONS_FILE = Path(os.environ.get("CTYUN_WORKFLOW_REGIONS_FILE", ROOT / "regions_10ip.json")).resolve()
ALL_REGIONS_CACHE_FILE = Path(
    os.environ.get("CTYUN_ALL_REGIONS_CACHE_FILE", REGIONS_FILE.parent / "all_regions_catalog.json")
).resolve()
STATE_FILE = ROOT / "workflow_2region_state.json"

def check_stop_requested() -> None:
    client_check_stop_requested()


def sleep_with_stop(seconds: float) -> None:
    end_at = time.time() + max(0, seconds)
    while True:
        check_stop_requested()
        remaining = end_at - time.time()
        if remaining <= 0:
            return
        time.sleep(min(0.5, remaining))


def load_json(path: Path, default: Any | None = None) -> Any:
    if not path.exists():
        if default is not None:
            return default
        raise FileNotFoundError(path)
    return json.loads(path.read_text(encoding="utf-8-sig"))


def save_json(path: Path, data: Any) -> None:
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


def save_cleanup_query_error(message: str) -> None:
    save_json(
        ROOT / "cleanup_resources.json",
        {
            "items": [],
            "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "completed": False,
            "error": str(message or "资源查询失败"),
            "preserveExisting": True,
        },
    )


def valid_ipv4_text(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)


def load_used_ip_table_from_env() -> set[str]:
    path_text = os.environ.get("CTYUN_USED_IP_TABLE", "").strip()
    if not path_text:
        return set()
    path = Path(path_text)
    if not path.exists():
        return set()
    used: set[str] = set()
    if path.suffix.lower() == ".xlsx" and read_excel_table_rows is not None:
        headers = ("日期时间", "天翼云用户名-简AK-SK", "地区", "IP")
        for row in read_excel_table_rows(path, headers):
            for value in row.values():
                ip = valid_ipv4_text(value)
                if ip:
                    used.add(ip)
        return used
    try:
        with path.open("r", encoding="utf-8-sig", newline="") as handle:
            reader = csv.DictReader(handle)
            fieldnames = [str(name or "").strip() for name in (reader.fieldnames or [])]
            if fieldnames:
                for row in reader:
                    for key in ("ip", "IP", "公网IP", "旧IP"):
                        value = valid_ipv4_text(row.get(key))
                        if value:
                            used.add(value)
                    if row:
                        for value in row.values():
                            ip = valid_ipv4_text(value)
                        if ip:
                            used.add(ip)
            handle.seek(0)
            for row in csv.reader(handle):
                for cell in row:
                    value = valid_ipv4_text(cell)
                    if value:
                        used.add(value)
    except Exception:
        return set()
    return used


def new_ip_purchase_eip_is_bound(eip: dict[str, Any]) -> bool:
    return bool(
        eip_has_association(eip)
        or eip.get("associationName")
        or eip.get("bindingName")
        or eip.get("bindResourceID")
        or eip.get("bindResourceId")
        or eip.get("masterResourceID")
        or eip.get("masterResourceId")
    )


def new_ip_purchase_eligible_eips(eips: list[dict[str, Any]], used_ips: set[str]) -> list[dict[str, Any]]:
    eligible: list[dict[str, Any]] = []
    for eip in eips:
        address = valid_ipv4_text(eip_public_address(eip))
        if new_ip_purchase_eip_is_bound(eip) or not address or address not in used_ips:
            eligible.append(eip)
    return eligible


def prepare_new_ip_purchase_eips(
    region: dict[str, Any],
    execute: bool,
    used_ips: set[str],
) -> list[dict[str, Any]]:
    current = list_eips(region, execute) if execute else []
    repeated = [
        eip
        for eip in current
        if not new_ip_purchase_eip_is_bound(eip)
        and valid_ipv4_text(eip_public_address(eip)) in used_ips
    ]
    if not repeated:
        return new_ip_purchase_eligible_eips(current, used_ips)

    name = region["displayName"]
    print(f"{name} 发现 {len(repeated)} 条未绑定弹性IP命中旧IP库，先并发释放后再补购新IP。")
    released_ids: set[str] = set()
    with ThreadPoolExecutor(max_workers=min(EIP_PARALLEL_WORKERS, len(repeated))) as pool:
        future_map = {}
        for eip in repeated:
            eip_id = eip_resource_id(eip)
            if not eip_id:
                print(f"{name} 旧IP {eip_public_address(eip) or '未知'} 缺少 EIP ID，无法自动释放。")
                continue
            item = {**eip, "eipID": eip_id, "address": eip_public_address(eip)}
            future_map[pool.submit(release_eip_item, region, item, execute)] = eip_id
        for future in as_completed(future_map):
            eip_id = future_map[future]
            try:
                if future.result():
                    released_ids.add(eip_id)
            except Exception as exc:
                print(f"{name} 释放旧IP库重复 EIP {eip_id} 异常：{exc}")
    if released_ids:
        wait_for_eip_ids_gone(region, released_ids, execute, timeout=90)
    refreshed = list_eips(region, execute) if execute else []
    return new_ip_purchase_eligible_eips(refreshed, used_ips)


def save_region_catalog_cache(regions: list[dict[str, Any]]) -> None:
    cleaned = [
        dict(region)
        for region in regions
        if isinstance(region, dict) and region.get("displayName") and region.get("regionID")
    ]
    if not cleaned:
        return
    try:
        ALL_REGIONS_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
        save_json(
            ALL_REGIONS_CACHE_FILE,
            {
                "updatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
                "regions": cleaned,
            },
        )
    except Exception:
        pass


def state() -> dict[str, Any]:
    return load_json(STATE_FILE, {"regions": {}})


def save_state(data: dict[str, Any]) -> None:
    save_json(STATE_FILE, data)


def region_state(data: dict[str, Any], name: str) -> dict[str, Any]:
    return data.setdefault("regions", {}).setdefault(name, {})


def response_reason(result: dict[str, Any]) -> str:
    parts: list[str] = []
    saw_success = False
    for key in ("description", "message"):
        value = str(result.get(key, "")).strip()
        if not value:
            continue
        if value.lower() in {"success", "ok", "success."} or value.upper() == "SUCCESS":
            saw_success = True
            continue
        if value not in parts:
            parts.append(value)
    if parts:
        return " / ".join(parts)
    if saw_success or ok(result):
        return "成功"
    return str(result.get("errorCode") or "未知原因").strip()


def hide_resource_ids(text: str, replacements: dict[str, str] | None = None, fallback: str = "资源") -> str:
    """中文注释：日志给用户看名称，不直接暴露云平台内部资源 ID；接口调用仍使用原始 ID。"""
    result = str(text or "")
    for resource_id, label in sorted((replacements or {}).items(), key=lambda item: len(item[0]), reverse=True):
        resource_id = str(resource_id or "")
        label = str(label or "").strip()
        if resource_id and label:
            result = result.replace(resource_id, label)
    return RESOURCE_ID_RE.sub(fallback, result)


def operation_result(result: dict[str, Any], success_text: str, failure_text: str = "失败") -> str:
    if ok(result):
        return success_text
    return f"{failure_text}：{hide_resource_ids(response_reason(result))}"


def bool_result(success: bool, success_text: str, failure_text: str, detail: str = "") -> str:
    text = success_text if success else failure_text
    detail = hide_resource_ids(str(detail or "").strip())
    return f"{text}：{detail}" if detail else text


def is_eip_sold_out_reason(reason: str) -> bool:
    return "EIP已售罄" in reason or "弹性IP已售罄" in reason or "所选线路类型弹性IP已售罄" in reason


def is_eip_sold_out_result(result: dict[str, Any]) -> bool:
    return is_eip_sold_out_reason(response_reason(result))


def is_flavor_sold_out_reason(reason: str) -> bool:
    text = str(reason or "")
    lower = text.lower()
    return (
        "flavorsoldout" in lower
        or "sold out" in lower
        or "售罄" in text
        or "余量不足" in text
    )


def is_flavor_sold_out_result(result: dict[str, Any]) -> bool:
    return is_flavor_sold_out_reason(response_reason(result))


def is_vpc_not_found_reason(reason: str) -> bool:
    text = str(reason or "").strip()
    lower = text.lower()
    return (
        "vpccheck.notfound" in lower
        or "vpc not found" in lower
        or "the vpc not found" in lower
        or "query vpc ip error" in lower
        or "subnetworkorder.accessfailed" in lower
        or "vpc信息" in text
        or "查询vpc ip信息错误" in lower
        or "未找到对应的vpc" in lower
        or "请确认输入的vpc信息是否有效" in lower
    )


def is_vpc_not_found_result(result: dict[str, Any]) -> bool:
    reason = response_reason(result)
    error_code = str(result.get("errorCode") or "").strip()
    message = str(result.get("message") or "").strip()
    description = str(result.get("description") or "").strip()
    return (
        is_vpc_not_found_reason(reason)
        or is_vpc_not_found_reason(error_code)
        or is_vpc_not_found_reason(message)
        or is_vpc_not_found_reason(description)
    )


def mark_skip_remaining(data: dict[str, Any], name: str, reason: str) -> None:
    region_state(data, name)["skipRemainingReason"] = reason
    save_state(data)


def clear_skip_remaining(data: dict[str, Any], name: str) -> None:
    region_state(data, name).pop("skipRemainingReason", None)
    region_state(data, name).pop("eipWarning", None)
    save_state(data)


def reset_purchase_region_state(data: dict[str, Any], name: str) -> None:
    rs = region_state(data, name)
    for key in (
        "skipRemainingReason",
        "eipWarning",
        "havipWarning",
        "havipBindFailures",
        "ecsEipWarning",
        "ecsBoundEip",
        "finalizeWarning",
        "retained",
        "monthlyConvert",
        "securityRules",
        "deletedInstances",
        "eipOrders",
        "ecsOrders",
        "ecsOrder",
    ):
        rs.pop(key, None)


def skip_remaining_reason(data: dict[str, Any], name: str) -> str:
    rs = data.get("regions", {}).get(name, {})
    explicit = str(rs.get("skipRemainingReason", ""))
    if explicit:
        return explicit
    eip_orders = rs.get("eipOrders", [])
    has_success = any(isinstance(o.get("response"), dict) and ok(o["response"]) for o in eip_orders)
    sold_out = [
        response_reason(o.get("response", {}))
        for o in eip_orders
        if isinstance(o.get("response"), dict)
        and is_eip_sold_out_result(o["response"])
    ]
    if eip_orders and sold_out and not has_success:
        return f"EIP 售罄，跳过该地区后续任务。{sold_out[-1]}"
    return ""


def actual_eip_count(region: dict[str, Any], execute: bool) -> int:
    if not execute:
        return 0
    eips = list_eips(region, execute)
    if not is_new_ip_purchase_enabled():
        return len(eips)
    used_ips = load_used_ip_table_from_env()
    return len(new_ip_purchase_eligible_eips(eips, used_ips))


def wait_until_eips_visible(region: dict[str, Any], execute: bool, target: int | None = None, *, max_attempts: int = 30, interval: int = 10) -> int:
    target = target_eip_count() if target is None else target
    if not execute:
        return 0
    last_count = 0
    stagnant = 0
    for attempt in range(1, max_attempts + 1):
        previous_count = last_count
        last_count = actual_eip_count(region, execute)
        if last_count >= target:
            return last_count
        stagnant = stagnant + 1 if last_count <= previous_count else 0
        print(f"{region['displayName']} 等待 EIP 资源列表同步：第 {attempt} 次，当前 {last_count}/{target}")
        if stagnant >= 12 and last_count > 0:
            print(f"{region['displayName']} EIP 数量暂时停在 {last_count}/{target}，继续等云端订单处理完成。")
        sleep_with_stop(interval)
    return last_count


def wait_for_eip_growth(region: dict[str, Any], execute: bool, before_count: int, expected_growth: int, seconds: int = 30) -> int:
    target = before_count + expected_growth
    last_count = before_count
    loops = max(1, seconds // 5)
    for attempt in range(1, loops + 1):
        last_count = actual_eip_count(region, execute)
        if last_count >= target:
            return last_count
        print(f"{region['displayName']} 等待 EIP 到账：第 {attempt} 次，当前 {last_count}/{target}")
        sleep_with_stop(5)
    return last_count


def wait_for_eip_submit_sync(region: dict[str, Any], execute: bool, target: int, *, reason: str) -> int:
    """EIP 提交后按固定窗口确认异步到账。

    天翼云 EIP 订单偶尔先返回规则校验/配额类失败，但资源会在几十秒内进入列表。
    每 10 秒确认一次，最多 60 秒；只要达到目标数量就立即进入后续流程。
    """
    if not execute or EIP_SYNC_CHECK_TOTAL_SECONDS <= 0:
        return actual_eip_count(region, execute)
    interval = max(1, EIP_SYNC_CHECK_INTERVAL_SECONDS)
    attempts = max(1, EIP_SYNC_CHECK_TOTAL_SECONDS // interval)
    last_count = actual_eip_count(region, execute)
    if last_count >= target:
        return last_count
    for attempt in range(1, attempts + 1):
        sleep_with_stop(interval)
        last_count = actual_eip_count(region, execute)
        print(f"{region['displayName']} {reason}，等待 EIP 到账 {attempt}/{attempts}：当前 {last_count}/{target}")
        if last_count >= target:
            return last_count
    return last_count


def submit_ecs_order_with_network_wait(
    c: CtyunClient,
    region: dict[str, Any],
    body: dict[str, Any],
    bought: int,
    count: int,
    used_spec: str,
    execute: bool,
    label: str = "ECS",
) -> dict[str, Any]:
    result = call_with_retry(c.post, "/v4/ecs/batch-create-instances", body)
    prefix = f"{region['displayName']} {label} {bought + 1}-{bought + count}/{TARGET_ECS_COUNT} [{used_spec}]"
    if not ok(result) and is_vpc_not_found_result(result):
        print(f"{prefix} VPC/子网或固定内网 IP 可能仍在云端同步，等待后重试下单。")
        for attempt in range(1, 7):
            sleep_with_stop(10 if execute else 0)
            result = call_with_retry(c.post, "/v4/ecs/batch-create-instances", body)
            if not is_vpc_not_found_result(result):
                print(f"{prefix} 云端同步后重试第 {attempt} 次 {operation_result(result, '下单成功', '下单失败')}")
                return result
            print(f"{region['displayName']} VPC/子网/IP 同步等待：第 {attempt} 次仍未可用于 ECS 下单。")
        print(f"{prefix} 云端同步等待后仍不可用。")
        return result
    print(f"{prefix} {operation_result(result, '下单成功', '下单失败')}")
    return result


def clear_eip_skip_if_ready(data: dict[str, Any], region: dict[str, Any], execute: bool) -> bool:
    name = region["displayName"]
    reason = str(region_state(data, name).get("skipRemainingReason", ""))
    target_count = target_eip_count()
    if reason and reason.startswith("EIP") and actual_eip_count(region, execute) >= target_count:
        clear_skip_remaining(data, name)
        print(f"{name} 已查询到 {target_count} 条 EIP，清除之前的 EIP 跳过原因，继续后续流程。")
        return True
    return False


def active_regions_after_eip(regions: list[dict[str, Any]], execute: bool) -> list[dict[str, Any]]:
    st = state()
    active = []
    for region in regions:
        clear_eip_skip_if_ready(st, region, execute)
        if skip_remaining_reason(st, region["displayName"]):
            continue
        active.append(region)
    return active


def phase_has_active_regions(regions: list[dict[str, Any]], execute: bool) -> bool:
    active = active_regions_after_eip(regions, execute)
    if active:
        print("可继续执行地区: " + "、".join(r["displayName"] for r in active))
        return True
    print("所有填写地区均已跳过，直接导出运行结果。")
    return False


def submit_eip_parallel_batch(c: CtyunClient, region: dict[str, Any], base: dict[str, Any], bw: int, start_index: int, count: int) -> list[dict[str, Any]]:
    name = region["displayName"]
    bodies = []
    for i in range(count):
        body = eip_create_body({**base, "eip": {**base["eip"], "bandwidth": bw}}, start_index + i)
        body["name"] = f"{body['name']}-{uuid.uuid4().hex[:6]}"
        bodies.append(body)
    print(f"{name} {bw} Mbps 小批量并发提交 {count} 条 EIP 创建请求。")
    results: list[dict[str, Any]] = []
    with ThreadPoolExecutor(max_workers=min(EIP_PARALLEL_WORKERS, count)) as pool:
        future_map = {pool.submit(call_with_retry, c.post, "/v4/eip/create", body, retries=5, delay=5): body for body in bodies}
        completed = 0
        success = 0
        for future in as_completed(future_map):
            result = future.result()
            completed += 1
            if ok(result):
                success += 1
            print(f"{name} {bw} Mbps 小批量进度 {completed}/{count}，已提交成功 {success}/{count}。")
            results.append({"request": future_map[future], "response": result})
    return results


def eip_bandwidth_candidates(name: str) -> list[int]:
    """按购买页地区分组固定 EIP 带宽。

    300M 地区只能补购 300M，不能因库存/配额失败自动降到 60M；
    60M 地区必须显式配置在 REGION_EIP_BANDWIDTHS，避免混买不同规格 IP。
    """
    if is_monthly_dedicated_eip_enabled():
        return [MONTHLY_DEDICATED_EIP_BANDWIDTH]
    configured = REGION_EIP_BANDWIDTHS.get(name)
    if configured:
        return list(configured)
    return list(BANDWIDTH_CANDIDATES)


def bound_havip_eip_count(region: dict[str, Any], execute: bool) -> int:
    try:
        net = sync_network_from_ecs(region, execute)
    except SystemExit:
        return bound_havip_eip_count_any_network(region, execute)
    havips = list_havips(region, net["subnetID"], execute)
    assoc_eips = eips_by_association(region, execute)
    count = 0
    for ip in planned_havip_ips():
        h = havips.get(ip, {})
        if h.get("networkInfo") or h.get("id") in assoc_eips:
            count += 1
    return max(count, bound_havip_eip_count_any_network(region, execute))


def havip_binding_status(region: dict[str, Any], subnet_id: str, execute: bool) -> tuple[int, list[str]]:
    havips = list_havips(region, subnet_id, execute)
    assoc_eips = eips_by_association(region, execute)
    bound_ips = []
    for ip in planned_havip_ips():
        h = havips.get(ip, {})
        if h and (h.get("networkInfo") or h.get("id") in assoc_eips):
            bound_ips.append(ip)
    return len(bound_ips), bound_ips


def latest_failure_reason(data: dict[str, Any], name: str) -> str:
    rs = data.get("regions", {}).get(name, {})
    reasons = []
    for order in rs.get("eipOrders", []):
        result = order.get("response", {})
        if isinstance(result, dict) and not ok(result):
            reasons.append(f"EIP: {response_reason(result)}")
    for order in rs.get("ecsOrders", []):
        result = order.get("response", {})
        if isinstance(result, dict) and not ok(result):
            reasons.append(f"ECS: {response_reason(result)}")
    result = rs.get("ecsOrder", {}).get("response", {})
    if isinstance(result, dict) and not ok(result):
        reasons.append(f"ECS: {response_reason(result)}")
    return reasons[-1] if reasons else ""


def successful_eip_order_count(data: dict[str, Any], name: str) -> int:
    rs = data.get("regions", {}).get(name, {})
    now = time.time()
    count = 0
    for order in rs.get("eipOrders", []):
        submitted_at = order.get("submittedAt")
        if not isinstance(submitted_at, (int, float)) or now - submitted_at > 7200:
            continue
        if isinstance(order.get("response"), dict) and eip_submit_accepted(order["response"]):
            count += 1
    return count


def latest_eip_failure_reason(data: dict[str, Any], name: str) -> str:
    rs = data.get("regions", {}).get(name, {})
    reasons = []
    for order in rs.get("eipOrders", []):
        result = order.get("response", {})
        if isinstance(result, dict) and is_order_in_progress_result(result):
            continue
        if isinstance(result, dict) and not ok(result):
            reasons.append(response_reason(result))
    return reasons[-1] if reasons else ""


def has_successful_ecs_order(data: dict[str, Any], name: str) -> bool:
    rs = data.get("regions", {}).get(name, {})
    for order in rs.get("ecsOrders", []):
        if isinstance(order.get("response"), dict) and ok(order["response"]):
            return True
    result = rs.get("ecsOrder", {}).get("response", {})
    return isinstance(result, dict) and ok(result)


def region_map() -> dict[str, dict[str, Any]]:
    out: dict[str, dict[str, Any]] = {}
    for region in load_json(REGIONS_FILE)["regions"]:
        out[region["displayName"]] = region
        official = region.get("officialName")
        if official:
            out[official] = region
        for alias in region.get("aliases", []):
            out[alias] = region
    for old_name, new_name in REGION_ALIASES.items():
        if new_name in out:
            out[old_name] = out[new_name]
    return out


def configured_regions() -> list[dict[str, Any]]:
    return [region for region in load_json(REGIONS_FILE, {"regions": []}).get("regions", []) if isinstance(region, dict)]


def regions_from_arg(text: str) -> list[dict[str, Any]]:
    names = [x.strip() for x in text.replace("，", ",").split(",") if x.strip()]
    by_name = region_map()
    missing = [n for n in names if n not in by_name]
    if missing:
        raise SystemExit(f"regions_10ip.json 里找不到地区: {', '.join(missing)}")
    return [by_name[n] for n in names]


def slug(region: dict[str, Any]) -> str:
    return REGION_SLUGS.get(region["displayName"], f"region{region.get('index', '')}")


def read_input_lines(path: Path) -> list[str]:
    lines: list[str] = []
    for raw in path.read_text(encoding="utf-8-sig").splitlines():
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        lines.append(line)
    return lines


def set_keys_from_file(path: Path) -> None:
    lines = read_input_lines(path)
    if len(lines) < 2:
        raise SystemExit("AK/SK 文件至少需要两行：第一行 AK，第二行 SK。")
    ak, sk = lines[0], lines[1]
    if os.environ.get("CTYUN_PERSIST_KEYS") == "1" and os.name == "nt":
        subprocess.check_call(
            [
                "powershell",
                "-NoProfile",
                "-Command",
                (
                    "& { param($ak, $sk) "
                    "[Environment]::SetEnvironmentVariable('CTYUN_ACCESS_KEY', $ak, 'User');"
                    "[Environment]::SetEnvironmentVariable('CTYUN_SECRET_KEY', $sk, 'User')"
                    " }"
                ),
                ak,
                sk,
            ],
            **hidden_subprocess_kwargs(),
        )
    os.environ["CTYUN_ACCESS_KEY"] = ak
    os.environ["CTYUN_SECRET_KEY"] = sk


def regions_from_file(path: Path) -> str:
    lines = read_input_lines(path)
    if len(lines) < 3:
        raise SystemExit("输入文件第 3 行需要填写第一个地区名；第 4 行可选填写第二个地区名。")
    regions = lines[2:4]
    return ",".join(regions)


def client(endpoint: str, execute: bool, timeout: int = 20) -> CtyunClient:
    ak, sk = load_credentials(required=execute)
    return CtyunClient(ak, sk, endpoint, dry_run=not execute, timeout=timeout)


def call_with_retry(fn, *args, retries: int = 5, delay: int = 4, **kwargs):
    last: Exception | None = None
    for attempt in range(1, retries + 1):
        check_stop_requested()
        try:
            return fn(*args, **kwargs)
        except CtyunSslCertificateError:
            raise
        except HTTPError as exc:
            last = exc
            if exc.code in {401, 403}:
                url = getattr(exc, "url", "") or ""
                raise SystemExit(
                    f"天翼云接口拒绝访问 HTTP {exc.code}。请检查当前 AK/SK 是否有效、是否有该接口权限、账号是否被风控/冻结，或该地区是否允许当前账号调用。接口: {url}"
                ) from exc
            if attempt < retries:
                print(f"接口连接中断或暂时失败，自动重试 {attempt}/{retries - 1}: {exc}")
                sleep_with_stop(delay)
        except (TimeoutError, URLError) as exc:
            if is_ctyun_ssl_certificate_error(exc):
                raise CtyunSslCertificateError(ctyun_ssl_failure_message(exc)) from exc
            last = exc
            if attempt < retries:
                print(f"接口连接中断或暂时失败，自动重试 {attempt}/{retries - 1}: {exc}")
                sleep_with_stop(delay)
    raise last or RuntimeError("request failed")


def call_or_error(fn, *args, retries: int = 1, delay: int = 2, **kwargs) -> dict[str, Any]:
    try:
        return call_with_retry(fn, *args, retries=retries, delay=delay, **kwargs)
    except Exception as exc:
        return {"statusCode": 900, "message": str(exc), "description": str(exc), "errorCode": exc.__class__.__name__}


def query_account_balance(execute: bool) -> dict[str, Any]:
    if not execute:
        print(f"\nDRY-RUN POST {ENDPOINT_BILLING}/v1/bill/queryAccountBalance")
        return {"dryRun": True, "balance": "999999.00"}
    return {
        "statusCode": 900,
        "message": "天翼云官方 AK/SK OpenAPI 暂未开放账户余额查询",
        "description": "天翼云 API 中心未开放费用/余额类 AK/SK 接口，旧 acct-global 余额路径返回 API Not Found。",
        "errorCode": "BalanceApiUnavailable",
    }


def extract_balance_amount(payload: Any) -> float | None:
    if isinstance(payload, dict):
        for key in ("cashBalance", "balance", "availableBalance", "availableCash", "accountBalance"):
            value = payload.get(key)
            if value not in (None, ""):
                try:
                    return float(str(value).replace(",", ""))
                except ValueError:
                    pass
        for value in payload.values():
            found = extract_balance_amount(value)
            if found is not None:
                return found
    elif isinstance(payload, list):
        for value in payload:
            found = extract_balance_amount(value)
            if found is not None:
                return found
    return None


def phase_check_balance(execute: bool) -> None:
    print("\n== 查询天翼云账户余额 ==")
    result = call_or_error(query_account_balance, execute, retries=2, delay=3)
    amount = extract_balance_amount(result)
    if amount is None:
        detail = response_reason(result)
        print("未能通过天翼云 AK/SK OpenAPI 查询到账户余额。")
        print(f"接口返回: {detail}")
        print("余额接口不可用，本次不按余额规则拦截。")
        return
    print(f"当前账户余额: {amount:.2f} 元")
    if amount < MIN_ACCOUNT_BALANCE:
        print(f"账户余额低于 {MIN_ACCOUNT_BALANCE:.0f} 元。为避免余额接口误判，本次仅提醒，不自动停止。")
        return
    print("账户余额满足要求，继续执行后续任务。")


def phase_check_partner(execute: bool) -> None:
    print("\n== 合作伙伴核对已取消，直接继续 ==")
    return


def payload_text(payload: Any) -> str:
    try:
        return json.dumps(payload, ensure_ascii=False, sort_keys=True)
    except Exception:
        return str(payload)


def ok(result: dict[str, Any]) -> bool:
    return bool(result.get("dryRun")) or result.get("statusCode") == 800 or result.get("errorCode") == "SUCCESS"


def is_order_in_progress_result(result: dict[str, Any]) -> bool:
    """识别天翼云已受理但还未生成资源的订单，避免换 token 重复下单。"""
    if not isinstance(result, dict):
        return False
    error_code = str(result.get("errorCode") or "").strip().lower()
    message = str(result.get("message") or "").strip().lower()
    description = str(result.get("description") or "").strip()
    return_obj = result.get("returnObj") if isinstance(result.get("returnObj"), dict) else {}
    resource_status = str(return_obj.get("masterResourceStatus") or "").strip().lower()
    return (
        error_code == "openapi.order.inprogress"
        or message == "order in progress"
        or resource_status == "in_progress"
        or "订单处理中" in description
    )


def eip_submit_accepted(result: dict[str, Any]) -> bool:
    """EIP 创建接口返回成功或订单处理中，都表示已经提交到云端。"""
    return ok(result) or is_order_in_progress_result(result)


def find_items(payload: Any) -> list[dict[str, Any]]:
    if isinstance(payload, list):
        return [x for x in payload if isinstance(x, dict)]
    if not isinstance(payload, dict):
        return []
    for key in ("results", "images", "eips", "instances", "instanceList", "ecsList", "servers", "records", "rows", "returnObj", "data", "list", "items"):
        if key in payload:
            found = find_items(payload[key])
            if found:
                return found
    for value in payload.values():
        found = find_items(value)
        if found:
            return found
    return []


def pick(item: dict[str, Any], *keys: str) -> str:
    lowered = {k.lower(): v for k, v in item.items()}
    for key in keys:
        value = item.get(key, lowered.get(key.lower()))
        if isinstance(value, str) and value:
            return value
    return ""


def _positive_int(value: Any) -> int | None:
    if isinstance(value, bool):
        return None
    if isinstance(value, (int, float)):
        return int(value) if value > 0 else 0
    if isinstance(value, str):
        text = value.strip().replace(",", "")
        if not text:
            return None
        try:
            number = float(text)
        except ValueError:
            return None
        return int(number) if number > 0 else 0
    return None


def customer_resource_count(payload: Any) -> int:
    """从账号资源索引响应中提取资源数量，避免对空资源池继续深查。

    天翼云不同资源池返回字段不完全一致，这里只读取明确表示数量的字段，
    不把 statusCode/pageSize 等接口元数据当作资源数量。
    """
    count_keys = {
        "count",
        "total",
        "totalcount",
        "quantity",
        "resourcenum",
        "resourcecount",
        "instancecount",
        "prodinstnum",
        "usedcount",
    }
    ignored_count_keys = {"statuscode", "errorcode", "pagecount", "pagesize", "pageno", "currentpage"}
    total = 0
    saw_count = False

    def walk(node: Any) -> None:
        nonlocal total, saw_count
        if isinstance(node, dict):
            for key, value in node.items():
                lower_key = key.lower()
                looks_like_count = (
                    lower_key in count_keys
                    or lower_key.endswith("count")
                    or lower_key.endswith("num")
                ) and lower_key not in ignored_count_keys
                if looks_like_count:
                    number = _positive_int(value)
                    if number is not None:
                        saw_count = True
                        total += max(0, number)
                if isinstance(value, (dict, list)):
                    walk(value)
        elif isinstance(node, list):
            for item in node:
                walk(item)

    return_obj = payload.get("returnObj") if isinstance(payload, dict) else payload
    walk(return_obj)
    if saw_count:
        return total
    return len(customer_resource_items(payload))


def customer_resource_items(payload: Any) -> list[dict[str, Any]]:
    resource_id_keys = (
        "prodInstId",
        "prodInstID",
        "resourceID",
        "resourceId",
        "instanceID",
        "instanceId",
        "eipID",
        "eipId",
        "bandwidthID",
        "bandwidthId",
        "ID",
        "id",
    )
    items: list[dict[str, Any]] = []
    for item in find_items(payload):
        if any(pick(item, key) for key in resource_id_keys):
            items.append(item)
    return items


def region_id_of(item: dict[str, Any]) -> str:
    return pick(
        item,
        "regionID",
        "regionId",
        "region_id",
        "resourcePoolID",
        "resourcePoolId",
        "poolID",
        "poolId",
        "id",
        "ID",
    )


def region_name_of(item: dict[str, Any], region_id: str = "") -> str:
    return pick(
        item,
        "displayName",
        "displayname",
        "regionName",
        "region_name",
        "regionDisplayName",
        "regionCnName",
        "resourcePoolName",
        "resourcePoolDisplayName",
        "name",
        "cnName",
        "city",
    ) or region_id


def region_identity(region: dict[str, Any]) -> tuple[str, str]:
    return str(region.get("regionID") or "").strip(), str(region.get("displayName") or "").strip()


def normalize_region_candidate(item: dict[str, Any], index: int) -> dict[str, Any] | None:
    region_id = region_id_of(item)
    if not region_id:
        return None
    display_name = region_name_of(item, region_id)
    return {
        "index": int(item.get("index") or index),
        "province": pick(item, "province", "provinceName", "province_name", "regionProvince", "resourcePoolProvince", "area"),
        "city": pick(item, "city", "cityName", "regionCity", "areaName"),
        "displayName": display_name,
        "officialName": pick(item, "officialName", "name", "regionName"),
        "regionID": region_id,
        "azName": pick(item, "azName", "az", "availabilityZone") or "default",
        "regionType": pick(item, "regionType", "resourcePoolType", "poolType", "poolTypeName"),
        "operationType": pick(item, "operationType", "operateType", "operateMode", "supplierType", "providerType"),
        "isSelfOperated": pick(item, "isSelfOperated", "isSelfOperate", "selfOperate"),
        "isCooperation": pick(item, "isCooperation", "isCooperate", "isJoint", "jointOperate"),
        "vpcID": "",
        "subnetID": "",
        "securityGroupID": "",
        "flavorID": "",
        "imageID": "",
        "_source": "ctyun-region-list",
    }


def merge_region_candidates(primary: list[dict[str, Any]], extras: list[dict[str, Any]]) -> list[dict[str, Any]]:
    merged: list[dict[str, Any]] = []
    by_id: dict[str, dict[str, Any]] = {}
    by_name: dict[str, dict[str, Any]] = {}

    def add(region: dict[str, Any]) -> None:
        region_id, name = region_identity(region)
        if not region_id or not name:
            return
        existing = by_id.get(region_id) or by_name.get(name)
        if existing:
            existing.update({k: v for k, v in region.items() if v not in (None, "")})
            return
        by_id[region_id] = region
        by_name[name] = region
        merged.append(region)

    for region in primary:
        add(dict(region))
    for region in extras:
        add(dict(region))
    return merged


def query_region_catalog(execute: bool) -> list[dict[str, Any]]:
    if not execute:
        return []
    c = client(ENDPOINT_ECS, execute, timeout=20)
    attempts = (
        (c.get, "/v4/region/list-regions", {}),
        (c.post, "/v4/region/list-regions", {}),
        (c.get, "/v4/region/list", {}),
        (c.post, "/v4/region/list", {}),
    )
    for fn, path, payload in attempts:
        result = call_or_error(fn, path, payload, retries=1, delay=1)
        result_items = find_items(result)
        if not ok(result) and not result_items:
            continue
        regions: list[dict[str, Any]] = []
        for index, item in enumerate(result_items, start=1):
            region = normalize_region_candidate(item, 10000 + index)
            if region:
                regions.append(region)
        if regions:
            save_region_catalog_cache(regions)
            return regions
    return []


def expand_account_region_candidates(regions: list[dict[str, Any]], execute: bool) -> list[dict[str, Any]]:
    configured = configured_regions()
    primary = merge_region_candidates(configured, regions)
    cloud_regions = query_region_catalog(execute)
    if cloud_regions:
        expanded = merge_region_candidates(primary, cloud_regions)
        extra_count = max(0, len(expanded) - len(primary))
        print(f"账号资源查询范围：已读取云端资源池 {len(cloud_regions)} 个，新增任务选项外资源池 {extra_count} 个。")
        return expanded
    if execute:
        print("账号资源查询范围：未能读取云端资源池列表，暂按本地配置地区查询。")
    return primary


def query_customer_region_resources(region: dict[str, Any], execute: bool) -> dict[str, Any]:
    """查询某资源池内本账号是否已有资源。

    资源管理页只需要先知道账号在哪些资源池有资源；确认有资源后再读取
    ECS/EIP/HAVIP/VPC 详情，避免空资源池反复触发 VPC/子网/虚拟 IP 查询。
    """
    if not execute:
        return {"dryRun": True, "returnObj": [{"resourceCount": 1}]}
    c = client(ENDPOINT_ECS, execute, timeout=20)
    query = {"regionID": region["regionID"], "projectID": "0"}
    attempts = (
        (c.post, query),
        (c.get, query),
    )
    last_result: dict[str, Any] = {}
    for fn, payload in attempts:
        result = call_or_error(fn, "/v4/region/customerResources", payload, retries=1, delay=1)
        if ok(result):
            return result
        last_result = result
    return last_result or {"statusCode": 900, "message": "账号资源索引接口无返回"}


def discover_cleanup_regions(regions: list[dict[str, Any]], execute: bool) -> list[dict[str, Any]]:
    if not execute:
        return regions
    regions = expand_account_region_candidates(regions, execute)
    if not regions:
        return []
    print("\n== 账号资源索引：读取本账号已有资源地区 ==")
    active: list[tuple[int, dict[str, Any], int]] = []
    failed: list[str] = []
    by_name = {region["displayName"]: index for index, region in enumerate(regions)}
    workers = min(CLEANUP_INDEX_WORKERS, max(1, len(regions)))
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(query_customer_region_resources, region, execute): region for region in regions}
        for future in as_completed(futures):
            region = futures[future]
            name = region["displayName"]
            try:
                payload = future.result()
            except Exception as exc:
                failed.append(f"{name}: {exc}")
                continue
            if not ok(payload):
                failed.append(f"{name}: {response_reason(payload)}")
                continue
            count = customer_resource_count(payload)
            if count > 0:
                region_with_count = dict(region)
                region_with_count["_resourceCount"] = count
                active.append((by_name.get(name, 9999), region_with_count, count))

    if active:
        active.sort(key=lambda item: item[0])
        text = "、".join(f"{region['displayName']}({count})" for _, region, count in active)
        print(f"账号资源索引发现 {len(active)} 个有资源地区：{text}")
        print("资源索引作为本次查询范围；仅查询这些有资源地区的 ECS / EIP / HAVIP / VPC / 子网 / 安全组详情。")
        return [region for _, region, _ in active]

    if len(failed) >= len(regions):
        print("账号资源索引接口全部不可用，无法确认账号资源数量，本次不继续详情查询。")
        for detail in failed[:5]:
            print(f"- {detail}")
        if len(failed) > 5:
            print(f"- 其余 {len(failed) - 5} 个资源池同样不可用。")
        reason = failed[0] if failed else "账号资源索引接口全部不可用"
        raise RuntimeError(f"账号资源索引接口全部不可用，未覆盖现有资源库。原因：{reason}")

    print("账号资源索引未发现本账号可管理资源，本次不继续详情查询。")
    return []


def discover_image(region_id: str, execute: bool) -> str:
    c = client(ENDPOINT_IMAGE, execute, timeout=30)
    for page in range(1, 6):
        payload = call_with_retry(c.get, "/v4/image/list", {"regionID": region_id, "pageNo": page, "pageSize": 100}, retries=5, delay=5)
        for item in find_items(payload):
            text = json.dumps(item, ensure_ascii=False).lower()
            name = f"{item.get('imageDisplayName', '')} {item.get('imageName', '')}".lower()
            arch = str(item.get("architecture", "")).lower()
            if "debian" in text and "12.12" in text and "arm" not in name and arch not in ("arm64", "aarch64"):
                return pick(item, "imageID", "imageId", "id")
    return ""


def discover_flavor_info(region_id: str, spec_name: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    payload = call_with_retry(c.get, "/v4/common/get-ecs-flavors", {"regionID": region_id}, retries=5, delay=5)
    items = find_items(payload)
    if spec_name != "generic-2c4g":
        for item in items:
            if item.get("specName") == spec_name:
                return item
        return {}

    candidates = []
    for item in items:
        if item.get("cpuNum") != 2 or item.get("memSize") != 4:
            continue
        if item.get("flavorName") != "通用型":
            continue
        candidates.append(item)
    return candidates[0] if candidates else {}


def discover_flavor_candidates(region_id: str, execute: bool) -> list[dict[str, Any]]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    payload = call_with_retry(c.get, "/v4/common/get-ecs-flavors", {"regionID": region_id}, retries=5, delay=5)
    candidates = []
    for item in find_items(payload):
        if int(float(item.get("cpuNum") or 0)) != 2:
            continue
        if int(float(item.get("memSize") or 0)) != 4:
            continue
        if item.get("flavorName") != "通用型":
            continue
        candidates.append(item)
    return candidates


def discover_flavor(region_id: str, spec_name: str, execute: bool) -> str:
    item = discover_flavor_info(region_id, spec_name, execute)
    return pick(item, "flavorID", "flavorId", "id") if item else ""


def discover_security_group(region_id: str, execute: bool) -> str:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    payload = call_with_retry(c.get, "/v4/ecs/vpc/query-security-groups", {"regionID": region_id}, retries=5, delay=5)
    for item in find_items(payload):
        if item.get("securityGroupName") == "default" or item.get("name") == "default":
            return pick(item, "securityGroupID", "securityGroupId", "ID", "id")
    return ""


def security_group_id(item: dict[str, Any]) -> str:
    return pick(item, "securityGroupID", "securityGroupId", "ID", "id")


def security_group_name(item: dict[str, Any]) -> str:
    return str(item.get("securityGroupName") or item.get("name") or item.get("displayName") or "")


def security_group_vpc_id(item: dict[str, Any]) -> str:
    return pick(item, "vpcID", "vpcId", "vpc_id", "networkID", "networkId")


def security_group_matches_vpc(item: dict[str, Any], vpc_id: str) -> bool:
    group_vpc_id = security_group_vpc_id(item)
    return not vpc_id or not group_vpc_id or group_vpc_id == vpc_id


def is_default_security_group(item: dict[str, Any]) -> bool:
    name = security_group_name(item).strip().lower()
    if name == "default":
        return True
    for key in ("isDefault", "default", "defaultSecurityGroup"):
        value = item.get(key)
        if value is True or str(value).strip().lower() == "true":
            return True
    return False


def list_security_groups(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    payload = call_with_retry(c.get, "/v4/ecs/vpc/query-security-groups", {"regionID": region["regionID"]}, retries=5, delay=5)
    return find_items(payload)


def create_security_group(region: dict[str, Any], execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    last: dict[str, Any] = {}
    for attempt in range(1, 4):
        suffix = "" if attempt == 1 else f"-{int(time.time())}-{attempt}"
        name = f"{slug(region)}-sg{suffix}"
        body = {
            "clientToken": str(uuid.uuid4()),
            "regionID": region["regionID"],
            "projectID": "0",
            "vpcID": region.get("vpcID") or "",
            "name": name,
            "securityGroupName": name,
            "description": f"{slug(region)} security group",
        }
        result = call_or_error(c.post, "/v4/vpc/create-security-group", body, retries=2, delay=3)
        if ok(result):
            result.setdefault("_securityGroupName", name)
            return result
        last = result
        reason = response_reason(result)
        if "exist" in reason.lower() or "已存在" in reason or "重复" in reason:
            print(f"{region['displayName']} 安全组名称 {name} 已存在，换新名称重试。")
            continue
        return result
    return last


def ensure_security_group(region: dict[str, Any], execute: bool) -> str:
    name = region["displayName"]
    wanted_name = f"{slug(region)}-sg"
    vpc_id = region.get("vpcID") or ""
    groups = list_security_groups(region, execute) if execute else []
    configured_id = region.get("securityGroupID") or ""
    configured_group = next(
        (group for group in groups if security_group_id(group) == configured_id and security_group_matches_vpc(group, vpc_id)),
        None,
    )
    if configured_id and configured_group:
        print(f"{name} 安全组复用 {security_group_name(configured_group) or '已记录安全组'}")
        sg_id = configured_id
    else:
        named_group = next(
            (group for group in groups if security_group_name(group) == wanted_name and security_group_matches_vpc(group, vpc_id)),
            None,
        )
        if named_group:
            sg_id = security_group_id(named_group)
            print(f"{name} 安全组复用 {security_group_name(named_group) or wanted_name}")
        else:
            result = create_security_group(region, execute)
            if ok(result):
                sg_id = extract_id(result, ["securityGroupID", "securityGroupId", "id"]) or "DRY_RUN_SECURITY_GROUP_ID"
                print(f"{name} 安全组 创建成功")
            else:
                reason = response_reason(result)
                groups = list_security_groups(region, execute) if execute else []
                named_group = next(
                    (group for group in groups if security_group_name(group) == wanted_name and security_group_matches_vpc(group, vpc_id)),
                    None,
                )
                default_group = next(
                    (group for group in groups if security_group_name(group) == "default" and security_group_matches_vpc(group, vpc_id)),
                    None,
                )
                fallback = named_group or default_group
                sg_id = security_group_id(fallback) if fallback else ""
                if sg_id:
                    fallback_name = security_group_name(fallback or {}) or "现有安全组"
                    print(f"{name} 安全组创建未确认：{hide_resource_ids(reason)}，复用{fallback_name}。")
                else:
                    print(f"{name} 安全组创建失败：{hide_resource_ids(reason)}")
                    return ""
        if execute:
            update_region(name, {"securityGroupID": sg_id})
        region["securityGroupID"] = sg_id
    for protocol, port_range in SECURITY_GROUP_RULES:
        rule = ensure_security_rule(region, protocol, port_range, execute)
        print(f"{name} 安全组入方向 {protocol} {port_range} {bool_result(bool(rule['ok']), '准备成功', '准备失败', rule['message'])}")
    return sg_id


def update_region(region_name: str, updates: dict[str, Any]) -> None:
    doc = load_json(REGIONS_FILE)
    for region in doc["regions"]:
        if region["displayName"] == region_name:
            region.update(updates)
    save_json(REGIONS_FILE, doc)


def subnet_exists(region: dict[str, Any], vpc_id: str, subnet_id: str, execute: bool) -> bool:
    if not execute or not vpc_id or not subnet_id:
        return bool(vpc_id and subnet_id)
    # Some pools return 404 for subnet-list variants even when the subnet is valid.
    # Treat configured IDs as usable and let create/bind calls report a real failure.
    return True


def vpc_id_of(item: dict[str, Any]) -> str:
    return pick(item, "vpcID", "vpcId", "id", "ID", "networkID", "networkId")


def subnet_id_of(item: dict[str, Any]) -> str:
    return pick(item, "subnetID", "subnetId", "id", "ID")


def subnet_vpc_id_of(item: dict[str, Any]) -> str:
    return pick(item, "vpcID", "vpcId", "networkID", "networkId")


def havip_id_of(item: dict[str, Any]) -> str:
    return pick(item, "havipID", "haVipID", "id", "ID")


def resource_name(item: dict[str, Any]) -> str:
    return str(item.get("name") or item.get("displayName") or item.get("vpcName") or item.get("subnetName") or "")


def resource_cidr(item: dict[str, Any]) -> str:
    return str(item.get("CIDR") or item.get("cidr") or item.get("cidrBlock") or item.get("subnetCIDR") or "")


def ip_sort_key(value: str) -> tuple[int, ...]:
    if re.match(r"^\d+\.\d+\.\d+\.\d+$", value or ""):
        return tuple(int(part) for part in value.split("."))
    return (999, 999, 999, 999)


def subnet_records_from_havips(havips: dict[str, dict[str, Any]], vpc_id: str = "") -> list[dict[str, Any]]:
    seen: set[str] = set()
    records: list[dict[str, Any]] = []
    for ip, havip in sorted(havips.items(), key=lambda item: ip_sort_key(item[0])):
        havip_vpc_id, subnet_id = havip_network_ids(havip)
        if vpc_id and havip_vpc_id and havip_vpc_id != vpc_id:
            continue
        if not subnet_id or subnet_id in seen:
            continue
        seen.add(subnet_id)
        records.append(
            {
                "subnetID": subnet_id,
                "vpcID": havip_vpc_id or vpc_id,
                "name": f"subnet-{subnet_id[:8]}",
                "CIDR": "",
                "_source": "havip",
            }
        )
    return records


def vpc_records_from_havips(havips: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
    seen: set[str] = set()
    records: list[dict[str, Any]] = []
    for _ip, havip in sorted(havips.items(), key=lambda item: ip_sort_key(item[0])):
        vpc_id, _subnet_id = havip_network_ids(havip)
        if not vpc_id or vpc_id in seen:
            continue
        seen.add(vpc_id)
        records.append({"vpcID": vpc_id, "name": f"VPC-{vpc_id[:8]}", "CIDR": "", "_source": "havip"})
    return records


def list_existing_vpcs(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    if not execute:
        return []
    c = client(ENDPOINT_VPC, execute, timeout=30)
    attempts = [
        ("post", "/v4/vpc/list", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "pageNo": 1, "pageSize": 100}),
        ("post", "/v4/vpc/query", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "pageNo": 1, "pageSize": 100}),
        ("get", "/v4/vpc/list", {"regionID": region["regionID"], "projectID": "0", "pageNo": 1, "pageSize": 100}),
    ]
    seen: set[str] = set()
    items: list[dict[str, Any]] = []
    for method, path, payload in attempts:
        result = call_or_error(c.post if method == "post" else c.get, path, payload, retries=2, delay=2)
        for item in find_items(result):
            item_id = vpc_id_of(item)
            if item_id and item_id not in seen:
                seen.add(item_id)
                items.append(item)
        if items:
            break
    return items


def list_existing_subnets(region: dict[str, Any], vpc_id: str, execute: bool) -> list[dict[str, Any]]:
    if not execute or not vpc_id:
        return []
    c = client(ENDPOINT_VPC, execute, timeout=30)
    base = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "vpcID": vpc_id, "pageNo": 1, "pageSize": 100}
    attempts = [
        ("post", "/v4/vpc/subnet/list", base),
        ("post", "/v4/vpc/list-subnet", base),
        ("post", "/v4/vpc/query-subnets", base),
        ("get", "/v4/vpc/subnet/list", {k: v for k, v in base.items() if k != "clientToken"}),
    ]
    seen: set[str] = set()
    items: list[dict[str, Any]] = []
    for method, path, payload in attempts:
        result = call_or_error(c.post if method == "post" else c.get, path, payload, retries=2, delay=2)
        for item in find_items(result):
            item_id = subnet_id_of(item)
            if item_id and item_id not in seen:
                seen.add(item_id)
                items.append(item)
        if items:
            break
    if not items:
        for item in subnet_records_from_havips(list_all_havips(region, execute), vpc_id):
            item_id = subnet_id_of(item)
            if item_id and item_id not in seen:
                seen.add(item_id)
                items.append(item)
    return items


def subnet_summary(items: list[dict[str, Any]]) -> str:
    parts: list[str] = []
    for item in items:
        name = resource_name(item) or subnet_id_of(item) or "子网"
        cidr = resource_cidr(item)
        parts.append(f"{name}({cidr})" if cidr else name)
    return "，".join(parts)


def subnet_cleanup_records(items: list[dict[str, Any]]) -> list[dict[str, str]]:
    records: list[dict[str, str]] = []
    for item in items:
        subnet_id = subnet_id_of(item)
        if not subnet_id:
            continue
        records.append(
            {
                "subnetID": subnet_id,
                "name": resource_name(item) or subnet_id,
                "cidr": resource_cidr(item),
            }
        )
    return records


def configured_vpc_fallback(region: dict[str, Any]) -> dict[str, Any] | None:
    vpc_id = str(region.get("vpcID") or "")
    if not vpc_id:
        return None
    return {"vpcID": vpc_id, "name": f"{slug(region)}-network", "CIDR": vpc_cidr(), "_source": "configured"}


def configured_subnet_fallback(region: dict[str, Any], vpc_id: str) -> dict[str, Any] | None:
    if str(region.get("vpcID") or "") != str(vpc_id or ""):
        return None
    subnet_id = str(region.get("subnetID") or "")
    if not subnet_id:
        return None
    return {"subnetID": subnet_id, "name": f"{slug(region)}-subnet", "CIDR": subnet_cidr(), "_source": "configured"}


def pick_preferred_vpc(items: list[dict[str, Any]], region: dict[str, Any]) -> dict[str, Any] | None:
    wanted_name = f"{slug(region)}-network"
    named = [item for item in items if resource_name(item) == wanted_name]
    if named:
        return named[0]
    cidr_match = [item for item in items if resource_cidr(item) in preferred_vpc_cidrs()]
    return cidr_match[0] if cidr_match else (items[0] if items else None)


def pick_preferred_subnet(items: list[dict[str, Any]], region: dict[str, Any], vpc_id: str) -> dict[str, Any] | None:
    wanted_name = f"{slug(region)}-subnet"
    filtered = [item for item in items if not subnet_vpc_id_of(item) or subnet_vpc_id_of(item) == vpc_id]
    named = [item for item in filtered if resource_name(item) == wanted_name]
    if named:
        return named[0]
    cidr_match = [item for item in filtered if resource_cidr(item) == subnet_cidr()]
    return cidr_match[0] if cidr_match else (filtered[0] if filtered else None)


def discover_existing_network(region: dict[str, Any], execute: bool) -> dict[str, str] | None:
    """购买前先发现账号里已有的 VPC/子网，避免状态文件丢失时重复新建网络资源。"""
    for vpc in list_existing_vpcs(region, execute):
        vpc_id = vpc_id_of(vpc)
        subnet = pick_preferred_subnet(list_existing_subnets(region, vpc_id, execute), region, vpc_id)
        subnet_id = subnet_id_of(subnet) if subnet else ""
        if vpc_id and subnet_id:
            updates = {"vpcID": vpc_id, "subnetID": subnet_id, "privateIPPlan": private_ip_plan_key()}
            region.update(updates)
            if execute:
                update_region(region["displayName"], updates)
            print(f"{region['displayName']} 发现账号已有 VPC/子网，复用 VPC={vpc_id} 子网={subnet_id}。")
            return updates
    vpc = pick_preferred_vpc(list_existing_vpcs(region, execute), region)
    if vpc:
        print(f"{region['displayName']} 发现已有 VPC={vpc_id_of(vpc)}，但未查询到可复用子网。")
    return None


def ensure_network(region: dict[str, Any], execute: bool, force_rebuild: bool = False, reason: str = "") -> dict[str, str]:
    vpc_id = region.get("vpcID") or ""
    subnet_id = region.get("subnetID") or ""
    if vpc_id and subnet_id and not region_matches_private_ip_plan(region):
        print(
            f"{region['displayName']} 本地 VPC/子网属于 {region_private_ip_plan(region)}，"
            f"当前购买选项要求 {private_ip_plan_key()}（{private_ip_plan_label()}），清空旧网络记录后重建。"
        )
        vpc_id = ""
        subnet_id = ""
        region["vpcID"] = ""
        region["subnetID"] = ""
        region["securityGroupID"] = ""
        region["privateIPPlan"] = private_ip_plan_key()
        if execute:
            update_region(
                region["displayName"],
                {"vpcID": "", "subnetID": "", "securityGroupID": "", "privateIPPlan": private_ip_plan_key()},
            )
    if force_rebuild:
        detail = f"原因: {reason}" if reason else "原因: 购买前置阶段要求使用新网络"
        print(f"{region['displayName']} {detail}，清空本地 VPC/子网记录并新建网络。")
        vpc_id = ""
        subnet_id = ""
        region["vpcID"] = ""
        region["subnetID"] = ""
        region["securityGroupID"] = ""
        region["privateIPPlan"] = private_ip_plan_key()
        if execute:
            update_region(
                region["displayName"],
                {"vpcID": "", "subnetID": "", "securityGroupID": "", "privateIPPlan": private_ip_plan_key()},
            )
    if vpc_id and subnet_id:
        if subnet_exists(region, vpc_id, subnet_id, execute):
            print(f"{region['displayName']} VPC/子网复用。")
            sg_id = ensure_security_group(region, execute)
            return {"vpcID": vpc_id, "subnetID": subnet_id, "securityGroupID": sg_id}
        print(f"{region['displayName']} 本地记录的 VPC/子网云上不存在，准备重建。")
        vpc_id = ""
        subnet_id = ""
        region["vpcID"] = ""
        region["subnetID"] = ""
        region["securityGroupID"] = ""
        region["privateIPPlan"] = private_ip_plan_key()
        if execute:
            update_region(
                region["displayName"],
                {"vpcID": "", "subnetID": "", "securityGroupID": "", "privateIPPlan": private_ip_plan_key()},
            )

    if not force_rebuild:
        discovered = discover_existing_network(region, execute)
        if discovered:
            vpc_id = discovered["vpcID"]
            subnet_id = discovered["subnetID"]
            sg_id = ensure_security_group(region, execute)
            return {"vpcID": vpc_id, "subnetID": subnet_id, "securityGroupID": sg_id}

    c = client(ENDPOINT_VPC, execute, timeout=30)
    updates: dict[str, str] = {}
    if not vpc_id:
        for attempt in range(1, 4):
            suffix = "" if attempt == 1 else f"-{int(time.time())}-{attempt}"
            body = {
                "clientToken": str(uuid.uuid4()),
                "regionID": region["regionID"],
                "projectID": "0",
                "name": f"{slug(region)}-network{suffix}",
                "CIDR": vpc_cidr(),
            }
            result = call_with_retry(c.post, "/v4/vpc/create", body)
            print(f"{region['displayName']} VPC {operation_result(result, '创建成功', '创建失败')}")
            if ok(result):
                vpc_id = extract_id(result, ["vpcID", "vpcId", "id"]) or "DRY_RUN_VPC_ID"
                updates["vpcID"] = vpc_id
                updates["privateIPPlan"] = private_ip_plan_key()
                break
            if "exists" not in response_reason(result).lower():
                raise SystemExit(f"{region['displayName']} 创建 VPC 失败。")
            print(f"{region['displayName']} VPC 名称已存在，换新名称重试。")
        if not vpc_id:
            raise SystemExit(f"{region['displayName']} 创建 VPC 失败：连续重试后仍未拿到 VPC ID。")
    if not subnet_id:
        for attempt in range(1, 4):
            suffix = "" if attempt == 1 else f"-{int(time.time())}-{attempt}"
            body = {
                "clientToken": str(uuid.uuid4()),
                "regionID": region["regionID"],
                "projectID": "0",
                "vpcID": vpc_id,
                "name": f"{slug(region)}-subnet{suffix}",
                "CIDR": subnet_cidr(),
            }
            result = call_with_retry(c.post, "/v4/vpc/create-subnet", body)
            print(f"{region['displayName']} 子网 {operation_result(result, '创建成功', '创建失败')}")
            if ok(result):
                subnet_id = extract_id(result, ["subnetID", "subnetId", "id"]) or "DRY_RUN_SUBNET_ID"
                updates["subnetID"] = subnet_id
                updates["privateIPPlan"] = private_ip_plan_key()
                break
            if "exists" not in response_reason(result).lower():
                raise SystemExit(f"{region['displayName']} 创建子网失败。")
            print(f"{region['displayName']} 子网名称已存在，换新名称重试。")
        if not subnet_id:
            raise SystemExit(f"{region['displayName']} 创建子网失败：连续重试后仍未拿到子网 ID。")
    if execute and updates:
        update_region(region["displayName"], updates)
    region.update(updates)
    sg_id = ensure_security_group(region, execute)
    return {"vpcID": vpc_id, "subnetID": subnet_id, "securityGroupID": sg_id}


def rebuild_network(region: dict[str, Any], execute: bool) -> dict[str, str]:
    return ensure_network(region, execute, force_rebuild=True, reason="需要重新创建网络资源")


def create_new_network_for_ecs_purchase(region: dict[str, Any], execute: bool) -> dict[str, str]:
    return ensure_network(region, execute, force_rebuild=True, reason="本次需要购买新的 ECS")


def phase_ensure_network(regions: list[dict[str, Any]], execute: bool) -> None:
    for region in regions:
        name = region["displayName"]
        print(f"\n== {name} 准备 VPC/子网/安全组 ==")
        net = ensure_network(region, execute)
        print(json.dumps({"region": name, **net}, ensure_ascii=False, indent=2))


def extract_id(payload: Any, keys: list[str]) -> str:
    if isinstance(payload, dict):
        for key in keys:
            value = payload.get(key)
            if isinstance(value, str) and value:
                return value
        for value in payload.values():
            found = extract_id(value, keys)
            if found:
                return found
    elif isinstance(payload, list):
        for value in payload:
            found = extract_id(value, keys)
            if found:
                return found
    return ""


def phase_discover(regions: list[dict[str, Any]], execute: bool, spec_name: str) -> None:
    for region in regions:
        name = region["displayName"]
        rid = region["regionID"]
        print(f"\n== 读取 {name} 参数 ==")
        # 先检查账号内是否已有符合本流程的 ECS；有旧资源时不能再刷新规格后直接下单。
        old_ecs = account_planned_ecs(region, execute)
        if old_ecs and reuse_existing_ecs_if_present(region, execute, old_ecs):
            desc = existing_ecs_description(old_ecs)
            print(f"{name} 已有符合当前购买需求的云服务器，{desc}，跳过规格/镜像读取并继续后续任务。")
            continue
        if old_ecs:
            print(f"{name} 发现旧云服务器，但未能确认网络信息，继续读取规格/镜像用于必要时补购。")
        else:
            print(f"{name} 未发现主内网 IP 为 {ecs_private_ip()} 的可复用 2C4G 云服务器，开始读取规格/镜像。")
        print(f"{name} 从云端刷新规格/镜像参数，避免使用旧缓存。")
        flavor_info = discover_flavor_info(rid, spec_name, execute)
        flavor_id = pick(flavor_info, "flavorID", "flavorId", "id") if flavor_info else ""
        flavor_name = flavor_info.get("specName", spec_name) if flavor_info else spec_name
        if not flavor_id and execute:
            raise SystemExit(f"{name} 找不到通用型 2核4G。为避免买贵/买错，脚本已停止。")
        image_id = discover_image(rid, execute)
        updates = {"flavorID": flavor_id, "imageID": image_id, "flavorName": flavor_name}
        print(f"{name} 已读取规格：{flavor_name or spec_name}，Debian 镜像已确认。")
        if execute:
            update_region(name, updates)


def cfg(region: dict[str, Any], ecs_count: int = TARGET_ECS_COUNT) -> dict[str, Any]:
    s = slug(region)
    ecs_on_demand = is_on_demand_debug_enabled("ctyun")
    monthly_dedicated_eip = is_monthly_dedicated_eip_enabled()
    eip_cfg = {
        "cycleType": "MONTH" if monthly_dedicated_eip else "on_demand",
        "bandwidth": "auto",
        "namePrefix": f"{s}-eip",
        "extra": {},
    }
    if monthly_dedicated_eip:
        eip_cfg["cycleCount"] = MONTHLY_DEDICATED_EIP_CYCLE_COUNT
    else:
        eip_cfg["demandBillingType"] = "upflowc"
    return {
        "common": {
            "regionName": region["displayName"],
            "regionID": region["regionID"],
            "azName": region.get("azName") or "default",
            "projectID": "0",
            "vpcID": region.get("vpcID") or "",
            "subnetID": region.get("subnetID") or "",
            "ecsPrivateIP": ecs_private_ip(),
        },
        "ecs": {
            "orderCount": ecs_count,
            "flavorID": region.get("flavorID") or "",
            "imageID": region.get("imageID") or "",
            "imageType": 1,
            "bootDiskType": "SATA",
            "bootDiskSize": 40,
            "onDemand": ecs_on_demand,
            "extIP": "0",
            "password": PASSWORD,
            "fixedIP": ecs_private_ip(),
            "securityGroupID": region.get("securityGroupID") or "",
            "instanceName": f"{s}-ecm",
            "displayName": f"{s}-ecm",
            "extra": {}
            if ecs_on_demand
            else {
                "cycleType": "MONTH",
                "cycleCount": 1,
                "autoRenew": True,
                "autoRenewStatus": 1,
            },
        },
        "eip": eip_cfg,
    }


def phase_buy_eip(regions: list[dict[str, Any]], execute: bool) -> bool:
    c = client(ENDPOINT_VPC, execute)
    st = state()
    new_ip_only = is_new_ip_purchase_enabled()
    used_ips = load_used_ip_table_from_env() if new_ip_only else set()
    if new_ip_only and not used_ips:
        print("购买选择地区新IP失败：旧IP库为空，无法过滤已使用IP。")
        return False
    if new_ip_only:
        print(f"购买选择地区新IP：旧IP库已加载 {len(used_ips)} 条，仅复用已有云服务器。")
    for region in regions:
        reset_purchase_region_state(st, region["displayName"])
    save_state(st)
    for region in regions:
        name = region["displayName"]
        if new_ip_only:
            planned_ecs = account_planned_ecs(region, execute)
            if not planned_ecs or not reuse_existing_ecs_if_present(region, execute, planned_ecs):
                reason = "未发现可复用的云服务器；购买选择地区新IP不会创建新云服务器。"
                mark_skip_remaining(st, name, reason)
                print(f"{name} {reason}")
                continue
        base = cfg(region)
        region_state(st, name).setdefault("eipOrders", [])
        current_eips = (
            prepare_new_ip_purchase_eips(region, execute, used_ips)
            if new_ip_only
            else (list_eips(region, execute) if execute else [])
        )
        existing_count = len(current_eips)
        usable_count = len([e for e in current_eips if not (e.get("associationID") or e.get("associationType") or e.get("portID"))])
        target_count = target_eip_count()
        print(f"\n== {name} 补足 EIP 到 {target_count} 条 ==")
        count_label = "当前有效 EIP" if new_ip_only else "当前全部 EIP"
        print(f"{name} {count_label}={existing_count}，可用未绑定 EIP={usable_count}，目标={target_count}")
        if existing_count >= target_count:
            if new_ip_only:
                reason = f"已达到当前购买选项的 {target_count} 条弹性IP上限；请先释放旧IP后再购买新IP。"
                mark_skip_remaining(st, name, reason)
                print(f"{name} {reason}")
                continue
            clear_skip_remaining(st, name)
            print(f"{name} 已有 EIP {existing_count}/{target_count} 条，无需新购，继续后续任务。")
            continue
        print(f"{name} 已有 EIP {existing_count}/{target_count} 条，本次只补足 {target_count - existing_count} 条。")

        success = False
        region_failed = False
        bandwidth_candidates = eip_bandwidth_candidates(name)
        if bandwidth_candidates == [60]:
            print(f"{name} 按地区统计直接购买 60 Mbps。")
        elif bandwidth_candidates == [300]:
            print(f"{name} 按地区统计固定购买 300 Mbps。")
        for bw in bandwidth_candidates:
            failed_attempts = 0
            batch_attempt = 0
            while failed_attempts < EIP_MAX_NO_PROGRESS_BATCHES:
                batch_attempt += 1
                current_count = (
                    len(prepare_new_ip_purchase_eips(region, execute, used_ips))
                    if new_ip_only
                    else actual_eip_count(region, execute)
                )
                missing = max(0, target_count - current_count)
                if missing == 0:
                    clear_skip_remaining(st, name)
                    print(f"{name} 资源列表已确认 EIP={current_count}/{target_count}，继续后续任务。")
                    success = True
                    break
                batch_count = min(missing, EIP_PARALLEL_WORKERS)
                print(f"{name} EIP {bw} Mbps 第 {batch_attempt} 轮，小批量下单 {batch_count} 条，当前已有 {current_count}/{target_count}。")
                parallel_results = submit_eip_parallel_batch(c, region, base, bw, current_count + 1, batch_count)
                submitted_count = sum(1 for item in parallel_results if eip_submit_accepted(item["response"]))
                in_progress_count = sum(1 for item in parallel_results if is_order_in_progress_result(item["response"]))
                if submitted_count == 0 and batch_count > EIP_FALLBACK_WORKERS:
                    print(f"{name} {bw} Mbps {batch_count} 条并发本轮未成功，自动降级为 {EIP_FALLBACK_WORKERS} 条并发重试。")
                    batch_count = min(missing, EIP_FALLBACK_WORKERS)
                    parallel_results = submit_eip_parallel_batch(c, region, base, bw, current_count + 1, batch_count)
                    submitted_count = sum(1 for item in parallel_results if eip_submit_accepted(item["response"]))
                    in_progress_count = sum(1 for item in parallel_results if is_order_in_progress_result(item["response"]))
                for item in parallel_results:
                    region_state(st, name)["eipOrders"].append(
                        {
                            "bandwidth": bw,
                            "count": 1,
                            "attempt": batch_attempt,
                            "submittedAt": time.time(),
                            "request": item["request"],
                            "response": item["response"],
                        }
                    )
                save_state(st)
                sold_out_result = next((item["response"] for item in parallel_results if is_eip_sold_out_result(item["response"])), None)
                if sold_out_result:
                    reason = f"EIP 售罄，跳过该地区后续任务。{response_reason(sold_out_result)}"
                    mark_skip_remaining(st, name, reason)
                    print(f"{name} {reason}，继续处理下一个地区。")
                    region_failed = True
                    break
                if submitted_count == 0:
                    print(f"{name} {bw} Mbps 本轮没有成功提交 EIP 创建请求。")
                    visible_count = wait_for_eip_submit_sync(
                        region,
                        execute,
                        target_count,
                        reason="接口未确认提交成功，但云端可能仍在异步生成 EIP",
                    )
                    if new_ip_only:
                        visible_count = len(prepare_new_ip_purchase_eips(region, execute, used_ips))
                    if visible_count >= target_count:
                        clear_skip_remaining(st, name)
                        print(f"{name} 资源列表同步后确认 EIP={visible_count}/{target_count}，继续后续任务。")
                        success = True
                        break
                    if visible_count > current_count:
                        failed_attempts = 0
                        print(f"{name} EIP 已到账 {visible_count - current_count} 条，继续补剩余数量。")
                        continue
                    failed_attempts += 1
                    continue
                if in_progress_count:
                    print(f"{name} {bw} Mbps 已提交 {submitted_count} 条，其中 {in_progress_count} 条订单仍处理中，等待云端生成 EIP。")
                else:
                    print(f"{name} {bw} Mbps 已提交 {submitted_count} 条，等待云端同步到资源列表。")
                visible_count = wait_for_eip_submit_sync(
                    region,
                    execute,
                    target_count,
                    reason="提交后确认 EIP 数量",
                )
                if new_ip_only:
                    visible_count = len(prepare_new_ip_purchase_eips(region, execute, used_ips))
                if visible_count >= target_count:
                    clear_skip_remaining(st, name)
                    print(f"{name} 资源列表已确认 EIP={visible_count}/{target_count}，继续后续任务。")
                    success = True
                    break
                if visible_count > current_count:
                    failed_attempts = 0
                    print(f"{name} {bw} Mbps 已到账 {visible_count - current_count} 条，继续补剩余数量。")
                else:
                    if in_progress_count:
                        reason = (
                            f"EIP 订单仍在云端处理中：本轮已提交 {submitted_count} 条，"
                            f"资源列表仍为 {visible_count}/{target_count}；已停止继续下单，避免重复购买。"
                            "稍后重新运行会先读取已到账 EIP 并继续补齐。"
                        )
                        mark_skip_remaining(st, name, reason)
                        print(f"{name} {reason}")
                        region_failed = True
                        break
                    failed_attempts += 1
                    if failed_attempts < EIP_MAX_NO_PROGRESS_BATCHES:
                        print(f"{name} {bw} Mbps 本轮后仍未到 {target_count} 条，继续重试本带宽。")
            if success or region_failed:
                break
            bw_reason = latest_eip_failure_reason(st, name) or f"{bw} Mbps 两次下单后资源列表仍未达到 {target_count} 条"
            if bw != bandwidth_candidates[-1]:
                print(f"{name} {bw} Mbps 两次失败，原因: {bw_reason}。切换下一档带宽。")
            else:
                print(f"{name} {bw} Mbps 两次失败，原因: {bw_reason}。停止该地区后续任务。")
        if success:
            continue
        if region_failed:
            continue
        final_count = (
            len(prepare_new_ip_purchase_eips(region, execute, used_ips))
            if new_ip_only
            else actual_eip_count(region, execute)
        )
        if final_count >= target_count:
            clear_skip_remaining(st, name)
            print(f"{name} 最终同步确认 EIP={final_count}/{target_count}，继续后续任务。")
            continue
        bandwidth_text = "/".join(str(bw) for bw in bandwidth_candidates)
        reason = f"EIP 补足未成功确认：当前资源列表 {final_count}/{target_count} 条；{latest_eip_failure_reason(st, name) or (bandwidth_text + ' Mbps 均失败')}"
        if is_eip_sold_out_reason(reason):
            reason = f"EIP 售罄，跳过该地区后续任务。{reason}"
        mark_skip_remaining(st, name, reason)
        print(f"{name} {reason}，继续处理下一个地区。")
        continue
    return True


def phase_buy_ecs(regions: list[dict[str, Any]], execute: bool, batch_size: int) -> None:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    st = state()
    if batch_size < 1 or batch_size > 10:
        raise SystemExit("--ecs-batch-size 必须在 1 到 10 之间。")
    for region in regions:
        name = region["displayName"]
        clear_eip_skip_if_ready(st, region, execute)
        if clear_skip_if_reusable_resources(st, region, execute):
            continue
        skip_reason = skip_remaining_reason(st, region["displayName"])
        if skip_reason:
            print(f"{region['displayName']} 已跳过 ECS 购买。原因: {skip_reason}")
            continue
        existing_ecs = list_ecs(region, execute) if execute else []
        planned_account_ecs = account_planned_ecs(region, execute)
        if planned_account_ecs and adopt_planned_ecs_network(region, planned_account_ecs, execute):
            clear_skip_remaining(st, name)
            continue
        if reuse_existing_ecs_if_present(region, execute, planned_account_ecs):
            print(f"{name} 旧 ECS 已满足 ECS 购买需求，复用旧 ECS 继续后续任务。")
            continue
        if is_new_ip_purchase_enabled():
            reason = "未发现可复用的云服务器；购买选择地区新IP不会创建新云服务器。"
            mark_skip_remaining(st, name, reason)
            print(f"{name} {reason}")
            continue
        net = create_new_network_for_ecs_purchase(region, execute)
        existing_usable = usable_servers(region, net["subnetID"], execute) if execute else []
        planned_usable = [server for server in existing_usable if primary_private_ip(server) == ecs_private_ip()]
        if len(planned_usable) >= TARGET_ECS_COUNT:
            print(f"{name} 新建网络内已存在规划 ECS={len(planned_usable)}，内网 IP={ecs_private_ip()}，跳过重复购买。账号可查询 ECS={len(existing_ecs)}")
            continue
        if existing_usable:
            detail = server_private_ip_summary(existing_usable)
            reason = f"新建网络内已有 ECS，但内网 IP 不符合规划：{detail}；本流程要求 {private_ip_plan_label()}。为避免重复购买或撞 IP，已停止该地区后续任务，请先退订该地区后重新购买。"
            mark_skip_remaining(st, name, reason)
            print(f"{name} {reason}")
            continue
        if existing_ecs:
            print(f"{name} 账号可查询 ECS={len(existing_ecs)}，但未发现可复用规划 ECS；已新建 VPC/子网，继续购买 1 台 ECS。")
        missing = [k for k in ("flavorID", "imageID") if not region.get(k)]
        if missing and execute:
            raise SystemExit(f"{region['displayName']} 缺少 {', '.join(missing)}，请先运行 discover。")
        if is_on_demand_debug_enabled("ctyun"):
            print(f"\n== {region['displayName']} 购买 {TARGET_ECS_COUNT} 台按需 ECS，仅供调试 ==")
        else:
            print(f"\n== {region['displayName']} 购买 {TARGET_ECS_COUNT} 台包年包月 ECS，周期 1 个月，启用自动续费 ==")
        orders = []
        bought = 0
        order_index = 1
        while bought < TARGET_ECS_COUNT:
            count = min(batch_size, TARGET_ECS_COUNT - bought)
            candidates = discover_flavor_candidates(region["regionID"], execute) if execute else []
            if not candidates:
                candidates = [{"flavorID": region.get("flavorID"), "specName": region.get("flavorName", "configured")}]
            result: dict[str, Any] = {}
            body: dict[str, Any] = {}
            used_spec = ""
            sold_out_spec = ""
            for flavor in candidates:
                region["flavorID"] = pick(flavor, "flavorID", "flavorId", "id")
                region["flavorName"] = flavor.get("specName", region.get("flavorName", ""))
                body = ecs_batch_body(cfg(region, count))
                body["instanceName"] = f"{slug(region)}-ecm-{order_index:02d}"
                body["displayName"] = f"{slug(region)}-ecm-{order_index:02d}"
                used_spec = region.get("flavorName", "")
                result = submit_ecs_order_with_network_wait(c, region, body, bought, count, used_spec, execute)
                if not ok(result) and is_vpc_not_found_result(result):
                    print(f"{region['displayName']} 新建 VPC/子网等待后仍不可用，重新新建网络后重试 ECS 下单。")
                    net_retry = create_new_network_for_ecs_purchase(region, execute)
                    rebuilt_network = bool(net_retry)
                    ensure_security_group(region, execute)
                    body = ecs_batch_body(cfg(region, count))
                    body["instanceName"] = f"{slug(region)}-ecm-{order_index:02d}"
                    body["displayName"] = f"{slug(region)}-ecm-{order_index:02d}"
                    retry_label = "ECS 新建网络后重试" if rebuilt_network else "ECS 重建网络后重试"
                    result = submit_ecs_order_with_network_wait(c, region, body, bought, count, used_spec, execute, label=retry_label)
                    if not ok(result) and is_vpc_not_found_result(result) and not has_reusable_ecs_or_havip(region, execute):
                        net_retry = create_new_network_for_ecs_purchase(region, execute)
                        if net_retry:
                            body = ecs_batch_body(cfg(region, count))
                            body["instanceName"] = f"{slug(region)}-ecm-{order_index:02d}"
                            body["displayName"] = f"{slug(region)}-ecm-{order_index:02d}"
                            result = submit_ecs_order_with_network_wait(c, region, body, bought, count, used_spec, execute, label="ECS 新建网络后再次重试")
                if not ok(result) and "Image.ImageCheck.NotFound" in response_reason(result):
                    print(f"{region['displayName']} 缓存镜像失效，重新发现可用镜像后重试 ECS 下单。")
                    region["imageID"] = discover_image(region["regionID"], execute)
                    update_region(region["displayName"], {"imageID": region["imageID"]})
                    body = ecs_batch_body(cfg(region, count))
                    body["instanceName"] = f"{slug(region)}-ecm-{order_index:02d}"
                    body["displayName"] = f"{slug(region)}-ecm-{order_index:02d}"
                    result = submit_ecs_order_with_network_wait(c, region, body, bought, count, used_spec, execute, label="ECS 镜像刷新后重试")
                if ok(result):
                    if sold_out_spec and sold_out_spec != used_spec:
                        print(f"{region['displayName']} ECS [{sold_out_spec}] 库存不足，已自动替换同规格 2核4G [{used_spec}] 下单成功。")
                    if execute:
                        update_region(region["displayName"], {"flavorID": region["flavorID"], "flavorName": used_spec, "imageID": region["imageID"]})
                    break
                if is_flavor_sold_out_result(result):
                    sold_out_spec = used_spec
                    print(f"{region['displayName']} ECS [{used_spec}] 库存不足，继续尝试同规格 2核4G 其他规格。")
                    continue
                if not is_flavor_sold_out_result(result):
                    break
            orders.append({"count": count, "request": body, "response": result})
            st["regions"].setdefault(region["displayName"], {})["ecsOrders"] = orders
            st["regions"][region["displayName"]]["ecsOrder"] = {"request": body, "response": result}
            save_state(st)
            if execute and not ok(result):
                if is_fixed_ip_used_result(result):
                    planned_account_ecs = account_planned_ecs(region, execute)
                    if planned_account_ecs and adopt_planned_ecs_network(region, planned_account_ecs, execute):
                        clear_skip_remaining(st, name)
                        break
                if clear_skip_if_reusable_resources(st, region, execute):
                    break
                reason = f"ECS 第 {bought + 1}-{bought + count} 台下单失败：{response_reason(result)}"
                mark_skip_remaining(st, region["displayName"], reason)
                print(f"{region['displayName']} {reason}，继续处理后续地区。")
                break
            bought += count
            order_index += 1


def list_ecs(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    results: list[dict[str, Any]] = []
    seen: set[str] = set()
    page_no = 1
    page_size = 50
    # Atuo_Ecs 的成功流程查询 ECS 时不带 projectID；部分资源池带 projectID=0 会返回空。
    # 这里双路兼容并翻页合并，避免旧 ECS 已存在却被误判为 0 台后重复购买。
    for include_project_id in (False, True):
        page_no = 1
        variant_count = 0
        while True:
            body = {"regionID": region["regionID"], "pageNo": page_no, "pageSize": page_size}
            if include_project_id:
                body["projectID"] = "0"
            payload = call_with_retry(c.post, "/v4/ecs/list-instances", body, retries=5, delay=5)
            return_obj = payload.get("returnObj", {}) if isinstance(payload, dict) else {}
            page_items = return_obj.get("results", []) if isinstance(return_obj, dict) else []
            if not page_items and isinstance(return_obj, (dict, list)):
                page_items = find_items(return_obj)
            if not page_items and isinstance(payload, (dict, list)):
                page_items = find_items(payload)
            if not isinstance(page_items, list):
                break
            clean_items = [item for item in page_items if isinstance(item, dict)]
            for item in clean_items:
                key = ecs_instance_key(item)
                if key in seen:
                    continue
                seen.add(key)
                results.append(item)
                variant_count += 1
            total = int(return_obj.get("totalCount") or return_obj.get("total") or return_obj.get("count") or 0) if isinstance(return_obj, dict) else 0
            if not clean_items or len(clean_items) < page_size or (total and variant_count >= total):
                break
            page_no += 1
    if not results:
        for item in list_ecs_via_instance_list(region, execute):
            key = ecs_instance_key(item)
            if key in seen:
                continue
            seen.add(key)
            results.append(item)
    if not results:
        for item in list_ecs_via_monitor(region, execute):
            key = ecs_instance_key(item)
            if key in seen:
                continue
            seen.add(key)
            results.append(item)
    return results


def list_ecs_via_instance_list(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    """备用读取 ECS 列表。

    天翼云不同资源池的 ECS 列表字段和入口不完全一致；主入口返回空时，
    再尝试官方 instance-list 入口，避免已有 10.0.0.10 ECS 被误判为不存在。
    """
    if not execute:
        return []
    c = client(ENDPOINT_ECS, execute, timeout=30)
    results: list[dict[str, Any]] = []
    seen: set[str] = set()
    variants = [
        {"regionID": region["regionID"], "pageNo": 1, "pageSize": 50},
        {"regionID": region["regionID"], "page": 1, "pageSize": 50},
        {"regionID": region["regionID"], "currentPage": 1, "pageSize": 50},
        {"regionID": region["regionID"], "projectID": "0", "pageNo": 1, "pageSize": 50},
        {"regionID": region["regionID"], "ID": "", "pageNo": 1, "pageSize": 50},
    ]
    for body in variants:
        payload = call_or_error(c.post, "/v4/ecs/instance-list", body, retries=1, delay=2)
        if not ok(payload):
            continue
        for item in find_items(payload):
            key = ecs_instance_key(item)
            if key in seen:
                continue
            seen.add(key)
            results.append(item)
    return results


def query_ecs_detail_by_id(region: dict[str, Any], instance_id: str, execute: bool) -> dict[str, Any] | None:
    """按 instanceID 读取单台 ECS 详情，用于监控列表只返回 ID 时补齐网络信息。"""
    if not execute or not instance_id:
        return None
    c = client(ENDPOINT_ECS, execute, timeout=30)
    bodies = [
        {"regionID": region["regionID"], "ID": instance_id},
        {"regionID": region["regionID"], "instanceID": instance_id},
        {"regionID": region["regionID"], "serverID": instance_id},
        {"regionID": region["regionID"], "azName": region.get("azName") or "default", "ID": instance_id},
        {"regionID": region["regionID"], "projectID": "0", "ID": instance_id},
    ]
    for body in bodies:
        payload = call_or_error(c.post, "/v4/ecs/instance-list", body, retries=1, delay=2)
        if not ok(payload):
            continue
        for item in find_items(payload):
            if ecs_instance_key(item) == instance_id or pick(item, "instanceID", "instanceId", "id", "ID") == instance_id:
                return item
    return None


def list_ecs_via_monitor(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    """通过云监控资源列表兜底发现 ECS。

    控制台能看到云主机、ECS 主列表接口却返回 0 时，云监控资源列表通常仍按
    regionID 返回 deviceUUID/instanceID，用它补齐旧 ECS 复用和后续绑定所需 ID。
    """
    if not execute:
        return []
    c = client("https://monitor-global.ctapi.ctyun.cn", execute, timeout=30)
    results: list[dict[str, Any]] = []
    seen: set[str] = set()
    page_no = 1
    page_size = 50
    while True:
        payload = call_or_error(
            c.get,
            "/v4/monitor/query-ecs-list",
            {"regionID": region["regionID"], "pageNo": page_no, "pageSize": page_size},
            retries=1,
            delay=2,
        )
        if not ok(payload):
            break
        return_obj = payload.get("returnObj", {}) if isinstance(payload, dict) else {}
        page_items = return_obj.get("list", []) if isinstance(return_obj, dict) else []
        if not page_items:
            page_items = find_items(payload)
        clean_items = [item for item in page_items if isinstance(item, dict)]
        for item in clean_items:
            instance_id = pick(item, "instanceID", "instanceId", "deviceUUID", "id", "ID")
            detail = query_ecs_detail_by_id(region, instance_id, execute)
            if detail:
                item = {**item, **detail}
            key = ecs_instance_key(item)
            if key in seen:
                continue
            seen.add(key)
            results.append(item)
        total = int(return_obj.get("totalCount") or return_obj.get("total") or return_obj.get("count") or 0) if isinstance(return_obj, dict) else 0
        if not clean_items or len(clean_items) < page_size or (total and len(results) >= total):
            break
        page_no += 1
    return results


def wait_for_ecs_list(region: dict[str, Any], execute: bool, target: int = len(HAVIP_RANGE)) -> list[dict[str, Any]]:
    if not execute:
        return list_ecs(region, execute)
    for attempt in range(1, 21):
        items = list_ecs(region, execute)
        if len(items) >= target:
            return items
        print(f"{region['displayName']} 等待 ECS 列表生效 {attempt}/20：当前 ECS={len(items)}/{target}")
        sleep_with_stop(15)
    return list_ecs(region, execute)


def list_eips(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    eips: list[dict[str, Any]] = []
    page_no = 1
    page_size = 100
    # EIP 列表接口按资源池分页返回；资源查询必须翻页，避免账号 EIP 数量超过第一页时漏退订项。
    while True:
        payload = call_with_retry(
            c.post,
            "/v4/eip/list",
            {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "pageNo": page_no, "pageSize": page_size},
            retries=5,
            delay=5,
        )
        return_obj = payload.get("returnObj", {}) if isinstance(payload, dict) else {}
        page_items = return_obj.get("eips", []) if isinstance(return_obj, dict) else []
        if not isinstance(page_items, list):
            break
        eips.extend([item for item in page_items if isinstance(item, dict)])
        total = int(return_obj.get("totalCount") or return_obj.get("total") or return_obj.get("count") or 0) if isinstance(return_obj, dict) else 0
        if not page_items or len(page_items) < page_size or (total and len(eips) >= total):
            break
        page_no += 1
    return eips


def shared_bandwidth_id(item: dict[str, Any]) -> str:
    return pick(item or {}, "bandwidthID", "bandwidthId", "id", "ID", "resourceID", "resourceId")


def shared_bandwidth_name(item: dict[str, Any]) -> str:
    return resource_name(item or {}) or shared_bandwidth_id(item) or "共享带宽"


def shared_bandwidth_size_text(item: dict[str, Any]) -> str:
    for key in ("bandwidth", "bandwidthSize", "bandwidth_size", "bandwidthValue"):
        value = clean_resource_display_text((item or {}).get(key))
        if value:
            return value
    return ""


def list_shared_bandwidths(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    page_size = 100
    results: list[dict[str, Any]] = []
    seen: set[str] = set()
    paths = ("/v4/bandwidth/new-list", "/v4/bandwidth/list")
    for path in paths:
        page_no = 1
        page_results: list[dict[str, Any]] = []
        while True:
            payload = call_or_error(
                c.get,
                path,
                {"regionID": region["regionID"], "projectID": "0", "pageNo": page_no, "pageSize": page_size},
                retries=2,
                delay=2,
            )
            if not ok(payload) and not find_items(payload):
                break
            page_items = [item for item in find_items(payload) if isinstance(item, dict)]
            for item in page_items:
                bandwidth_id = shared_bandwidth_id(item)
                key = bandwidth_id or f"{shared_bandwidth_name(item)}|{item.get('createdAt') or ''}"
                if not key or key in seen:
                    continue
                seen.add(key)
                page_results.append(item)
            return_obj = payload.get("returnObj", {}) if isinstance(payload, dict) else {}
            total = int(return_obj.get("totalCount") or return_obj.get("total") or return_obj.get("count") or 0) if isinstance(return_obj, dict) else 0
            if not page_items or len(page_items) < page_size or (total and len(page_results) >= total):
                break
            page_no += 1
        if page_results:
            results.extend(page_results)
            break
    return results


def list_havips(region: dict[str, Any], subnet_id: str, execute: bool) -> dict[str, dict[str, Any]]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    payload = call_with_retry(
        c.post,
        "/v4/vpc/havip/list",
        {
            "clientToken": str(uuid.uuid4()),
            "regionID": region["regionID"],
            "projectID": "0",
            "filters": [{"key": "subnetID", "value": subnet_id}],
        },
        retries=5,
        delay=5,
    )
    items = payload.get("returnObj") if isinstance(payload, dict) else []
    if not isinstance(items, list):
        items = find_items(payload)
    return {havip_ip(x): x for x in items if isinstance(x, dict) and havip_ip(x)}


def list_all_havips(region: dict[str, Any], execute: bool) -> dict[str, dict[str, Any]]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    payload = call_with_retry(
        c.post,
        "/v4/vpc/havip/list",
        {
            "clientToken": str(uuid.uuid4()),
            "regionID": region["regionID"],
            "projectID": "0",
        },
        retries=5,
        delay=5,
    )
    items = payload.get("returnObj") if isinstance(payload, dict) else []
    if not isinstance(items, list):
        items = find_items(payload)
    return {havip_ip(x): x for x in items if isinstance(x, dict) and havip_ip(x)}


def havip_bound_to_server(havip: dict[str, Any], server: dict[str, Any]) -> bool:
    instance_id = str(server.get("instanceID") or "").strip()
    network_interface_id = str(server.get("networkInterfaceID") or "").strip()
    instance_info = havip.get("instanceInfo") or []
    if isinstance(instance_info, dict):
        instance_info = [instance_info]
    for item in instance_info:
        if not isinstance(item, dict):
            continue
        bound_instance_id = pick(item, "id", "ID", "instanceID", "instanceId")
        if instance_id and bound_instance_id == instance_id:
            return True
    bind_ports = havip.get("bindPorts") or []
    if isinstance(bind_ports, dict):
        bind_ports = [bind_ports]
    for item in bind_ports:
        if not isinstance(item, dict):
            continue
        bound_port_id = pick(item, "portID", "portId", "networkInterfaceID", "networkInterfaceId", "id", "ID")
        if network_interface_id and bound_port_id == network_interface_id:
            return True
    return False


def bind_havip_to_server(
    region: dict[str, Any],
    havip: dict[str, Any],
    server: dict[str, Any],
    execute: bool,
) -> dict[str, Any]:
    havip_id = pick(havip, "id", "ID", "haVipID", "havipID", "havipId")
    instance_id = str(server.get("instanceID") or "").strip()
    network_interface_id = str(server.get("networkInterfaceID") or "").strip()
    if not havip_id or not instance_id or not network_interface_id:
        missing = []
        if not havip_id:
            missing.append("HAVIP ID")
        if not instance_id:
            missing.append("ECS ID")
        if not network_interface_id:
            missing.append("主网卡 ID")
        reason = f"缺少{'、'.join(missing)}，无法将虚拟 IP 绑定到 ECS 主网卡"
        return {"statusCode": 900, "errorCode": "LOCAL.PARAMETER.MISSING", "description": reason, "message": reason}

    vpc_client = client(ENDPOINT_VPC, execute, timeout=30)
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "resourceType": "VM",
        "haVipID": havip_id,
        "instanceID": instance_id,
        "networkInterfaceID": network_interface_id,
    }
    last_result: dict[str, Any] = {}
    for attempt in range(1, 21):
        last_result = call_with_retry(vpc_client.post, "/v4/vpc/havip/bind", body, retries=3, delay=3)
        if not ok(last_result) or not execute:
            return last_result
        return_obj = last_result.get("returnObj") if isinstance(last_result.get("returnObj"), dict) else {}
        bind_status = str(return_obj.get("status") or "").strip().lower()
        if bind_status in {"", "done"}:
            return last_result
        if bind_status != "in_progress":
            reason = str(return_obj.get("message") or f"未知绑定状态: {bind_status}").strip()
            return {**last_result, "statusCode": 900, "errorCode": "HAVIP.BIND.STATUS.UNKNOWN", "description": reason}
        print(f"{region['displayName']} 虚拟 IP 绑定 ECS 主网卡处理中 {attempt}/20。")
        sleep_with_stop(3)
    reason = "虚拟 IP 绑定 ECS 主网卡等待超时"
    return {**last_result, "statusCode": 900, "errorCode": "HAVIP.BIND.TIMEOUT", "description": reason, "message": reason}


def ecs_instance_key(inst: dict[str, Any]) -> str:
    key = pick(inst, "instanceID", "instanceId", "instance_id", "serverID", "serverId", "id", "ID", "uuid")
    if key:
        return key
    return json.dumps(inst, ensure_ascii=False, sort_keys=True, default=str)


def havip_ip(havip: dict[str, Any]) -> str:
    return pick(havip, "ipv4", "ipAddress", "ip", "privateIP", "privateIp")


def network_cards(inst: dict[str, Any]) -> list[dict[str, Any]]:
    cards: list[dict[str, Any]] = []
    for key in ("networkCardList", "networkCards", "networkInterfaceList", "networkInterfaces", "interfaces", "nics", "ports"):
        value = inst.get(key)
        if isinstance(value, list):
            cards.extend([item for item in value if isinstance(item, dict)])
        elif isinstance(value, dict):
            cards.append(value)
    return cards


def ip_from_address(addr: dict[str, Any]) -> str:
    for key in ("addr", "ip", "ipAddress", "privateIP", "privateIp", "fixedIP", "fixedIp", "ipv4"):
        value = addr.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    return ""


def card_private_ip(card: dict[str, Any]) -> str:
    for key in ("fixedIP", "fixedIp", "privateIP", "privateIp", "private_ip", "ipv4", "ipAddress", "ip"):
        value = card.get(key)
        if isinstance(value, str) and value.strip():
            return value.strip()
    for key in ("addressList", "addresses", "fixedIPList", "privateIPList", "privateIps", "privateIPs"):
        values = card.get(key) or []
        if isinstance(values, dict):
            values = [values]
        for addr in values:
            if not isinstance(addr, dict):
                continue
            value = ip_from_address(addr)
            if value:
                return value
    return ""


def card_subnet_id(card: dict[str, Any]) -> str:
    subnet_id = pick(card, "subnetID", "subnetId", "subnet_id")
    if subnet_id:
        return subnet_id
    subnet = card.get("subnet")
    if isinstance(subnet, dict):
        subnet_id = pick(subnet, "subnetID", "subnetId", "id", "ID")
        if subnet_id:
            return subnet_id
    subnet_list = card.get("subnetIDList") or card.get("subnetIdList") or []
    if isinstance(subnet_list, list) and subnet_list:
        return str(subnet_list[0] or "")
    return ""


def card_vpc_id(card: dict[str, Any]) -> str:
    vpc_id = pick(card, "vpcID", "vpcId", "vpc_id", "networkID", "networkId", "network_id")
    if vpc_id:
        return vpc_id
    network = card.get("network") or card.get("vpc")
    if isinstance(network, dict):
        return pick(network, "vpcID", "vpcId", "networkID", "networkId", "id", "ID")
    return ""


def primary_network_card(inst: dict[str, Any]) -> dict[str, Any]:
    cards = network_cards(inst)
    for card in cards:
        marker = card.get("isMaster", card.get("primary", card.get("isPrimary", card.get("mainCard"))))
        if marker is True or str(marker).strip().lower() in {"true", "1", "yes", "master", "primary"}:
            return card
    return cards[0] if cards else {}


def primary_private_ip(inst: dict[str, Any]) -> str:
    card_ip = card_private_ip(primary_network_card(inst))
    return card_ip or private_ip(inst)


def numeric_value(value: Any) -> float | None:
    if isinstance(value, (int, float)):
        return float(value)
    text = str(value or "").strip()
    if not text:
        return None
    match = re.search(r"(\d+(?:\.\d+)?)", text)
    return float(match.group(1)) if match else None


def ecs_cpu_mem(inst: dict[str, Any]) -> tuple[float | None, float | None]:
    cpu = None
    mem = None
    for key in ("cpuNum", "vCPU", "vcpus", "cpu", "cores", "cpuCount", "flavorCPU", "vCPUCount"):
        cpu = numeric_value(inst.get(key))
        if cpu is not None:
            break
    for key in ("memSize", "memory", "ram", "mem", "memorySize", "flavorRAM", "ramSize", "memoryGB", "memorySizeGB"):
        mem = numeric_value(inst.get(key))
        if mem is not None:
            break
    flavor = inst.get("flavor") if isinstance(inst.get("flavor"), dict) else {}
    if cpu is None:
        for key in ("cpuNum", "vCPU", "vcpus", "cpu", "cores", "cpuCount", "flavorCPU", "vCPUCount"):
            cpu = numeric_value(flavor.get(key))
            if cpu is not None:
                break
    if mem is None:
        for key in ("memSize", "memory", "ram", "mem", "memorySize", "flavorRAM", "ramSize", "memoryGB", "memorySizeGB"):
            mem = numeric_value(flavor.get(key))
            if mem is not None:
                break
    if mem is not None and mem >= 1024:
        mem = mem / 1024
    return cpu, mem


def ecs_created_sort_value(inst: dict[str, Any]) -> str:
    value = inst.get("createdTime") or inst.get("createdAt") or inst.get("createTime") or inst.get("created") or ""
    return str(value or "9999-12-31T23:59:59Z")


def select_reusable_planned_ecs(candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
    if not candidates:
        return None
    # 同一地区可能因为旧失败流程出现多台 10.0.0.10 ECS；优先复用最早创建的旧资源，避免继续追着新误购资源走。
    return sorted(
        candidates,
        key=lambda inst: (
            0 if ecs_2c4g_match(inst) is True else 1,
            ecs_created_sort_value(inst),
            str(inst.get("displayName") or inst.get("instanceName") or inst.get("name") or ""),
        ),
    )[0]


def ecs_2c4g_match(inst: dict[str, Any]) -> bool | None:
    cpu, mem = ecs_cpu_mem(inst)
    if cpu is not None or mem is not None:
        return cpu == 2 and mem == 4
    text = " ".join(
        str(inst.get(key) or "")
        for key in ("flavorName", "flavorID", "flavorId", "specName", "spec", "instanceSpec", "resourceSpec")
    ).lower()
    if not text and isinstance(inst.get("flavor"), dict):
        text = json.dumps(inst["flavor"], ensure_ascii=False).lower()
    if not text:
        return None
    if re.search(r"(^|[\W_])2\s*(c|核|vcpu)", text) and re.search(r"(^|[\W_])4\s*(g|gb|GiB|内存)", text, re.IGNORECASE):
        return True
    if ".large.2" in text or "large.2" in text:
        return True
    return None


def is_planned_2c4g_ecs(inst: dict[str, Any]) -> bool:
    if is_release_only_ecs_item(inst):
        return False
    if primary_private_ip(inst) != ecs_private_ip():
        return False
    spec_match = ecs_2c4g_match(inst)
    if spec_match is False:
        return False
    # 部分资源池的 ECS 列表不返回规格字段；主网卡 IP 已符合规划时先复用，避免重复购买。
    return True


def existing_ecs_description(inst: dict[str, Any]) -> str:
    name = inst.get("displayName") or inst.get("instanceName") or "旧 ECS"
    spec_match = ecs_2c4g_match(inst)
    spec = "2C4G" if spec_match is True else "规格未返回"
    return f"ECS={name}，主内网 IP={primary_private_ip(inst) or '未知'}，规格={spec}"


def havip_network_ids(havip: dict[str, Any]) -> tuple[str, str]:
    return (
        pick(havip, "vpcID", "vpcId", "networkID", "networkId", "network_id"),
        pick(havip, "subnetID", "subnetId", "subnet_id"),
    )


def havip_has_bound_eip(havip: dict[str, Any], assoc_eips: dict[str, dict[str, Any]]) -> bool:
    havip_id = str(havip.get("id") or havip.get("ID") or "")
    return bool(havip.get("networkInfo")) or bool(havip_id and havip_id in assoc_eips)


def bound_havip_eip_count_any_network(region: dict[str, Any], execute: bool) -> int:
    if not execute:
        return 0
    havips = list_all_havips(region, execute)
    assoc_eips = eips_by_association(region, execute)
    count = 0
    for ip in planned_havip_ips():
        h = havips.get(ip, {})
        if h and havip_has_bound_eip(h, assoc_eips):
            count += 1
    return count


def planned_havip_count_any_network(region: dict[str, Any], execute: bool) -> int:
    if not execute:
        return 0
    havips = list_all_havips(region, execute)
    return sum(1 for ip in planned_havip_ips() if ip in havips)


def has_reusable_ecs_or_havip(region: dict[str, Any], execute: bool) -> bool:
    # 有规划 ECS 或 10.0.0.11~10.0.0.19 旧 HAVIP 时，说明旧资源已经进入本流程，不能重建网络。
    return bool(account_planned_ecs(region, execute)) or planned_havip_count_any_network(region, execute) > 0


def rebuild_network_for_missing_ecs(region: dict[str, Any], execute: bool) -> dict[str, str] | None:
    if has_reusable_ecs_or_havip(region, execute):
        print(f"{region['displayName']} 已发现可复用旧 ECS/HAVIP，禁止重建 VPC/子网。")
        return None
    print(f"{region['displayName']} 未发现可复用旧 ECS/HAVIP，旧 VPC/子网不可用于 ECS 下单，自动重建网络后继续购买 ECS。")
    return rebuild_network(region, execute)


def infer_network_from_havips(region: dict[str, Any], execute: bool) -> dict[str, str] | None:
    if not execute:
        return None
    havips = list_all_havips(region, execute)
    for ip in planned_havip_ips():
        h = havips.get(ip)
        if not h:
            continue
        vpc_id, subnet_id = havip_network_ids(h)
        vpc_id = vpc_id or str(region.get("vpcID") or "")
        subnet_id = subnet_id or str(region.get("subnetID") or "")
        if not vpc_id or not subnet_id:
            continue
        updates = {"vpcID": vpc_id, "subnetID": subnet_id, "privateIPPlan": private_ip_plan_key()}
        region.update(updates)
        update_region(region["displayName"], updates)
        print(f"{region['displayName']} 已从旧 HAVIP {ip} 反推并复用 VPC={vpc_id} 子网={subnet_id}。")
        return updates
    return None


def private_ip(inst: dict[str, Any]) -> str:
    addresses = inst.get("addresses") or []
    if isinstance(addresses, dict):
        addresses = list(addresses.values())
    for group in addresses:
        if not isinstance(group, dict):
            continue
        addr_list = group.get("addressList") or group.get("addresses") or group.get("addrList") or []
        if isinstance(addr_list, dict):
            addr_list = [addr_list]
        for addr in addr_list:
            if not isinstance(addr, dict):
                continue
            addr_type = str(addr.get("type") or addr.get("OS-EXT-IPS:type") or addr.get("ipType") or "").lower()
            value = ip_from_address(addr)
            if value and (not addr_type or addr_type in {"fixed", "private", "inner", "intranet"}):
                return value
    for card in network_cards(inst):
        value = card_private_ip(card)
        if value:
            return value
    return pick(inst, "privateIP", "privateIp", "private_ip", "fixedIP", "fixedIp", "innerIP", "innerIp", "intranetIP", "intranetIp")


def server_private_ip_summary(servers: list[dict[str, Any]]) -> str:
    parts = []
    for inst in servers:
        name = inst.get("displayName") or inst.get("instanceName") or "云服务器"
        parts.append(f"{name}={primary_private_ip(inst) or '未知'}")
    return "，".join(parts)


def instance_network_ids(inst: dict[str, Any], preferred_ip: str = "") -> tuple[str, str]:
    vpc_id = pick(inst, "vpcID", "vpcId", "vpc_id", "networkID", "networkId", "network_id")
    subnet_id = pick(inst, "subnetID", "subnetId", "subnet_id")
    subnet_list = inst.get("subnetIDList") or inst.get("subnetIdList") or []
    if isinstance(subnet_list, list) and subnet_list:
        subnet_id = str(subnet_list[0] or "")
    for card in network_cards(inst):
        card_ip = card_private_ip(card)
        if preferred_ip and card_ip and card_ip != preferred_ip:
            continue
        vpc_id = vpc_id or card_vpc_id(card)
        subnet_id = subnet_id or card_subnet_id(card)
        if subnet_id:
            break
    return vpc_id, subnet_id


def account_planned_ecs(region: dict[str, Any], execute: bool) -> dict[str, Any] | None:
    if not execute:
        return None
    candidates: list[dict[str, Any]] = []
    for inst in list_ecs(region, execute):
        status = normalized_ecs_status(inst)
        if is_cycle_unsubscribed_status(status):
            continue
        if status and not is_running_status(status):
            continue
        if is_planned_2c4g_ecs(inst):
            candidates.append(inst)
    if candidates:
        return select_reusable_planned_ecs(candidates)
    bound = discover_ecs_bound_eip(region, execute)
    if bound and is_release_only_ecs_item(bound):
        return None
    if bound and primary_private_ip(bound) == ecs_private_ip():
        return bound
    return None


def adopt_planned_ecs_network(region: dict[str, Any], inst: dict[str, Any], execute: bool) -> dict[str, str] | None:
    vpc_id, subnet_id = instance_network_ids(inst, ecs_private_ip())
    vpc_id = vpc_id or str(region.get("vpcID") or "")
    subnet_id = subnet_id or str(region.get("subnetID") or "")
    if not vpc_id or not subnet_id:
        return None
    updates = {"vpcID": vpc_id, "subnetID": subnet_id, "privateIPPlan": private_ip_plan_key()}
    sg_id = server_security_group_id(inst)
    if sg_id:
        updates["securityGroupID"] = sg_id
    region.update(updates)
    if execute:
        update_region(region["displayName"], updates)
    name = inst.get("displayName") or inst.get("instanceName") or "旧 ECS"
    print(f"{region['displayName']} 已发现旧 ECS {name} 使用内网 IP {ecs_private_ip()}，复用该 ECS 和网络，跳过重复购买。")
    return updates


def is_fixed_ip_used_result(result: dict[str, Any]) -> bool:
    reason = response_reason(result)
    lower = reason.lower()
    return ecs_private_ip() in reason and ("已被使用" in reason or "not valid" in lower or "fixedip" in lower or "already" in lower)


def first_network_card(inst: dict[str, Any], subnet_id: str = "") -> dict[str, Any]:
    cards = network_cards(inst)
    if subnet_id:
        for card in cards:
            if card_subnet_id(card) == subnet_id:
                return card
    return cards[0] if cards else {}


def try_stop_instance(region: dict[str, Any], instance_id: str, execute: bool, *, force: bool = False) -> tuple[bool, str]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    base_bodies = [
        ("/v4/ecs/stop-instances", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDList": [instance_id]}),
        ("/v4/ecs/stop-instances", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDs": [instance_id]}),
        ("/v4/ecs/stop-instance", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceID": instance_id}),
        ("/v4/ecs/lite/stop", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceID": instance_id}),
    ]
    bodies = []
    if force:
        for path, body in base_bodies:
            force_body = {**body, "force": True, "forceStop": True, "isForce": True}
            bodies.append((path, force_body))
    bodies.extend(base_bodies)
    reasons = []
    for path, body in bodies:
        result = call_or_error(c.post, path, body, retries=1)
        if ok(result):
            return True, f"{path}: {response_reason(result)}"
        reasons.append(f"{path}: {response_reason(result)}")
    return False, "; ".join(reasons)


def normalized_ecs_status(inst: dict[str, Any]) -> str:
    for key in ("instanceStatus", "instanceStatusName", "status", "statusName", "vmStatus", "powerStatus", "state", "stateName", "lifeCycleState", "lifecycleState", "serviceStatus"):
        value = inst.get(key)
        if value not in (None, ""):
            return str(value).strip().lower()
    return ""


def is_stopped_status(status: str) -> bool:
    value = status.strip().lower()
    return value in {"stopped", "stop", "shutoff", "shutdown", "poweroff", "poweredoff", "关机", "已关机", "已停止"}


def is_running_status(status: str) -> bool:
    value = status.strip().lower()
    return value in {"running", "active", "run", "started", "poweron", "poweredon", "available", "normal", "ok", "success", "开机", "运行", "运行中", "已运行", "已开机", "可用", "正常"}


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


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


def ecs_billing_text(item: dict[str, Any]) -> str:
    keys = (
        "billing",
        "chargeType",
        "chargeMode",
        "payType",
        "payMode",
        "billingType",
        "instanceChargeType",
        "cycleType",
        "cycleTypeName",
        "paymentType",
        "orderType",
        "subscriptionType",
        "chargeTypeName",
    )
    values = [clean_resource_display_text(item.get(key)) for key in keys if clean_resource_display_text(item.get(key))]
    if item.get("onDemand") is True or item.get("isOnDemand") is True:
        values.append("on_demand")
    return " ".join(values)


def scalar_payload_value_text(payload: Any) -> str:
    values: list[str] = []

    def walk(node: Any) -> None:
        if isinstance(node, dict):
            for value in node.values():
                walk(value)
        elif isinstance(node, list):
            for value in node:
                walk(value)
        elif isinstance(node, str):
            if node.strip():
                values.append(node.strip())
        elif isinstance(node, (int, float)) and not isinstance(node, bool):
            values.append(str(node))

    walk(payload)
    return " ".join(values)


def is_on_demand_ecs_item(item: dict[str, Any]) -> bool:
    if item.get("onDemand") is True or item.get("isOnDemand") is True:
        return True
    text = f"{ecs_billing_text(item)} {scalar_payload_value_text(item)}".lower()
    if any(token in text for token in ("on_demand", "ondemand", "on demand", "postpaid", "post_paid", "pay_as_you_go")):
        return True
    if any(token in text for token in ("按量", "按需", "后付费", "按小时", "小时计费")):
        return True
    if any(token in text for token in ("prepaid", "pre_paid", "包年", "包月", "包周期", "yearly", "monthly")):
        return False
    if item.get("onDemand") is False or item.get("isOnDemand") is False:
        return False
    return False


def is_on_demand_resource_item(item: dict[str, Any]) -> bool:
    if item.get("onDemand") is True or item.get("isOnDemand") is True:
        return True
    text = f"{ecs_billing_text(item)} {scalar_payload_value_text(item)}".lower()
    if any(token in text for token in ("on_demand", "ondemand", "on demand", "postpaid", "post_paid", "pay_as_you_go")):
        return True
    if any(token in text for token in ("按量", "按需", "后付费", "按小时", "小时计费")):
        return True
    if any(token in text for token in ("prepaid", "pre_paid", "包年", "包月", "包周期", "yearly", "monthly")):
        return False
    return False


def is_prepaid_resource_item(item: dict[str, Any]) -> bool:
    text = f"{ecs_billing_text(item)} {scalar_payload_value_text(item)}".lower()
    return any(token in text for token in ("prepaid", "pre_paid", "包年", "包月", "包周期", "yearly", "monthly", "cycle"))


def resource_expire_display(item: dict[str, Any]) -> str:
    expire_time = clean_resource_display_text(resource_expire_time(item))
    if expire_time:
        return expire_time
    return "按量付费" if (is_on_demand_resource_item(item) or not is_prepaid_resource_item(item)) else ""


def is_release_only_ecs_item(item: dict[str, Any]) -> bool:
    status = str(item.get("status") or "")
    if is_ecs_unsubscribe_in_progress_status(status):
        return False
    return bool(item.get("releaseOnly")) or is_cycle_unsubscribed_status(status)


def find_ecs_instance(region: dict[str, Any], instance_id: str, execute: bool) -> dict[str, Any] | None:
    for inst in list_ecs(region, execute):
        if (inst.get("instanceID") or inst.get("id")) == instance_id:
            return inst
    return None


def wait_instance_stopped(region: dict[str, Any], instance_id: str, execute: bool, *, max_attempts: int = 30, interval: int = 10) -> tuple[bool, str]:
    if not execute:
        return True, "dry-run"
    last_status = ""
    for attempt in range(1, max_attempts + 1):
        inst = find_ecs_instance(region, instance_id, execute)
        if not inst:
            print(f"{region['displayName']} 等待云服务器关机 {attempt}/{max_attempts}：列表暂未找到该云服务器，继续等待")
            sleep_with_stop(interval)
            continue
        last_status = normalized_ecs_status(inst)
        if is_stopped_status(last_status):
            return True, last_status
        print(f"{region['displayName']} 等待云服务器关机 {attempt}/{max_attempts}：当前状态 {last_status or '未知'}")
        sleep_with_stop(interval)
    return False, last_status or "未知"


def stop_instance_and_wait(region: dict[str, Any], instance_id: str, execute: bool, display_name: str = "", *, force: bool = False) -> tuple[bool, str]:
    label = display_name.strip() or "云服务器"
    inst = find_ecs_instance(region, instance_id, execute)
    if inst and is_stopped_status(normalized_ecs_status(inst)):
        return True, "云服务器已是关机状态"
    stopped, stop_msg = try_stop_instance(region, instance_id, execute, force=force)
    stop_msg = hide_resource_ids(stop_msg, {instance_id: label})
    action = "强制关机" if force else "关机"
    print(f"{region['displayName']} {action}云服务器 {label} {bool_result(stopped, '成功', '失败', stop_msg)}")
    if not stopped:
        return False, stop_msg
    return wait_instance_stopped(region, instance_id, execute)


def try_start_instance(region: dict[str, Any], instance_id: str, execute: bool) -> tuple[bool, str]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    bodies = [
        ("/v4/ecs/start-instances", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDList": [instance_id]}),
        ("/v4/ecs/start-instances", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDs": [instance_id]}),
        ("/v4/ecs/start-instance", {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceID": instance_id}),
    ]
    reasons = []
    for path, body in bodies:
        result = call_or_error(c.post, path, body, retries=1)
        if ok(result):
            return True, f"{path}: {response_reason(result)}"
        reasons.append(f"{path}: {response_reason(result)}")
    return False, "; ".join(reasons)


def wait_instance_running(region: dict[str, Any], instance_id: str, execute: bool, *, max_attempts: int = 18, interval: int = 10) -> tuple[bool, str]:
    if not execute:
        return True, "dry-run"
    last_status = ""
    for attempt in range(1, max_attempts + 1):
        inst = find_ecs_instance(region, instance_id, execute)
        if not inst:
            print(f"{region['displayName']} 等待云服务器开机 {attempt}/{max_attempts}：列表暂未找到该云服务器，继续等待")
            sleep_with_stop(interval)
            continue
        last_status = normalized_ecs_status(inst)
        if is_running_status(last_status):
            return True, last_status
        print(f"{region['displayName']} 等待云服务器开机 {attempt}/{max_attempts}：当前状态 {last_status or '未知'}")
        sleep_with_stop(interval)
    return False, last_status or "未知"


def start_instance_after_reinstall(region: dict[str, Any], instance_id: str, execute: bool, display_name: str = "") -> tuple[bool, str]:
    label = display_name.strip() or "云服务器"
    inst = find_ecs_instance(region, instance_id, execute)
    if inst and is_running_status(normalized_ecs_status(inst)):
        return True, "云服务器已是开机状态"
    last_msg = ""
    for attempt in range(1, 13):
        started, start_msg = try_start_instance(region, instance_id, execute)
        start_msg = hide_resource_ids(start_msg, {instance_id: label})
        last_msg = start_msg
        print(f"{region['displayName']} 重装后开机 {label} {attempt}/12 {bool_result(started, '成功', '失败', start_msg)}")
        if started:
            running, running_status = wait_instance_running(region, instance_id, execute)
            if running:
                return True, f"开机成功，当前状态 {running_status}"
            return False, f"已提交开机，但未确认运行：{running_status}"
        sleep_with_stop(10 if execute else 0)
    return False, last_msg or "开机接口未成功"


def sync_network_from_ecs(region: dict[str, Any], execute: bool) -> dict[str, str]:
    vpc_id = region.get("vpcID") or ""
    subnet_id = region.get("subnetID") or ""
    if vpc_id and subnet_id:
        if not region_matches_private_ip_plan(region):
            print(
                f"{region['displayName']} 本地 VPC/子网 IP 规划为 {region_private_ip_plan(region)}，"
                f"当前要求 {private_ip_plan_key()}，不复用该网络。"
            )
            region["vpcID"] = ""
            region["subnetID"] = ""
            region["securityGroupID"] = ""
            region["privateIPPlan"] = private_ip_plan_key()
            if execute:
                update_region(
                    region["displayName"],
                    {"vpcID": "", "subnetID": "", "securityGroupID": "", "privateIPPlan": private_ip_plan_key()},
                )
        elif subnet_exists(region, vpc_id, subnet_id, execute):
            return {"vpcID": vpc_id, "subnetID": subnet_id}
        else:
            print(f"{region['displayName']} 本地记录的 VPC/子网云上不存在，尝试从 ECS 或重建网络。")
            region["vpcID"] = ""
            region["subnetID"] = ""
    instances = list_ecs(region, execute)
    planned = account_planned_ecs(region, execute)
    planned_key = ecs_instance_key(planned) if planned else ""
    ordered_instances = ([planned] if planned else []) + [item for item in instances if ecs_instance_key(item) != planned_key]
    for inst in ordered_instances:
        vpc_id, subnet_id = instance_network_ids(inst, ecs_private_ip() if inst is planned else "")
        if vpc_id and subnet_id:
            updates = {"vpcID": vpc_id, "subnetID": subnet_id, "privateIPPlan": private_ip_plan_key()}
            region.update(updates)
            if execute:
                update_region(region["displayName"], updates)
            return updates
    inferred = infer_network_from_havips(region, execute)
    if inferred:
        return inferred
    if execute:
        raise SystemExit(f"{region['displayName']} 没有找到 ECS 自动创建的 VPC/子网。")
    return {"vpcID": "DRY_RUN_VPC_ID", "subnetID": "DRY_RUN_SUBNET_ID"}


def phase_havip_bind(regions: list[dict[str, Any]], execute: bool) -> None:
    vpc_client = client(ENDPOINT_VPC, execute, timeout=30)
    st = state()
    for region in regions:
        clear_eip_skip_if_ready(st, region, execute)
        skip_reason = skip_remaining_reason(st, region["displayName"])
        if skip_reason and clear_skip_if_reusable_resources(st, region, execute):
            skip_reason = ""
        if skip_reason:
            print(f"\n== {region['displayName']} 创建并绑定 HAVIP ==")
            print(f"{region['displayName']} 已跳过 HAVIP 绑定。原因: {skip_reason}")
            continue
        print(f"\n== {region['displayName']} 创建并绑定 HAVIP ==")
        try:
            net = sync_network_from_ecs(region, execute)
        except SystemExit as exc:
            print(f"{exc} 已跳过该地区。")
            continue
        vpc_id, subnet_id = net["vpcID"], net["subnetID"]
        existing = list_havips(region, subnet_id, execute)
        ecs_list = wait_for_usable_servers(region, subnet_id, execute, TARGET_ECS_COUNT)
        if execute and len(ecs_list) < TARGET_ECS_COUNT:
            discovered_server = discover_ecs_bound_eip(region, execute)
            if discovered_server:
                ecs_list = [discovered_server]
        if execute and len(ecs_list) < TARGET_ECS_COUNT:
            reason = f"ECS 未生效或不足，当前 ECS={len(ecs_list)}/{TARGET_ECS_COUNT}，暂不创建/绑定 HAVIP。"
            region_state(st, region["displayName"])["havipWarning"] = reason
            save_state(st)
            print(f"{region['displayName']} {reason}")
            continue
        if not execute and not ecs_list:
            ecs_list = [
                {
                    "name": f"{slug(region)}-ecm",
                    "instanceID": "DRY_RUN_ECS_ID",
                    "networkInterfaceID": "DRY_RUN_NETWORK_INTERFACE_ID",
                    "privateIP": ecs_private_ip(),
                }
            ]
        fixed_owner: dict[str, dict[str, Any]] = {}
        for inst in ecs_list:
            ip = str(inst.get("privateIP") or primary_private_ip(inst)).strip()
            if ip:
                fixed_owner[ip] = inst
        havip_ips = set(planned_havip_ips())
        conflicting_ips = sorted([ip for ip in fixed_owner if ip in havip_ips], key=lambda x: tuple(map(int, x.split("."))))
        if execute and (ecs_private_ip() not in fixed_owner or conflicting_ips):
            detail = server_private_ip_summary(ecs_list)
            conflict_text = f"，且占用了 HAVIP 地址 {', '.join(conflicting_ips)}" if conflicting_ips else ""
            reason = f"ECS 内网 IP 规划不匹配：当前 {detail or '未查询到'}；本流程要求 {private_ip_plan_label()}{conflict_text}。请先退订该地区后重新购买。"
            mark_skip_remaining(st, region["displayName"], reason)
            print(f"{region['displayName']} {reason}")
            continue
        bound_count, _ = havip_binding_status(region, subnet_id, execute)
        eip_bindings_complete = bound_count >= len(planned_havip_ips())
        server_bind_ips = planned_havip_ips() if target_eip_count() == 2 else []
        if eip_bindings_complete and not server_bind_ips:
            print(f"{region['displayName']} 子网虚拟 IP 已完成 {bound_count}/{len(planned_havip_ips())} 个绑定弹性 IP，跳过重复绑定。")
            continue
        if eip_bindings_complete:
            print(f"{region['displayName']} 子网虚拟 IP 已完成 {bound_count}/{len(planned_havip_ips())} 个绑定弹性 IP，继续校验 ECS 主网卡绑定。")
        skip_region = False
        for ip in planned_havip_ips():
            if ip in existing:
                continue
            if ip in fixed_owner:
                owner_name = fixed_owner[ip].get("name") or fixed_owner[ip].get("displayName") or fixed_owner[ip].get("instanceName") or "ECS"
                msg = f"{ip} 被 {owner_name} 占用；本流程要求 {private_ip_plan_label()}。请先退订该地区后重新购买。"
                mark_skip_remaining(st, region["displayName"], msg)
                print(f"{region['displayName']} {msg}")
                skip_region = True
                break
            body = {
                "clientToken": str(uuid.uuid4()),
                "regionID": region["regionID"],
                "networkID": vpc_id,
                "subnetID": subnet_id,
                "ipAddress": ip,
                "vipType": "v4",
                "description": f"{slug(region)}-vip-{ip}",
            }
            result = call_with_retry(vpc_client.post, "/v4/vpc/havip/create", body)
            print(f"创建 {ip} {operation_result(result, '成功', '失败')}")
            if execute and not ok(result):
                if "has been used" in response_reason(result).lower() or "已被" in response_reason(result):
                    ecs_list = usable_servers(region, subnet_id, execute)
                    owner = None
                    for inst in ecs_list:
                        inst_ip = str(inst.get("privateIP") or primary_private_ip(inst)).strip()
                        if inst_ip == ip:
                            owner = inst
                    if owner:
                        owner_name = owner.get("name") or owner.get("displayName") or owner.get("instanceName") or "ECS"
                        msg = f"{ip} 被 {owner_name} 占用；本流程要求 {private_ip_plan_label()}。请先退订该地区后重新购买。"
                        mark_skip_remaining(st, region["displayName"], msg)
                        print(f"{region['displayName']} {msg}")
                        skip_region = True
                        break
                if skip_region:
                    break
                print(f"{region['displayName']} 创建 {ip} 失败，继续处理后续 HAVIP。")
                continue
            sleep_with_stop(0.5)
        if skip_region:
            continue

        havips = list_havips(region, subnet_id, execute)
        target_server = next((item for item in ecs_list if item.get("privateIP") == ecs_private_ip()), ecs_list[0])
        server_bind_failed_ips: set[str] = set()
        for ip in server_bind_ips:
            havip = havips.get(ip)
            if not havip:
                continue
            if havip_bound_to_server(havip, target_server):
                print(f"{region['displayName']} 虚拟 IP {ip} 已绑定 ECS 主网卡 {target_server.get('privateIP') or ecs_private_ip()}，跳过重复绑定。")
                continue
            result = bind_havip_to_server(region, havip, target_server, execute)
            print(
                f"{region['displayName']} 虚拟 IP {ip} 绑定 ECS 主网卡 "
                f"{target_server.get('privateIP') or ecs_private_ip()} {operation_result(result, '成功', '失败')}"
            )
            if execute and not ok(result):
                refreshed_havip = list_havips(region, subnet_id, execute).get(ip)
                if refreshed_havip and havip_bound_to_server(refreshed_havip, target_server):
                    print(f"{region['displayName']} 虚拟 IP {ip} 已从云端确认绑定 ECS 主网卡成功。")
                else:
                    server_bind_failed_ips.add(ip)
                    failure = f"{ip} 绑定 ECS 主网卡失败：{response_reason(result)}"
                    region_state(st, region["displayName"]).setdefault("havipBindFailures", []).append(failure)
                    region_state(st, region["displayName"])["havipWarning"] = failure
                    save_state(st)

        if eip_bindings_complete:
            if not server_bind_failed_ips:
                region_state(st, region["displayName"]).pop("havipWarning", None)
                save_state(st)
            continue

        assoc_eips = eips_by_association(region, execute)
        unbound_ips = []
        for ip in planned_havip_ips():
            h = havips.get(ip)
            if not h:
                print(f"{region['displayName']} 缺少 HAVIP {ip}，跳过该 IP。")
                continue
            if ip in server_bind_failed_ips:
                print(f"{region['displayName']} HAVIP {ip} 尚未绑定 ECS 主网卡，暂不绑定 EIP。")
                continue
            if h.get("networkInfo") or h["id"] in assoc_eips:
                continue
            unbound_ips.append(ip)
        eips = usable_eips(region, execute)
        if execute and len(eips) < len(unbound_ips):
            reason = f"可用未绑定 EIP 不足：当前 EIP={len(eips)}/{len(unbound_ips)}，已先绑定当前可用数量。"
            region_state(st, region["displayName"])["havipWarning"] = reason
            save_state(st)
            print(f"{region['displayName']} {reason}")
        eip_index = 0
        for ip in unbound_ips:
            h = havips.get(ip)
            bound = False
            while eip_index < len(eips):
                eip = eips[eip_index]
                eip_index += 1
                bind_eip_body = {
                    "clientToken": str(uuid.uuid4()),
                    "regionID": region["regionID"],
                    "resourceType": "NETWORK",
                    "haVipID": h["id"],
                    "floatingID": eip["eipID"],
                }
                result = call_with_retry(vpc_client.post, "/v4/vpc/havip/bind", bind_eip_body)
                print(f"{ip} 直接绑定 EIP {eip['address']} {operation_result(result, '成功', '失败')}")
                if ok(result) or not execute:
                    bound = True
                    sleep_with_stop(2)
                    break
                failure = f"{ip} 绑定 EIP {eip['address']} 失败：{response_reason(result)}"
                region_state(st, region["displayName"]).setdefault("havipBindFailures", []).append(failure)
                save_state(st)
                print(f"{region['displayName']} {failure}，换下一条 EIP 继续补 {ip}。")
                sleep_with_stop(2)
            if not bound:
                reason = f"{ip} 没有可用 EIP 可继续尝试，暂未绑定。"
                region_state(st, region["displayName"])["havipWarning"] = reason
                save_state(st)
                print(f"{region['displayName']} {reason}")


def usable_servers(region: dict[str, Any], subnet_id: str, execute: bool) -> list[dict[str, Any]]:
    def key(name: str) -> int:
        if name == f"{slug(region)}-ecm":
            return 0
        m = re.search(r"-(\d+)$", name or "")
        return int(m.group(1)) if m else 999

    servers = []
    for inst in list_ecs(region, execute):
        status = normalized_ecs_status(inst)
        if is_cycle_unsubscribed_status(status) or is_release_only_ecs_item(inst):
            continue
        if status and not is_running_status(status):
            continue
        cards = [c for c in network_cards(inst) if card_subnet_id(c) == subnet_id]
        if not cards and instance_network_ids(inst)[1] == subnet_id:
            cards = [{}]
        if not cards:
            continue
        card = next((item for item in cards if card_private_ip(item) == ecs_private_ip()), None)
        if card is None:
            primary_card = primary_network_card(inst)
            card = primary_card if primary_card in cards else cards[0]
        servers.append(
            {
                "name": inst.get("displayName") or inst.get("instanceName") or "云服务器",
                "instanceID": inst.get("instanceID") or inst.get("id"),
                "networkInterfaceID": pick(card, "networkCardID", "networkCardId", "networkInterfaceID", "networkInterfaceId", "portID", "portId", "id", "ID"),
                "privateIP": card_private_ip(card) or primary_private_ip(inst),
                "securityGroupID": server_security_group_id(inst),
                "secGroupList": inst.get("secGroupList") or [],
                "networkCardList": network_cards(inst),
            }
        )
    return sorted(servers, key=lambda s: key(s["name"]))


def wait_for_usable_servers(region: dict[str, Any], subnet_id: str, execute: bool, target: int = len(HAVIP_RANGE)) -> list[dict[str, Any]]:
    if not execute:
        return usable_servers(region, subnet_id, execute)
    for attempt in range(1, 21):
        servers = usable_servers(region, subnet_id, execute)
        if len(servers) >= target:
            return servers
        if target <= TARGET_ECS_COUNT:
            discovered_server = discover_ecs_bound_eip(region, execute)
            if discovered_server:
                return [discovered_server]
        print(f"{region['displayName']} 等待 ECS 生效 {attempt}/20：当前可用 ECS={len(servers)}/{target}")
        sleep_with_stop(15)
    return usable_servers(region, subnet_id, execute)


def usable_eips(region: dict[str, Any], execute: bool) -> list[dict[str, Any]]:
    eips = []
    new_ip_only = is_new_ip_purchase_enabled()
    used_ips = load_used_ip_table_from_env() if new_ip_only else set()
    for eip in list_eips(region, execute):
        if (
            eip_association_id(eip)
            or eip.get("associationType")
            or (new_ip_only and new_ip_purchase_eip_is_bound(eip))
        ):
            continue
        address = eip_public_address(eip)
        if used_ips and valid_ipv4_text(address) in used_ips:
            continue
        eips.append({"name": eip.get("name"), "eipID": eip_resource_id(eip), "address": address})
    return sorted(eips, key=lambda e: e["name"] or "")


def eips_by_association(region: dict[str, Any], execute: bool) -> dict[str, dict[str, Any]]:
    return eips_by_association_from_items(list_eips(region, execute))


def eips_by_association_from_items(eips: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
    """按绑定资源 ID 建立 EIP 索引，避免资源查询阶段重复请求同一个地区的 EIP 列表。"""
    out = {}
    for eip in eips:
        assoc = eip_association_id(eip)
        if assoc:
            out[assoc] = eip
    return out


def eip_association_id(eip: dict[str, Any]) -> str:
    return pick(eip or {}, "associationID", "associationId", "resourceID", "resourceId", "portID", "masterResourceID")


def eip_public_address(eip: dict[str, Any]) -> str:
    return pick(eip or {}, "eipAddress", "address", "publicIP", "publicIp", "ip")


def eip_resource_id(eip: dict[str, Any]) -> str:
    return pick(eip or {}, "ID", "id", "eipID", "eipId", "floatingID", "floatingId")


ECS_EIP_ASSOC_TYPES = {
    "1",
    "ecs",
    "vm",
    "server",
    "instance",
    "cloudserver",
    "\u4e91\u4e3b\u673a",
    "\u4e91\u670d\u52a1\u5668",
}
HAVIP_EIP_ASSOC_TYPES = {"vip", "havip", "virtualip", "virtual_ip", "virtual-ip"}


def eip_association_type(eip: dict[str, Any]) -> str:
    return str(eip.get("associationType") or eip.get("associationTypeName") or eip.get("resourceType") or "").strip().lower()


def havip_related_ids(havips: dict[str, dict[str, Any]]) -> tuple[set[str], set[str]]:
    havip_ids: set[str] = set()
    eip_ids: set[str] = set()
    for havip in havips.values():
        havip_id = pick(havip, "id", "ID", "havipID", "havipId")
        if havip_id:
            havip_ids.add(havip_id)
        network_info = havip.get("networkInfo") or []
        if isinstance(network_info, dict):
            network_info = [network_info]
        for info in network_info:
            if not isinstance(info, dict):
                continue
            eip_id = pick(info, "eipID", "eipId", "ID", "id", "floatingID", "floatingId")
            if eip_id:
                eip_ids.add(eip_id)
    return havip_ids, eip_ids


def is_havip_bound_eip(eip: dict[str, Any], havip_ids: set[str], havip_eip_ids: set[str]) -> bool:
    assoc_id = eip_association_id(eip)
    assoc_type = eip_association_type(eip)
    eip_id = eip_resource_id(eip)
    return bool(
        (assoc_id and assoc_id in havip_ids)
        or (eip_id and eip_id in havip_eip_ids)
        or (assoc_type and assoc_type in HAVIP_EIP_ASSOC_TYPES)
    )


def server_identifier_values(server: dict[str, Any]) -> set[str]:
    values: set[str] = set()
    for key in (
        "instanceID",
        "instanceId",
        "serverID",
        "serverId",
        "deviceUUID",
        "networkInterfaceID",
        "networkInterfaceId",
        "networkCardID",
        "networkCardId",
        "portID",
        "portId",
        "id",
        "ID",
        "uuid",
    ):
        value = server.get(key)
        if value:
            values.add(str(value))
    for card in network_cards(server):
        for key in ("networkCardID", "networkCardId", "networkInterfaceID", "networkInterfaceId", "portID", "portId", "id", "ID"):
            value = card.get(key)
            if value:
                values.add(str(value))
    return values


def server_name_matches_eip(server: dict[str, Any], eip: dict[str, Any]) -> bool:
    server_name = str(server.get("name") or server.get("displayName") or server.get("instanceName") or "").strip()
    eip_name = pick(eip, "associationName", "resourceName", "instanceName", "serverName")
    if not server_name or not eip_name:
        return False
    return server_name == eip_name or server_name in eip_name or eip_name in server_name


def ecs_bound_record_from_eip(region: dict[str, Any], server: dict[str, Any], eip: dict[str, Any]) -> dict[str, Any]:
    instance_id = pick(server, "instanceID", "instanceId", "serverID", "serverId", "deviceUUID", "id", "ID") or eip_association_id(eip)
    private_ip = server.get("privateIP") or primary_private_ip(server) or ecs_private_ip()
    server_name = server.get("name") or server.get("displayName") or server.get("instanceName") or pick(eip, "associationName", "resourceName") or f"{slug(region)}-ecm"
    return {
        "region": region["displayName"],
        "server": server_name,
        "instanceID": instance_id,
        "privateIP": private_ip,
        "eip": eip_public_address(eip),
    }


def find_ecs_bound_eip_from_items(
    region: dict[str, Any],
    eips: list[dict[str, Any]],
    havips: dict[str, dict[str, Any]],
    servers: list[dict[str, Any]] | None = None,
    execute: bool = False,
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
    havip_ids, havip_eip_ids = havip_related_ids(havips)
    server_list = [item for item in (servers or []) if isinstance(item, dict)]
    server_by_id: dict[str, dict[str, Any]] = {}
    for server in server_list:
        for value in server_identifier_values(server):
            server_by_id.setdefault(value, server)
    candidates: list[tuple[dict[str, Any], str, str]] = []
    for eip in eips:
        public_ip = eip_public_address(eip)
        if not public_ip or is_havip_bound_eip(eip, havip_ids, havip_eip_ids):
            continue
        assoc_id = eip_association_id(eip)
        assoc_type = eip_association_type(eip)
        if assoc_type and assoc_type not in ECS_EIP_ASSOC_TYPES:
            continue
        if not assoc_id and not assoc_type:
            continue
        candidates.append((eip, assoc_id, assoc_type))
    for eip, assoc_id, assoc_type in candidates:
        server = server_by_id.get(assoc_id)
        if not server and server_list:
            server = next((item for item in server_list if server_name_matches_eip(item, eip)), None)
            if not server and len(server_list) == 1 and len(candidates) == 1 and assoc_type in ECS_EIP_ASSOC_TYPES:
                server = server_list[0]
        if not server and not server_list and execute and assoc_id:
            server = query_ecs_detail_by_id(region, assoc_id, execute)
        if server_list and not server:
            continue
        if not server:
            server = {
                "name": pick(eip, "associationName", "resourceName") or f"{slug(region)}-ecm",
                "instanceID": assoc_id,
                "privateIP": ecs_private_ip(),
                "vpcID": region.get("vpcID", ""),
                "securityGroupID": region.get("securityGroupID", ""),
                "secGroupList": [],
                "networkCardList": [],
                "_source": "eip_association_reuse",
            }
        if is_release_only_ecs_item(server):
            continue
        bound = {"eipID": eip_resource_id(eip), "address": public_ip, "name": eip.get("name")}
        server["_boundEip"] = bound
        return server, ecs_bound_record_from_eip(region, server, eip)
    return None, None


def discover_ecs_bound_eip(region: dict[str, Any], execute: bool) -> dict[str, Any] | None:
    if not execute:
        return None
    server, record = find_ecs_bound_eip_from_items(region, list_eips(region, execute), list_all_havips(region, execute), execute=execute)
    if server and record:
        print(f"{region['displayName']} 已从云端实时关联关系识别云服务器绑定 IP: {record['eip']}")
        return server
    return None


def reuse_existing_ecs_if_present(region: dict[str, Any], execute: bool, inst: dict[str, Any] | None = None) -> bool:
    if not execute:
        return False
    inst = inst or account_planned_ecs(region, execute)
    if not inst:
        return False
    if adopt_planned_ecs_network(region, inst, execute):
        return True
    inferred = infer_network_from_havips(region, execute)
    if inferred:
        name = inst.get("displayName") or inst.get("instanceName") or "旧 ECS"
        print(f"{region['displayName']} 已发现旧 ECS {name}，并复用旧 HAVIP 网络，跳过重复购买。")
        return True
    print(f"{region['displayName']} 已发现符合规划的旧云服务器，{existing_ecs_description(inst)}；暂未读取到 VPC/子网，仍先复用旧 ECS，避免重复购买。")
    return True


def old_purchase_resources_satisfied(region: dict[str, Any], execute: bool) -> tuple[bool, str]:
    if not execute:
        return False, ""
    eip_count = actual_eip_count(region, execute)
    havip_count = bound_havip_eip_count(region, execute)
    has_ecs = reuse_existing_ecs_if_present(region, execute)
    target_havips = len(planned_havip_ips())
    target_count = target_eip_count()
    detail = f"EIP {eip_count}/{target_count}、HAVIP {havip_count}/{target_havips}、ECS {'已存在' if has_ecs else '未确认'}"
    return eip_count >= target_count and havip_count >= target_havips and has_ecs, detail


def clear_skip_if_reusable_resources(data: dict[str, Any], region: dict[str, Any], execute: bool) -> bool:
    name = region["displayName"]
    skip_reason = skip_remaining_reason(data, name)
    if not skip_reason:
        if reuse_existing_ecs_if_present(region, execute):
            print(f"{name} 旧 ECS 已满足 ECS 购买需求，复用旧 ECS 继续后续任务。")
            return True
        return False
    # 旧资源已经满足当前购买拓扑时，清掉上次下单失败留下的跳过原因，继续后续绑定/导出/搭建。
    satisfied, detail = old_purchase_resources_satisfied(region, execute)
    if satisfied:
        clear_skip_remaining(data, name)
        print(f"{name} 旧资源已满足本次购买需求（{detail}），复用旧资源继续后续任务。")
        return True
    if skip_reason.startswith("EIP") and actual_eip_count(region, execute) < target_eip_count():
        return False
    if reuse_existing_ecs_if_present(region, execute):
        clear_skip_remaining(data, name)
        print(f"{name} 旧 ECS 已满足 ECS 购买需求，复用旧 ECS 继续后续任务。")
        return True
    return False


def server_security_group_id(server: dict[str, Any]) -> str:
    for item in server.get("secGroupList") or []:
        if isinstance(item, str) and item:
            return item
        sg_id = pick(item, "securityGroupID", "securityGroupId", "id")
        if sg_id:
            return sg_id
    for card in network_cards(server):
        groups = card.get("securityGroup") or card.get("securityGroups") or []
        for group in groups:
            if isinstance(group, str) and group:
                return group
            if isinstance(group, dict):
                sg_id = pick(group, "securityGroupID", "securityGroupId", "id")
                if sg_id:
                    return sg_id
    return pick(server, "securityGroupID", "securityGroupId", "secGroupID", "secGroupId")


def disassociate_eip(region: dict[str, Any], eip_id: str, execute: bool, *, association_id: str = "", association_type: str = "") -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    base = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "eipID": eip_id}
    if association_id:
        base["associationID"] = association_id
        base["resourceID"] = association_id
    if association_type:
        base["associationType"] = association_type
    attempts = [
        ("/v4/eip/disassociate", base),
        ("/v4/eip/unassociate", base),
        ("/v4/eip/unbind", base),
        ("/v4/eip/disassociate", {**base, "ID": eip_id}),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def call_first_ok(
    c: CtyunClient,
    attempts: list[tuple[str, dict[str, Any]]],
    *,
    retries: int = 2,
    delay: int = 2,
    method: str = "post",
) -> dict[str, Any]:
    last: dict[str, Any] = {}
    reasons: list[str] = []
    caller = c.delete if method.lower() == "delete" else c.post
    for path, body in attempts:
        result = call_or_error(caller, path, body, retries=retries, delay=delay)
        if ok(result):
            result.setdefault("_path", path)
            return result
        reasons.append(f"{path}: {response_reason(result)}")
        last = result
    return {
        "statusCode": 900,
        "message": "all attempts failed",
        "description": "; ".join(reasons) or response_reason(last),
        "errorCode": "Openapi.Attempts.Failed",
    }


def havip_bound_eip_ids(havip: dict[str, Any], assoc_eips: dict[str, dict[str, Any]]) -> list[str]:
    ids: list[str] = []
    for item in havip.get("networkInfo") or []:
        eip_id = item.get("eipID")
        if eip_id and eip_id not in ids:
            ids.append(eip_id)
    assoc = assoc_eips.get(havip.get("id", ""))
    if assoc:
        eip_id = assoc.get("ID") or assoc.get("eipID")
        if eip_id and eip_id not in ids:
            ids.append(eip_id)
    return ids


def try_region_network(region: dict[str, Any], execute: bool) -> dict[str, str] | None:
    try:
        return sync_network_from_ecs(region, execute)
    except SystemExit as exc:
        print(f"{region['displayName']} 未找到可用 VPC/子网：{exc}")
        return None


def unbind_havip_eip(region: dict[str, Any], havip_id: str, eip_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    base = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "resourceType": "NETWORK", "haVipID": havip_id}
    attempts = [
        ("/v4/vpc/havip/unbind", {**base, "floatingID": eip_id}),
        ("/v4/vpc/havip/unbind", {**base, "eipID": eip_id}),
        ("/v4/vpc/havip/unassociate", {**base, "floatingID": eip_id}),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def delete_eip_resource(region: dict[str, Any], eip_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body_a = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "eipID": eip_id}
    body_b = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "ID": eip_id}
    attempts = [
        ("/v4/eip/delete", body_a),
        ("/v4/eip/delete", body_b),
        ("/v4/eip/release", body_a),
        ("/v4/eip/unsubscribe", body_a),
        ("/v4/eip/destroy", body_a),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def delete_shared_bandwidth_resource(region: dict[str, Any], bandwidth_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "projectID": "0",
        "bandwidthID": bandwidth_id,
    }
    attempts = [
        ("/v4/bandwidth/delete", body),
        ("/v4/bandwidth/delete", {**body, "id": bandwidth_id}),
        ("/v4/bandwidth/delete", {**body, "ID": bandwidth_id}),
    ]
    deleted = call_first_ok(c, attempts, retries=2, delay=2, method="delete")
    if ok(deleted):
        return deleted
    fallback = call_first_ok(c, attempts, retries=1, delay=1)
    if ok(fallback):
        return fallback
    return {
        "statusCode": 900,
        "message": "all attempts failed",
        "description": f"DELETE: {response_reason(deleted)}; POST: {response_reason(fallback)}",
        "errorCode": "Openapi.Attempts.Failed",
    }


def delete_subnet_resource(region: dict[str, Any], subnet_id: str, vpc_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "vpcID": vpc_id, "subnetID": subnet_id}
    attempts = [
        ("/v4/vpc/delete-subnet", body),
        ("/v4/vpc/subnet/delete", body),
        ("/v4/vpc/delete-subnet", {**body, "ID": subnet_id}),
        ("/v4/vpc/delete-subnet", {**body, "id": subnet_id}),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def subnet_dependency_reason(result: dict[str, Any]) -> bool:
    reason = response_reason(result)
    lower = reason.lower()
    return (
        "虚拟ip" in reason
        or "虚拟 ip" in reason
        or "havip" in lower
        or "ha-vip" in lower
        or "subnet" in lower and ("used" in lower or "in use" in lower or "occupied" in lower)
        or "占用" in reason
        or "使用" in reason
        or "关联" in reason
    )


def ecs_matches_network(inst: dict[str, Any], vpc_id: str, subnet_id: str = "") -> bool:
    inst_vpc_id, inst_subnet_id = instance_network_ids(inst)
    if subnet_id and inst_subnet_id == subnet_id:
        return True
    if vpc_id and inst_vpc_id == vpc_id:
        return True
    for card in network_cards(inst):
        if subnet_id and card_subnet_id(card) == subnet_id:
            return True
        if vpc_id and card_vpc_id(card) == vpc_id:
            return True
    return False


def release_ecs_in_subnet(region: dict[str, Any], vpc_id: str, subnet_id: str, execute: bool) -> bool:
    ok_all = True
    ecs_items = [inst for inst in list_ecs(region, execute) if ecs_matches_network(inst, vpc_id, subnet_id)]
    if not ecs_items:
        return True
    print(f"{region['displayName']} 子网内发现云服务器 {len(ecs_items)} 台，先释放云服务器后再删除子网。")
    for inst in ecs_items:
        instance_id = str(inst.get("instanceID") or inst.get("id") or "")
        inst_name = str(inst.get("displayName") or inst.get("instanceName") or inst.get("name") or "云服务器")
        if not instance_id:
            print(f"{region['displayName']} {inst_name} 缺少云服务器 ID，跳过。")
            ok_all = False
            continue
        deleted_info = cleanup_delete_ecs_instance(region, {**inst, "instanceID": instance_id, "name": inst_name}, execute)
        if not deleted_info:
            ok_all = False
            continue
        action, deleted = deleted_info
        print(f"{region['displayName']} {action}云服务器 {inst_name} {operation_result(deleted, '成功', '失败')}")
        if not ok(deleted):
            ok_all = False
    return ok_all


def delete_havip_resource(region: dict[str, Any], havip_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "haVipID": havip_id, "havipID": havip_id}
    attempts = [
        ("/v4/vpc/havip/delete", body),
        ("/v4/vpc/havip/delete", {**body, "ID": havip_id}),
        ("/v4/vpc/havip/delete", {**body, "id": havip_id}),
        ("/v4/vpc/havip/release", body),
        ("/v4/vpc/delete-havip", body),
        ("/v4/vpc/delete-havip", {**body, "ID": havip_id}),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def delete_havips_in_subnet(region: dict[str, Any], subnet_id: str, execute: bool) -> bool:
    try:
        havips = list_havips(region, subnet_id, execute)
    except Exception as exc:
        print(f"{region['displayName']} 查询子网虚拟IP失败：{exc}")
        return False
    if not havips:
        return True
    print(f"{region['displayName']} 子网内发现虚拟IP {len(havips)} 个，先删除全部虚拟IP后再删除子网。")
    assoc_eips = eips_by_association(region, execute)
    ok_all = True
    for ip, havip in sorted(havips.items(), key=lambda item: tuple(map(int, item[0].split("."))) if re.match(r"^\d+\.\d+\.\d+\.\d+$", item[0]) else (999,)):
        havip_id = pick(havip, "id", "ID", "havipID", "haVipID")
        if not havip_id:
            print(f"{region['displayName']} 虚拟IP {ip} 缺少 ID，跳过。")
            ok_all = False
            continue
        for eip_id in havip_bound_eip_ids(havip, assoc_eips):
            unbind = unbind_havip_eip(region, havip_id, eip_id, execute)
            print(f"{region['displayName']} 虚拟IP {ip} 解绑 EIP {operation_result(unbind, '成功', '失败')}")
            if not ok(unbind):
                ok_all = False
        deleted = delete_havip_resource(region, havip_id, execute)
        print(f"{region['displayName']} 删除虚拟IP {ip} {operation_result(deleted, '成功', '失败')}")
        if not ok(deleted):
            ok_all = False
    return ok_all


def delete_havip_item(region: dict[str, Any], item: dict[str, Any], execute: bool) -> bool:
    havip_id = str(item.get("havipID") or item.get("haVipID") or item.get("id") or item.get("ID") or "")
    ip = str(item.get("vip") or item.get("name") or "虚拟IP")
    if not havip_id:
        print(f"{region['displayName']} 虚拟IP {ip} 缺少 ID，跳过。")
        return False
    assoc_eips = eips_by_association(region, execute)
    fresh = None
    for _vip, havip in list_all_havips(region, execute).items():
        if havip_id_of(havip) == havip_id:
            fresh = havip
            ip = _vip or ip
            break
    havip = fresh or item
    ok_all = True
    for eip_id in havip_bound_eip_ids(havip, assoc_eips):
        unbind = unbind_havip_eip(region, havip_id, eip_id, execute)
        print(f"{region['displayName']} 虚拟IP {ip} 解绑 EIP {operation_result(unbind, '成功', '失败')}")
        if not ok(unbind):
            ok_all = False
    deleted = delete_havip_resource(region, havip_id, execute)
    print(f"{region['displayName']} 删除虚拟IP {ip} {operation_result(deleted, '成功', '失败')}")
    return ok_all and ok(deleted)


def delete_subnet_with_dependencies(region: dict[str, Any], subnet_id: str, subnet_name: str, vpc_id: str, execute: bool) -> bool:
    release_ecs_in_subnet(region, vpc_id, subnet_id, execute)
    delete_havips_in_subnet(region, subnet_id, execute)
    deleted_subnet = delete_subnet_resource(region, subnet_id, vpc_id, execute)
    print(f"{region['displayName']} 删除子网 {subnet_name} {operation_result(deleted_subnet, '成功', '失败')}")
    if ok(deleted_subnet):
        return True
    if subnet_dependency_reason(deleted_subnet):
        print(f"{region['displayName']} 子网 {subnet_name} 仍有依赖资源，重新清理云服务器和虚拟IP后重试。")
        release_ecs_in_subnet(region, vpc_id, subnet_id, execute)
        delete_havips_in_subnet(region, subnet_id, execute)
        retry = delete_subnet_resource(region, subnet_id, vpc_id, execute)
        print(f"{region['displayName']} 重试删除子网 {subnet_name} {operation_result(retry, '成功', '失败')}")
        return ok(retry)
    return False


def delete_vpc_resource(region: dict[str, Any], vpc_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "projectID": "0", "vpcID": vpc_id}
    attempts = [
        ("/v4/vpc/delete", body),
        ("/v4/vpc/delete-vpc", body),
        ("/v4/vpc/vpc-delete", body),
        ("/v4/vpc/delete", {**body, "ID": vpc_id}),
        ("/v4/vpc/delete", {**body, "id": vpc_id}),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def vpc_subnet_dependency_reason(result: dict[str, Any]) -> bool:
    reason = response_reason(result)
    lower = reason.lower()
    return "有子网" in reason or "子网" in reason or "subnet" in lower


def delete_vpc_item(region: dict[str, Any], item: dict[str, Any], execute: bool) -> bool:
    vpc_id = str(item.get("vpcID") or item.get("ID") or item.get("id") or "")
    name = str(item.get("name") or "VPC")
    if not vpc_id:
        print(f"{region['displayName']} {name} 缺少 VPC ID，跳过。")
        return False
    subnets = item.get("subnets")
    if not isinstance(subnets, list):
        subnets = []
    if not subnets:
        subnets = subnet_cleanup_records(list_existing_subnets(region, vpc_id, execute))
    last_vpc_result: dict[str, Any] = {}
    max_attempts = 4
    retry_delay_seconds = 8
    for attempt in range(1, max_attempts + 1):
        current_subnets = subnets if attempt == 1 else subnet_cleanup_records(list_existing_subnets(region, vpc_id, execute))
        all_subnets_deleted = True
        for subnet in current_subnets:
            if not isinstance(subnet, dict):
                continue
            subnet_id = str(subnet.get("subnetID") or subnet.get("id") or "")
            subnet_name = str(subnet.get("name") or subnet_id or "子网")
            if not subnet_id:
                continue
            if not delete_subnet_with_dependencies(region, subnet_id, subnet_name, vpc_id, execute):
                all_subnets_deleted = False
        if not all_subnets_deleted:
            if attempt < max_attempts:
                print(f"{region['displayName']} VPC {name} 子网依赖未清理完成，等待后重新查询并重试。")
                sleep_with_stop(retry_delay_seconds if execute else 0)
                continue
            print(f"{region['displayName']} VPC {name} 仍有子网依赖未清理完成，跳过 VPC 删除。")
            return False
        if attempt > 1 and not current_subnets:
            print(f"{region['displayName']} VPC {name} 未查询到残留子网，重试删除 VPC。")
        deleted_vpc = delete_vpc_resource(region, vpc_id, execute)
        last_vpc_result = deleted_vpc
        print(f"{region['displayName']} 删除 VPC {name} {operation_result(deleted_vpc, '成功', '失败')}")
        if ok(deleted_vpc):
            if execute and region.get("vpcID") == vpc_id:
                region.update({"vpcID": "", "subnetID": "", "securityGroupID": ""})
                update_region(region["displayName"], {"vpcID": "", "subnetID": "", "securityGroupID": ""})
            return True
        if vpc_subnet_dependency_reason(deleted_vpc) and attempt < max_attempts:
            print(f"{region['displayName']} VPC {name} 云端仍提示存在子网，等待后重新查询并重试。")
            sleep_with_stop(retry_delay_seconds if execute else 0)
            continue
        break
    if last_vpc_result:
        print(f"{region['displayName']} VPC {name} 删除未完成：{response_reason(last_vpc_result)}")
    return False


def delete_security_group_resource(region: dict[str, Any], security_group_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "projectID": "0",
        "securityGroupID": security_group_id,
    }
    return call_or_error(c.post, "/v4/vpc/delete-security-group", body, retries=2, delay=2)


def delete_security_group_item(region: dict[str, Any], item: dict[str, Any], execute: bool) -> bool:
    sg_name = security_group_name(item) or str(item.get("name") or "安全组")
    sg_id = security_group_id(item)
    vpc_id = str(item.get("vpcID") or region.get("vpcID") or "")
    if not sg_id and sg_name:
        for group in list_security_groups(region, execute):
            if security_group_name(group) == sg_name and security_group_matches_vpc(group, vpc_id):
                item = {**item, **group}
                sg_id = security_group_id(item)
                sg_name = security_group_name(item) or sg_name
                break
    if is_default_security_group(item):
        print(f"{region['displayName']} {sg_name or 'default'} 安全组：默认安全组不允许修改删除，已跳过。")
        return True
    if not sg_id:
        print(f"{region['displayName']} {sg_name or '安全组'} 缺少安全组 ID，跳过。")
        return False
    deleted = delete_security_group_resource(region, sg_id, execute)
    print(f"{region['displayName']} 删除安全组 {sg_name or sg_id} {operation_result(deleted, '成功', '失败')}")
    if ok(deleted) and execute and region.get("securityGroupID") == sg_id:
        region["securityGroupID"] = ""
        update_region(region["displayName"], {"securityGroupID": ""})
    return ok(deleted)


def find_eip_resource(region: dict[str, Any], eip_id: str, execute: bool) -> dict[str, Any] | None:
    for eip in list_eips(region, execute):
        if (eip.get("ID") or eip.get("eipID")) == eip_id:
            return eip
    return None


def eip_has_association(eip: dict[str, Any] | None) -> bool:
    if not eip:
        return False
    return bool(eip.get("associationID") or eip.get("associationType") or eip.get("portID"))


def delete_ecs_instance(region: dict[str, Any], instance_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    body_single = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceID": instance_id}
    body_list = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDList": [instance_id]}
    body_ids = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDs": [instance_id]}
    attempts = [
        ("退订", "/v4/ecs/unsubscribe-instance", body_single),
        ("立即释放", "/v4/ecs/release-instance", body_single),
        ("立即释放", "/v4/ecs/destroy-instance", body_single),
        ("立即释放", "/v4/ecs/delete-instance", body_single),
        ("立即释放", "/v4/ecs/delete-instances", body_list),
        ("立即释放", "/v4/ecs/delete-instances", body_ids),
        ("立即释放", "/v4/ecs/release-instance", body_single),
    ]
    successes: list[str] = []
    failures: list[str] = []
    for action, path, body in attempts:
        result = call_or_error(c.post, path, body, retries=2, delay=2)
        reason = response_reason(result)
        print(f"{region['displayName']} 云服务器{action}接口 {path} {operation_result(result, '提交成功', '提交失败')}")
        if ok(result):
            successes.append(f"{path}: {reason}")
            if action == "退订":
                print(f"{region['displayName']} 云服务器退订提交成功，继续执行立即释放。")
                sleep_with_stop(8 if execute else 0)
            else:
                sleep_with_stop(3 if execute else 0)
            if execute and not find_ecs_instance(region, instance_id, execute):
                return {
                    "statusCode": 800,
                    "message": "success",
                    "description": "云服务器已退订并立即释放",
                    "details": "; ".join(successes),
                }
        else:
            failures.append(f"{path}: {reason}")
    if successes:
        inst = find_ecs_instance(region, instance_id, execute) if execute else None
        current_status = normalized_ecs_status(inst) if inst else "列表未找到"
        return {
            "statusCode": 800,
            "message": "submitted",
            "description": f"已提交退订/立即释放请求，当前状态：{current_status}",
            "details": "; ".join(successes),
        }
    return {
        "statusCode": 900,
        "message": "all attempts failed",
        "description": "; ".join(failures),
        "errorCode": "Openapi.Attempts.Failed",
    }


def delete_on_demand_ecs_instance(region: dict[str, Any], instance_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    body_single = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceID": instance_id}
    body_list = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDList": [instance_id]}
    body_ids = {"clientToken": str(uuid.uuid4()), "regionID": region["regionID"], "instanceIDs": [instance_id]}
    attempts = [
        ("/v4/ecs/delete-instance", body_single),
        ("/v4/ecs/delete-instances", body_list),
        ("/v4/ecs/delete-instances", body_ids),
        ("/v4/ecs/destroy-instance", body_single),
    ]
    successes: list[str] = []
    failures: list[str] = []
    for path, body in attempts:
        result = call_or_error(c.post, path, body, retries=2, delay=2)
        reason = response_reason(result)
        print(f"{region['displayName']} 按量云服务器删除接口 {path} {operation_result(result, '提交成功', '提交失败')}")
        if ok(result):
            successes.append(f"{path}: {reason}")
            sleep_with_stop(3 if execute else 0)
            if execute and not find_ecs_instance(region, instance_id, execute):
                return {
                    "statusCode": 800,
                    "message": "success",
                    "description": "按量云服务器已删除",
                    "details": "; ".join(successes),
                }
        else:
            failures.append(f"{path}: {reason}")
    if successes:
        inst = find_ecs_instance(region, instance_id, execute) if execute else None
        current_status = normalized_ecs_status(inst) if inst else "列表未找到"
        return {
            "statusCode": 800,
            "message": "submitted",
            "description": f"已提交按量云服务器删除请求，当前状态：{current_status}",
            "details": "; ".join(successes),
        }
    return {
        "statusCode": 900,
        "message": "all delete attempts failed",
        "description": "; ".join(failures),
        "errorCode": "Openapi.DeleteAttempts.Failed",
    }


def cleanup_delete_ecs_instance(region: dict[str, Any], item: dict[str, Any], execute: bool) -> tuple[str, dict[str, Any]] | None:
    instance_id = str(item.get("instanceID") or item.get("id") or "")
    inst_name = str(item.get("name") or item.get("displayName") or item.get("instanceName") or "云服务器")
    if not instance_id:
        print(f"{region['displayName']} {inst_name or '云服务器'} 缺少云服务器信息，跳过。")
        return None
    fresh = find_ecs_instance(region, instance_id, execute) if execute else None
    merged = {**item, **(fresh or {})}
    status = normalized_ecs_status(merged)
    if is_release_only_ecs_item(merged) or "包周期已退订" in payload_text(merged):
        print(f"{region['displayName']} {inst_name} 状态为包周期已退订，直接释放云服务器。")
        return "释放", delete_ecs_instance(region, instance_id, execute)
    if is_on_demand_ecs_item(merged):
        print(f"{region['displayName']} {inst_name} 识别为按量云服务器，强制关机后直接删除。")
        stopped, stop_reason = stop_instance_and_wait(region, instance_id, execute, inst_name, force=True)
        if not stopped:
            print(f"{region['displayName']} {inst_name} 未强制关机成功，跳过删除：{stop_reason}")
            return None
        return "删除", delete_on_demand_ecs_instance(region, instance_id, execute)
    if is_cycle_unsubscribed_status(status):
        print(f"{region['displayName']} {inst_name} 状态为包周期已退订，直接释放云服务器。")
        return "释放", delete_ecs_instance(region, instance_id, execute)
    stopped, stop_reason = stop_instance_and_wait(region, instance_id, execute, inst_name)
    if not stopped:
        print(f"{region['displayName']} {inst_name} 未关机成功，跳过退订：{stop_reason}")
        return None
    return "退订/释放", delete_ecs_instance(region, instance_id, execute)


def reinstall_ecs_instance(region: dict[str, Any], inst: dict[str, Any], execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    instance_id = inst.get("instanceID") or inst.get("id") or ""
    image_id = inst.get("imageID") or inst.get("imageId") or region.get("imageID") or ""
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "instanceID": instance_id,
        "imageID": image_id,
        "password": PASSWORD,
    }
    attempts = [
        ("/v4/ecs/reinstall-instance", body),
        ("/v4/ecs/rebuild-instance", body),
        ("/v4/ecs/reset-instance-os", body),
        ("/v4/ecs/change-os", body),
        ("/v4/ecs/lite/reinstall", body),
    ]
    return call_first_ok(c, attempts, retries=2, delay=2)


def release_eip_item(region: dict[str, Any], item: dict[str, Any], execute: bool, *, processed: set[str] | None = None) -> bool:
    processed = processed if processed is not None else set()
    eip_id = str(item.get("eipID") or item.get("ID") or "")
    address = str(item.get("address") or item.get("eipAddress") or eip_id)
    if not eip_id:
        print(f"{region['displayName']} EIP {address} 缺少 EIP ID，跳过。")
        return False
    if eip_id in processed:
        print(f"{region['displayName']} EIP {address} 已处理，跳过重复项。")
        return True
    havip_id = str(item.get("havipID") or "")
    last_reason = ""
    for attempt in range(1, 4):
        fresh = find_eip_resource(region, eip_id, execute)
        if fresh:
            address = str(fresh.get("eipAddress") or fresh.get("address") or address)
        elif attempt > 1:
            processed.add(eip_id)
            print(f"{region['displayName']} EIP {address} 已不在资源列表中，按已退订处理。")
            return True

        if havip_id:
            unbind = unbind_havip_eip(region, havip_id, eip_id, execute)
            print(f"{region['displayName']} {item.get('vip', '')} 解绑 EIP {address} 第 {attempt}/3 次 {operation_result(unbind, '成功', '失败')}")
            if not ok(unbind):
                assoc_id = str((fresh or {}).get("associationID") or item.get("associationID") or "")
                assoc_type = str((fresh or {}).get("associationType") or item.get("associationType") or "")
                fallback = disassociate_eip(region, eip_id, execute, association_id=assoc_id, association_type=assoc_type)
                print(f"{region['displayName']} EIP {address} 普通解绑兜底 第 {attempt}/3 次 {operation_result(fallback, '成功', '失败')}")
                if not ok(fallback) and eip_has_association(fresh):
                    last_reason = response_reason(fallback)
                    sleep_with_stop(3 if execute else 0)
                    continue
        elif item.get("associationID") or item.get("associationType") or item.get("portID") or eip_has_association(fresh):
            assoc_id = str((fresh or {}).get("associationID") or item.get("associationID") or "")
            assoc_type = str((fresh or {}).get("associationType") or item.get("associationType") or "")
            assoc_label = str(item.get("associationName") or "其他资源").strip()
            assoc_text = f"，绑定资源 {assoc_label}" if assoc_id else ""
            print(f"{region['displayName']} EIP {address} 当前已绑定云服务器/资源{assoc_text}，先解绑再退订。")
            unbind = disassociate_eip(region, eip_id, execute, association_id=assoc_id, association_type=assoc_type)
            print(f"{region['displayName']} 解绑 EIP {address} 第 {attempt}/3 次 {operation_result(unbind, '成功', '失败')}")
            if not ok(unbind):
                last_reason = response_reason(unbind)
                sleep_with_stop(3 if execute else 0)
                continue

        sleep_with_stop(2 if execute else 0)
        deleted = delete_eip_resource(region, eip_id, execute)
        print(f"{region['displayName']} 退订 EIP {address} 第 {attempt}/3 次 {operation_result(deleted, '成功', '失败')}")
        if ok(deleted):
            processed.add(eip_id)
            return True
        last_reason = response_reason(deleted)
        if "bound" in last_reason.lower() or "绑定" in last_reason or "关联" in last_reason:
            fresh = find_eip_resource(region, eip_id, execute)
            disassociate_eip(
                region,
                eip_id,
                execute,
                association_id=str((fresh or {}).get("associationID") or item.get("associationID") or ""),
                association_type=str((fresh or {}).get("associationType") or item.get("associationType") or ""),
            )
        sleep_with_stop(3 if execute else 0)

    print(f"{region['displayName']} EIP {address} 退订仍未成功，请手动检查。原因：{last_reason or '未知'}")
    return False


def delete_shared_bandwidth_item(region: dict[str, Any], item: dict[str, Any], execute: bool) -> bool:
    bandwidth_id = str(item.get("bandwidthID") or item.get("id") or item.get("ID") or "")
    name = str(item.get("name") or bandwidth_id or "共享带宽")
    if not bandwidth_id:
        print(f"{region['displayName']} 共享带宽 {name} 缺少资源 ID，跳过。")
        return False
    deleted = delete_shared_bandwidth_resource(region, bandwidth_id, execute)
    print(f"{region['displayName']} 删除共享带宽 {name} {operation_result(deleted, '成功', '失败')}")
    return ok(deleted)


def cleanup_query_region_items(region: dict[str, Any], execute: bool) -> tuple[str, list[dict[str, Any]], bool, list[str]]:
    items: list[dict[str, Any]] = []
    logs: list[str] = []

    def log(message: str) -> None:
        logs.append(str(message))

    name = region["displayName"]
    region_id = region["regionID"]
    log(f"\n{name} 查询地区资源。")
    ecs_items = list_ecs(region, execute)

    def cleanup_ecs_status(inst: dict[str, Any]) -> str:
        return str(inst.get("instanceStatus") or inst.get("status") or inst.get("statusName") or inst.get("instanceStatusName") or "")

    def cleanup_ecs_unsubscribing(inst: dict[str, Any]) -> bool:
        return is_ecs_unsubscribe_in_progress_status(cleanup_ecs_status(inst)) or is_ecs_unsubscribe_in_progress_status(payload_text(inst))

    def cleanup_ecs_release_ready(inst: dict[str, Any]) -> bool:
        return is_cycle_unsubscribed_status(cleanup_ecs_status(inst)) or "包周期已退订" in payload_text(inst)

    active_ecs_count = sum(1 for inst in ecs_items if not (cleanup_ecs_release_ready(inst) or cleanup_ecs_unsubscribing(inst)))
    release_only_ecs_count = sum(1 for inst in ecs_items if cleanup_ecs_release_ready(inst))
    unsubscribing_ecs_count = sum(1 for inst in ecs_items if cleanup_ecs_unsubscribing(inst))
    log(f"{name} 云服务器：{active_ecs_count} 台")
    if release_only_ecs_count:
        log(f"{name} 待释放云服务器：{release_only_ecs_count} 台")
    if unsubscribing_ecs_count:
        log(f"{name} 退订中云服务器：{unsubscribing_ecs_count} 台，已隐藏为不可操作资源。")
    for inst in ecs_items:
        inst_name = inst.get("displayName") or inst.get("instanceName") or inst.get("name") or "云服务器"
        inst_id = inst.get("instanceID") or inst.get("id") or ""
        status = cleanup_ecs_status(inst)
        if cleanup_ecs_unsubscribing(inst):
            log(f"  {name} {inst_name} 状态为退订中，删除请求已提交，暂不显示为可操作资源。")
            continue
        log(f"- ECS {inst_name} {status}".strip())
        release_only = cleanup_ecs_release_ready(inst)
        if release_only:
            log(f"  {name} {inst_name} 状态为包周期已退订，已加入可释放列表。")
        if inst_id:
            items.append(
                {
                    "key": f"ECS|{name}|{inst_id}",
                    "type": "ECS",
                    "region": name,
                    "regionID": region_id,
                    "instanceID": inst_id,
                    "name": inst_name,
                    "status": status,
                    "releaseOnly": release_only,
                    "billing": ecs_billing_text(inst),
                    "chargeType": inst.get("chargeType") or inst.get("chargeMode") or inst.get("payType") or inst.get("payMode") or inst.get("billingType") or inst.get("instanceChargeType") or inst.get("cycleType") or "",
                    "onDemand": bool(inst.get("onDemand") is True or inst.get("isOnDemand") is True),
                    "privateIP": primary_private_ip(inst),
                    "expireTime": resource_expire_display(inst),
                    "imageID": inst.get("imageID") or inst.get("imageId") or "",
                    "flavorID": inst.get("flavorID") or inst.get("flavorId") or "",
                    "flavorName": inst.get("flavorName") or inst.get("specName") or "",
                }
            )
    all_eips = list_eips(region, execute)
    try:
        all_shared_bandwidths = list_shared_bandwidths(region, execute)
    except Exception as exc:
        log(f"{name} 共享带宽查询失败：{exc}")
        all_shared_bandwidths = []
    all_havips = list_all_havips(region, execute)
    all_vpcs = list_existing_vpcs(region, execute)
    try:
        all_security_groups = list_security_groups(region, execute)
    except Exception as exc:
        log(f"{name} 安全组查询失败：{exc}")
        all_security_groups = []
    existing_vpc_ids = {vpc_id_of(vpc) for vpc in all_vpcs if vpc_id_of(vpc)}
    for vpc in vpc_records_from_havips(all_havips):
        vpc_id = vpc_id_of(vpc)
        if vpc_id and vpc_id not in existing_vpc_ids:
            existing_vpc_ids.add(vpc_id)
            all_vpcs.append(vpc)
    if not ecs_items and not all_eips and not all_shared_bandwidths and not all_vpcs and not all_havips and not all_security_groups:
        log(f"{name} 账号下未发现云服务器、弹性IP、共享带宽、虚拟IP、VPC 或安全组，跳过该地区深度网络查询。")
        return name, items, False, logs
    vpc_count = 0
    for vpc in all_vpcs:
        vpc_id = vpc_id_of(vpc)
        if not vpc_id:
            continue
        vpc_name = resource_name(vpc) or "VPC"
        cidr = resource_cidr(vpc)
        subnets = list_existing_subnets(region, vpc_id, execute)
        configured_subnet = configured_subnet_fallback(region, vpc_id)
        if configured_subnet and subnet_id_of(configured_subnet) not in {subnet_id_of(subnet) for subnet in subnets}:
            subnets.append(configured_subnet)
        subnets_for_cleanup = subnet_cleanup_records(subnets)
        subnet_text = subnet_summary(subnets)
        label = f"{vpc_name}({cidr})" if cidr and cidr not in vpc_name else vpc_name
        log(f"- VPC {label} 子网 {len(subnets_for_cleanup)} 个{('：' + subnet_text) if subnet_text else ''}")
        items.append(
            {
                "key": f"VPC|{name}|{vpc_id}",
                "type": "VPC",
                "region": name,
                "regionID": region_id,
                "vpcID": vpc_id,
                "name": label,
                "status": str(vpc.get("status") or vpc.get("state") or ("本地记录" if vpc.get("_source") == "configured" else "")),
                "cidr": cidr,
                "subnets": subnets_for_cleanup,
                "subnetSummary": subnet_text,
            }
        )
        vpc_count += 1
    if vpc_count:
        log(f"{name} 可删除 VPC：{vpc_count} 个")
    net = try_region_network(region, execute)
    if not net:
        log(f"{name} 未找到已记录子网，改为查询该地区全部虚拟 IP。")
        havips = all_havips
    else:
        havips = list_havips(region, net["subnetID"], execute)
        for ip, havip in all_havips.items():
            havips.setdefault(ip, havip)
    assoc_eips = eips_by_association_from_items(all_eips)
    eips_by_id = {eip_resource_id(e): e for e in all_eips if eip_resource_id(e)}
    association_labels: dict[str, str] = {}
    association_private_ips: dict[str, str] = {}
    for inst in ecs_items:
        inst_id = inst.get("instanceID") or inst.get("id") or ""
        if inst_id:
            association_labels[str(inst_id)] = str(inst.get("displayName") or inst.get("instanceName") or inst.get("name") or "云服务器")
            association_private_ips[str(inst_id)] = primary_private_ip(inst)
    for vip, havip in havips.items():
        havip_id = pick(havip, "id", "ID", "havipID", "haVipID")
        if havip_id:
            association_labels[havip_id] = f"虚拟IP {vip}"
            association_private_ips[havip_id] = vip
    havip_eip_ids: set[str] = set()
    found = 0
    for ip, havip in sorted(havips.items(), key=lambda item: ip_sort_key(item[0])):
        havip_id = havip_id_of(havip)
        if not havip_id:
            continue
        eip_ids = havip_bound_eip_ids(havip, assoc_eips)
        found += len(eip_ids)
        address_text = ""
        for eip_id in eip_ids:
            havip_eip_ids.add(eip_id)
            eip_item = eips_by_id.get(eip_id) or {}
            address = eip_item.get("eipAddress", "")
            address_text = address_text or address
            log(f"- 虚拟IP {ip} 已绑定 EIP {address or '弹性IP'}")
            items.append(
                {
                    "key": f"HAVIP_EIP|{name}|{ip}|{eip_id}",
                    "type": "HAVIP_EIP",
                    "region": name,
                    "regionID": region_id,
                    "name": f"虚拟IP {ip}",
                    "vip": ip,
                    "havipID": havip.get("id", ""),
                    "eipID": eip_id,
                    "address": address,
                    "status": f"已绑定虚拟IP{ip}",
                    "associationName": f"虚拟IP {ip}",
                    "associationPrivateIP": ip,
                    "billing": ecs_billing_text(eip_item),
                    "chargeType": eip_item.get("chargeType") or eip_item.get("chargeMode") or eip_item.get("payType") or eip_item.get("payMode") or eip_item.get("billingType") or eip_item.get("cycleType") or "",
                    "onDemand": is_on_demand_resource_item(eip_item),
                    "expireTime": resource_expire_display(eip_item),
                }
            )
        vpc_id, subnet_id = havip_network_ids(havip)
        log(f"- 子网虚拟IP {ip} {'已绑定 EIP ' + address_text if address_text else '未绑定 EIP'}")
        if not address_text:
            items.append(
                {
                    "key": f"HAVIP|{name}|{havip_id}",
                    "type": "HAVIP",
                    "region": name,
                    "regionID": region_id,
                    "name": f"虚拟IP {ip}",
                    "vip": ip,
                    "havipID": havip_id,
                    "vpcID": vpc_id,
                    "subnetID": subnet_id,
                    "address": "",
                    "status": "未绑定EIP",
                }
            )
    log(f"{name} 虚拟IP：{len(havips)} 个；待解绑并退订的虚拟IP EIP：{found} 条")
    eip_count = 0
    for eip in all_eips:
        eip_id = eip_resource_id(eip)
        if not eip_id or eip_id in havip_eip_ids:
            continue
        eip_count += 1
        address = eip.get("eipAddress") or eip.get("address") or ""
        status = eip.get("status") or eip.get("state") or ""
        assoc_id = eip.get("associationID") or ""
        assoc_type = eip.get("associationType") or ""
        assoc_label = association_labels.get(str(assoc_id), "其他资源") if assoc_id else ""
        bind_text = f"已绑定 {assoc_label}" if assoc_id else "未绑定"
        log(f"- 弹性IP {address or '弹性IP'} {status} {bind_text}".strip())
        items.append(
            {
                "key": f"EIP|{name}|{eip_id}",
                "type": "EIP",
                "region": name,
                "regionID": region_id,
                "name": address or "弹性IP",
                "eipID": eip_id,
                "address": address,
                "status": status,
                "associationID": assoc_id,
                "associationName": assoc_label,
                "associationPrivateIP": association_private_ips.get(str(assoc_id), ""),
                "associationType": assoc_type,
                "portID": eip.get("portID") or "",
                "billing": ecs_billing_text(eip),
                "chargeType": eip.get("chargeType") or eip.get("chargeMode") or eip.get("payType") or eip.get("payMode") or eip.get("billingType") or eip.get("cycleType") or "",
                "onDemand": is_on_demand_resource_item(eip),
                "expireTime": resource_expire_display(eip),
            }
        )
    log(f"{name} 其他弹性IP：{eip_count} 条")
    shared_bandwidth_count = 0
    for bandwidth in all_shared_bandwidths:
        bandwidth_id = shared_bandwidth_id(bandwidth)
        if not bandwidth_id:
            continue
        shared_bandwidth_count += 1
        bandwidth_name = shared_bandwidth_name(bandwidth)
        size_text = shared_bandwidth_size_text(bandwidth)
        status = str(bandwidth.get("status") or bandwidth.get("state") or "")
        attached_eips = bandwidth.get("eips") if isinstance(bandwidth.get("eips"), list) else []
        attached_count = len(attached_eips)
        spec_parts: list[str] = []
        if size_text:
            spec_parts.append(f"带宽 {size_text}M")
        spec_parts.append(f"已加入弹性IP {attached_count}条")
        spec_text = "；".join(spec_parts)
        log(f"- 共享带宽 {bandwidth_name} {status} {spec_text}".strip())
        items.append(
            {
                "key": f"SHARED_BANDWIDTH|{name}|{bandwidth_id}",
                "type": "SHARED_BANDWIDTH",
                "region": name,
                "regionID": region_id,
                "bandwidthID": bandwidth_id,
                "name": bandwidth_name,
                "status": status,
                "bandwidth": size_text,
                "eipCount": attached_count,
                "resourceSpec": spec_text,
                "expireTime": resource_expire_display(bandwidth),
                "billing": ecs_billing_text(bandwidth),
                "chargeType": bandwidth.get("chargeType") or bandwidth.get("chargeMode") or bandwidth.get("payType") or bandwidth.get("payMode") or bandwidth.get("billingType") or bandwidth.get("cycleType") or "",
                "onDemand": is_on_demand_resource_item(bandwidth),
            }
        )
    if shared_bandwidth_count:
        log(f"{name} 共享带宽：{shared_bandwidth_count} 个")
    security_group_count = 0
    for sg in all_security_groups:
        sg_id = security_group_id(sg)
        if not sg_id:
            continue
        sg_name = security_group_name(sg) or sg_id
        sg_vpc_id = security_group_vpc_id(sg)
        rules = sg.get("securityGroupRuleList") or sg.get("rules") or sg.get("securityGroupRules") or []
        rule_count = len(rules) if isinstance(rules, list) else 0
        security_group_count += 1
        log(f"- 安全组 {sg_name} 规则 {rule_count} 条".strip())
        items.append(
            {
                "key": f"SECURITY_GROUP|{name}|{sg_id}",
                "type": "SECURITY_GROUP",
                "region": name,
                "regionID": region_id,
                "securityGroupID": sg_id,
                "name": sg_name,
                "status": str(sg.get("status") or sg.get("state") or ""),
                "vpcID": sg_vpc_id,
                "ruleSummary": f"规则 {rule_count} 条" if rule_count else "",
                "_displayOnly": True,
            }
        )
    if security_group_count:
        log(f"{name} 安全组：{security_group_count} 个")
    return name, items, bool(items), logs


def phase_cleanup_query(regions: list[dict[str, Any]], execute: bool) -> int:
    items: list[dict[str, Any]] = []
    found_region_names: list[str] = []
    region_scope_only = os.environ.get("CTYUN_CLEANUP_QUERY_SCOPE") == "regions"
    if region_scope_only:
        target_regions = list(regions)
        region_text = "、".join(region["displayName"] for region in target_regions) if target_regions else "无"
        print(f"\n任务地区资源查询：仅查询指定地区：{region_text}。")
    else:
        try:
            target_regions = discover_cleanup_regions(regions, execute)
        except Exception as exc:
            reason = str(exc) or "账号资源索引失败"
            print(f"\n账号资源查询失败：{reason}")
            print("本次查询失败，已保留现有资源库显示，不写入 0 资源结果。")
            save_cleanup_query_error(reason)
            return 1
        print(f"\n账号资源查询：本账号发现 {len(target_regions)} 个有资源地区，仅查询这些地区 ECS / EIP / HAVIP / VPC / 子网 / 安全组详情。")
    if not target_regions:
        save_json(ROOT / "cleanup_resources.json", {"items": items, "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "completed": True})
        if region_scope_only:
            print("\n任务地区资源查询完成：指定地区 0 个，可管理资源 0 项。")
        else:
            print("\n账号资源查询完成：发现 0 个有资源地区，可管理资源 0 项。")
        print(f"已生成资源查询结果: {ROOT / 'cleanup_resources.json'}")
        return 0

    results_by_index: dict[int, tuple[str, list[dict[str, Any]], bool]] = {}

    def rebuild_and_save(result_name: str) -> None:
        nonlocal items, found_region_names
        items = []
        found_region_names = []
        for ordered_index in sorted(results_by_index):
            ordered_name, ordered_items, ordered_has_items = results_by_index[ordered_index]
            items.extend(ordered_items)
            if ordered_has_items:
                found_region_names.append(ordered_name)
        save_json(ROOT / "cleanup_resources.json", {"items": items, "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"), "completed": False})
        print(f"{result_name} 查询结果已保存。当前已发现 {len(found_region_names)} 个有资源地区。")

    def run_detail_batch(
        batch: list[tuple[int, dict[str, Any]]],
        *,
        workers: int,
        label: str,
    ) -> list[tuple[int, dict[str, Any], str]]:
        failed: list[tuple[int, dict[str, Any], str]] = []
        if not batch:
            return failed
        with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
            futures = {
                pool.submit(cleanup_query_region_items, region, execute): (index, region)
                for index, region in batch
            }
            for future in as_completed(futures):
                index, region = futures[future]
                name = region["displayName"]
                try:
                    result_name, region_items, has_items, logs = future.result()
                except Exception as exc:
                    reason = str(exc) or "未知错误"
                    print(f"\n{name} {label}失败：{reason}")
                    failed.append((index, region, reason))
                    continue
                for line in logs:
                    print(line)
                results_by_index[index] = (result_name, region_items, has_items)
                rebuild_and_save(result_name)
        return failed

    indexed_regions = list(enumerate(target_regions))
    fast_workers = max(1, len(indexed_regions))
    scope_label = "任务地区资源详情" if region_scope_only else "账号资源详情"
    print(f"{scope_label}查询快速模式：{fast_workers} 个地区全部并发读取。")
    failed_regions = run_detail_batch(indexed_regions, workers=fast_workers, label="快速查询")
    retry_failed: list[tuple[int, dict[str, Any], str]] = []
    if failed_regions:
        retry_workers = min(CLEANUP_DETAIL_RETRY_WORKERS, CLEANUP_DETAIL_WORKERS, max(1, len(failed_regions)))
        failed_text = "、".join(region["displayName"] for _, region, _ in failed_regions)
        print(f"{scope_label}快速模式有 {len(failed_regions)} 个地区失败：{failed_text}。自动降级为 {retry_workers} 并发重试。")
        retry_batch = [(index, region) for index, region, _ in failed_regions]
        retry_failed = run_detail_batch(retry_batch, workers=retry_workers, label="降级重试")
        for index, region, reason in retry_failed:
            name = region["displayName"]
            print(f"{name} 降级重试仍失败，已跳过该地区详情。原因：{reason}")

    failed_region_names = [region["displayName"] for _, region, _ in retry_failed]
    if retry_failed and not results_by_index:
        reason = retry_failed[0][2] or "全部地区详情查询失败"
        message = f"{scope_label}全部失败，未覆盖现有资源库。原因：{reason}"
        print(f"\n{message}")
        save_cleanup_query_error(message)
        return 1

    save_json(
        ROOT / "cleanup_resources.json",
        {
            "items": items,
            "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "completed": True,
            "failedRegions": failed_region_names,
        },
    )
    found_text = "、".join(found_region_names) if found_region_names else "无"
    if region_scope_only:
        print(f"\n任务地区资源查询完成：发现 {len(items)} 项可管理资源；有资源地区：{found_text}")
    else:
        print(f"\n账号资源查询完成：发现 {len(items)} 项可管理资源；有资源地区：{found_text}")
    if failed_region_names:
        print(f"以下地区查询失败，资源库保留原显示：{'、'.join(failed_region_names)}。")
    print(f"已生成退订资源查询结果: {ROOT / 'cleanup_resources.json'}")
    return 0


def regions_for_selected_items(regions: list[dict[str, Any]], selected: list[Any], execute: bool) -> list[dict[str, Any]]:
    expanded = expand_account_region_candidates(regions, execute)
    merged = merge_region_candidates(expanded, [])
    by_name = {str(region.get("displayName") or ""): region for region in merged}
    for item in selected:
        if not isinstance(item, dict):
            continue
        name = str(item.get("region") or "").strip()
        region_id = str(item.get("regionID") or item.get("regionId") or "").strip()
        if not name or name in by_name or not region_id:
            continue
        region = {
            "index": 90000 + len(merged),
            "displayName": name,
            "regionID": region_id,
            "azName": "default",
            "vpcID": str(item.get("vpcID") or ""),
            "subnetID": str(item.get("subnetID") or ""),
            "securityGroupID": str(item.get("securityGroupID") or ""),
            "flavorID": str(item.get("flavorID") or ""),
            "imageID": str(item.get("imageID") or item.get("imageId") or ""),
            "_source": "cleanup-selection",
        }
        merged.append(region)
        by_name[name] = region
    return merged


def phase_cleanup_execute(regions: list[dict[str, Any]], execute: bool, selection_file: Path | None = None) -> None:
    if selection_file and selection_file.exists():
        selected = load_json(selection_file, [])
        if not isinstance(selected, list):
            raise SystemExit(f"退订选择文件格式错误: {selection_file}")
        regions = regions_for_selected_items(regions, selected, execute)
        by_region = {region["displayName"]: region for region in regions}
        print(f"\n== 按勾选项退订资源，共 {len(selected)} 项 ==")
        grouped: dict[str, list[dict[str, Any]]] = {}
        for item in selected:
            if isinstance(item, dict):
                grouped.setdefault(str(item.get("region") or ""), []).append(item)
        processed_eips: set[str] = set()
        for name, items_for_region in grouped.items():
            region = by_region.get(name)
            if not region:
                print(f"{name} 不在当前地区列表中，跳过。")
                continue
            ecs_items = [item for item in items_for_region if str(item.get("type") or "") == "ECS"]
            havip_items = [item for item in items_for_region if str(item.get("type") or "") == "HAVIP"]
            eip_items = [item for item in items_for_region if str(item.get("type") or "") in {"HAVIP_EIP", "EIP"}]
            shared_bandwidth_items = [
                item for item in items_for_region if str(item.get("type") or "") in {"SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽"}
            ]
            security_group_items = [
                item for item in items_for_region if str(item.get("type") or "") in {"SECURITY_GROUP", "SECURITYGROUP", "安全组"}
            ]
            vpc_items = [item for item in items_for_region if str(item.get("type") or "") == "VPC"]

            def run_cleanup_stage(stage_name: str, stage_items: list[dict[str, Any]], worker, *, workers: int) -> None:
                if not stage_items:
                    return
                worker_count = max(1, min(workers, len(stage_items)))
                print(f"{name} {stage_name} 并发处理 {len(stage_items)} 项，并发 {worker_count}。")
                with ThreadPoolExecutor(max_workers=worker_count) as pool:
                    future_map = {pool.submit(worker, item): item for item in stage_items}
                    for future in as_completed(future_map):
                        item = future_map[future]
                        try:
                            future.result()
                        except Exception as exc:
                            label = item.get("name") or item.get("address") or item.get("vip") or item.get("id") or item.get("eipID") or "资源"
                            print(f"{name} {stage_name} {label} 处理异常：{exc}")

            def cleanup_selected_ecs(item: dict[str, Any]) -> None:
                instance_id = str(item.get("instanceID") or "")
                inst_name = str(item.get("name") or "云服务器")
                if not instance_id:
                    print(f"{name} {inst_name or '云服务器'} 缺少云服务器信息，跳过。")
                    return
                deleted_info = cleanup_delete_ecs_instance(region, {**item, "instanceID": instance_id, "name": inst_name}, execute)
                if not deleted_info:
                    return
                action, deleted = deleted_info
                print(f"{name} {action} ECS {inst_name} {operation_result(deleted, '成功', '失败')}")

            deduped_eip_items: list[dict[str, Any]] = []
            seen_eip_ids: set[str] = set()
            for item in eip_items:
                eip_id = str(item.get("eipID") or item.get("ID") or "")
                if eip_id and eip_id in seen_eip_ids:
                    print(f"{name} EIP {item.get('address') or eip_id} 重复选择，已合并为一次处理。")
                    continue
                if eip_id:
                    seen_eip_ids.add(eip_id)
                deduped_eip_items.append(item)
            eip_items = deduped_eip_items

            run_cleanup_stage("云服务器", ecs_items, cleanup_selected_ecs, workers=4)
            run_cleanup_stage("虚拟IP", havip_items, lambda item: delete_havip_item(region, item, execute), workers=4)
            run_cleanup_stage("EIP", eip_items, lambda item: release_eip_item(region, item, execute, processed=processed_eips), workers=8)
            run_cleanup_stage("共享带宽", shared_bandwidth_items, lambda item: delete_shared_bandwidth_item(region, item, execute), workers=4)
            run_cleanup_stage("安全组", security_group_items, lambda item: delete_security_group_item(region, item, execute), workers=2)
            run_cleanup_stage("VPC", vpc_items, lambda item: delete_vpc_item(region, item, execute), workers=2)
            for item in items_for_region:
                item_type = str(item.get("type") or "")
                if item_type not in {"ECS", "HAVIP", "HAVIP_EIP", "EIP", "SHARED_BANDWIDTH", "BANDWIDTH", "共享带宽", "带宽", "SECURITY_GROUP", "SECURITYGROUP", "安全组", "VPC"}:
                    print(f"{name} 未识别资源类型 {item_type}，跳过。")
        return

    processed_eips: set[str] = set()
    for region in regions:
        name = region["displayName"]
        print(f"\n== {name} 按需退订资源 ==")
        net = try_region_network(region, execute)
        if net:
            havips = list_havips(region, net["subnetID"], execute)
            assoc_eips = eips_by_association(region, execute)
            eips_by_id = {e.get("ID"): e for e in list_eips(region, execute)}
            for ip in planned_havip_ips():
                havip = havips.get(ip)
                if not havip:
                    continue
                for eip_id in havip_bound_eip_ids(havip, assoc_eips):
                    address = (eips_by_id.get(eip_id) or {}).get("eipAddress", "")
                    release_eip_item(
                        region,
                        {"type": "HAVIP_EIP", "vip": ip, "havipID": havip["id"], "eipID": eip_id, "address": address},
                        execute,
                        processed=processed_eips,
                    )
        else:
            print(f"{name} 未找到已记录子网，改为查询该地区全部虚拟 IP。")
            havips = list_all_havips(region, execute)
            assoc_eips = eips_by_association(region, execute)
            eips_by_id = {e.get("ID"): e for e in list_eips(region, execute)}
            for ip in planned_havip_ips():
                havip = havips.get(ip)
                if not havip:
                    continue
                for eip_id in havip_bound_eip_ids(havip, assoc_eips):
                    address = (eips_by_id.get(eip_id) or {}).get("eipAddress", "")
                    release_eip_item(
                        region,
                        {"type": "HAVIP_EIP", "vip": ip, "havipID": havip["id"], "eipID": eip_id, "address": address},
                        execute,
                        processed=processed_eips,
                    )

        ecs_items = list_ecs(region, execute)
        if not ecs_items:
            print(f"{name} 没有查询到云服务器。")
            continue
        for inst in ecs_items:
            inst_id = inst.get("instanceID") or inst.get("id") or ""
            inst_name = inst.get("displayName") or inst.get("instanceName") or "云服务器"
            if not inst_id:
                print(f"{name} {inst_name or '云服务器'} 缺少云服务器信息，跳过。")
                continue
            status = normalized_ecs_status(inst)
            deleted_info = cleanup_delete_ecs_instance(region, {**inst, "instanceID": inst_id, "name": inst_name, "status": status}, execute)
            if not deleted_info:
                continue
            action, deleted = deleted_info
            print(f"{name} {action} ECS {inst_name} {operation_result(deleted, '成功', '失败')}")


def phase_reinstall_execute(regions: list[dict[str, Any]], execute: bool, selection_file: Path | None = None) -> None:
    if not selection_file or not selection_file.exists():
        raise SystemExit("重装系统需要先查询资源并勾选云服务器。")
    selected = load_json(selection_file, [])
    if not isinstance(selected, list):
        raise SystemExit(f"重装选择文件格式错误: {selection_file}")
    regions = regions_for_selected_items(regions, selected, execute)
    by_region = {region["displayName"]: region for region in regions}
    ecs_items = [item for item in selected if isinstance(item, dict) and str(item.get("type") or "") == "ECS"]
    if not ecs_items:
        print("没有勾选云服务器，已跳过重装系统。")
        return
    print(f"\n== 重装勾选云服务器，共 {len(ecs_items)} 台 ==")
    successful_regions: set[str] = set()
    failed_items: list[str] = []
    for item in ecs_items:
        name = str(item.get("region") or "")
        region = by_region.get(name)
        if not region:
            reason = f"{name} 不在当前地区列表中，跳过。"
            print(reason)
            failed_items.append(reason)
            continue
        instance_id = str(item.get("instanceID") or "")
        inst_name = str(item.get("name") or "云服务器")
        if not instance_id:
            reason = f"{name} {inst_name or '云服务器'} 缺少云服务器信息，跳过。"
            print(reason)
            failed_items.append(reason)
            continue
        stopped, stop_reason = stop_instance_and_wait(region, instance_id, execute, inst_name)
        if not stopped:
            reason = f"{name} {inst_name} 未关机成功，跳过重装：{stop_reason}"
            print(reason)
            failed_items.append(reason)
            continue
        inst = find_ecs_instance(region, instance_id, execute) or item
        result = reinstall_ecs_instance(region, inst, execute)
        path_text = f" 接口 {result.get('_path')}" if result.get("_path") else ""
        print(f"{name} 重装系统 {inst_name} {operation_result(result, '提交成功', '提交失败')}{path_text}")
        if ok(result):
            successful_regions.add(name)
            sleep_with_stop(10 if execute else 0)
            refreshed = find_ecs_instance(region, instance_id, execute)
            if refreshed:
                print(f"{name} 重装请求已提交，当前云服务器状态：{normalized_ecs_status(refreshed) or '未知'}")
            started, start_reason = start_instance_after_reinstall(region, instance_id, execute, inst_name)
            print(f"{name} 重装后开机 {inst_name} {bool_result(started, '成功', '失败', start_reason)}")
        else:
            failed_items.append(f"{name} {inst_name} 重装提交失败")

    result_path = selection_file.parent / "reinstall_result.json"
    result_path.write_text(
        json.dumps(
            {
                "completed": True,
                "successfulRegions": sorted(successful_regions),
                "failedItems": failed_items,
            },
            ensure_ascii=False,
            indent=2,
        )
        + "\n",
        encoding="utf-8",
    )
    if failed_items:
        raise SystemExit(f"重装系统存在 {len(failed_items)} 个失败项，请查看上方日志。")


def phase_replace_ip(regions: list[dict[str, Any]], execute: bool, selection_file: Path | None = None) -> None:
    if not selection_file or not selection_file.exists():
        raise SystemExit("更换新IP需要先查询资源并勾选已绑定弹性IP。")
    selected = load_json(selection_file, [])
    if not isinstance(selected, list):
        raise SystemExit(f"更换新IP选择文件格式错误: {selection_file}")
    regions = regions_for_selected_items(regions, selected, execute)
    by_region = {region["displayName"]: region for region in regions}
    grouped: dict[str, list[dict[str, Any]]] = {}
    for item in selected:
        if not isinstance(item, dict):
            continue
        item_type = str(item.get("type") or "")
        if item_type not in {"EIP", "HAVIP_EIP"}:
            continue
        region_name = str(item.get("region") or "")
        if region_name:
            grouped.setdefault(region_name, []).append(item)
    if not grouped:
        print("没有勾选已绑定弹性IP，已跳过更换新IP。")
        return

    used_ips = load_used_ip_table_from_env()
    if used_ips:
        print(f"旧IP库已加载 {len(used_ips)} 条，用于避开重复新IP。")
    replacements: dict[str, str] = {}
    result_items: list[dict[str, Any]] = []
    failure_reasons: list[str] = []
    total_success = 0
    total_failed = 0
    processed_eips: set[str] = set()

    for name, region_items in grouped.items():
        region = by_region.get(name)
        if not region:
            print(f"{name} 不在当前地区列表中，跳过。")
            total_failed += len(region_items)
            failure_reasons.append(f"{name}：未找到地区信息，未执行更换")
            continue
        print(f"\n== {name} 更换新IP，共 {len(region_items)} 条候选 EIP ==")
        snapshots: list[dict[str, Any]] = []
        for item in region_items:
            eip_id = str(item.get("eipID") or item.get("ID") or "")
            if not eip_id:
                print(f"{name} {item.get('name') or '弹性IP'} 缺少 EIP ID，跳过。")
                total_failed += 1
                failure_reasons.append(f"{name}：选中资源缺少 EIP ID")
                continue
            if eip_id in processed_eips:
                print(f"{name} EIP {item.get('address') or eip_id} 重复选择，已跳过。")
                continue
            processed_eips.add(eip_id)
            fresh = find_eip_resource(region, eip_id, execute) if execute else None
            merged = {**item, **(fresh or {})}
            old_address = eip_public_address(merged) or str(item.get("address") or item.get("name") or eip_id)
            havip_id = str(item.get("havipID") or "")
            association_id = str(item.get("associationID") or eip_association_id(fresh or item) or "")
            association_type = str(item.get("associationType") or eip_association_type(fresh or item) or "")
            if not havip_id and not association_id:
                print(f"{name} EIP {old_address} 未绑定资源，跳过更换。")
                total_failed += 1
                failure_reasons.append(f"{name} {old_address}：未绑定资源，无法更换")
                continue
            if old_address:
                used_ips.add(old_address)
            snapshots.append(
                {
                    "item": item,
                    "eipID": eip_id,
                    "oldAddress": old_address,
                    "havipID": havip_id,
                    "associationID": association_id,
                    "associationType": association_type,
                    "associationName": str(item.get("associationName") or ""),
                    "vip": str(item.get("vip") or ""),
                    "bandwidth": replacement_eip_bandwidth(region, merged, fresh),
                    "releaseItem": {**merged, "eipID": eip_id, "address": old_address},
                }
            )

        if snapshots:
            batch_result = replace_region_eips_batch(region, snapshots, used_ips, execute)
            total_success += int(batch_result["success"])
            total_failed += int(batch_result["failed"])
            replacements.update(batch_result["replacements"])
            result_items.extend(batch_result["items"])
            failure_reasons.extend(batch_result["failureReasons"])

    save_json(
        ROOT / "replace_ip_results.json",
        {
            "generatedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
            "completed": True,
            "success": total_success,
            "failed": total_failed,
            "replacements": replacements,
            "items": result_items,
            "failureReasons": failure_reasons,
        },
    )
    print(f"\n更换新IP完成：成功 {total_success} 条，失败 {total_failed} 条。")
    if replacements:
        for old_ip, new_ip in replacements.items():
            print(f"- {old_ip} -> {new_ip}")
    if total_failed:
        raise SystemExit(1)


def bind_eip_to_ecs(region: dict[str, Any], server: dict[str, Any], eip: dict[str, Any], execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "eipID": eip["eipID"],
        "associationID": server["instanceID"],
        "associationType": 1,
    }
    return call_with_retry(c.post, "/v4/eip/associate", body, retries=5, delay=5)


def associate_eip_to_resource(region: dict[str, Any], eip_id: str, association_id: str, association_type: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    normalized_type = str(association_type or "").strip().lower()
    if normalized_type in ECS_EIP_ASSOC_TYPES or not normalized_type:
        association_type_value: int | str = 1
    else:
        try:
            association_type_value = int(normalized_type)
        except ValueError:
            association_type_value = association_type
    body: dict[str, Any] = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "eipID": eip_id,
        "associationID": association_id,
        "resourceID": association_id,
        "associationType": association_type_value,
    }
    attempts = [
        ("/v4/eip/associate", body),
        ("/v4/eip/bind", body),
        ("/v4/eip/associate", {**body, "ID": eip_id}),
    ]
    return call_first_ok(c, attempts, retries=3, delay=3)


def bind_eip_to_havip(region: dict[str, Any], havip_id: str, eip_id: str, execute: bool) -> dict[str, Any]:
    c = client(ENDPOINT_VPC, execute, timeout=30)
    body = {
        "clientToken": str(uuid.uuid4()),
        "regionID": region["regionID"],
        "resourceType": "NETWORK",
        "haVipID": havip_id,
        "floatingID": eip_id,
    }
    attempts = [
        ("/v4/vpc/havip/bind", body),
        ("/v4/vpc/havip/bind", {**body, "eipID": eip_id}),
        ("/v4/vpc/havip/associate", body),
    ]
    return call_first_ok(c, attempts, retries=3, delay=3)


def replacement_eip_bandwidth(region: dict[str, Any], item: dict[str, Any], fresh: dict[str, Any] | None = None) -> int:
    for value in (
        (fresh or {}).get("bandwidth"),
        item.get("bandwidth"),
        item.get("resourceSpec"),
    ):
        match = re.search(r"\d+", str(value or ""))
        if match:
            number = int(match.group(0))
            if number > 0:
                return number
    candidates = eip_bandwidth_candidates(region["displayName"])
    return int(candidates[0]) if candidates else 300


def wait_for_eip_ids_gone(
    region: dict[str, Any],
    eip_ids: set[str],
    execute: bool,
    *,
    timeout: int = 240,
) -> tuple[set[str], dict[str, dict[str, Any]]]:
    """等待退订真正生效；接口受理成功不代表配额已经归还。"""
    if not eip_ids:
        return set(), {}
    if not execute:
        return set(eip_ids), {}
    deadline = time.time() + timeout
    attempt = 0
    current_by_id: dict[str, dict[str, Any]] = {}
    previous_remaining = -1
    while time.time() < deadline:
        attempt += 1
        current = list_eips(region, execute)
        current_by_id = {eip_resource_id(eip): eip for eip in current if eip_resource_id(eip)}
        remaining = eip_ids.intersection(current_by_id)
        if not remaining:
            print(f"{region['displayName']} 已确认 {len(eip_ids)} 条 EIP 全部从资源列表消失，配额释放阶段完成。")
            return set(eip_ids), current_by_id
        if len(remaining) != previous_remaining or attempt == 1:
            print(f"{region['displayName']} 等待 EIP 退订和配额生效：剩余 {len(remaining)}/{len(eip_ids)} 条。")
            previous_remaining = len(remaining)
        sleep_with_stop(5)
    remaining = eip_ids.intersection(current_by_id)
    gone = eip_ids.difference(remaining)
    print(f"{region['displayName']} 等待 EIP 退订超时：已释放 {len(gone)}/{len(eip_ids)} 条，仍有 {len(remaining)} 条在云端处理中。")
    return gone, current_by_id


def submit_replacement_eip_batch(
    region: dict[str, Any],
    slots: list[dict[str, Any]],
    execute: bool,
    *,
    round_index: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str]:
    if not slots:
        return [], [], ""
    c = client(ENDPOINT_VPC, execute, timeout=30)
    token = uuid.uuid4().hex[:5]
    prefix = f"{slug(region)[:10]}-r{token}"
    requests: list[tuple[dict[str, Any], dict[str, Any]]] = []
    for index, slot in enumerate(slots, start=1):
        bandwidth = int(slot.get("bandwidth") or replacement_eip_bandwidth(region, slot))
        base = cfg(region)
        base["eip"] = {**base["eip"], "bandwidth": bandwidth}
        body = eip_create_body(base, index)
        body["name"] = f"{prefix}-{round_index}-{index:02d}"
        requests.append((slot, body))

    print(f"{region['displayName']} 第 {round_index}/3 轮并发提交 {len(requests)} 条新 EIP。")
    accepted: list[dict[str, Any]] = []
    rejected: list[dict[str, Any]] = []
    with ThreadPoolExecutor(max_workers=min(10, len(requests))) as pool:
        future_map = {
            pool.submit(call_or_error, c.post, "/v4/eip/create", body, retries=5, delay=5): (slot, body)
            for slot, body in requests
        }
        completed = 0
        for future in as_completed(future_map):
            slot, body = future_map[future]
            result = future.result()
            completed += 1
            record = {"slot": slot, "name": body["name"], "response": result}
            if eip_submit_accepted(result):
                accepted.append(record)
            else:
                rejected.append(record)
                print(f"{region['displayName']} 新 EIP {completed}/{len(requests)} 提交失败：{hide_resource_ids(response_reason(result))}")
            print(f"{region['displayName']} 新 EIP 提交进度 {completed}/{len(requests)}，云端已受理 {len(accepted)} 条。")
    return accepted, rejected, prefix


def wait_for_replacement_eip_batch(
    region: dict[str, Any],
    before_ids: set[str],
    expected_names: set[str],
    expected_count: int,
    name_prefix: str,
    execute: bool,
    *,
    timeout: int = 180,
) -> list[dict[str, Any]]:
    if expected_count <= 0:
        return []
    if not execute:
        return [
            {
                "eipID": f"DRY_RUN_EIP_{index}",
                "address": f"203.0.113.{10 + index}",
                "name": expected_name,
            }
            for index, expected_name in enumerate(sorted(expected_names))
        ]
    deadline = time.time() + timeout
    attempt = 0
    candidates: list[dict[str, Any]] = []
    while time.time() < deadline:
        attempt += 1
        candidates = []
        for eip in list_eips(region, execute):
            eip_id = eip_resource_id(eip)
            if not eip_id or eip_id in before_ids or eip_has_association(eip):
                continue
            eip_name = resource_name(eip)
            if eip_name and eip_name not in expected_names and not eip_name.startswith(name_prefix):
                continue
            candidates.append(eip)
        candidates.sort(key=lambda item: ip_sort_key(eip_public_address(item)))
        if len(candidates) >= expected_count:
            print(f"{region['displayName']} 已确认新 EIP 到账 {expected_count}/{expected_count} 条。")
            return candidates[:expected_count]
        print(f"{region['displayName']} 等待新 EIP 到账 {attempt}：当前 {len(candidates)}/{expected_count} 条。")
        sleep_with_stop(6)
    print(f"{region['displayName']} 新 EIP 到账等待超时：仅发现 {len(candidates)}/{expected_count} 条；不重复提交已受理订单。")
    return candidates


def bind_replacement_target(
    region: dict[str, Any],
    snapshot: dict[str, Any],
    eip: dict[str, Any],
    execute: bool,
) -> tuple[dict[str, Any], str]:
    eip_id = eip_resource_id(eip)
    havip_id = str(snapshot.get("havipID") or "")
    if havip_id:
        target = f"虚拟IP {snapshot.get('vip') or havip_id}"
        return bind_eip_to_havip(region, havip_id, eip_id, execute), target
    association_id = str(snapshot.get("associationID") or "")
    association_type = str(snapshot.get("associationType") or "")
    target = str(snapshot.get("associationName") or association_id or "原资源")
    return associate_eip_to_resource(region, eip_id, association_id, association_type, execute), target


def replace_region_eips_batch(
    region: dict[str, Any],
    snapshots: list[dict[str, Any]],
    used_ips: set[str],
    execute: bool,
) -> dict[str, Any]:
    name = region["displayName"]
    result: dict[str, Any] = {
        "success": 0,
        "failed": 0,
        "replacements": {},
        "items": [],
        "failureReasons": [],
    }
    print(f"{name} 阶段 1/4：并发解绑并释放 {len(snapshots)} 条旧 EIP。")
    release_results: dict[str, bool] = {}
    with ThreadPoolExecutor(max_workers=min(10, len(snapshots))) as pool:
        future_map = {
            pool.submit(release_eip_item, region, snapshot["releaseItem"], execute): snapshot
            for snapshot in snapshots
        }
        completed = 0
        for future in as_completed(future_map):
            snapshot = future_map[future]
            eip_id = str(snapshot["eipID"])
            try:
                released = bool(future.result())
            except Exception as exc:
                released = False
                print(f"{name} 旧 EIP {snapshot['oldAddress']} 释放异常：{exc}")
            release_results[eip_id] = released
            completed += 1
            print(f"{name} 旧 EIP 释放进度 {completed}/{len(snapshots)}，已提交成功 {sum(release_results.values())} 条。")

    submitted = [snapshot for snapshot in snapshots if release_results.get(str(snapshot["eipID"]))]
    failed_release = [snapshot for snapshot in snapshots if not release_results.get(str(snapshot["eipID"]))]
    result["failed"] += len(failed_release)
    if failed_release:
        print(f"{name} 有 {len(failed_release)} 条旧 EIP 未成功释放，不为这些目标重复购买。")
        for snapshot in failed_release:
            result["failureReasons"].append(
                f"{name} {snapshot['oldAddress']}：旧 EIP 解绑或释放失败"
            )
            current = find_eip_resource(region, str(snapshot["eipID"]), execute) if execute else snapshot["releaseItem"]
            if current and not eip_has_association(current):
                restored, target = bind_replacement_target(region, snapshot, current, execute)
                print(f"{name} 原 EIP {snapshot['oldAddress']} 恢复绑定 {target} {operation_result(restored, '成功', '失败')}")
    if not submitted:
        return result

    print(f"{name} 阶段 2/4：等待旧 EIP 从资源列表消失并归还配额。")
    submitted_ids = {str(snapshot["eipID"]) for snapshot in submitted}
    gone_ids, current_by_id = wait_for_eip_ids_gone(region, submitted_ids, execute)
    ready = [snapshot for snapshot in submitted if str(snapshot["eipID"]) in gone_ids]
    still_present = [snapshot for snapshot in submitted if str(snapshot["eipID"]) not in gone_ids]
    if still_present:
        print(f"{name} 有 {len(still_present)} 条旧 EIP 仍在退订处理中，本轮停止对应购买并尝试恢复原绑定。")
        for snapshot in still_present:
            result["failureReasons"].append(
                f"{name} {snapshot['oldAddress']}：旧 EIP 仍在退订处理中"
            )
            current = current_by_id.get(str(snapshot["eipID"]))
            if current and not eip_has_association(current):
                restored, target = bind_replacement_target(region, snapshot, current, execute)
                print(f"{name} 原 EIP {snapshot['oldAddress']} 恢复绑定 {target} {operation_result(restored, '成功', '失败')}")
        result["failed"] += len(still_present)
    if not ready:
        return result

    print(f"{name} 旧 EIP 已释放 {len(ready)} 条，等待 30 秒让配额和地址池完成同步。")
    sleep_with_stop(30 if execute else 0)
    target_count = len(ready)
    unique_eips: list[dict[str, Any]] = []
    fallback_eips: list[dict[str, Any]] = []

    print(f"{name} 阶段 3/4：并发购买 {target_count} 条新 EIP。")
    for round_index in range(1, 4):
        allocated_count = len(unique_eips) + len(fallback_eips)
        needed = target_count - allocated_count
        if needed <= 0:
            break
        current = list_eips(region, execute) if execute else []
        before_ids = {eip_resource_id(eip) for eip in current if eip_resource_id(eip)}
        pending_slots = ready[allocated_count:allocated_count + needed]
        accepted, rejected, prefix = submit_replacement_eip_batch(
            region,
            pending_slots,
            execute,
            round_index=round_index,
        )
        expected_names = {str(record["name"]) for record in accepted}
        arrived = wait_for_replacement_eip_batch(
            region,
            before_ids,
            expected_names,
            len(accepted),
            prefix,
            execute,
        )
        if len(arrived) < len(accepted):
            for eip in arrived:
                address = eip_public_address(eip)
                if address and address in used_ips:
                    fallback_eips.append({**eip, "_replacementRepeatedAddress": True})
                else:
                    unique_eips.append({**eip, "_replacementRepeatedAddress": False})
                    if address:
                        used_ips.add(address)
            print(f"{name} 已受理订单尚未全部到账，为避免重复购买，本地区停止继续提交。")
            break

        repeated: list[dict[str, Any]] = []
        for eip in arrived:
            address = eip_public_address(eip)
            if address and address in used_ips:
                repeated.append(eip)
                continue
            unique_eips.append({**eip, "_replacementRepeatedAddress": False})
            if address:
                used_ips.add(address)

        if rejected:
            print(f"{name} 本轮有 {len(rejected)} 条创建请求未受理，等待后续轮次重试。")
        if not repeated:
            if len(unique_eips) + len(fallback_eips) < target_count:
                sleep_with_stop(15 if execute else 0)
            continue
        print(f"{name} 本轮有 {len(repeated)} 条新资源被云端重新分配为旧公网地址。")
        if round_index >= 3:
            fallback_eips.extend({**eip, "_replacementRepeatedAddress": True} for eip in repeated)
            print(f"{name} 已达到 3 轮上限，保留这些 EIP 用于恢复原绑定，并标记为未完成换新。")
            break

        print(f"{name} 并发释放 {len(repeated)} 条重复地址 EIP，确认配额恢复后重试。")
        with ThreadPoolExecutor(max_workers=min(10, len(repeated))) as pool:
            release_map = {
                pool.submit(
                    release_eip_item,
                    region,
                    {**eip, "eipID": eip_resource_id(eip), "address": eip_public_address(eip)},
                    execute,
                ): eip
                for eip in repeated
            }
            repeated_released: set[str] = set()
            for future in as_completed(release_map):
                eip = release_map[future]
                eip_id = eip_resource_id(eip)
                try:
                    if future.result():
                        repeated_released.add(eip_id)
                    else:
                        fallback_eips.append({**eip, "_replacementRepeatedAddress": True})
                except Exception as exc:
                    print(f"{name} 重复地址 EIP {eip_public_address(eip)} 释放异常：{exc}")
                    fallback_eips.append({**eip, "_replacementRepeatedAddress": True})
        gone_repeated, repeated_current = wait_for_eip_ids_gone(region, repeated_released, execute, timeout=180)
        for eip_id in repeated_released.difference(gone_repeated):
            current_eip = repeated_current.get(eip_id)
            if current_eip:
                fallback_eips.append({**current_eip, "_replacementRepeatedAddress": True})
        if len(unique_eips) + len(fallback_eips) < target_count:
            print(f"{name} 重复地址资源释放完成，等待 30 秒后继续下一轮。")
            sleep_with_stop(30 if execute else 0)

    available = unique_eips[:target_count]
    if len(available) < target_count:
        available.extend(fallback_eips[: target_count - len(available)])
    available.sort(key=lambda item: (bool(item.get("_replacementRepeatedAddress")), ip_sort_key(eip_public_address(item))))

    print(f"{name} 阶段 4/4：并发绑定新 EIP，当前可绑定 {len(available)}/{target_count} 条。")
    pairs = list(zip(ready, available))
    if pairs:
        with ThreadPoolExecutor(max_workers=min(10, len(pairs))) as pool:
            future_map = {
                pool.submit(bind_replacement_target, region, snapshot, eip, execute): (snapshot, eip)
                for snapshot, eip in pairs
            }
            for future in as_completed(future_map):
                snapshot, eip = future_map[future]
                old_address = str(snapshot["oldAddress"])
                new_id = eip_resource_id(eip)
                new_address = eip_public_address(eip) or new_id
                repeated_address = bool(eip.get("_replacementRepeatedAddress"))
                try:
                    bind_result, target = future.result()
                except Exception as exc:
                    bind_result = {"statusCode": 900, "message": str(exc), "description": str(exc)}
                    target = str(snapshot.get("associationName") or snapshot.get("vip") or "原资源")
                print(f"{name} EIP {new_address} 绑定回 {target} {operation_result(bind_result, '成功', '失败')}")
                if not ok(bind_result):
                    result["failed"] += 1
                    result["failureReasons"].append(
                        f"{name} {new_address}：绑定回 {target} 失败："
                        f"{hide_resource_ids(response_reason(bind_result))}"
                    )
                    cleanup = delete_eip_resource(region, new_id, execute)
                    print(f"{name} 新 EIP {new_address} 绑定失败，释放新 EIP {operation_result(cleanup, '成功', '失败')}")
                    continue
                record = {
                    "region": name,
                    "oldEipID": snapshot["eipID"],
                    "oldAddress": old_address,
                    "newEipID": new_id,
                    "newAddress": new_address,
                    "target": target,
                    "changed": not repeated_address,
                }
                result["items"].append(record)
                if repeated_address:
                    result["failed"] += 1
                    result["failureReasons"].append(
                        f"{name} {target}：云端连续 3 轮分配到旧 IP {new_address}，"
                        "已恢复绑定但未完成换新"
                    )
                    print(f"{name} {target} 已恢复连接，但公网地址 {new_address} 命中旧IP库，本条未完成换新。")
                    continue
                result["success"] += 1
                result["replacements"][old_address] = new_address

    missing = target_count - len(pairs)
    if missing:
        result["failed"] += missing
        for snapshot in ready[len(pairs):]:
            target = str(snapshot.get("associationName") or snapshot.get("vip") or "原资源")
            result["failureReasons"].append(
                f"{name} {snapshot['oldAddress']}：新 EIP 未到账，{target} 未恢复绑定"
            )
        print(f"{name} 缺少 {missing} 条已到账 EIP，对应目标保持未绑定并标记失败。")
    return result


def security_rule_todo(region: dict[str, Any], protocol: str, port_range: str) -> dict[str, Any]:
    return {
        "region": region["displayName"],
        "securityGroupID": region.get("securityGroupID", ""),
        "direction": "入方向",
        "strategy": "允许",
        "priority": 1,
        "ipVersion": "IPv4",
        "protocol": protocol,
        "portRange": port_range,
        "source": "0.0.0.0/0",
    }


def security_rule_exists(region: dict[str, Any], protocol: str, port_range: str, execute: bool) -> bool:
    c = client(ENDPOINT_ECS, execute, timeout=30)
    payload = call_or_error(c.get, "/v4/ecs/vpc/query-security-groups", {"regionID": region["regionID"]}, retries=2, delay=3)
    sg_id = region.get("securityGroupID") or ""
    expected_protocol = protocol.lower()
    for sg in find_items(payload):
        if pick(sg, "securityGroupID", "securityGroupId", "id") != sg_id:
            continue
        for rule in sg.get("securityGroupRuleList", []) or []:
            if str(rule.get("direction", "")).lower() != "ingress":
                continue
            rule_protocol = str(rule.get("protocol", "")).lower()
            rule_range = str(rule.get("range", "") or rule.get("portRange", "") or "")
            if protocol != "Any" and rule_protocol != expected_protocol:
                continue
            if protocol == "Any" and rule_protocol not in ("any", ""):
                continue
            if port_range == "Any" or rule_range in (port_range, "Any"):
                return True
    return False


def ensure_security_rule(region: dict[str, Any], protocol: str, port_range: str, execute: bool) -> dict[str, Any]:
    if not region.get("securityGroupID"):
        return {"ok": False, "message": "没有找到安全组 ID。"}
    if security_rule_exists(region, protocol, port_range, execute):
        return {"ok": True, "message": "规则已存在。"}
    c = client(ENDPOINT_VPC, execute, timeout=30)
    rule = {
        "direction": "ingress",
        "action": "accept",
        "priority": 1,
        "protocol": protocol.upper() if protocol != "Any" else "ANY",
        "ethertype": "IPv4",
        "destCidrIp": "0.0.0.0/0",
        "description": "auto-open",
        "range": port_range,
    }
    result = call_or_error(
        c.post,
        "/v4/vpc/create-security-group-ingress",
        {
            "clientToken": str(uuid.uuid4()),
            "regionID": region["regionID"],
            "securityGroupID": region["securityGroupID"],
            "securityGroupRules": [rule],
        },
        retries=3,
        delay=3,
    )
    if ok(result):
        return {"ok": True, "message": response_reason(result) or "success"}
    reason = response_reason(result)
    if "already" in reason.lower() or "已存在" in reason or "重复" in reason:
        return {"ok": True, "message": "规则已存在。"}
    return {"ok": False, "message": reason}


def phase_ecs_eip_security(regions: list[dict[str, Any]], execute: bool) -> None:
    st = state()
    retained: dict[str, Any] = {}
    security_todos: list[dict[str, Any]] = []
    if not execute:
        for region in regions:
            print(f"\n== {region['displayName']} 绑定剩余 1 条 EIP 到 ECS 并处理安全组 ==")
            print(f"{region['displayName']} dry-run：真实执行时会把剩余 1 条 EIP 绑定 ECS，并添加 TCP/UDP 1-52、54-65535、ICMP Any 入方向安全组规则。")
        return
    for region in regions:
        name = region["displayName"]
        print(f"\n== {name} 绑定剩余 1 条 EIP 到 ECS 并处理安全组 ==")
        clear_eip_skip_if_ready(st, region, execute)
        skip_reason = skip_remaining_reason(st, name)
        if skip_reason and clear_skip_if_reusable_resources(st, region, execute):
            skip_reason = ""
        if skip_reason:
            print(f"{name} 已跳过。原因: {skip_reason}")
            continue
        current_havip_count = bound_havip_eip_count(region, execute)
        target_havips = len(planned_havip_ips())
        if current_havip_count < target_havips:
            print(f"{name} 子网虚拟 IP 当前只完成 {current_havip_count}/{target_havips}，先补齐虚拟 IP 绑定。")
            phase_havip_bind([region], execute)
            current_havip_count = bound_havip_eip_count(region, execute)
            if current_havip_count < target_havips:
                reason = f"子网虚拟 IP 未补齐：当前 {current_havip_count}/{target_havips}，暂不绑定云主机 EIP。"
                region_state(st, name)["ecsEipWarning"] = reason
                save_state(st)
                print(f"{name} {reason}")
                continue
        try:
            net = sync_network_from_ecs(region, execute)
        except SystemExit as exc:
            reason = str(exc)
            region_state(st, name)["ecsEipWarning"] = reason
            save_state(st)
            print(f"{name} {reason}")
            continue
        servers = wait_for_usable_servers(region, net["subnetID"], execute, TARGET_ECS_COUNT)
        if not servers:
            discovered_server = discover_ecs_bound_eip(region, execute)
            if discovered_server:
                servers = [discovered_server]
        if not servers:
            reason = "没有可用 ECS，无法绑定云服务器 EIP。"
            region_state(st, name)["ecsEipWarning"] = reason
            save_state(st)
            print(f"{name} {reason}")
            continue
        server = next((item for item in servers if primary_private_ip(item) == ecs_private_ip()), None)
        if not server:
            reason = f"ECS 内网 IP 规划不匹配：当前 {server_private_ip_summary(servers) or '未查询到'}；本流程要求 ECS={ecs_private_ip()}，暂不绑定云主机 EIP。"
            region_state(st, name)["ecsEipWarning"] = reason
            save_state(st)
            print(f"{name} {reason}")
            continue
        already_bound = False
        all_eips = list_eips(region, execute)
        assoc_eips = eips_by_association_from_items(all_eips)
        existing = assoc_eips.get(server["instanceID"])
        if not existing:
            detected_server, _ = find_ecs_bound_eip_from_items(region, all_eips, list_havips(region, net["subnetID"], execute), [server], execute=execute)
            if detected_server and isinstance(detected_server.get("_boundEip"), dict):
                server = detected_server
        if not existing and isinstance(server.get("_boundEip"), dict):
            existing = {
                "ID": server["_boundEip"].get("eipID"),
                "eipAddress": server["_boundEip"].get("address"),
                "name": server["_boundEip"].get("name"),
            }
        if existing:
            eips = [{"eipID": existing.get("ID"), "address": existing.get("eipAddress"), "name": existing.get("name")}]
            already_bound = True
            print(f"{name} ECS {server['name']} 已绑定 EIP {eips[0]['address']}，跳过重复绑定。")
        else:
            eips = wait_for_usable_eips(region, execute, 1)
            if not eips:
                reason = "没有剩余可用 EIP，无法绑定到 ECS。"
                region_state(st, name)["ecsEipWarning"] = reason
                save_state(st)
                print(f"{name} {reason}")
                continue
        eip = eips[0]
        if not eip.get("address"):
            reason = "没有剩余可用 EIP，无法绑定到 ECS。"
            region_state(st, name)["ecsEipWarning"] = reason
            save_state(st)
            print(f"{name} {reason}")
            continue
        if eip.get("eipID") and not already_bound:
            result = bind_eip_to_ecs(region, server, eip, execute)
            print(f"{name} ECS {server['name']} 绑定 EIP {eip['address']} {operation_result(result, '成功', '失败')}")
            if execute and not ok(result):
                reason = f"ECS 绑定云服务器 EIP 失败：{response_reason(result)}"
                region_state(st, name)["ecsEipWarning"] = reason
                save_state(st)
                print(f"{name} {reason}")
                continue
        sg_id = server.get("securityGroupID") or server_security_group_id(server)
        if not sg_id:
            refreshed = next((s for s in wait_for_usable_servers(region, net["subnetID"], execute, 1) if s.get("instanceID") == server.get("instanceID")), {})
            sg_id = refreshed.get("securityGroupID") or server_security_group_id(refreshed)
        security_region = {**region, "securityGroupID": sg_id}
        if sg_id:
            region["securityGroupID"] = sg_id
            update_region(name, {"securityGroupID": sg_id})
            print(f"{name} 使用云服务器安全组添加规则。")
        else:
            print(f"{name} 未能从云服务器 {server['name']} 读取到安全组信息。")
        rules = []
        for protocol, port_range in SECURITY_GROUP_RULES:
            rule = ensure_security_rule(security_region, protocol, port_range, execute)
            rules.append({"protocol": protocol, "range": port_range, **rule})
            print(f"{name} 安全组入方向 {protocol} {port_range} {bool_result(bool(rule['ok']), '添加成功', '添加失败', rule['message'])}")
            if not rule["ok"]:
                security_todos.append(security_rule_todo(security_region, protocol, port_range))
        item = {
            "region": name,
            "server": server["name"],
            "instanceID": server["instanceID"],
            "privateIP": server.get("privateIP", ""),
            "eip": eip["address"],
        }
        region_state(st, name)["ecsBoundEip"] = item
        region_state(st, name)["securityRules"] = rules
        if any(not r["ok"] for r in rules):
            region_state(st, name)["ecsEipWarning"] = "EIP 已绑定 ECS，但安全组规则有未完成项。"
        else:
            region_state(st, name).pop("ecsEipWarning", None)
        retained[name] = item
        save_state(st)
    save_json(ROOT / "retained_ips.json", retained)
    save_json(ROOT / "security_group_rules_todo.json", security_todos)
    print(f"已导出云服务器绑定 IP: {ROOT / 'retained_ips.json'}")
    if security_todos:
        print(f"安全组规则未自动添加，已导出待添加规则: {ROOT / 'security_group_rules_todo.json'}")


def wait_for_usable_eips(region: dict[str, Any], execute: bool, target: int | None = None) -> list[dict[str, Any]]:
    target = target_eip_count() if target is None else target
    if not execute:
        return usable_eips(region, execute)
    for attempt in range(1, 13):
        eips = usable_eips(region, execute)
        if len(eips) >= target:
            return eips
        print(f"{region['displayName']} 等待 EIP 生效：第 {attempt} 次，当前 {len(eips)}/{target}")
        sleep_with_stop(10)
    return usable_eips(region, execute)


def phase_export(regions: list[dict[str, Any]], execute: bool) -> None:
    out: dict[str, Any] = {}
    summary: dict[str, Any] = {}
    st = state()
    state_changed = False
    for region in regions:
        name = region["displayName"]
        clear_eip_skip_if_ready(st, region, execute)
        skip_reason = skip_remaining_reason(st, name)
        if skip_reason and clear_skip_if_reusable_resources(st, region, execute):
            skip_reason = ""
        if skip_reason:
            out[name] = []
            summary[name] = {"status": "skipped", "reason": skip_reason}
            continue
        try:
            net = sync_network_from_ecs(region, execute)
        except SystemExit as exc:
            out[name] = []
            summary[name] = {"status": "skipped", "reason": str(exc) or latest_failure_reason(st, name)}
            continue
        havips = list_havips(region, net["subnetID"], execute)
        all_eips = list_eips(region, execute)
        eips_by_id = {eip_resource_id(e): e for e in all_eips if eip_resource_id(e)}
        assoc_eips = eips_by_association_from_items(all_eips)
        servers = usable_servers(region, net["subnetID"], execute)
        discovered_server, live_ecs_bound_eip = find_ecs_bound_eip_from_items(region, all_eips, havips, servers, execute=execute)
        if discovered_server and not any(server_identifier_values(discovered_server) & server_identifier_values(item) for item in servers):
            servers = [discovered_server, *servers]
        if not servers:
            discovered_server = discover_ecs_bound_eip(region, execute)
            if discovered_server:
                servers = [discovered_server]
                live_ecs_bound_eip = ecs_bound_record_from_eip(
                    region,
                    discovered_server,
                    {
                        "eipAddress": (discovered_server.get("_boundEip") or {}).get("address", ""),
                        "ID": (discovered_server.get("_boundEip") or {}).get("eipID", ""),
                        "name": (discovered_server.get("_boundEip") or {}).get("name", ""),
                    },
                )
        eips = usable_eips(region, execute)
        rows = []
        for ip in sorted(planned_havip_ips(), key=lambda x: tuple(map(int, x.split(".")))):
            h = havips.get(ip, {})
            eip_ids = [x.get("eipID") for x in (h.get("networkInfo") or []) if x.get("eipID")]
            associated_eip = assoc_eips.get(h.get("id", ""), {})
            rows.append(
                {
                    "vip": ip,
                    "server": (h.get("instanceInfo") or [{}])[0].get("instanceName", ""),
                    "privateIP": (h.get("instanceInfo") or [{}])[0].get("privateIp", ""),
                    "eip": (
                        eips_by_id.get(eip_ids[0], {}).get("eipAddress", "")
                        if eip_ids
                        else associated_eip.get("eipAddress", "")
                    ),
                }
            )
        bound_count = sum(1 for row in rows if row["eip"])
        out[name] = rows
        rs = st.get("regions", {}).get(name, {})
        bind_failures = rs.get("havipBindFailures") or []
        if live_ecs_bound_eip:
            rs = region_state(st, name)
            if rs.get("ecsBoundEip") != live_ecs_bound_eip:
                state_changed = True
            rs["ecsBoundEip"] = live_ecs_bound_eip
            if rs.pop("ecsEipWarning", None) is not None:
                state_changed = True
        ecs_bound_eip = live_ecs_bound_eip or rs.get("ecsBoundEip") or {}
        if not ecs_bound_eip:
            discovered_server = next((item for item in servers if item.get("_boundEip")), None)
            if discovered_server:
                bound = discovered_server.get("_boundEip") or {}
                ecs_bound_eip = {
                    "region": name,
                    "server": discovered_server.get("name") or discovered_server.get("instanceID") or "",
                    "instanceID": discovered_server.get("instanceID") or "",
                    "privateIP": discovered_server.get("privateIP") or ecs_private_ip(),
                    "eip": bound.get("address") or "",
                }
        has_ecs_eip = bool(ecs_bound_eip.get("eip"))
        status = "completed" if bound_count >= len(planned_havip_ips()) and has_ecs_eip else "incomplete"
        reason = (
            rs.get("finalizeWarning", "")
            or rs.get("ecsEipWarning", "")
            or rs.get("havipWarning", "")
            or ("；".join(bind_failures[-3:]) if bind_failures else "")
            or (latest_failure_reason(st, name) if status != "completed" else "")
            or (rs.get("eipWarning", "") if status != "completed" else "")
        )
        summary[name] = {
            "status": status,
            "havipEipCount": bound_count,
            "ecsEip": ecs_bound_eip,
            "usableEcs": len(servers),
            "usableEip": len(eips),
            "reason": reason,
        }
    if state_changed:
        save_state(st)
    save_json(ROOT / "workflow_summary.json", summary)
    retained = {name: info.get("ecsEip", {}) for name, info in summary.items() if info.get("ecsEip")}
    has_success_output = any(info.get("status") == "completed" or info.get("ecsEip") for info in summary.values())
    if has_success_output:
        save_json(ROOT / "export_bound_ips.json", out)
        save_json(ROOT / "retained_ips.json", retained)
    save_json(ROOT / "run_result.json", {"virtualIps": out, "ecsIp": retained, "summary": summary})
    for path in [ROOT / "L2 - OK.txt", ROOT / "SK5 - OK.txt"]:
        path.unlink(missing_ok=True)
    end_time = os.environ.get("WORKFLOW_END_TIME", "")
    log_file = os.environ.get("WORKFLOW_LOG_FILE", "")
    if end_time:
        print(f"结束时间: {end_time}")
    if log_file:
        print(f"日志文件: {log_file}")
    print("")
    print("")
    print("")
    if has_success_output:
        print(f"已导出运行结果: {ROOT / 'run_result.json'}")
        print(f"已导出云服务器绑定 IP: {ROOT / 'retained_ips.json'}")
    else:
        for path in [ROOT / "L2 - OK.txt", ROOT / "SK5 - OK.txt", ROOT / "export_bound_ips.json", ROOT / "retained_ips.json"]:
            path.unlink(missing_ok=True)
        print("本次没有成功绑定结果，不导出结果文件。")
    print("")
    print("")
    for name, rows in out.items():
        info = summary.get(name, {})
        ecs_eip = (info.get("ecsEip") or {}).get("eip", "")
        if ecs_eip:
            print(f"[{name}] 服务器地区: {name}")
            print(f"[{name}] 服务器IP: {ecs_eip}")
            print(f"[{name}] 服务器密码: {PASSWORD}")
        else:
            reason = str(info.get("reason") or "").strip()
            if reason:
                print(f"{name}: 购买绑定未完成：{reason}")
            else:
                print(f"{name}: 云服务器还没有绑定弹性 IP")


def reminder(title: str, lines: list[str]) -> None:
    print(f"\n== {title} ==")
    for line in lines:
        print(f"- {line}")


def main() -> int:
    parser = argparse.ArgumentParser(description="Tianyi Cloud two-region workflow")
    parser.add_argument("--regions", help="例如: 合肥2,贵州3；如果使用 --input-file 可省略")
    parser.add_argument("--keys-file", help="两行文件：第一行 AK，第二行 SK")
    parser.add_argument("--input-file", help="四行输入文件：AK、SK、地区1、地区2可选")
    parser.add_argument("--execute", action="store_true", help="真实执行；不加则只 dry-run/预览")
    parser.add_argument("--run-end-time", default="", help=argparse.SUPPRESS)
    parser.add_argument("--run-log-file", default="", help=argparse.SUPPRESS)
    parser.add_argument("--cleanup-selection-file", default="", help=argparse.SUPPRESS)
    parser.add_argument("--ecs-spec", default=DEFAULT_ECS_SPEC, help="默认选择通用型 2核4G；也可指定 s6.large.2 等精确规格")
    parser.add_argument("--ecs-batch-size", type=int, default=10, help="ECS 每批购买数量，默认 10；库存紧张时可设为 1")
    parser.add_argument(
        "--phase",
        required=True,
        choices=[
            "check-balance",
            "check-partner",
            "buy-eip",
            "has-active-regions",
            "discover",
            "ensure-network",
            "buy-ecs",
            "op-protection-reminder",
            "havip-bind",
            "ecs-eip-security",
            "cleanup-query",
            "cleanup-execute",
            "reinstall-execute",
            "replace-ip",
            "retain-one-reminder",
            "cleanup-reminder",
            "security-group-reminder",
            "export",
        ],
    )
    args = parser.parse_args()
    if args.input_file:
        input_path = Path(args.input_file)
        set_keys_from_file(input_path)
        region_text = args.regions or regions_from_file(input_path)
    elif args.keys_file:
        set_keys_from_file(Path(args.keys_file))
        region_text = args.regions or "合肥2,贵州3"
    else:
        region_text = args.regions or "合肥2,贵州3"
    regs = regions_from_arg(region_text)
    if args.run_end_time:
        os.environ["WORKFLOW_END_TIME"] = args.run_end_time
    if args.run_log_file:
        os.environ["WORKFLOW_LOG_FILE"] = args.run_log_file
    if args.phase == "check-balance":
        phase_check_balance(args.execute)
    elif args.phase == "check-partner":
        phase_check_partner(args.execute)
    elif args.phase == "ensure-network":
        phase_ensure_network(regs, args.execute)
    elif args.phase == "discover":
        phase_discover(regs, args.execute, args.ecs_spec)
    elif args.phase == "buy-eip":
        if not phase_buy_eip(regs, args.execute):
            return 1
    elif args.phase == "has-active-regions":
        if not phase_has_active_regions(regs, args.execute):
            return 2
    elif args.phase == "buy-ecs":
        phase_buy_ecs(regs, args.execute, args.ecs_batch_size)
    elif args.phase == "havip-bind":
        phase_havip_bind(regs, args.execute)
    elif args.phase == "ecs-eip-security":
        phase_ecs_eip_security(regs, args.execute)
    elif args.phase == "cleanup-query":
        return int(phase_cleanup_query(regs, args.execute) or 0)
    elif args.phase == "cleanup-execute":
        phase_cleanup_execute(regs, args.execute, Path(args.cleanup_selection_file) if args.cleanup_selection_file else None)
    elif args.phase == "reinstall-execute":
        phase_reinstall_execute(regs, args.execute, Path(args.cleanup_selection_file) if args.cleanup_selection_file else None)
    elif args.phase == "replace-ip":
        phase_replace_ip(regs, args.execute, Path(args.cleanup_selection_file) if args.cleanup_selection_file else None)
    elif args.phase == "export":
        phase_export(regs, args.execute)
    elif args.phase == "op-protection-reminder":
        reminder("手动关闭操作保护", ["账号 -> 安全设置 -> 登录保护 -> 操作保护 -> 立即修改 -> 关闭 -> 保存。", "涉及账号安全，脚本只提醒，不自动关闭。"])
    elif args.phase == "retain-one-reminder":
        reminder("保留一台并转包周期", ["每个地区选择 1 条未绑定 EIP 绑定任意一台云主机。", "把这台云主机转包周期 1 个月并开启自动续费；支付动作由你确认。"])
    elif args.phase == "cleanup-reminder":
        reminder("关机并删除其他云主机", ["每个地区保留已转包周期的那台。", "其他云主机先关机，再删除；此步骤破坏性强，建议网页确认后操作。"])
    elif args.phase == "security-group-reminder":
        reminder("安全组规则", ["在每个地区默认安全组添加入方向 TCP 1-52 允许。", "添加入方向 TCP 54-65535 允许。", "添加入方向 UDP 1-52 允许。", "添加入方向 UDP 54-65535 允许。", "添加入方向 ICMP Any 允许。"])
    return 0


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