"""奇成 MySQL 只读查询封装。

接入 kyhl 库,仅 SELECT,强制 READ ONLY 事务防止误写。
所有查询按 ICCID/agent_id 批量分块,避免单条 SQL 过大。
"""
from __future__ import annotations

import contextlib
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable, Iterator, Optional


# 重构后分阶段索引查询已无大 JOIN,1000 为安全默认值
BATCH_SIZE = 1000


class _StageTimer:
    """阶段耗时计时器,记录迁移各阶段用时。"""

    def __init__(self, func_name: str) -> None:
        self._func = func_name
        self._t = time.perf_counter()

    def log(self, stage: str, **kw: Any) -> None:
        elapsed = time.perf_counter() - self._t
        parts = " ".join(f"{k}={v}" for k, v in kw.items())
        suffix = f" {parts}" if parts else ""
        print(f"[legacy] {self._func}.{stage}:{suffix} elapsed={elapsed:.1f}s")
        self._t = time.perf_counter()


def _normalize_iccid_pairs(iccid_pairs: Iterable[tuple]) -> list[tuple[str, str, bool]]:
    """标准化 ICCID 查询参数。

    tuple 第三项表示是否允许用 iccid_19 查询奇成 tbl_card 主表:
    - 只填 iccid_20 的固定 20 位卡:false,避免 19 位前缀误撞其他卡
    - 显式填了 iccid_19:true,允许作为主表查询键或 20 位缺失时兜底
    """
    out: list[tuple[str, str, bool]] = []
    for item in iccid_pairs:
        i19 = str(item[0] or "").strip() if len(item) > 0 else ""
        i20 = str(item[1] or "").strip() if len(item) > 1 else ""
        allow_i19_lookup = bool(item[2]) if len(item) > 2 else True
        if i19:
            out.append((i19, i20, allow_i19_lookup))
    return out


def _import_pymysql():
    """延迟导入 pymysql,这样脚本1(纯离线)不需要安装 pymysql 也能运行。"""
    try:
        import pymysql
        from pymysql.cursors import DictCursor
        return pymysql, DictCursor
    except ImportError as exc:
        raise ImportError("缺少 pymysql,请先 pip install -r requirements.txt") from exc


@dataclass(frozen=True)
class LegacyPackage:
    """奇成当前生效正式套餐快照(取自 tbl_card_life)。"""

    iccid: str
    meal_id: str
    meal_name: str
    meal_type: str
    start_date: Optional[str]
    expire_date: Optional[str]
    flow_size_mb: int  # 套餐总量,单位 MB
    status: int


@dataclass(frozen=True)
class LegacyPackageLifecycle:
    """奇成正式套餐生命周期记录。"""

    iccid: str
    source_table: str
    life_id: str
    meal_id: str
    meal_name: str
    meal_type: str
    status: int
    start_date: Optional[str]
    expire_date: Optional[str]
    flow_size_mb: int
    stable_sort_key: str
    migration_status: str
    skip_reason: str = ""


@dataclass(frozen=True)
class LegacyCardUsage:
    """奇成卡累计流量快照。

    奇成 ``tbl_card.total_bytes_cnt`` 存的是经过当前套餐 ``flow_add_discount``
    加成后的"虚用量",我们这里同时拉取当前生效正式套餐(``tbl_card_life`` status=1,type!=1
    且 ``expire_date`` 最大)的 ``flow_add_discount``,反算回新系统期望的"真用量"。

    精度说明:跨多个套餐周期累计时,``total_bytes_cnt`` 是按各时段对应套餐
    虚比加权累加的,这里只用"当前生效套餐"的虚比反算,**有误差但可接受**——
    奇成没有按套餐分段的用量明细,无更好做法。
    """

    iccid: str                       # 业务方视角的完整 ICCID
    virtual_used_mb: Decimal         # 奇成 total_bytes_cnt 原始值(虚 MB)
    flow_add_discount_pct: Decimal   # 当前生效套餐虚比百分比(0~100)
    has_active_package: bool         # 是否在 tbl_card_life 中找到当前生效正式套餐

    @property
    def real_used_mb(self) -> Decimal:
        """反算真用量(Decimal MB,保留小数)。

        虚 = 真 × (1 + pct/100) → 真 = 虚 / (1 + pct/100)
        """
        if self.virtual_used_mb <= 0:
            return Decimal("0")
        ratio = Decimal(1) + self.flow_add_discount_pct / Decimal(100)
        return self.virtual_used_mb / ratio

    @property
    def real_used_mb_floor(self) -> int:
        """适配新系统 ``data_usage_mb``(BIGINT)的整数 MB。

        取整规则:0 → 0,(0, 1) → 1,≥1 → 向下取整。
        sub-MB 上取整为 1,避免"明明用过的卡显示 0"。
        """
        real = self.real_used_mb
        if real <= 0:
            return 0
        if real < 1:
            return 1
        return int(real)


