package main

import (
	"context"
	"encoding/csv"
	"flag"
	"fmt"
	"math"
	"math/big"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"gorm.io/gorm/logger"
)

const (
	defaultLegacySchema = "kyhl"
	defaultBatchSize    = 500
	timeFormat          = "2006-01-02 15:04:05"
	sourceCardLife      = "tbl_card_life"
	sourceNextMonthLife = "tbl_next_month_card_life"
)

var identifierPattern = regexp.MustCompile(`^[A-Za-z0-9_]+$`)

type inputAsset struct {
	Line            int
	DeviceVirtualNo string
	IMEI            string
	SN              string
	SlotPosition    int
	ICCID           string
	ResolvedBy      string
	ResolveNote     string
}

type cardSnapshot struct {
	ICCID         string     `gorm:"column:iccid"`
	CardID        string     `gorm:"column:card_id"`
	Phone         string     `gorm:"column:phone"`
	PhoneZuhe     string     `gorm:"column:phone_zuhe"`
	OldIMEI       string     `gorm:"column:old_imei"`
	OldVCode      string     `gorm:"column:old_vcode"`
	Status        string     `gorm:"column:status"`
	Type          string     `gorm:"column:type"`
	Category      string     `gorm:"column:category"`
	CategoryName  string     `gorm:"column:category_name"`
	CardFlow      string     `gorm:"column:card_flow"`
	SetMealID     string     `gorm:"column:set_meal_id"`
	CardPackage   string     `gorm:"column:card_package"`
	ExpireTime    string     `gorm:"column:expire_time"`
	FlowSize      string     `gorm:"column:flow_size"`
	TotalBytesCnt string     `gorm:"column:total_bytes_cnt"`
	SynchroTime   string     `gorm:"column:synchro_time"`
	ServCreateAt  *time.Time `gorm:"column:serv_create_at"`
	BalanceMoney  string     `gorm:"column:balance_money"`
	AgentID       string     `gorm:"column:agent_id"`
	AgentName     string     `gorm:"column:agent_name"`
	AccountID     string     `gorm:"column:account_id"`
	AccountName   string     `gorm:"column:account_name"`
	AgentThreeID  string     `gorm:"column:agent_three_id"`
}

type packageSnapshot struct {
	SourceTable        string     `gorm:"column:source_table"`
	ID                 string     `gorm:"column:id"`
	ICCID              string     `gorm:"column:iccid"`
	MealID             string     `gorm:"column:meal_id"`
	MealName           string     `gorm:"column:meal_name"`
	OrderID            string     `gorm:"column:order_id"`
	StartAt            *time.Time `gorm:"column:start_at"`
	ExpiresAt          *time.Time `gorm:"column:expires_at"`
	Status             string     `gorm:"column:status"`
	Type               string     `gorm:"column:type"`
	FlowSize           string     `gorm:"column:flow_size"`
	EffectType         string     `gorm:"column:effect_type"`
	UpdateDateTime     string     `gorm:"column:update_date_time"`
	EffectBeginTime    *time.Time `gorm:"column:effect_begin_time"`
	EffectCompleteTime *time.Time `gorm:"column:effect_complete_time"`
	OrderType          string     `gorm:"column:order_type"`
	CreatedAt          *time.Time `gorm:"column:created_at"`
	UpdatedAt          *time.Time `gorm:"column:updated_at"`
}

type walletSnapshot struct {
	ICCID                  string     `gorm:"column:iccid"`
	MoneyBalance           string     `gorm:"column:money_balance"`
	TxVersion              string     `gorm:"column:tx_version"`
	MealAutoRenewStatus    string     `gorm:"column:meal_auto_renew_status"`
	AutoRenewMealID        string     `gorm:"column:auto_renew_meal_id"`
	AddonAutoRenewStatus   string     `gorm:"column:addon_auto_renew_status"`
	AddonAutoRenewMealID   string     `gorm:"column:addon_auto_renew_meal_id"`
	MealAutoRenewUpdatedAt *time.Time `gorm:"column:meal_auto_renew_updated_at"`
	CreatedAt              *time.Time `gorm:"column:created_at"`
}

type commissionAccountSnapshot struct {
	AgentID                          string
	AgentName                        string
	AgentLoginAccount                string
	TotalCommissionAmount            string
	TotalActiveAwardCommissionAmount string
	MFCommissionAmount               string
	NoMFCommissionAmount             string
	TotalDrawAmount                  string
	CanDrawAmount                    string
	ApplyingDrawAmount               string
	Remark                           string
	TxVersion                        string
	CreatedAt                        *time.Time
	UpdatedAt                        *time.Time
}

type commissionDetailSnapshot struct {
	ID                               string     `gorm:"column:id"`
	CommissionAccountID              string     `gorm:"column:commission_account_id"`
	WxOrderID                        string     `gorm:"column:wx_order_id"`
	WxOrderNumber                    string     `gorm:"column:wx_order_number"`
	ICCID                            string     `gorm:"column:iccid"`
	Phone                            string     `gorm:"column:phone"`
	WxOrderAt                        *time.Time `gorm:"column:wx_order_at"`
	CreatedAt                        *time.Time `gorm:"column:created_at"`
	CommissionAmount                 string     `gorm:"column:commission_amount"`
	TotalCommissionAmount            string     `gorm:"column:total_commission_amount"`
	MFStatus                         string     `gorm:"column:mf_status"`
	CommissionType                   string     `gorm:"column:commission_type"`
	ActiveAwardCommissionAmount      string     `gorm:"column:active_award_commission_amount"`
	TotalActiveAwardCommissionAmount string     `gorm:"column:total_active_award_commission_amount"`
}

type legacyCardIdentity struct {
	ICCID        string `gorm:"column:iccid"`
	IMEI         string `gorm:"column:imei"`
	VCode        string `gorm:"column:vcode"`
	ResolvedBy   string
	ResolveNote  string
	SlotPosition int
}

type cardRelation struct {
	MainICCID string `gorm:"column:main_iccid"`
	FuICCID   string `gorm:"column:fu_iccid"`
	FuICCID2  string `gorm:"column:fu_iccid2"`
}

type exportData struct {
	Inputs             []inputAsset
	UnresolvedInputs   []inputAsset
	InputByICCID       map[string][]inputAsset
	Cards              map[string]cardSnapshot
	PackagesByICCID    map[string][]packageSnapshot
	Wallets            map[string]walletSnapshot
	CommissionAccounts map[string]commissionAccountSnapshot
	CommissionDetails  []commissionDetailSnapshot
	MissingICCIDs      []string
}

func main() {
	inputPath := flag.String("input", "", "设备/卡关系 CSV 路径")
	outputDir := flag.String("out", "legacy_device_export_output", "导出目录")
	dsn := flag.String("dsn", os.Getenv("LEGACY_MYSQL_DSN"), "老 MySQL DSN，也可使用 LEGACY_MYSQL_DSN")
	schema := flag.String("schema", getenvDefault("LEGACY_MYSQL_SCHEMA", defaultLegacySchema), "老库 schema/database 名称")
	includeCommissionDetails := flag.Bool("include-commission-details", false, "是否导出输入 ICCID 对应的佣金明细")
	flag.Parse()

	if *inputPath == "" {
		exitWithError("请通过 -input 指定设备/卡关系 CSV")
	}
	if *dsn == "" {
		exitWithError("请通过 -dsn 或 LEGACY_MYSQL_DSN 提供老 MySQL 连接串")
	}
	if !identifierPattern.MatchString(*schema) {
		exitWithError("schema 只能包含字母、数字和下划线")
	}

	ctx := context.Background()
	inputs, err := readInputCSV(*inputPath)
	if err != nil {
		exitWithError("读取输入 CSV 失败: %v", err)
	}
	if len(inputs) == 0 {
		exitWithError("输入 CSV 中没有识别到任何设备/卡线索")
	}

	db, err := openLegacyDB(*dsn)
	if err != nil {
		exitWithError("连接老 MySQL 失败: %v", err)
	}

	resolvedInputs, unresolvedInputs, err := resolveInputAssets(ctx, db, *schema, inputs)
	if err != nil {
		exitWithError("按设备号/IMEI 解析 ICCID 失败: %v", err)
	}

	data := &exportData{
		Inputs:             resolvedInputs,
		UnresolvedInputs:   unresolvedInputs,
		InputByICCID:       groupInputsByICCID(resolvedInputs),
		Cards:              make(map[string]cardSnapshot),
		PackagesByICCID:    make(map[string][]packageSnapshot),
		Wallets:            make(map[string]walletSnapshot),
		CommissionAccounts: make(map[string]commissionAccountSnapshot),
	}

	iccids := sortedKeys(data.InputByICCID)
	if err := loadCardSnapshots(ctx, db, *schema, iccids, data.Cards); err != nil {
		exitWithError("查询老卡本体失败: %v", err)
	}
	if err := loadPackageSnapshots(ctx, db, *schema, iccids, data.PackagesByICCID); err != nil {
		exitWithError("查询老套餐快照失败: %v", err)
	}
	if err := loadWalletSnapshots(ctx, db, *schema, iccids, data.Wallets); err != nil {
		exitWithError("查询老卡钱包失败: %v", err)
	}

	agentIDs := collectAgentIDs(data.Cards)
	if len(agentIDs) > 0 {
		if err := loadCommissionAccounts(ctx, db, *schema, agentIDs, data.CommissionAccounts); err != nil {
			exitWithError("查询老佣金账户失败: %v", err)
		}
	}
	if *includeCommissionDetails {
		details, err := loadCommissionDetails(ctx, db, *schema, iccids)
		if err != nil {
			exitWithError("查询老佣金明细失败: %v", err)
		}
		data.CommissionDetails = details
	}

	for _, iccid := range iccids {
		if _, ok := data.Cards[iccid]; !ok {
			data.MissingICCIDs = append(data.MissingICCIDs, iccid)
		}
	}

	if err := os.MkdirAll(*outputDir, 0755); err != nil {
		exitWithError("创建导出目录失败: %v", err)
	}
	if err := writeOutputs(*outputDir, data, *includeCommissionDetails); err != nil {
		exitWithError("写入导出文件失败: %v", err)
	}

	fmt.Printf("导出完成: %s\n", *outputDir)
	fmt.Printf("已解析关系: %d 条，ICCID: %d 个，未解析输入: %d 条，老库缺失: %d 个\n", len(data.Inputs), len(iccids), len(data.UnresolvedInputs), len(data.MissingICCIDs))
	fmt.Println("已跳过日记录导出，仅保留当前套餐/流量/钱包/佣金快照。")
}