@dataclass(frozen=True)
class LegacyCommissionAccount:
    """奇成代理佣金账户快照。"""

    agent_id: str
    agent_name: str
    can_draw_amount_fen: int      # 当前可提现佣金(分)
    applying_draw_amount_fen: int # 申请提现中佣金(分)
    total_commission_amount_fen: int  # 累计总佣金(分,做参考)
    total_draw_amount_fen: int    # 累计已提现(分,做参考)


@dataclass(frozen=True)
class LegacyAgentBalance:
    """奇成代理主钱包余额快照(取自 tbl_agent.balance_money)。"""

    agent_id: str
    balance_fen: int


@dataclass(frozen=True)
class LegacyCardMeta:
    """奇成卡基础元数据(由 scan_legacy / migrate_assets 共享)。

    业务方提供的 iccid_19 + iccid_20 才是新系统的权威值,奇成的 iccid_mark 长度不可信。
    legacy_raw_iccid 仅作调试参考。
    """

    legacy_raw_iccid: str        # 奇成实际存的 iccid_mark(可能 19 或 20 位)
    account_id: str              # tbl_card.account_id(开卡公司/运营商账号 ID)
    account_name: str            # tbl_card.account_name(开卡公司/运营商账号名称)
    category: int                # tbl_card.category(运营商分类编号)
    category_name: str           # tbl_vendor_category.category_name
    agent_id: str
    agent_name: str
    msisdn: str                  # tbl_card.phone
    virtual_no: str              # tbl_virtual_number.vcode(如有)
    current_meal_id: str         # 当前生效正式套餐 ID(可空,取自 tbl_card_life status=1,type!=1,expire_date 最大)
    current_meal_name: str
    current_meal_type: str       # tbl_card_life.type,1=叠加包/加油包,2=月卡,3=季卡,4=半年卡,5=年卡
    current_expire_date: Optional[str]  # ISO 字符串
    current_series_id: str       # 当前生效正式套餐所属奇成套餐系列 ID
    current_series_name: str


@dataclass(frozen=True)
class LegacyMealMeta:
    """奇成套餐 + 系列元数据(由 scan_legacy 用)。"""

    meal_id: str
    meal_name: str
    series_id: str
    series_name: str


# ---------------- 连接管理 ----------------


@contextlib.contextmanager
def connect_readonly(dsn: dict) -> Iterator[Any]:
    """打开只读连接。

    用法:
        with connect_readonly(dsn) as conn:
            rows = fetch_current_packages(conn, iccids)
    """
    pymysql, DictCursor = _import_pymysql()
    conn = pymysql.connect(
        host=dsn["host"],
        port=int(dsn.get("port", 3306)),
        user=dsn["user"],
        password=dsn["password"],
        database=dsn["database"],
        charset=dsn.get("charset", "utf8mb4"),
        connect_timeout=int(dsn.get("connect_timeout", 10)),
        cursorclass=DictCursor,
        autocommit=False,
    )
    try:
        with conn.cursor() as cur:
            cur.execute("SET SESSION TRANSACTION READ ONLY")
            cur.execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
        conn.begin()
        yield conn
        conn.rollback()  # 只读事务,回滚即结束
    finally:
        conn.close()


def _chunks(items: list[str], size: int = BATCH_SIZE) -> Iterator[list[str]]:
    for start in range(0, len(items), size):
        yield items[start : start + size]


def _to_fen(value) -> int:
    """元 → 分。奇成的 decimal 是元单位。"""
    if value is None:
        return 0
    try:
        d = Decimal(str(value))
    except Exception:
        return 0
    return int((d * Decimal("100")).quantize(Decimal("1")))


def _to_mb_decimal(value) -> Decimal:
    """字符串/数字 → Decimal MB。奇成 total_bytes_cnt 是小数 MB 读数。"""
    if value is None or value == "":
        return Decimal("0")
    try:
        d = Decimal(str(value))
        if d < 0:
            return Decimal("0")
        return d
    except Exception:
        return Decimal("0")