func openLegacyDB(rawDSN string) (*gorm.DB, error) {
	dsn := ensureMySQLDSNOptions(rawDSN)
	return gorm.Open(mysql.Open(dsn), &gorm.Config{
		Logger: logger.Default.LogMode(logger.Silent),
	})
}

func ensureMySQLDSNOptions(dsn string) string {
	options := []string{"parseTime=true", "loc=Asia%2FShanghai", "charset=utf8mb4"}
	sep := "?"
	if strings.Contains(dsn, "?") {
		sep = "&"
	}
	lower := strings.ToLower(dsn)
	for _, option := range options {
		key := strings.ToLower(strings.SplitN(option, "=", 2)[0]) + "="
		if strings.Contains(lower, key) {
			continue
		}
		dsn += sep + option
		sep = "&"
	}
	return dsn
}

func readInputCSV(path string) ([]inputAsset, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	reader := csv.NewReader(file)
	reader.FieldsPerRecord = -1
	reader.TrimLeadingSpace = true
	rows, err := reader.ReadAll()
	if err != nil {
		return nil, err
	}
	if len(rows) < 2 {
		return nil, nil
	}

	header := make(map[string]int)
	for i, h := range rows[0] {
		header[normalizeHeader(h)] = i
	}

	var result []inputAsset
	for rowIndex := 1; rowIndex < len(rows); rowIndex++ {
		row := rows[rowIndex]
		base := inputAsset{
			Line:            rowIndex + 1,
			DeviceVirtualNo: pickValue(row, header, "device_virtual_no", "device_no", "virtual_no", "mchno", "设备号", "设备虚拟号", "机器码"),
			IMEI:            pickValue(row, header, "imei", "device_imei", "mchno", "设备imei", "机器码"),
			SN:              pickValue(row, header, "sn", "设备sn"),
		}

		if singleICCID := pickValue(row, header, "iccid", "卡号", "主卡", "main_iccid"); singleICCID != "" {
			slot := parseIntDefault(pickValue(row, header, "slot", "slot_position", "卡槽", "槽位"), 0)
			result = append(result, withICCID(base, slot, singleICCID, "input_iccid"))
		}

		for slot := 1; slot <= 4; slot++ {
			keys := slotHeaderCandidates(slot)
			iccid := pickValue(row, header, keys...)
			if iccid == "" {
				continue
			}
			result = append(result, withICCID(base, slot, iccid, "input_slot_iccid"))
		}

		if base.ICCID == "" && base.DeviceVirtualNo == "" && base.IMEI == "" && base.SN == "" {
			continue
		}
		if !rowHasICCID(result, base.Line) {
			base.ResolvedBy = "pending_device_key"
			if base.IMEI == "" && base.DeviceVirtualNo == "" {
				base.ResolvedBy = "unresolved"
				base.ResolveNote = "未提供 ICCID 或设备号/IMEI，无法从老库反查卡号"
			}
			result = append(result, base)
		}
	}

	return dedupeInputAssets(result), nil
}

func normalizeHeader(s string) string {
	s = strings.TrimSpace(strings.ToLower(s))
	s = strings.TrimPrefix(s, "\ufeff")
	replacer := strings.NewReplacer(" ", "_", "-", "_", "（", "", "）", "", "(", "", ")", "")
	return replacer.Replace(s)
}

func slotHeaderCandidates(slot int) []string {
	n := strconv.Itoa(slot)
	return []string{
		"iccid" + n,
		"iccid_" + n,
		"slot" + n + "_iccid",
		"slot_" + n + "_iccid",
		"card" + n,
		"card_" + n,
		"sim" + n,
		"sim_" + n,
		"卡" + n,
		"卡" + n + "iccid",
		"卡" + n + "_iccid",
	}
}

func pickValue(row []string, header map[string]int, keys ...string) string {
	for _, key := range keys {
		idx, ok := header[normalizeHeader(key)]
		if !ok || idx >= len(row) {
			continue
		}
		if value := normalizeCell(row[idx]); value != "" {
			return value
		}
	}
	return ""
}

func normalizeCell(s string) string {
	s = strings.TrimSpace(s)
	s = strings.TrimPrefix(s, "'")
	s = strings.ReplaceAll(s, "\u00a0", "")
	return s
}

func withICCID(base inputAsset, slot int, iccid string, resolvedBy string) inputAsset {
	base.SlotPosition = slot
	base.ICCID = normalizeCell(iccid)
	base.ResolvedBy = resolvedBy
	return base
}

func dedupeInputAssets(inputs []inputAsset) []inputAsset {
	seen := make(map[string]bool)
	result := make([]inputAsset, 0, len(inputs))
	for _, item := range inputs {
		key := fmt.Sprintf("%d|%s|%s|%s|%d", item.Line, item.DeviceVirtualNo, item.IMEI, item.ICCID, item.SlotPosition)
		if seen[key] {
			continue
		}
		seen[key] = true
		result = append(result, item)
	}
	return result
}

func groupInputsByICCID(inputs []inputAsset) map[string][]inputAsset {
	result := make(map[string][]inputAsset)
	for _, item := range inputs {
		if item.ICCID == "" {
			continue
		}
		result[item.ICCID] = append(result[item.ICCID], item)
	}
	return result
}

func rowHasICCID(inputs []inputAsset, line int) bool {
	for _, item := range inputs {
		if item.Line == line && item.ICCID != "" {
			return true
		}
	}
	return false
}

func resolveInputAssets(ctx context.Context, db *gorm.DB, schema string, inputs []inputAsset) ([]inputAsset, []inputAsset, error) {
	deviceKeys := collectPendingDeviceKeys(inputs)
	cardsByDeviceKey := make(map[string][]legacyCardIdentity)
	if len(deviceKeys) > 0 {
		var err error
		cardsByDeviceKey, err = loadCardIdentitiesByDeviceKey(ctx, db, schema, deviceKeys)
		if err != nil {
			return nil, nil, err
		}
	}

	resolved := make([]inputAsset, 0, len(inputs))
	unresolved := make([]inputAsset, 0)
	for _, item := range inputs {
		if item.ICCID != "" {
			if item.ResolvedBy == "" {
				item.ResolvedBy = "input_iccid"
			}
			resolved = append(resolved, item)
			continue
		}
		keys := inputDeviceKeys(item)
		if len(keys) == 0 {
			item.ResolvedBy = "unresolved"
			if item.ResolveNote == "" {
				item.ResolveNote = "未提供 ICCID 或设备号/IMEI，无法从老库反查卡号"
			}
			unresolved = append(unresolved, item)
			continue
		}

		matches := uniqueIdentitiesForKeys(cardsByDeviceKey, keys)
		if len(matches) == 0 {
			item.ResolvedBy = "unresolved_device_key"
			item.ResolveNote = "老库 tbl_card 未按 vcode/imei 找到设备号对应的 ICCID"
			unresolved = append(unresolved, item)
			continue
		}
		for _, match := range matches {
			resolvedItem := item
			resolvedItem.ICCID = match.ICCID
			resolvedItem.ResolvedBy = match.ResolvedBy
			resolvedItem.ResolveNote = match.ResolveNote
			if match.SlotPosition > 0 {
				resolvedItem.SlotPosition = match.SlotPosition
			}
			if resolvedItem.ResolveNote == "" && len(matches) > 1 {
				resolvedItem.ResolveNote = fmt.Sprintf("同一设备号在老库命中 %d 张卡，已按 tbl_card_relate 展开", len(matches))
			}
			if resolvedItem.ResolveNote == "" {
				if match.VCode != "" {
					resolvedItem.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID"
				} else if match.IMEI != "" {
					resolvedItem.ResolveNote = "按老库 tbl_card.imei 反查 ICCID"
				}
			}
			resolved = append(resolved, resolvedItem)
		}
	}

	return dedupeInputAssets(resolved), dedupeInputAssets(unresolved), nil
}

func collectPendingDeviceKeys(inputs []inputAsset) []string {
	set := make(map[string]bool)
	for _, item := range inputs {
		if item.ICCID == "" {
			for _, key := range inputDeviceKeys(item) {
				addNonEmpty(set, key)
			}
		}
	}
	return sortedKeys(set)
}

func inputDeviceKeys(item inputAsset) []string {
	set := make(map[string]bool)
	addNonEmpty(set, item.IMEI)
	addNonEmpty(set, item.DeviceVirtualNo)
	return sortedKeys(set)
}

func uniqueIdentitiesForKeys(source map[string][]legacyCardIdentity, keys []string) []legacyCardIdentity {
	byICCID := make(map[string]legacyCardIdentity)
	for _, key := range keys {
		for _, item := range source[key] {
			if item.ICCID == "" {
				continue
			}
			existing, ok := byICCID[item.ICCID]
			if !ok || identityPriority(item) > identityPriority(existing) {
				byICCID[item.ICCID] = item
			}
		}
	}
	iccids := sortedKeys(byICCID)
	result := make([]legacyCardIdentity, 0, len(iccids))
	for _, iccid := range iccids {
		result = append(result, byICCID[iccid])
	}
	sort.SliceStable(result, func(i, j int) bool {
		if result[i].SlotPosition != result[j].SlotPosition {
			if result[i].SlotPosition == 0 {
				return false
			}
			if result[j].SlotPosition == 0 {
				return true
			}
			return result[i].SlotPosition < result[j].SlotPosition
		}
		return result[i].ICCID < result[j].ICCID
	})
	return result
}

func identityPriority(item legacyCardIdentity) int {
	if item.SlotPosition > 0 && strings.Contains(item.ResolvedBy, "relate") {
		return 3
	}
	if item.VCode != "" {
		return 2
	}
	return 1
}

func loadCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) {
	directByKey, err := loadDirectCardIdentitiesByDeviceKey(ctx, db, schema, keys)
	if err != nil {
		return nil, err
	}
	if len(directByKey) == 0 {
		return directByKey, nil
	}

	directICCIDs := collectDirectIdentityICCIDs(directByKey)
	relations, err := loadCardRelationsByICCIDs(ctx, db, schema, directICCIDs)
	if err != nil {
		return nil, err
	}
	if len(relations) == 0 {
		return directByKey, nil
	}

	expanded := make(map[string][]legacyCardIdentity, len(directByKey))
	for key, directRows := range directByKey {
		byICCID := make(map[string]legacyCardIdentity)
		for _, row := range directRows {
			byICCID[row.ICCID] = row
			for _, relation := range relations {
				if !relationContains(relation, row.ICCID) {
					continue
				}
				addRelationIdentity(byICCID, relation.MainICCID, 1, "主卡，按 tbl_card_relate 从设备号命中卡展开")
				addRelationIdentity(byICCID, relation.FuICCID, 2, "副卡1，按 tbl_card_relate 从设备号命中卡展开")
				addRelationIdentity(byICCID, relation.FuICCID2, 3, "副卡2，按 tbl_card_relate 从设备号命中卡展开")
			}
		}
		expanded[key] = identitiesFromMap(byICCID)
	}
	return expanded, nil
}

func loadDirectCardIdentitiesByDeviceKey(ctx context.Context, db *gorm.DB, schema string, keys []string) (map[string][]legacyCardIdentity, error) {
	result := make(map[string][]legacyCardIdentity)
	keySet := make(map[string]bool, len(keys))
	for _, key := range keys {
		keySet[key] = true
	}

	for _, batch := range chunkStrings(keys, defaultBatchSize) {
		var rows []legacyCardIdentity
		sql := fmt.Sprintf(`
SELECT
	iccid_mark AS iccid,
	imei,
	vcode
FROM %s.tbl_card
WHERE (vcode IN ? OR imei IN ?)
  AND iccid_mark IS NOT NULL
  AND iccid_mark <> ''
ORDER BY vcode ASC, imei ASC, iccid_mark ASC
`, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch, batch).Scan(&rows).Error; err != nil {
			return nil, err
		}
		for _, row := range rows {
			if row.ICCID == "" {
				continue
			}
			if row.VCode != "" && keySet[row.VCode] {
				item := row
				item.ResolvedBy = "old_tbl_card_vcode"
				item.ResolveNote = "按老库 tbl_card.vcode 反查 ICCID"
				result[row.VCode] = append(result[row.VCode], item)
			}
			if row.IMEI != "" && keySet[row.IMEI] {
				item := row
				item.ResolvedBy = "old_tbl_card_imei"
				item.ResolveNote = "按老库 tbl_card.imei 反查 ICCID"
				result[row.IMEI] = append(result[row.IMEI], item)
			}
		}
	}
	return result, nil
}

func collectDirectIdentityICCIDs(directByKey map[string][]legacyCardIdentity) []string {
	set := make(map[string]bool)
	for _, rows := range directByKey {
		for _, row := range rows {
			addNonEmpty(set, row.ICCID)
		}
	}
	return sortedKeys(set)
}

func loadCardRelationsByICCIDs(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]cardRelation, error) {
	var result []cardRelation
	for _, batch := range chunkStrings(iccids, defaultBatchSize) {
		var rows []cardRelation
		sql := fmt.Sprintf(`
SELECT
	main_iccid,
	fu_iccid,
	fu_iccid2
FROM %s.tbl_card_relate
WHERE main_iccid IN ? OR fu_iccid IN ? OR fu_iccid2 IN ?
`, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch, batch, batch).Scan(&rows).Error; err != nil {
			return nil, err
		}
		result = append(result, rows...)
	}
	return result, nil
}

func relationContains(relation cardRelation, iccid string) bool {
	return relation.MainICCID == iccid || relation.FuICCID == iccid || relation.FuICCID2 == iccid
}

func addRelationIdentity(byICCID map[string]legacyCardIdentity, iccid string, slot int, note string) {
	if iccid == "" {
		return
	}
	item := legacyCardIdentity{
		ICCID:        iccid,
		ResolvedBy:   "old_tbl_card_relate",
		ResolveNote:  note,
		SlotPosition: slot,
	}
	existing, ok := byICCID[iccid]
	if ok {
		item.IMEI = existing.IMEI
		item.VCode = existing.VCode
		if existing.ResolvedBy == "old_tbl_card_vcode" || existing.ResolvedBy == "old_tbl_card_imei" {
			item.ResolvedBy = existing.ResolvedBy + "+old_tbl_card_relate"
		}
	}
	if !ok || identityPriority(item) > identityPriority(existing) {
		byICCID[iccid] = item
	}
}

func identitiesFromMap(byICCID map[string]legacyCardIdentity) []legacyCardIdentity {
	iccids := sortedKeys(byICCID)
	result := make([]legacyCardIdentity, 0, len(iccids))
	for _, iccid := range iccids {
		result = append(result, byICCID[iccid])
	}
	sort.SliceStable(result, func(i, j int) bool {
		if result[i].SlotPosition != result[j].SlotPosition {
			if result[i].SlotPosition == 0 {
				return false
			} else {
				if result[j].SlotPosition == 0 {
					return true
				}
			}
			return result[i].SlotPosition < result[j].SlotPosition
		}
		return result[i].ICCID < result[j].ICCID
	})
	return result
}

func loadCardSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]cardSnapshot) error {
	for _, batch := range chunkStrings(iccids, defaultBatchSize) {
		var rows []cardSnapshot
		sql := fmt.Sprintf(`
SELECT
	c.id AS card_id,
	c.iccid_mark AS iccid,
	c.phone,
	c.phone_zuhe,
	c.imei AS old_imei,
	c.vcode AS old_vcode,
	CAST(c.status AS CHAR) AS status,
	CAST(c.type AS CHAR) AS type,
	CAST(c.category AS CHAR) AS category,
	vc.category_name AS category_name,
	c.card_flow,
	c.set_meal_id,
	c.card_package,
	c.expire_time,
	CAST(c.flow_size AS CHAR) AS flow_size,
	c.total_bytes_cnt,
	c.synchro_time,
	c.serv_create_date AS serv_create_at,
	c.balance_money,
	c.agent_id,
	c.agent_name,
	c.account_id,
	c.account_name,
	c.agent_three_id
FROM %s.tbl_card c
LEFT JOIN %s.tbl_vendor_category vc ON vc.category = c.category
WHERE c.iccid_mark IN ?
`, quoteSchema(schema), quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
			return err
		}
		for _, row := range rows {
			if row.ICCID == "" {
				continue
			}
			out[row.ICCID] = row
		}
	}
	return nil
}

func loadPackageSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string][]packageSnapshot) error {
	for _, batch := range chunkStrings(iccids, defaultBatchSize) {
		var currentRows []packageSnapshot
		currentSQL := fmt.Sprintf(`
SELECT
	'%s' AS source_table,
	id,
	iccid_mark AS iccid,
	meal_id,
	meal_name,
	order_id,
	start_date AS start_at,
	expire_date AS expires_at,
	CAST(status AS CHAR) AS status,
	CAST(type AS CHAR) AS type,
	CAST(flow_size AS CHAR) AS flow_size,
	CAST(effect_type AS CHAR) AS effect_type,
	update_date_time,
	create_time AS created_at,
	update_time AS updated_at
FROM %s.tbl_card_life
WHERE iccid_mark IN ?
`, sourceCardLife, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(currentSQL, batch).Scan(&currentRows).Error; err != nil {
			return err
		}
		for _, row := range currentRows {
			if row.ICCID == "" {
				continue
			}
			out[row.ICCID] = append(out[row.ICCID], row)
		}

		var pendingRows []packageSnapshot
		pendingSQL := fmt.Sprintf(`
SELECT
	'%s' AS source_table,
	id,
	iccid_mark AS iccid,
	meal_id,
	meal_name,
	agent_wx_order_id AS order_id,
	effect_begin_time AS effect_begin_time,
	effect_complete_time AS effect_complete_time,
	CAST(status AS CHAR) AS status,
	CAST(flow_size AS CHAR) AS flow_size,
	order_type,
	create_date AS created_at
FROM %s.tbl_next_month_card_life
WHERE iccid_mark IN ?
`, sourceNextMonthLife, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(pendingSQL, batch).Scan(&pendingRows).Error; err != nil {
			return err
		}
		for _, row := range pendingRows {
			if row.ICCID == "" {
				continue
			}
			out[row.ICCID] = append(out[row.ICCID], row)
		}
	}

	for iccid := range out {
		sort.SliceStable(out[iccid], func(i, j int) bool {
			left := out[iccid][i]
			right := out[iccid][j]
			if left.SourceTable != right.SourceTable {
				return left.SourceTable == sourceCardLife
			}
			return timePtrBefore(left.StartAt, right.StartAt)
		})
	}
	return nil
}

func loadWalletSnapshots(ctx context.Context, db *gorm.DB, schema string, iccids []string, out map[string]walletSnapshot) error {
	for _, batch := range chunkStrings(iccids, defaultBatchSize) {
		var rows []walletSnapshot
		sql := fmt.Sprintf(`
SELECT
	iccid_mark AS iccid,
	CAST(money_balance AS CHAR) AS money_balance,
	CAST(tx_version AS CHAR) AS tx_version,
	CAST(meal_auto_renew_status AS CHAR) AS meal_auto_renew_status,
	auto_renew_meal_id,
	CAST(djb_meal_auto_renew_status AS CHAR) AS addon_auto_renew_status,
	djb_auto_renew_meal_id AS addon_auto_renew_meal_id,
	meal_auto_renew_udpate_time AS meal_auto_renew_updated_at,
	create_date AS created_at
FROM %s.tbl_card_wallet
WHERE iccid_mark IN ?
`, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
			return err
		}
		for _, row := range rows {
			if row.ICCID == "" {
				continue
			}
			out[row.ICCID] = row
		}
	}
	return nil
}

func loadCommissionAccounts(ctx context.Context, db *gorm.DB, schema string, agentIDs []string, out map[string]commissionAccountSnapshot) error {
	for _, batch := range chunkStrings(agentIDs, defaultBatchSize) {
		var rows []commissionAccountSnapshot
		sql := fmt.Sprintf(`
SELECT
	agent_id,
	agent_name,
	agent_login_account,
	CAST(total_commision_amount AS CHAR) AS total_commission_amount,
	CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount,
	CAST(mf_commision_amount AS CHAR) AS mf_commission_amount,
	CAST(nomf_commision_amount AS CHAR) AS no_mf_commission_amount,
	CAST(total_draw_amount AS CHAR) AS total_draw_amount,
	CAST(can_draw_amount AS CHAR) AS can_draw_amount,
	CAST(applying_draw_amount AS CHAR) AS applying_draw_amount,
	remark,
	CAST(tx_version AS CHAR) AS tx_version,
	create_date AS created_at,
	update_date AS updated_at
FROM %s.tbl_agent_commission_account
WHERE agent_id IN ?
`, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
			return err
		}
		for _, row := range rows {
			out[row.AgentID] = row
		}
	}
	return nil
}

func loadCommissionDetails(ctx context.Context, db *gorm.DB, schema string, iccids []string) ([]commissionDetailSnapshot, error) {
	var result []commissionDetailSnapshot
	for _, batch := range chunkStrings(iccids, defaultBatchSize) {
		var rows []commissionDetailSnapshot
		sql := fmt.Sprintf(`
SELECT
	id,
	commission_account_id,
	wx_order_id,
	wx_order_number,
	iccid_mark AS iccid,
	phone,
	wx_order_date AS wx_order_at,
	create_date AS created_at,
	CAST(commission_amount AS CHAR) AS commission_amount,
	CAST(total_commission_amount AS CHAR) AS total_commission_amount,
	CAST(mf_status AS CHAR) AS mf_status,
	CAST(commission_type AS CHAR) AS commission_type,
	CAST(active_award_commision_amount AS CHAR) AS active_award_commission_amount,
	CAST(total_active_award_commision_amount AS CHAR) AS total_active_award_commission_amount
FROM %s.tbl_agent_commission_detail
WHERE iccid_mark IN ?
ORDER BY create_date ASC
`, quoteSchema(schema))
		if err := db.WithContext(ctx).Raw(sql, batch).Scan(&rows).Error; err != nil {
			return nil, err
		}
		for _, row := range rows {
			if row.ICCID == "" {
				continue
			}
			result = append(result, row)
		}
	}
	return result, nil
}

func writeOutputs(outputDir string, data *exportData, includeCommissionDetails bool) error {
	writers := []struct {
		name   string
		writer func(string, *exportData) error
	}{
		{"input_assets.csv", writeInputAssets},
		{"unresolved_inputs.csv", writeUnresolvedInputs},
		{"card_snapshot.csv", writeCardSnapshot},
		{"package_usage_snapshot.csv", writePackageUsageSnapshot},
		{"wallet_snapshot.csv", writeWalletSnapshot},
		{"commission_account_snapshot.csv", writeCommissionAccountSnapshot},
		{"missing_cards.csv", writeMissingCards},
		{"summary.txt", writeSummary},
		{"field_dictionary.csv", writeFieldDictionary},
		{"enum_dictionary.csv", writeEnumDictionary},
		{"README_zh.md", writeOutputGuide},
	}
	if includeCommissionDetails {
		writers = append(writers, struct {
			name   string
			writer func(string, *exportData) error
		}{"commission_detail_snapshot.csv", writeCommissionDetailSnapshot})
	}

	for _, item := range writers {
		if err := item.writer(filepath.Join(outputDir, item.name), data); err != nil {
			return fmt.Errorf("%s: %w", item.name, err)
		}
	}
	return nil
}

func writeInputAssets(path string, data *exportData) error {
	return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error {
		for _, item := range data.Inputs {
			if err := w.Write([]string{
				strconv.Itoa(item.Line),
				item.DeviceVirtualNo,
				item.IMEI,
				item.SN,
				intString(item.SlotPosition),
				slotPositionName(item.SlotPosition),
				item.ICCID,
				item.ResolvedBy,
				resolvedByName(item.ResolvedBy),
				item.ResolveNote,
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeUnresolvedInputs(path string, data *exportData) error {
	return writeCSV(path, []string{"line", "device_virtual_no", "imei", "sn", "slot_position", "slot_position_name", "iccid", "resolved_by", "resolved_by_name", "resolve_note"}, func(w *csv.Writer) error {
		for _, item := range data.UnresolvedInputs {
			if err := w.Write([]string{
				strconv.Itoa(item.Line),
				item.DeviceVirtualNo,
				item.IMEI,
				item.SN,
				intString(item.SlotPosition),
				slotPositionName(item.SlotPosition),
				item.ICCID,
				item.ResolvedBy,
				resolvedByName(item.ResolvedBy),
				item.ResolveNote,
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeCardSnapshot(path string, data *exportData) error {
	header := []string{
		"iccid", "input_device_virtual_no", "input_imei", "input_sn", "input_slot_positions",
		"old_card_id", "old_phone", "old_phone_zuhe", "old_imei", "old_vcode", "old_status", "old_status_name",
		"old_type", "old_type_name", "old_category", "old_category_name",
		"old_account_id", "old_account_name", "old_agent_id", "old_agent_name", "old_agent_three_id",
		"old_set_meal_id", "old_card_package", "old_expire_time", "old_flow_size_mb", "old_total_bytes_cnt_mb",
		"current_used_mb_decimal", "current_used_mb_int", "old_synchro_time", "old_serv_create_at", "old_balance_money",
		"suggested_tb_iot_card_data_usage_mb", "suggested_tb_iot_card_current_month_usage_mb", "suggested_tb_iot_card_last_gateway_reading_mb",
	}
	return writeCSV(path, header, func(w *csv.Writer) error {
		for _, iccid := range sortedKeys(data.InputByICCID) {
			card := data.Cards[iccid]
			inputSummary := summarizeInputs(data.InputByICCID[iccid])
			usedDecimal := card.TotalBytesCnt
			usedInt := int64FloorString(usedDecimal)
			if err := w.Write([]string{
				iccid,
				inputSummary.DeviceVirtualNos,
				inputSummary.IMEIs,
				inputSummary.SNs,
				inputSummary.Slots,
				card.CardID,
				card.Phone,
				card.PhoneZuhe,
				card.OldIMEI,
				card.OldVCode,
				card.Status,
				oldCardStatusName(card.Status),
				card.Type,
				oldCardTypeName(card.Type),
				card.Category,
				oldCardCategoryName(card.Category, card.CategoryName),
				card.AccountID,
				card.AccountName,
				card.AgentID,
				card.AgentName,
				card.AgentThreeID,
				card.SetMealID,
				card.CardPackage,
				card.ExpireTime,
				card.FlowSize,
				card.TotalBytesCnt,
				usedDecimal,
				int64String(usedInt),
				card.SynchroTime,
				formatTime(card.ServCreateAt),
				card.BalanceMoney,
				int64String(usedInt),
				usedDecimal,
				usedDecimal,
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writePackageUsageSnapshot(path string, data *exportData) error {
	header := []string{
		"iccid", "input_device_virtual_no", "source_table", "source_table_name", "old_usage_id", "old_meal_id", "old_meal_name",
		"old_order_id", "start_at", "expires_at", "effective_start_at", "effective_expires_at", "date_source_note",
		"effect_begin_time", "effect_complete_time", "record_created_at", "record_updated_at",
		"old_status", "old_status_name", "old_type", "old_type_name", "effect_type", "effect_type_name", "order_type", "order_type_name",
		"flow_limit_mb", "card_current_used_mb_decimal", "allocated_used_mb", "remaining_mb",
		"suggested_new_status", "suggested_new_status_name", "is_pending_next_month",
	}
	now := time.Now()
	return writeCSV(path, header, func(w *csv.Writer) error {
		for _, iccid := range sortedKeys(data.InputByICCID) {
			card := data.Cards[iccid]
			currentUsed := int64FloorString(card.TotalBytesCnt)
			remainingUsed := currentUsed
			inputSummary := summarizeInputs(data.InputByICCID[iccid])

			rows := data.PackagesByICCID[iccid]
			for _, pkg := range rows {
				limit := int64FloorString(pkg.FlowSize)
				allocatedUsed := int64(0)
				if pkg.SourceTable == sourceCardLife && isActiveOldPackage(pkg, now) {
					allocatedUsed = minInt64(remainingUsed, limit)
					remainingUsed -= allocatedUsed
				}
				remaining := limit - allocatedUsed
				if remaining < 0 {
					remaining = 0
				}
				suggestedStatus := suggestPackageUsageStatus(pkg, allocatedUsed, limit, now)
				isPendingNextMonth := ""
				if pkg.SourceTable == sourceNextMonthLife {
					isPendingNextMonth = "true"
				} else {
					isPendingNextMonth = "false"
				}

				if err := w.Write([]string{
					iccid,
					inputSummary.DeviceVirtualNos,
					pkg.SourceTable,
					sourceTableName(pkg.SourceTable),
					pkg.ID,
					pkg.MealID,
					pkg.MealName,
					pkg.OrderID,
					formatTime(pkg.StartAt),
					formatTime(pkg.ExpiresAt),
					formatTime(effectivePackageStartAt(pkg)),
					formatTime(effectivePackageExpiresAt(pkg)),
					packageDateSourceNote(pkg),
					formatTime(pkg.EffectBeginTime),
					formatTime(pkg.EffectCompleteTime),
					formatTime(pkg.CreatedAt),
					formatTime(pkg.UpdatedAt),
					pkg.Status,
					oldPackageStatusName(pkg.SourceTable, pkg.Status),
					pkg.Type,
					oldPackageTypeName(pkg.Type),
					pkg.EffectType,
					oldEffectTypeName(pkg.EffectType),
					pkg.OrderType,
					oldOrderTypeName(pkg.OrderType),
					int64String(limit),
					card.TotalBytesCnt,
					int64String(allocatedUsed),
					int64String(remaining),
					suggestedStatus,
					newPackageUsageStatusName(suggestedStatus),
					isPendingNextMonth,
				}); err != nil {
					return err
				}
			}
		}
		return nil
	})
}

func writeWalletSnapshot(path string, data *exportData) error {
	header := []string{
		"iccid", "money_balance_yuan", "money_balance_fen", "tx_version",
		"meal_auto_renew_status", "meal_auto_renew_status_name", "auto_renew_meal_id",
		"addon_auto_renew_status", "addon_auto_renew_status_name", "addon_auto_renew_meal_id",
		"meal_auto_renew_updated_at", "created_at",
	}
	return writeCSV(path, header, func(w *csv.Writer) error {
		for _, iccid := range sortedKeys(data.InputByICCID) {
			wallet := data.Wallets[iccid]
			if err := w.Write([]string{
				iccid,
				wallet.MoneyBalance,
				decimalStringToCentsString(wallet.MoneyBalance),
				wallet.TxVersion,
				wallet.MealAutoRenewStatus,
				autoRenewStatusName(wallet.MealAutoRenewStatus),
				wallet.AutoRenewMealID,
				wallet.AddonAutoRenewStatus,
				autoRenewStatusName(wallet.AddonAutoRenewStatus),
				wallet.AddonAutoRenewMealID,
				formatTime(wallet.MealAutoRenewUpdatedAt),
				formatTime(wallet.CreatedAt),
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeCommissionAccountSnapshot(path string, data *exportData) error {
	header := []string{
		"old_agent_id", "old_agent_name", "old_agent_login_account", "suggested_new_shop_id",
		"total_commission_yuan", "total_commission_fen",
		"total_active_award_commission_yuan", "total_active_award_commission_fen",
		"mf_commission_yuan", "mf_commission_fen",
		"nomf_commission_yuan", "nomf_commission_fen",
		"total_draw_yuan", "total_draw_fen",
		"can_draw_yuan", "can_draw_fen",
		"applying_draw_yuan", "applying_draw_fen",
		"remark", "tx_version", "created_at", "updated_at",
	}
	return writeCSV(path, header, func(w *csv.Writer) error {
		for _, agentID := range sortedKeys(data.CommissionAccounts) {
			account := data.CommissionAccounts[agentID]
			if err := w.Write([]string{
				account.AgentID,
				account.AgentName,
				account.AgentLoginAccount,
				"",
				account.TotalCommissionAmount,
				decimalStringToCentsString(account.TotalCommissionAmount),
				account.TotalActiveAwardCommissionAmount,
				decimalStringToCentsString(account.TotalActiveAwardCommissionAmount),
				account.MFCommissionAmount,
				decimalStringToCentsString(account.MFCommissionAmount),
				account.NoMFCommissionAmount,
				decimalStringToCentsString(account.NoMFCommissionAmount),
				account.TotalDrawAmount,
				decimalStringToCentsString(account.TotalDrawAmount),
				account.CanDrawAmount,
				decimalStringToCentsString(account.CanDrawAmount),
				account.ApplyingDrawAmount,
				decimalStringToCentsString(account.ApplyingDrawAmount),
				account.Remark,
				account.TxVersion,
				formatTime(account.CreatedAt),
				formatTime(account.UpdatedAt),
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeCommissionDetailSnapshot(path string, data *exportData) error {
	header := []string{
		"id", "commission_account_id", "wx_order_id", "wx_order_number", "iccid", "phone",
		"wx_order_at", "created_at", "commission_amount_yuan", "commission_amount_fen",
		"total_commission_amount_yuan", "total_commission_amount_fen", "mf_status", "mf_status_name", "commission_type", "commission_type_name",
		"active_award_commission_amount_yuan", "active_award_commission_amount_fen",
		"total_active_award_commission_amount_yuan", "total_active_award_commission_amount_fen",
	}
	return writeCSV(path, header, func(w *csv.Writer) error {
		for _, item := range data.CommissionDetails {
			if err := w.Write([]string{
				item.ID,
				item.CommissionAccountID,
				item.WxOrderID,
				item.WxOrderNumber,
				item.ICCID,
				item.Phone,
				formatTime(item.WxOrderAt),
				formatTime(item.CreatedAt),
				item.CommissionAmount,
				decimalStringToCentsString(item.CommissionAmount),
				item.TotalCommissionAmount,
				decimalStringToCentsString(item.TotalCommissionAmount),
				item.MFStatus,
				mfStatusName(item.MFStatus),
				item.CommissionType,
				commissionTypeName(item.CommissionType),
				item.ActiveAwardCommissionAmount,
				decimalStringToCentsString(item.ActiveAwardCommissionAmount),
				item.TotalActiveAwardCommissionAmount,
				decimalStringToCentsString(item.TotalActiveAwardCommissionAmount),
			}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeMissingCards(path string, data *exportData) error {
	return writeCSV(path, []string{"iccid"}, func(w *csv.Writer) error {
		for _, iccid := range data.MissingICCIDs {
			if err := w.Write([]string{iccid}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeSummary(path string, data *exportData) error {
	packageCount := 0
	for _, rows := range data.PackagesByICCID {
		packageCount += len(rows)
	}
	content := strings.Builder{}
	content.WriteString("老系统设备迁移快照导出摘要\n")
	content.WriteString("================================\n")
	content.WriteString(fmt.Sprintf("生成时间: %s\n", time.Now().Format(timeFormat)))
	content.WriteString(fmt.Sprintf("已解析输入关系行数: %d\n", len(data.Inputs)))
	content.WriteString(fmt.Sprintf("未解析输入行数: %d\n", len(data.UnresolvedInputs)))
	content.WriteString(fmt.Sprintf("输入 ICCID 数: %d\n", len(data.InputByICCID)))
	content.WriteString(fmt.Sprintf("老库卡本体命中数: %d\n", len(data.Cards)))
	content.WriteString(fmt.Sprintf("老库缺失 ICCID 数: %d\n", len(data.MissingICCIDs)))
	content.WriteString(fmt.Sprintf("套餐快照行数: %d\n", packageCount))
	content.WriteString(fmt.Sprintf("钱包快照行数: %d\n", len(data.Wallets)))
	content.WriteString(fmt.Sprintf("佣金账户行数: %d\n", len(data.CommissionAccounts)))
	content.WriteString(fmt.Sprintf("佣金明细行数: %d\n", len(data.CommissionDetails)))
	content.WriteString("\n说明:\n")
	content.WriteString("- 本工具只导出当前业务快照，不导出日流量记录。\n")
	content.WriteString("- 输入 CSV 可以不提供 SN 和 ICCID；不提供 ICCID 时会按老库 tbl_card.vcode/imei 反查 ICCID，并通过 tbl_card_relate 展开关联卡。\n")
	content.WriteString("- unresolved_inputs.csv 中的行没有解析出 ICCID，需要补设备号/IMEI/ICCID 后再重新导出。\n")
	content.WriteString("- 字段含义看 field_dictionary.csv；状态码和类型码看 enum_dictionary.csv；阅读入口看 README_zh.md。\n")
	content.WriteString("- package_usage_snapshot.csv 中 allocated_used_mb/remaining_mb 是按老卡当前已用量顺序分摊后的建议值，后续导入新库前仍需结合套餐映射复核。\n")
	content.WriteString("- commission_account_snapshot.csv 预留 suggested_new_shop_id 空列，后续导入时填新系统 shop_id。\n")
	return os.WriteFile(path, []byte(content.String()), 0644)
}

func writeCSV(path string, header []string, fill func(*csv.Writer) error) error {
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()

	writer := csv.NewWriter(file)
	if err := writer.Write(header); err != nil {
		return err
	}
	if err := fill(writer); err != nil {
		return err
	}
	writer.Flush()
	return writer.Error()
}

type inputSummary struct {
	DeviceVirtualNos string
	IMEIs            string
	SNs              string
	Slots            string
}

func summarizeInputs(inputs []inputAsset) inputSummary {
	deviceNos := make(map[string]bool)
	imeis := make(map[string]bool)
	sns := make(map[string]bool)
	slots := make(map[string]bool)
	for _, item := range inputs {
		addNonEmpty(deviceNos, item.DeviceVirtualNo)
		addNonEmpty(imeis, item.IMEI)
		addNonEmpty(sns, item.SN)
		if item.SlotPosition > 0 {
			slots[strconv.Itoa(item.SlotPosition)] = true
		}
	}
	return inputSummary{
		DeviceVirtualNos: strings.Join(sortedKeys(deviceNos), "|"),
		IMEIs:            strings.Join(sortedKeys(imeis), "|"),
		SNs:              strings.Join(sortedKeys(sns), "|"),
		Slots:            strings.Join(sortedKeys(slots), "|"),
	}
}

func addNonEmpty(set map[string]bool, value string) {
	if value != "" {
		set[value] = true
	}
}

func collectAgentIDs(cards map[string]cardSnapshot) []string {
	set := make(map[string]bool)
	for _, card := range cards {
		addNonEmpty(set, card.AgentID)
	}
	return sortedKeys(set)
}

func isActiveOldPackage(pkg packageSnapshot, now time.Time) bool {
	if pkg.Status != "1" {
		return false
	}
	if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) {
		return false
	}
	return true
}

func suggestPackageUsageStatus(pkg packageSnapshot, used, limit int64, now time.Time) string {
	if pkg.SourceTable == sourceNextMonthLife {
		return "0"
	}
	if pkg.ExpiresAt != nil && pkg.ExpiresAt.Before(now) {
		return "3"
	}
	if limit > 0 && used >= limit {
		return "2"
	}
	if pkg.Status == "1" {
		return "1"
	}
	return pkg.Status
}

func effectivePackageStartAt(pkg packageSnapshot) *time.Time {
	switch pkg.SourceTable {
	case sourceNextMonthLife:
		if pkg.EffectBeginTime != nil {
			return pkg.EffectBeginTime
		}
	case sourceCardLife:
		if pkg.StartAt != nil {
			return pkg.StartAt
		}
	}
	return pkg.CreatedAt
}

func effectivePackageExpiresAt(pkg packageSnapshot) *time.Time {
	if pkg.ExpiresAt != nil {
		return pkg.ExpiresAt
	}
	return nil
}

func packageDateSourceNote(pkg packageSnapshot) string {
	if pkg.SourceTable == sourceNextMonthLife {
		if pkg.EffectBeginTime != nil {
			return "待生效套餐开始时间来自 tbl_next_month_card_life.effect_begin_time；老库未提供到期时间"
		}
		if pkg.CreatedAt != nil {
			return "待生效套餐 effect_begin_time 为空，effective_start_at 使用 create_date 兜底；老库未提供到期时间"
		}
		return "待生效套餐没有开始/到期时间"
	}
	if pkg.StartAt != nil && pkg.ExpiresAt != nil {
		return "开始时间来自 tbl_card_life.start_date，到期时间来自 tbl_card_life.expire_date"
	}
	if pkg.StartAt == nil && pkg.CreatedAt != nil && pkg.ExpiresAt != nil {
		return "tbl_card_life.start_date 为空，effective_start_at 使用 create_time 兜底；到期时间来自 expire_date"
	}
	if pkg.StartAt == nil && pkg.CreatedAt != nil {
		return "tbl_card_life.start_date 为空，effective_start_at 使用 create_time 兜底"
	}
	if pkg.ExpiresAt != nil {
		return "开始时间为空；到期时间来自 tbl_card_life.expire_date"
	}
	return "老库套餐记录没有开始/到期时间"
}

func decimalStringToCentsString(value string) string {
	if strings.TrimSpace(value) == "" {
		return ""
	}
	r, ok := new(big.Rat).SetString(strings.TrimSpace(value))
	if !ok {
		return ""
	}
	r.Mul(r, big.NewRat(100, 1))
	result := new(big.Int)
	// 金额只用于迁移快照，采用四舍五入到分。
	if r.Sign() >= 0 {
		r.Add(r, big.NewRat(1, 2))
	} else {
		r.Sub(r, big.NewRat(1, 2))
	}
	result.Quo(r.Num(), r.Denom())
	return result.String()
}

func int64FloorString(value string) int64 {
	value = strings.TrimSpace(value)
	if value == "" {
		return 0
	}
	f, err := strconv.ParseFloat(value, 64)
	if err != nil {
		return 0
	}
	return int64(math.Floor(f))
}

func parseIntDefault(value string, defaultValue int) int {
	if value == "" {
		return defaultValue
	}
	n, err := strconv.Atoi(value)
	if err != nil {
		return defaultValue
	}
	return n
}

func formatTime(t *time.Time) string {
	if t == nil || t.IsZero() {
		return ""
	}
	return t.In(time.Local).Format(timeFormat)
}

func timePtrBefore(left, right *time.Time) bool {
	if left == nil && right == nil {
		return false
	}
	if left == nil {
		return false
	}
	if right == nil {
		return true
	}
	return left.Before(*right)
}

func chunkStrings(values []string, size int) [][]string {
	if size <= 0 {
		size = defaultBatchSize
	}
	var chunks [][]string
	for start := 0; start < len(values); start += size {
		end := start + size
		if end > len(values) {
			end = len(values)
		}
		chunks = append(chunks, values[start:end])
	}
	return chunks
}

func sortedKeys[V any](m map[string]V) []string {
	keys := make([]string, 0, len(m))
	for key := range m {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func quoteSchema(schema string) string {
	return "`" + schema + "`"
}

func minInt64(a, b int64) int64 {
	if a < b {
		return a
	}
	return b
}

func intString(value int) string {
	if value == 0 {
		return ""
	}
	return strconv.Itoa(value)
}

func int64String(value int64) string {
	return strconv.FormatInt(value, 10)
}

func getenvDefault(key, defaultValue string) string {
	value := strings.TrimSpace(os.Getenv(key))
	if value == "" {
		return defaultValue
	}
	return value
}

func exitWithError(format string, args ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", args...)
	os.Exit(1)
}

type fieldDefinition struct {
	FileName    string
	ColumnName  string
	ChineseName string
	Description string
}

type enumDefinition struct {
	FileName   string
	ColumnName string
	Value      string
	Meaning    string
	Source     string
	Note       string
}

var oldCardStatusNames = map[string]string{
	"1": "待激活",
	"2": "正常",
	"3": "断网",
	"4": "停机",
	"5": "异常",
	"6": "强制停机",
	"7": "注销",
}

var oldCardTypeNames = map[string]string{
	"1": "包月",
	"2": "包年",
}

var oldCategoryNames = map[string]string{
	"1":   "电信",
	"2":   "其他卡务",
	"3":   "移动",
	"4":   "联通",
	"5":   "dcp电信",
	"9":   "中国联通",
	"10":  "D移动",
	"21":  "S物联",
	"22":  "W物联",
	"23":  "L物联",
	"24":  "P物联",
	"25":  "D物联",
	"26":  "M物联",
	"27":  "WX物联",
	"28":  "CH物联",
	"31":  "YW物联",
	"40":  "XS物联",
	"56":  "ZT物联",
	"67":  "TY物联",
	"70":  "XR物联",
	"71":  "ZYM物联",
	"89":  "LW物联",
	"94":  "DC物联",
	"108": "CMP5G",
	"112": "GD物联",
	"113": "GDIot",
	"118": "GDWL",
	"124": "GS电信",
	"125": "GS联通",
	"126": "GS移动",
}

var oldCardLifeStatusNames = map[string]string{
	"1": "正常",
	"2": "停卡",
}

var oldNextMonthLifeStatusNames = map[string]string{
	"1": "未生效",
	"2": "已生效，已插入 tbl_card_life",
}

var oldPackageTypeNames = map[string]string{
	"1": "叠加包",
	"2": "月卡",
	"3": "季卡",
	"4": "半年卡",
	"5": "年卡",
}

var oldEffectTypeNames = map[string]string{
	"1": "本月生效",
	"2": "次月生效",
}

var oldOrderTypeNames = map[string]string{
	"1": "微信订单",
	"2": "后台订单",
}

var autoRenewStatusNames = map[string]string{
	"1": "启用",
	"2": "关闭",
}

var newPackageUsageStatusNames = map[string]string{
	"0": "待生效",
	"1": "生效中",
	"2": "已用完",
	"3": "已过期",
	"4": "已失效",
}

var mfStatusNames = map[string]string{
	"0": "成功",
	"1": "失败",
}

var commissionTypeNames = map[string]string{
	"1": "微信订单佣金",
	"2": "激活奖励佣金",
	"3": "微信订单佣金扣回",
	"4": "激活奖励佣金扣回",
}

func writeFieldDictionary(path string, data *exportData) error {
	fields := []fieldDefinition{
		{"input_assets.csv", "line", "输入行号", "原始输入 CSV 中的行号，便于回查业务给的源数据。"},
		{"input_assets.csv", "device_virtual_no", "输入设备号", "输入 CSV 中识别到的设备号/机器码。"},
		{"input_assets.csv", "imei", "输入 IMEI", "输入 CSV 中识别到的 IMEI；老系统接口参数 mchno 实际通常对应 tbl_card.vcode。"},
		{"input_assets.csv", "sn", "输入 SN", "输入 CSV 中透传的 SN，可为空。"},
		{"input_assets.csv", "slot_position", "卡槽序号", "脚本按老系统关联表展开后的卡槽：1 主卡，2 副卡1，3 副卡2。"},
		{"input_assets.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"},
		{"input_assets.csv", "iccid", "ICCID", "解析出来的老系统卡 ICCID。"},
		{"input_assets.csv", "resolved_by", "解析方式", "说明 ICCID 是由输入直接提供，还是按 tbl_card.vcode/imei 及 tbl_card_relate 反查得到。"},
		{"input_assets.csv", "resolved_by_name", "解析方式名称", "解析方式的中文摘要。"},
		{"input_assets.csv", "resolve_note", "解析说明", "更详细的解析过程说明。"},
		{"unresolved_inputs.csv", "line", "输入行号", "原始输入 CSV 中无法解析出 ICCID 的行号。"},
		{"unresolved_inputs.csv", "device_virtual_no", "输入设备号", "未解析行中的设备号/机器码。"},
		{"unresolved_inputs.csv", "imei", "输入 IMEI", "未解析行中的 IMEI。"},
		{"unresolved_inputs.csv", "sn", "输入 SN", "未解析行中的 SN。"},
		{"unresolved_inputs.csv", "slot_position", "卡槽序号", "未解析行中的卡槽序号。"},
		{"unresolved_inputs.csv", "slot_position_name", "卡槽名称", "卡槽序号的中文含义。"},
		{"unresolved_inputs.csv", "iccid", "ICCID", "未解析行通常为空；若有值仍未解析，需要人工核对。"},
		{"unresolved_inputs.csv", "resolved_by", "解析方式", "未解析状态码。"},
		{"unresolved_inputs.csv", "resolved_by_name", "解析方式名称", "未解析状态的中文摘要。"},
		{"unresolved_inputs.csv", "resolve_note", "未解析原因", "说明为什么无法从老系统反查到 ICCID。"},
		{"card_snapshot.csv", "iccid", "ICCID", "老系统卡 ICCID，也是后续新系统导入时对齐卡的关键标识。"},
		{"card_snapshot.csv", "input_device_virtual_no", "输入设备号", "该卡来自哪些输入设备号；多个值用 | 分隔。"},
		{"card_snapshot.csv", "input_imei", "输入 IMEI", "该卡来自哪些输入 IMEI；多个值用 | 分隔。"},
		{"card_snapshot.csv", "input_sn", "输入 SN", "该卡来自哪些输入 SN；多个值用 | 分隔。"},
		{"card_snapshot.csv", "input_slot_positions", "输入卡槽", "该卡在设备关联中的卡槽序号；多个值用 | 分隔。"},
		{"card_snapshot.csv", "old_card_id", "老卡 ID", "老系统 tbl_card.id。"},
		{"card_snapshot.csv", "old_phone", "老系统号码", "老系统 tbl_card.phone，一般为 MSISDN/卡号展示字段。"},
		{"card_snapshot.csv", "old_phone_zuhe", "组合号码", "老系统 tbl_card.phone_zuhe。"},
		{"card_snapshot.csv", "old_imei", "老系统 IMEI", "老系统 tbl_card.imei；本批样本中很多为空。"},
		{"card_snapshot.csv", "old_vcode", "老系统设备码", "老系统 tbl_card.vcode；老接口 mchno 通常按这个字段查询。"},
		{"card_snapshot.csv", "old_status", "老卡状态值", "老系统 tbl_card.status 原值。"},
		{"card_snapshot.csv", "old_status_name", "老卡状态", "old_status 的中文含义。"},
		{"card_snapshot.csv", "old_type", "老卡类型值", "老系统 tbl_card.type 原值。"},
		{"card_snapshot.csv", "old_type_name", "老卡类型", "old_type 的中文含义。"},
		{"card_snapshot.csv", "old_category", "老运营商分类值", "老系统 tbl_card.category 原值。"},
		{"card_snapshot.csv", "old_category_name", "老运营商分类", "优先来自 tbl_vendor_category.category_name。"},
		{"card_snapshot.csv", "old_account_id", "老供应账号 ID", "老系统 tbl_card.account_id。"},
		{"card_snapshot.csv", "old_account_name", "老供应账号名称", "老系统 tbl_card.account_name。"},
		{"card_snapshot.csv", "old_agent_id", "老代理 ID", "老系统 tbl_card.agent_id。"},
		{"card_snapshot.csv", "old_agent_name", "老代理名称", "老系统 tbl_card.agent_name。"},
		{"card_snapshot.csv", "old_agent_three_id", "三级代理 ID", "老系统 tbl_card.agent_three_id。"},
		{"card_snapshot.csv", "old_set_meal_id", "老套餐 ID", "老系统 tbl_card.set_meal_id。"},
		{"card_snapshot.csv", "old_card_package", "老套餐名称", "老系统 tbl_card.card_package。"},
		{"card_snapshot.csv", "old_expire_time", "老卡到期时间", "老系统 tbl_card.expire_time；行业卡可能有值，普通设备套餐以 package_usage_snapshot.csv 为准。"},
		{"card_snapshot.csv", "old_flow_size_mb", "老套餐总量 MB", "老系统 tbl_card.flow_size。"},
		{"card_snapshot.csv", "old_total_bytes_cnt_mb", "老网关累计已用 MB", "老系统 tbl_card.total_bytes_cnt 原值。"},
		{"card_snapshot.csv", "current_used_mb_decimal", "当前已用 MB", "迁移时建议保留的小数已用量。"},
		{"card_snapshot.csv", "current_used_mb_int", "当前已用 MB 整数", "向下取整后的已用量，便于新系统整数/DECIMAL 字段导入。"},
		{"card_snapshot.csv", "old_synchro_time", "老同步时间", "老系统 tbl_card.synchro_time。"},
		{"card_snapshot.csv", "old_serv_create_at", "运营商开户时间", "老系统 tbl_card.serv_create_date。"},
		{"card_snapshot.csv", "old_balance_money", "老余额字段", "老系统 tbl_card.balance_money。"},
		{"card_snapshot.csv", "suggested_tb_iot_card_data_usage_mb", "建议卡累计用量", "建议导入新系统 tb_iot_card.data_usage_mb。"},
		{"card_snapshot.csv", "suggested_tb_iot_card_current_month_usage_mb", "建议自然月已用量", "建议导入新系统 tb_iot_card.current_month_usage_mb；需结合新系统口径复核。"},
		{"card_snapshot.csv", "suggested_tb_iot_card_last_gateway_reading_mb", "建议网关读数基线", "建议导入新系统 tb_iot_card.last_gateway_reading_mb，避免首轮轮询重复扣历史流量。"},
		{"package_usage_snapshot.csv", "iccid", "ICCID", "套餐所属老卡 ICCID。"},
		{"package_usage_snapshot.csv", "input_device_virtual_no", "输入设备号", "该套餐所属卡来自哪些输入设备号；多个值用 | 分隔。"},
		{"package_usage_snapshot.csv", "source_table", "来源表", "tbl_card_life 表示当前/历史套餐，tbl_next_month_card_life 表示次月待生效套餐。"},
		{"package_usage_snapshot.csv", "source_table_name", "来源表名称", "来源表的中文说明。"},
		{"package_usage_snapshot.csv", "old_usage_id", "老套餐记录 ID", "老系统套餐使用记录主键。"},
		{"package_usage_snapshot.csv", "old_meal_id", "老套餐 ID", "老系统套餐 ID。"},
		{"package_usage_snapshot.csv", "old_meal_name", "老套餐名称", "老系统套餐名称。"},
		{"package_usage_snapshot.csv", "old_order_id", "老订单 ID", "老系统订单 ID。"},
		{"package_usage_snapshot.csv", "start_at", "开始时间", "tbl_card_life.start_date。"},
		{"package_usage_snapshot.csv", "expires_at", "到期时间", "tbl_card_life.expire_date。"},
		{"package_usage_snapshot.csv", "effective_start_at", "有效开始时间", "迁移参考开始时间；优先使用 start_at，老库 start_at 为空时使用记录创建时间兜底。"},
		{"package_usage_snapshot.csv", "effective_expires_at", "有效到期时间", "迁移参考到期时间；当前仅使用老库 expire_date，待生效套餐老库通常没有到期时间。"},
		{"package_usage_snapshot.csv", "date_source_note", "时间来源说明", "说明开始/到期时间来自哪个老库字段，以及是否使用兜底。"},
		{"package_usage_snapshot.csv", "effect_begin_time", "待生效开始时间", "tbl_next_month_card_life.effect_begin_time。"},
		{"package_usage_snapshot.csv", "effect_complete_time", "待生效完成时间", "tbl_next_month_card_life.effect_complete_time。"},
		{"package_usage_snapshot.csv", "record_created_at", "套餐记录创建时间", "tbl_card_life.create_time 或 tbl_next_month_card_life.create_date。"},
		{"package_usage_snapshot.csv", "record_updated_at", "套餐记录更新时间", "tbl_card_life.update_time；次月待生效表没有对应字段。"},
		{"package_usage_snapshot.csv", "old_status", "老套餐状态值", "来源表中的 status 原值。"},
		{"package_usage_snapshot.csv", "old_status_name", "老套餐状态", "old_status 的中文含义；不同来源表含义不同。"},
		{"package_usage_snapshot.csv", "old_type", "老套餐类型值", "tbl_card_life.type 原值。"},
		{"package_usage_snapshot.csv", "old_type_name", "老套餐类型", "old_type 的中文含义。"},
		{"package_usage_snapshot.csv", "effect_type", "生效类型值", "tbl_card_life.effect_type 原值。"},
		{"package_usage_snapshot.csv", "effect_type_name", "生效类型", "effect_type 的中文含义。"},
		{"package_usage_snapshot.csv", "order_type", "订单类型值", "tbl_next_month_card_life.order_type 原值。"},
		{"package_usage_snapshot.csv", "order_type_name", "订单类型", "order_type 的中文含义。"},
		{"package_usage_snapshot.csv", "flow_limit_mb", "套餐总量 MB", "老套餐记录 flow_size。"},
		{"package_usage_snapshot.csv", "card_current_used_mb_decimal", "卡当前已用 MB", "来自 card_snapshot 的当前已用量。"},
		{"package_usage_snapshot.csv", "allocated_used_mb", "建议套餐已用 MB", "按当前生效套餐顺序从卡已用量中分摊出来的迁移建议值。"},
		{"package_usage_snapshot.csv", "remaining_mb", "建议套餐剩余 MB", "flow_limit_mb - allocated_used_mb。"},
		{"package_usage_snapshot.csv", "suggested_new_status", "建议新套餐状态值", "建议导入新系统 tb_package_usage.status。"},
		{"package_usage_snapshot.csv", "suggested_new_status_name", "建议新套餐状态", "suggested_new_status 的中文含义。"},
		{"package_usage_snapshot.csv", "is_pending_next_month", "是否次月待生效", "true 表示来源于 tbl_next_month_card_life。"},
		{"wallet_snapshot.csv", "iccid", "ICCID", "钱包所属老卡 ICCID。"},
		{"wallet_snapshot.csv", "money_balance_yuan", "钱包余额元", "老系统 tbl_card_wallet.money_balance。"},
		{"wallet_snapshot.csv", "money_balance_fen", "钱包余额分", "钱包余额按分换算后的整数值。"},
		{"wallet_snapshot.csv", "tx_version", "钱包版本号", "老系统钱包乐观锁版本。"},
		{"wallet_snapshot.csv", "meal_auto_renew_status", "主套餐自动续费状态值", "老系统 tbl_card_wallet.meal_auto_renew_status。"},
		{"wallet_snapshot.csv", "meal_auto_renew_status_name", "主套餐自动续费状态", "1 启用，2 关闭。"},
		{"wallet_snapshot.csv", "auto_renew_meal_id", "主套餐自动续费套餐 ID", "老系统主套餐自动续费绑定的套餐 ID。"},
		{"wallet_snapshot.csv", "addon_auto_renew_status", "叠加包自动续费状态值", "老系统 tbl_card_wallet.djb_meal_auto_renew_status。"},
		{"wallet_snapshot.csv", "addon_auto_renew_status_name", "叠加包自动续费状态", "1 启用，2 关闭。"},
		{"wallet_snapshot.csv", "addon_auto_renew_meal_id", "叠加包自动续费套餐 ID", "老系统叠加包自动续费绑定的套餐 ID。"},
		{"wallet_snapshot.csv", "meal_auto_renew_updated_at", "自动续费更新时间", "老系统 tbl_card_wallet.meal_auto_renew_udpate_time。"},
		{"wallet_snapshot.csv", "created_at", "钱包创建时间", "老系统 tbl_card_wallet.create_date。"},
		{"commission_account_snapshot.csv", "old_agent_id", "老代理 ID", "老系统佣金账户 agent_id。"},
		{"commission_account_snapshot.csv", "old_agent_name", "老代理名称", "老系统佣金账户 agent_name。"},
		{"commission_account_snapshot.csv", "old_agent_login_account", "老代理登录账号", "老系统佣金账户 agent_login_account。"},
		{"commission_account_snapshot.csv", "suggested_new_shop_id", "建议新店铺 ID", "预留给后续导入新系统时填写 shop_id。"},
		{"commission_account_snapshot.csv", "total_commission_yuan", "累计佣金元", "老佣金账户累计佣金。"},
		{"commission_account_snapshot.csv", "total_commission_fen", "累计佣金分", "累计佣金按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "total_active_award_commission_yuan", "累计激活奖励元", "老佣金账户累计激活奖励佣金。"},
		{"commission_account_snapshot.csv", "total_active_award_commission_fen", "累计激活奖励分", "累计激活奖励按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "mf_commission_yuan", "秒返佣金元", "老佣金账户秒返佣金。"},
		{"commission_account_snapshot.csv", "mf_commission_fen", "秒返佣金分", "秒返佣金按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "nomf_commission_yuan", "非秒返佣金元", "老佣金账户非秒返佣金。"},
		{"commission_account_snapshot.csv", "nomf_commission_fen", "非秒返佣金分", "非秒返佣金按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "total_draw_yuan", "累计提现元", "老佣金账户累计提现金额。"},
		{"commission_account_snapshot.csv", "total_draw_fen", "累计提现分", "累计提现金额按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "can_draw_yuan", "可提现金额元", "老佣金账户可提现余额。"},
		{"commission_account_snapshot.csv", "can_draw_fen", "可提现金额分", "可提现余额按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "applying_draw_yuan", "提现中金额元", "老佣金账户申请提现中金额。"},
		{"commission_account_snapshot.csv", "applying_draw_fen", "提现中金额分", "提现中金额按分换算后的整数值。"},
		{"commission_account_snapshot.csv", "remark", "备注", "老佣金账户备注。"},
		{"commission_account_snapshot.csv", "tx_version", "佣金账户版本号", "老佣金账户乐观锁版本。"},
		{"commission_account_snapshot.csv", "created_at", "佣金账户创建时间", "老佣金账户 create_date。"},
		{"commission_account_snapshot.csv", "updated_at", "佣金账户更新时间", "老佣金账户 update_date。"},
		{"commission_detail_snapshot.csv", "id", "佣金明细 ID", "老系统佣金明细主键。"},
		{"commission_detail_snapshot.csv", "commission_account_id", "佣金账户 ID", "老系统佣金账户 ID。"},
		{"commission_detail_snapshot.csv", "wx_order_id", "微信订单 ID", "老系统微信订单 ID。"},
		{"commission_detail_snapshot.csv", "wx_order_number", "微信订单号", "老系统微信订单号。"},
		{"commission_detail_snapshot.csv", "iccid", "ICCID", "佣金明细关联的老卡 ICCID。"},
		{"commission_detail_snapshot.csv", "phone", "手机号/卡号", "佣金明细记录的老系统 phone。"},
		{"commission_detail_snapshot.csv", "wx_order_at", "微信订单时间", "老系统 wx_order_date。"},
		{"commission_detail_snapshot.csv", "created_at", "佣金明细创建时间", "老系统 create_date。"},
		{"commission_detail_snapshot.csv", "commission_amount_yuan", "本次佣金元", "本条佣金金额。"},
		{"commission_detail_snapshot.csv", "commission_amount_fen", "本次佣金分", "本条佣金金额按分换算后的整数值。"},
		{"commission_detail_snapshot.csv", "total_commission_amount_yuan", "累计佣金元", "该明细发生后的累计佣金金额。"},
		{"commission_detail_snapshot.csv", "total_commission_amount_fen", "累计佣金分", "累计佣金金额按分换算后的整数值。"},
		{"commission_detail_snapshot.csv", "mf_status", "秒返状态值", "老系统 tbl_agent_commission_detail.mf_status。"},
		{"commission_detail_snapshot.csv", "mf_status_name", "秒返状态", "0 成功，1 失败。"},
		{"commission_detail_snapshot.csv", "commission_type", "佣金类型值", "老系统 tbl_agent_commission_detail.commission_type。"},
		{"commission_detail_snapshot.csv", "commission_type_name", "佣金类型", "commission_type 的中文含义。"},
		{"commission_detail_snapshot.csv", "active_award_commission_amount_yuan", "本次激活奖励元", "本条激活奖励佣金金额。"},
		{"commission_detail_snapshot.csv", "active_award_commission_amount_fen", "本次激活奖励分", "本条激活奖励佣金按分换算后的整数值。"},
		{"commission_detail_snapshot.csv", "total_active_award_commission_amount_yuan", "累计激活奖励元", "该明细发生后的累计激活奖励佣金。"},
		{"commission_detail_snapshot.csv", "total_active_award_commission_amount_fen", "累计激活奖励分", "累计激活奖励佣金按分换算后的整数值。"},
		{"missing_cards.csv", "iccid", "缺失 ICCID", "输入中解析到但老系统 tbl_card 未命中的 ICCID。"},
	}

	return writeCSV(path, []string{"file_name", "column_name", "chinese_name", "description"}, func(w *csv.Writer) error {
		for _, item := range fields {
			if err := w.Write([]string{item.FileName, item.ColumnName, item.ChineseName, item.Description}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeEnumDictionary(path string, data *exportData) error {
	enums := baseEnumDefinitions()
	enums = append(enums, observedCategoryEnums(data)...)

	return writeCSV(path, []string{"file_name", "column_name", "value", "meaning", "source", "note"}, func(w *csv.Writer) error {
		for _, item := range enums {
			if err := w.Write([]string{item.FileName, item.ColumnName, item.Value, item.Meaning, item.Source, item.Note}); err != nil {
				return err
			}
		}
		return nil
	})
}

func writeOutputGuide(path string, data *exportData) error {
	packageCount := 0
	for _, rows := range data.PackagesByICCID {
		packageCount += len(rows)
	}

	content := strings.Builder{}
	content.WriteString("# 老系统设备迁移导出说明\n\n")
	content.WriteString(fmt.Sprintf("生成时间：%s\n\n", time.Now().Format(timeFormat)))
	content.WriteString("## 优先查看\n\n")
	content.WriteString("- `card_snapshot.csv`：卡本体、老卡状态、老套餐字段、当前已用量，以及新系统卡流量字段建议值。\n")
	content.WriteString("- `package_usage_snapshot.csv`：老套餐记录、已用/剩余分摊建议，以及新系统套餐状态建议值。\n")
	content.WriteString("- `field_dictionary.csv`：每个输出字段的中文说明。\n")
	content.WriteString("- `enum_dictionary.csv`：状态码、类型码、来源表等枚举值说明。\n\n")
	content.WriteString("## 本次摘要\n\n")
	content.WriteString(fmt.Sprintf("- 已解析输入关系：%d 行\n", len(data.Inputs)))
	content.WriteString(fmt.Sprintf("- 输入 ICCID：%d 个\n", len(data.InputByICCID)))
	content.WriteString(fmt.Sprintf("- 老库卡本体命中：%d 张\n", len(data.Cards)))
	content.WriteString(fmt.Sprintf("- 套餐快照：%d 行\n", packageCount))
	content.WriteString(fmt.Sprintf("- 钱包快照：%d 行\n", len(data.Wallets)))
	content.WriteString(fmt.Sprintf("- 佣金账户：%d 行\n", len(data.CommissionAccounts)))
	content.WriteString(fmt.Sprintf("- 未解析输入：%d 行\n", len(data.UnresolvedInputs)))
	content.WriteString(fmt.Sprintf("- 老库缺失 ICCID：%d 个\n\n", len(data.MissingICCIDs)))
	content.WriteString("## 口径提醒\n\n")
	content.WriteString("- 英文字段名保留给后续脚本处理，中文含义请看同表的 `*_name` 列和 `field_dictionary.csv`。\n")
	content.WriteString("- 老系统状态值来自老库字段注释；新系统建议状态来自当前项目常量。\n")
	content.WriteString("- `suggested_tb_iot_card_last_gateway_reading_mb` 是新系统轮询的基线，导入时要保留，避免首轮轮询重复计算历史流量。\n")
	content.WriteString("- `allocated_used_mb` 是迁移建议值，正式生成导入 SQL 前仍要结合新系统供应商、套餐系列、套餐映射复核。\n")
	return os.WriteFile(path, []byte(content.String()), 0644)
}

func baseEnumDefinitions() []enumDefinition {
	var result []enumDefinition
	appendMapEnums(&result, "card_snapshot.csv", "old_status", oldCardStatusNames, "老库 tbl_card.status 字段注释", "")
	appendMapEnums(&result, "card_snapshot.csv", "old_type", oldCardTypeNames, "老库 tbl_card.type 字段注释", "")
	appendMapEnums(&result, "package_usage_snapshot.csv", "source_table", map[string]string{
		sourceCardLife:      sourceTableName(sourceCardLife),
		sourceNextMonthLife: sourceTableName(sourceNextMonthLife),
	}, "脚本来源表约定", "")
	appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldCardLifeStatusNames, "老库 tbl_card_life.status 字段注释", "仅 source_table=tbl_card_life 时适用")
	appendMapEnums(&result, "package_usage_snapshot.csv", "old_status", oldNextMonthLifeStatusNames, "老库 tbl_next_month_card_life.status 字段注释", "仅 source_table=tbl_next_month_card_life 时适用")
	appendMapEnums(&result, "package_usage_snapshot.csv", "old_type", oldPackageTypeNames, "老库 tbl_card_life.type 字段注释", "")
	appendMapEnums(&result, "package_usage_snapshot.csv", "effect_type", oldEffectTypeNames, "老库 tbl_card_life.effect_type 字段注释", "")
	appendMapEnums(&result, "package_usage_snapshot.csv", "order_type", oldOrderTypeNames, "老库 tbl_next_month_card_life.order_type 字段注释", "")
	appendMapEnums(&result, "package_usage_snapshot.csv", "suggested_new_status", newPackageUsageStatusNames, "新系统 pkg/constants PackageUsageStatus*", "建议导入值，生成 SQL 前需复核")
	appendMapEnums(&result, "wallet_snapshot.csv", "meal_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.meal_auto_renew_status 字段注释", "")
	appendMapEnums(&result, "wallet_snapshot.csv", "addon_auto_renew_status", autoRenewStatusNames, "老库 tbl_card_wallet.djb_meal_auto_renew_status 字段注释", "")
	appendMapEnums(&result, "commission_detail_snapshot.csv", "mf_status", mfStatusNames, "老库 tbl_agent_commission_detail.mf_status 字段注释", "")
	appendMapEnums(&result, "commission_detail_snapshot.csv", "commission_type", commissionTypeNames, "老库 tbl_agent_commission_detail.commission_type 字段注释", "")
	return result
}

func appendMapEnums(result *[]enumDefinition, fileName, columnName string, values map[string]string, source, note string) {
	keys := sortedStringMapKeys(values)
	for _, key := range keys {
		*result = append(*result, enumDefinition{
			FileName:   fileName,
			ColumnName: columnName,
			Value:      key,
			Meaning:    values[key],
			Source:     source,
			Note:       note,
		})
	}
}

func observedCategoryEnums(data *exportData) []enumDefinition {
	seen := make(map[string]string)
	for _, card := range data.Cards {
		if card.Category == "" {
			continue
		}
		seen[card.Category] = oldCardCategoryName(card.Category, card.CategoryName)
	}
	keys := sortedStringMapKeys(seen)
	result := make([]enumDefinition, 0, len(keys))
	for _, key := range keys {
		result = append(result, enumDefinition{
			FileName:   "card_snapshot.csv",
			ColumnName: "old_category",
			Value:      key,
			Meaning:    seen[key],
			Source:     "老库 tbl_vendor_category.category_name",
			Note:       "仅列出本次导出中实际出现的运营商分类",
		})
	}
	return result
}

func oldCardStatusName(value string) string {
	return enumName(oldCardStatusNames, value)
}

func oldCardTypeName(value string) string {
	return enumName(oldCardTypeNames, value)
}

func oldCardCategoryName(value, dbName string) string {
	if strings.TrimSpace(dbName) != "" {
		return dbName
	}
	return enumName(oldCategoryNames, value)
}

func oldPackageStatusName(sourceTable, value string) string {
	if sourceTable == sourceNextMonthLife {
		return enumName(oldNextMonthLifeStatusNames, value)
	}
	return enumName(oldCardLifeStatusNames, value)
}

func oldPackageTypeName(value string) string {
	return enumName(oldPackageTypeNames, value)
}

func oldEffectTypeName(value string) string {
	return enumName(oldEffectTypeNames, value)
}

func oldOrderTypeName(value string) string {
	return enumName(oldOrderTypeNames, value)
}

func autoRenewStatusName(value string) string {
	return enumName(autoRenewStatusNames, value)
}

func newPackageUsageStatusName(value string) string {
	return enumName(newPackageUsageStatusNames, value)
}

func mfStatusName(value string) string {
	return enumName(mfStatusNames, value)
}

func commissionTypeName(value string) string {
	return enumName(commissionTypeNames, value)
}

func sourceTableName(value string) string {
	switch value {
	case sourceCardLife:
		return "当前/历史套餐记录"
	case sourceNextMonthLife:
		return "次月待生效套餐记录"
	default:
		return ""
	}
}

func slotPositionName(slot int) string {
	switch slot {
	case 1:
		return "主卡"
	case 2:
		return "副卡1"
	case 3:
		return "副卡2"
	case 4:
		return "卡槽4"
	default:
		return ""
	}
}

func resolvedByName(value string) string {
	switch {
	case value == "input_iccid":
		return "输入直接提供 ICCID"
	case value == "input_slot_iccid":
		return "输入按卡槽提供 ICCID"
	case strings.Contains(value, "old_tbl_card_vcode") && strings.Contains(value, "old_tbl_card_relate"):
		return "按设备码命中后展开关联卡"
	case value == "old_tbl_card_vcode":
		return "按老库 tbl_card.vcode 反查"
	case value == "old_tbl_card_imei":
		return "按老库 tbl_card.imei 反查"
	case value == "old_tbl_card_relate":
		return "按老库 tbl_card_relate 展开"
	case value == "pending_device_key":
		return "待按设备号/IMEI 解析"
	case value == "unresolved":
		return "无法解析"
	case value == "unresolved_device_key":
		return "设备号/IMEI 未命中老库"
	default:
		return ""
	}
}

func enumName(values map[string]string, value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	if name, ok := values[value]; ok {
		return name
	}
	return "未知值"
}

func sortedStringMapKeys(values map[string]string) []string {
	keys := make([]string, 0, len(values))
	for key := range values {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}