def _build_card_lookup(
    pairs: list[tuple[str, str, bool]],
) -> dict[str, tuple[str, int]]:
    """构建 tbl_card 查询键 → (完整 ICCID, rank) 映射。

    rank=0 表示完整精确命中,rank=1 表示 19 位兜底命中。
    """
    by_full: dict[str, tuple[str, int]] = {}
    for i19, i20, allow_i19_lookup in pairs:
        card_key = i20 or i19
        by_full[card_key] = (card_key, 0)
        if i20 and allow_i19_lookup:
            by_full[i19] = (card_key, 1)
    return by_full


# ---------------- 查询函数 ----------------


def fetch_current_packages(
    conn,
    iccids: Iterable[str],
    batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyPackage]:
    """查每张卡的当前生效主套餐:status=1、type!=1 且 expire_date 最大。

    返回: iccid → LegacyPackage(找不到的 iccid 不在结果中)
    """
    timer = _StageTimer("fetch_current_packages")
    iccid_list = sorted({i for i in iccids if i})
    out: dict[str, LegacyPackage] = {}
    if not iccid_list:
        return out

    sql = """
        SELECT
            iccid_mark,
            meal_id,
            meal_name,
            COALESCE(CAST(type AS CHAR), '') AS meal_type,
            start_date,
            expire_date,
            COALESCE(flow_size, 0) AS flow_size,
            status
        FROM tbl_card_life
        WHERE iccid_mark IN %s
          AND status = 1
          AND COALESCE(CAST(type AS CHAR), '') <> '1'
        ORDER BY iccid_mark ASC, expire_date DESC
    """
    seen: set[str] = set()
    rows = 0
    batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(iccid_list, batch_size):
            batches += 1
            cur.execute(sql, (tuple(batch),))
            for row in cur.fetchall():
                iccid = row["iccid_mark"]
                rows += 1
                if not iccid or iccid in seen:
                    continue
                seen.add(iccid)
                out[iccid] = LegacyPackage(
                    iccid=iccid,
                    meal_id=row.get("meal_id") or "",
                    meal_name=row.get("meal_name") or "",
                    meal_type=str(row.get("meal_type") or ""),
                    start_date=row["start_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("start_date") else None,
                    expire_date=row["expire_date"].strftime("%Y-%m-%d %H:%M:%S") if row.get("expire_date") else None,
                    flow_size_mb=int(row.get("flow_size") or 0),
                    status=int(row.get("status") or 0),
                )
    timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows, found=len(out))
    return out


def fetch_package_lifecycles(
    conn,
    iccid_pairs: Iterable[tuple],
    batch_size: int = BATCH_SIZE,
) -> dict[str, list[LegacyPackageLifecycle]]:
    """查当前生效和未生效待生效正式套餐生命周期。

    只返回可迁移范围(active/pending)和需要在审核文件中说明的跳过记录。
    加油包(type=1)和已过期记录不生成套餐 SQL,但保留 skip_reason 供审核产物输出。
    """
    timer = _StageTimer("fetch_package_lifecycles")
    pairs = _normalize_iccid_pairs(iccid_pairs)
    out: dict[str, list[LegacyPackageLifecycle]] = {}
    if not pairs:
        return out

    query_to_full: dict[str, str] = {}
    for i19, i20, allow_i19_lookup in pairs:
        card_key = i20 or i19
        query_to_full[card_key] = card_key
        if i20 and allow_i19_lookup:
            query_to_full[i19] = card_key
        if not i20:
            query_to_full[i19] = card_key
    iccid_list = sorted(query_to_full.keys())

    sql = """
        SELECT
            'tbl_card_life' AS source_table,
            COALESCE(CAST(id AS CHAR), '') AS life_id,
            iccid_mark,
            COALESCE(meal_id, '') AS meal_id,
            COALESCE(meal_name, '') AS meal_name,
            COALESCE(CAST(type AS CHAR), '') AS meal_type,
            status,
            start_date,
            expire_date,
            COALESCE(flow_size, 0) AS flow_size
        FROM tbl_card_life
        WHERE iccid_mark IN %s
        ORDER BY
            iccid_mark ASC,
            COALESCE(start_date, '1970-01-01') ASC,
            COALESCE(expire_date, '9999-12-31') ASC,
            id ASC
    """
    rows = 0
    batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(iccid_list, batch_size):
            batches += 1
            cur.execute(sql, (tuple(batch),))
            for row in cur.fetchall():
                legacy_iccid = row.get("iccid_mark") or ""
                if not legacy_iccid:
                    continue
                iccid = query_to_full.get(legacy_iccid)
                if not iccid:
                    continue
                rows += 1
                start = row.get("start_date")
                expire = row.get("expire_date")
                start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
                expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
                meal_type = str(row.get("meal_type") or "")
                raw_status = int(row.get("status") or 0)
                migration_status, skip_reason = _classify_package_lifecycle(meal_type, raw_status, start, expire)
                life_id = str(row.get("life_id") or "")
                stable_sort_key = "|".join([start_str or "", expire_str or "", life_id])
                item = LegacyPackageLifecycle(
                    iccid=iccid,
                    source_table=row.get("source_table") or "tbl_card_life",
                    life_id=life_id,
                    meal_id=row.get("meal_id") or "",
                    meal_name=row.get("meal_name") or "",
                    meal_type=meal_type,
                    status=raw_status,
                    start_date=start_str,
                    expire_date=expire_str,
                    flow_size_mb=int(row.get("flow_size") or 0),
                    stable_sort_key=stable_sort_key,
                    migration_status=migration_status,
                    skip_reason=skip_reason,
                )
                out.setdefault(iccid, []).append(item)
    timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows)

    next_sql = """
        SELECT
            'tbl_next_month_card_life' AS source_table,
            COALESCE(CAST(id AS CHAR), '') AS life_id,
            iccid_mark,
            COALESCE(meal_id, '') AS meal_id,
            COALESCE(meal_name, '') AS meal_name,
            COALESCE(CAST(order_type AS CHAR), '') AS meal_type,
            status,
            effect_begin_time AS start_date,
            effect_complete_time AS expire_date,
            COALESCE(flow_size, 0) AS flow_size
        FROM tbl_next_month_card_life
        WHERE iccid_mark IN %s
        ORDER BY
            iccid_mark ASC,
            COALESCE(effect_begin_time, '1970-01-01') ASC,
            COALESCE(effect_complete_time, '9999-12-31') ASC,
            id ASC
    """
    next_rows = 0
    next_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(iccid_list, batch_size):
            next_batches += 1
            cur.execute(next_sql, (tuple(batch),))
            for row in cur.fetchall():
                legacy_iccid = row.get("iccid_mark") or ""
                if not legacy_iccid:
                    continue
                iccid = query_to_full.get(legacy_iccid)
                if not iccid:
                    continue
                next_rows += 1
                start = row.get("start_date")
                expire = row.get("expire_date")
                start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
                expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
                raw_status = int(row.get("status") or 0)
                migration_status, skip_reason = _classify_next_month_lifecycle(raw_status)
                life_id = str(row.get("life_id") or "")
                stable_sort_key = "|".join([start_str or "", expire_str or "", f"next:{life_id}"])
                out.setdefault(iccid, []).append(LegacyPackageLifecycle(
                    iccid=iccid,
                    source_table=row.get("source_table") or "tbl_next_month_card_life",
                    life_id=life_id,
                    meal_id=row.get("meal_id") or "",
                    meal_name=row.get("meal_name") or "",
                    meal_type=str(row.get("meal_type") or ""),
                    status=raw_status,
                    start_date=start_str,
                    expire_date=expire_str,
                    flow_size_mb=int(row.get("flow_size") or 0),
                    stable_sort_key=stable_sort_key,
                    migration_status=migration_status,
                    skip_reason=skip_reason,
                ))
    timer.log("tbl_next_month_card_life", keys=len(iccid_list), batches=next_batches, rows=next_rows)
    return out


def _classify_package_lifecycle(meal_type: str, status: int, start_date, expire_date) -> tuple[str, str]:
    """把奇成生命周期记录分类为 active/pending/skipped。"""
    if meal_type == "1":
        return "skipped", "加油包不迁移"

    from datetime import datetime

    now = datetime.now()
    if expire_date and expire_date <= now:
        return "skipped", "已过期套餐不迁移"
    if status == 1:
        if start_date and start_date > now:
            return "pending", ""
        return "active", ""
    if start_date and start_date > now:
        return "pending", ""
    return "skipped", f"奇成状态 {status} 不在迁移范围"


def _classify_next_month_lifecycle(status: int) -> tuple[str, str]:
    """把奇成次月待生效套餐记录分类为 pending/skipped。"""
    if status == 1:
        return "pending", ""
    if status == 2:
        return "skipped", "次月待生效记录已插入 tbl_card_life,避免重复迁移"
    return "skipped", f"奇成次月待生效状态 {status} 不在迁移范围"


def fetch_card_usage(
    conn,
    iccid_pairs: Iterable[tuple],
    batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCardUsage]:
    """查每张卡的累计已用流量,分阶段索引查询替代大 JOIN。

    阶段:
        1. tbl_card — iccid_mark + total_bytes_cnt
        2. tbl_card_life — 当前生效正式套餐 meal_id(Python 选 expire_date 最大)
        3. tbl_set_meal — flow_add_discount(按 meal_id 精确查)
        4. Python 组装 LegacyCardUsage

    Args:
        iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。

    Returns:
        {完整 ICCID: LegacyCardUsage}。
    """
    timer = _StageTimer("fetch_card_usage")
    pairs = _normalize_iccid_pairs(iccid_pairs)
    out: dict[str, LegacyCardUsage] = {}
    if not pairs:
        return out

    by_full = _build_card_lookup(pairs)
    full_set = sorted(by_full.keys())

    # ── 阶段1: tbl_card ───────────────────────────────────────────────────────
    card_sql = """
        SELECT
            iccid_mark,
            COALESCE(total_bytes_cnt, 0) AS total_bytes_cnt
        FROM tbl_card
        WHERE iccid_mark IN %s
    """
    hits: dict[str, list[tuple[int, dict]]] = {}
    card_rows = 0
    card_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(full_set, batch_size):
            card_batches += 1
            cur.execute(card_sql, (tuple(batch),))
            for row in cur.fetchall():
                legacy_iccid = row["iccid_mark"]
                if not legacy_iccid:
                    continue
                matched = by_full.get(legacy_iccid)
                if matched is None:
                    continue
                card_key, rank = matched
                hits.setdefault(card_key, []).append((rank, row))
                card_rows += 1
    timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)

    # 取最优命中行,构建 legacy_iccid 列表
    card_data: dict[str, dict] = {}   # card_key → best tbl_card row
    for card_key, rows in hits.items():
        best_rank = min(r for r, _ in rows)
        card_data[card_key] = next(row for r, row in rows if r == best_rank)

    if not card_data:
        return out

    legacy_iccids = sorted({row["iccid_mark"] for row in card_data.values()})

    # ── 阶段2: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
    life_sql = """
        SELECT
            iccid_mark,
            COALESCE(meal_id, '') AS meal_id
        FROM tbl_card_life
        WHERE iccid_mark IN %s
          AND status = 1
          AND COALESCE(CAST(type AS CHAR), '') <> '1'
        ORDER BY iccid_mark ASC, expire_date DESC, id DESC
    """
    current_meal_by_iccid: dict[str, str] = {}  # legacy_iccid → meal_id
    life_rows = 0
    life_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(legacy_iccids, batch_size):
            life_batches += 1
            cur.execute(life_sql, (tuple(batch),))
            for row in cur.fetchall():
                iccid = row["iccid_mark"]
                life_rows += 1
                if iccid not in current_meal_by_iccid:
                    current_meal_by_iccid[iccid] = row.get("meal_id") or ""
    timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows)

    # ── 阶段3: tbl_set_meal flow_add_discount(按 distinct meal_id 查) ─────────
    meal_ids = sorted({mid for mid in current_meal_by_iccid.values() if mid})
    discount_by_meal: dict[str, Decimal] = {}
    if meal_ids:
        meal_sql = "SELECT id, COALESCE(flow_add_discount, 0) AS flow_add_discount FROM tbl_set_meal WHERE id IN %s"
        meal_batches = 0
        with conn.cursor() as cur:
            for batch in _chunks(meal_ids, batch_size):
                meal_batches += 1
                cur.execute(meal_sql, (tuple(batch),))
                for row in cur.fetchall():
                    mid = str(row["id"] or "")
                    if mid:
                        discount_by_meal[mid] = _to_mb_decimal(row.get("flow_add_discount"))
        timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)
    else:
        timer.log("tbl_set_meal", meal_ids=0, skipped=True)

    # ── 阶段4: Python 组装 ────────────────────────────────────────────────────
    for card_key, base_row in card_data.items():
        legacy_iccid = base_row["iccid_mark"]
        meal_id = current_meal_by_iccid.get(legacy_iccid, "")
        has_active = bool(meal_id or legacy_iccid in current_meal_by_iccid)
        discount = discount_by_meal.get(str(meal_id), Decimal("0")) if meal_id else Decimal("0")
        out[card_key] = LegacyCardUsage(
            iccid=card_key,
            virtual_used_mb=_to_mb_decimal(base_row.get("total_bytes_cnt")),
            flow_add_discount_pct=discount,
            has_active_package=has_active,
        )
    timer.log("assemble", cards=len(out))
    return out


def fetch_commission_accounts(
    conn,
    agent_ids: Iterable[str],
    batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCommissionAccount]:
    """查代理佣金账户。"""
    timer = _StageTimer("fetch_commission_accounts")
    agent_list = sorted({a for a in agent_ids if a})
    out: dict[str, LegacyCommissionAccount] = {}
    if not agent_list:
        return out

    sql = """
        SELECT
            agent_id, agent_name,
            can_draw_amount, applying_draw_amount,
            total_commision_amount, total_draw_amount
        FROM tbl_agent_commission_account
        WHERE agent_id IN %s
    """
    rows = 0
    batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(agent_list, batch_size):
            batches += 1
            cur.execute(sql, (tuple(batch),))
            for row in cur.fetchall():
                aid = row["agent_id"]
                rows += 1
                if not aid:
                    continue
                out[aid] = LegacyCommissionAccount(
                    agent_id=aid,
                    agent_name=row.get("agent_name") or "",
                    can_draw_amount_fen=_to_fen(row.get("can_draw_amount")),
                    applying_draw_amount_fen=_to_fen(row.get("applying_draw_amount")),
                    total_commission_amount_fen=_to_fen(row.get("total_commision_amount")),
                    total_draw_amount_fen=_to_fen(row.get("total_draw_amount")),
                )
    timer.log("tbl_agent_commission_account", agents=len(agent_list), batches=batches, rows=rows)
    return out


def fetch_card_meta(
    conn,
    iccid_pairs: Iterable[tuple],
    batch_size: int = BATCH_SIZE,
) -> tuple[dict[str, LegacyCardMeta], set[str]]:
    """查每张卡的元数据,分阶段索引查询替代大 JOIN。

    阶段:
        1. tbl_card — 卡基础字段 + 去重检测
        2. tbl_vendor_category — 运营商分类名称(按 distinct category 查)
        3. tbl_card_life — 当前生效正式套餐(Python 选 expire_date 最大,精确匹配)
        4. tbl_set_meal + tbl_set_meal_series — 套餐系列(按 distinct meal_id 查)
        5. tbl_virtual_number — 虚拟号(精确匹配,不做 LEFT() 前缀扫描)
        6. Python 组装 LegacyCardMeta

    匹配策略:
        - tbl_card 主表同时查优先键和显式允许的兜底键;20 位精确命中优先
        - 从表(card_life / virtual_number)用 tbl_card 实际返回的 iccid_mark 精确查询
        - 不再使用 LEFT(vn.iccid_mark, 19) 前缀匹配,避免全表扫描

    Args:
        iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。

    Returns:
        (metas, duplicate_iccids):
            - metas: {完整 ICCID: LegacyCardMeta}
            - duplicate_iccids: 一份业务方 ICCID 命中多条 tbl_card 记录的 set
    """
    timer = _StageTimer("fetch_card_meta")
    pairs = _normalize_iccid_pairs(iccid_pairs)
    if not pairs:
        return {}, set()

    by_full = _build_card_lookup(pairs)
    full_set = sorted(by_full.keys())

    # ── 阶段1: tbl_card ───────────────────────────────────────────────────────
    card_sql = """
        SELECT
            id                             AS card_id,
            iccid_mark,
            COALESCE(account_id, '')        AS account_id,
            COALESCE(account_name, '')      AS account_name,
            COALESCE(category, 0)           AS category,
            COALESCE(agent_id, '')          AS agent_id,
            COALESCE(agent_name, '')        AS agent_name,
            COALESCE(phone, '')             AS phone
        FROM tbl_card
        WHERE iccid_mark IN %s
    """
    hits: dict[str, list[tuple[int, dict]]] = {}
    card_rows = 0
    card_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(full_set, batch_size):
            card_batches += 1
            cur.execute(card_sql, (tuple(batch),))
            for row in cur.fetchall():
                legacy_iccid = row["iccid_mark"]
                if not legacy_iccid:
                    continue
                matched = by_full.get(legacy_iccid)
                if matched is None:
                    continue
                card_key, rank = matched
                hits.setdefault(card_key, []).append((rank, row))
                card_rows += 1
    timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)

    # 去重:同一完整 ICCID 命中多条不同 card_id → duplicates
    metas_base: dict[str, dict] = {}   # card_key → best tbl_card row
    duplicates: set[str] = set()
    categories: set[int] = set()

    for card_key, rows in hits.items():
        best_rank = min(r for r, _ in rows)
        best_rows = [row for r, row in rows if r == best_rank]
        rows_by_card_id: dict[str, dict] = {}
        for row in best_rows:
            rows_by_card_id[str(row.get("card_id") or row.get("iccid_mark") or "")] = row
        if len(rows_by_card_id) > 1:
            duplicates.add(card_key)
            continue
        row = next(iter(rows_by_card_id.values()))
        metas_base[card_key] = row
        categories.add(int(row.get("category") or 0))

    if not metas_base:
        return {}, duplicates

    # legacy_iccids: tbl_card 实际存储的 iccid_mark 值(用于从表精确查询)
    legacy_iccids = sorted({row["iccid_mark"] for row in metas_base.values()})

    # ── 阶段2: tbl_vendor_category(distinct category,通常极少几十个) ─────────
    category_names: dict[int, str] = {}
    cat_batches = 0
    if categories:
        cat_sql = """
            SELECT category, COALESCE(category_name, '') AS category_name
            FROM tbl_vendor_category
            WHERE category IN %s
        """
        cat_list = sorted(categories)
        with conn.cursor() as cur:
            for batch in _chunks(cat_list, batch_size):
                cat_batches += 1
                cur.execute(cat_sql, (tuple(batch),))
                for row in cur.fetchall():
                    category_names[int(row["category"])] = row.get("category_name") or ""
    timer.log("tbl_vendor_category", categories=len(categories), batches=cat_batches)

    # ── 阶段3: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
    # 精确用 legacy_iccid 查询;不再做 LEFT(iccid_mark, 19) 前缀扫描
    life_sql = """
        SELECT
            iccid_mark,
            COALESCE(meal_id, '')            AS meal_id,
            COALESCE(meal_name, '')          AS meal_name,
            COALESCE(CAST(type AS CHAR), '') AS meal_type,
            expire_date
        FROM tbl_card_life
        WHERE iccid_mark IN %s
          AND status = 1
          AND COALESCE(CAST(type AS CHAR), '') <> '1'
        ORDER BY iccid_mark ASC, expire_date DESC, id DESC
    """
    current_meals: dict[str, dict] = {}  # legacy_iccid → 当前生效套餐行
    meal_ids: set[str] = set()
    life_rows = 0
    life_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(legacy_iccids, batch_size):
            life_batches += 1
            cur.execute(life_sql, (tuple(batch),))
            for row in cur.fetchall():
                iccid = row["iccid_mark"]
                life_rows += 1
                if iccid not in current_meals:
                    current_meals[iccid] = row
                    if row.get("meal_id"):
                        meal_ids.add(row["meal_id"])
    timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows, found=len(current_meals))

    # ── 阶段4: tbl_set_meal + tbl_set_meal_series(按 distinct meal_id) ───────
    meal_series: dict[str, tuple[str, str]] = {}  # meal_id → (series_id, series_name)
    meal_batches = 0
    if meal_ids:
        meal_sql = """
            SELECT
                sm.id                            AS meal_id,
                COALESCE(sm.meal_series_id, '')  AS series_id,
                COALESCE(sms.name, '')           AS series_name
            FROM tbl_set_meal sm
            LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
            WHERE sm.id IN %s
        """
        meal_id_list = sorted(meal_ids)
        with conn.cursor() as cur:
            for batch in _chunks(meal_id_list, batch_size):
                meal_batches += 1
                cur.execute(meal_sql, (tuple(batch),))
                for row in cur.fetchall():
                    mid = str(row["meal_id"] or "")
                    if mid:
                        meal_series[mid] = (row.get("series_id") or "", row.get("series_name") or "")
    timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)

    # ── 阶段5: tbl_virtual_number(精确匹配,不做前缀扫描) ─────────────────────
    virtual_nos: dict[str, str] = {}  # legacy_iccid → vcode
    vn_rows = 0
    vn_batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(legacy_iccids, batch_size):
            vn_batches += 1
            cur.execute(
                "SELECT iccid_mark, COALESCE(vcode, '') AS vcode FROM tbl_virtual_number WHERE iccid_mark IN %s",
                (tuple(batch),),
            )
            for row in cur.fetchall():
                iccid = row["iccid_mark"]
                vn_rows += 1
                if iccid:
                    virtual_nos[iccid] = row.get("vcode") or ""
    timer.log("tbl_virtual_number", keys=len(legacy_iccids), batches=vn_batches, rows=vn_rows)

    # ── 阶段6: Python 组装 ────────────────────────────────────────────────────
    metas: dict[str, LegacyCardMeta] = {}
    for card_key, base_row in metas_base.items():
        legacy_iccid = base_row["iccid_mark"]
        meal_row = current_meals.get(legacy_iccid, {})
        expire = meal_row.get("expire_date")
        expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
        meal_id = meal_row.get("meal_id") or ""
        series_id, series_name = meal_series.get(str(meal_id), ("", ""))
        metas[card_key] = LegacyCardMeta(
            legacy_raw_iccid=legacy_iccid,
            account_id=base_row.get("account_id") or "",
            account_name=base_row.get("account_name") or "",
            category=int(base_row.get("category") or 0),
            category_name=category_names.get(int(base_row.get("category") or 0), ""),
            agent_id=base_row.get("agent_id") or "",
            agent_name=base_row.get("agent_name") or "",
            msisdn=base_row.get("phone") or "",
            virtual_no=virtual_nos.get(legacy_iccid, ""),
            current_meal_id=meal_id,
            current_meal_name=meal_row.get("meal_name") or "",
            current_meal_type=str(meal_row.get("meal_type") or ""),
            current_expire_date=expire_str,
            current_series_id=series_id,
            current_series_name=series_name,
        )
    timer.log("assemble", cards=len(metas), duplicates=len(duplicates))
    return metas, duplicates


def fetch_meal_meta(
    conn,
    meal_ids: Iterable[str],
    batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyMealMeta]:
    """查套餐 + 系列元数据(供 scan_legacy 收集映射 legacy_* 字段)。"""
    timer = _StageTimer("fetch_meal_meta")
    id_list = sorted({m for m in meal_ids if m})
    out: dict[str, LegacyMealMeta] = {}
    if not id_list:
        return out
    sql = """
        SELECT
            sm.id                            AS meal_id,
            COALESCE(sm.name, '')            AS meal_name,
            COALESCE(sm.meal_series_id, '')  AS series_id,
            COALESCE(sms.name, '')           AS series_name
        FROM tbl_set_meal sm
        LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
        WHERE sm.id IN %s
    """
    rows = 0
    batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(id_list, batch_size):
            batches += 1
            cur.execute(sql, (tuple(batch),))
            for row in cur.fetchall():
                mid = row["meal_id"]
                rows += 1
                if not mid:
                    continue
                out[mid] = LegacyMealMeta(
                    meal_id=mid,
                    meal_name=row.get("meal_name") or "",
                    series_id=row.get("series_id") or "",
                    series_name=row.get("series_name") or "",
                )
    timer.log("tbl_set_meal", meal_ids=len(id_list), batches=batches, rows=rows)
    return out


def fetch_agent_balances(
    conn,
    agent_ids: Iterable[str],
    batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyAgentBalance]:
    """查代理主钱包余额(tbl_agent.balance_money,元单位)。"""
    timer = _StageTimer("fetch_agent_balances")
    agent_list = sorted({a for a in agent_ids if a})
    out: dict[str, LegacyAgentBalance] = {}
    if not agent_list:
        return out

    sql = """
        SELECT id, balance_money
        FROM tbl_agent
        WHERE id IN %s
    """
    rows = 0
    batches = 0
    with conn.cursor() as cur:
        for batch in _chunks(agent_list, batch_size):
            batches += 1
            cur.execute(sql, (tuple(batch),))
            for row in cur.fetchall():
                aid = row["id"]
                rows += 1
                if not aid:
                    continue
                out[aid] = LegacyAgentBalance(agent_id=aid, balance_fen=_to_fen(row.get("balance_money")))
    timer.log("tbl_agent", agents=len(agent_list), batches=batches, rows=rows)
    return out
