package config

import (
	"encoding/hex"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"time"

	"hyapp/pkg/configx"
	"hyapp/pkg/logx"
	"hyapp/pkg/outboxpartition"
)

// Config 描述 wallet-service 启动配置。
type Config struct {
	ServiceName string `yaml:"service_name"`
	NodeID      string `yaml:"node_id"`
	Environment string `yaml:"environment"`
	GRPCAddr    string `yaml:"grpc_addr"`
	// HealthHTTPAddr 是给 CLB/发布脚本探活使用的 HTTP 监听地址，不承载业务流量。
	HealthHTTPAddr string `yaml:"health_http_addr"`
	// MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。
	MySQLDSN string `yaml:"mysql_dsn"`
	// ActivityServiceAddr 用于把 wallet 资源赠送 outbox 投影到 activity badge 展示读模型。
	ActivityServiceAddr string `yaml:"activity_service_addr"`
	// RedPacketExpiryWorker 控制红包过期退款扫描。
	RedPacketExpiryWorker RedPacketExpiryWorkerConfig `yaml:"red_packet_expiry_worker"`
	// RocketMQ 控制 wallet_outbox 事实发布。
	RocketMQ RocketMQConfig `yaml:"rocketmq"`
	// GooglePlay 控制 Google Play Developer API 购买校验。
	GooglePlay GooglePlayConfig `yaml:"google_play"`
	// GooglePaidSyncWorker controls durable paid-detail retries after Google purchase confirmation.
	GooglePaidSyncWorker GooglePaidSyncWorkerConfig `yaml:"google_paid_sync_worker"`
	// MifaPay 控制 H5 MiFaPay 站外支付下单和回调验签。
	MifaPay MifaPayConfig `yaml:"mifapay"`
	// V5Pay 控制 H5 V5Pay 站外支付下单、查询和回调验签。
	V5Pay V5PayConfig `yaml:"v5pay"`
	// TronGrid 控制 USDT-TRC20 tx_hash 链上校验。
	TronGrid TronGridConfig `yaml:"tron_grid"`
	// BSCUSDT 控制 USDT-BEP20 tx_hash 链上只读校验；默认关闭，避免生产误依赖公共 RPC。
	BSCUSDT BSCUSDTConfig `yaml:"bsc_usdt"`
	// Binance 控制 Binance 只读查单；账号按 app_code 注册，旧版 bian.<app_code> 仍由兼容映射读取。
	Binance BinanceConfig `yaml:"bian"`
	// ExternalRecharge 控制 H5 外部充值公开地址和回跳地址。
	ExternalRecharge ExternalRechargeConfig `yaml:"external_recharge"`
	// ExternalRechargeReconcileWorker 控制三方支付回调丢失后的服务端查单补偿。
	ExternalRechargeReconcileWorker ExternalRechargeReconcileWorkerConfig `yaml:"external_recharge_reconcile_worker"`
	// ResourceEquipmentCleanupWorker 把失效装备的物理删除收敛到 Wallet owner 后台，读取接口只做有效性过滤。
	ResourceEquipmentCleanupWorker ResourceEquipmentCleanupWorkerConfig `yaml:"resource_equipment_cleanup_worker"`
	// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
	OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
	// OutboxArchive 把已投递事实归档到外部存储；purge 仅对恢复验证且命中正向安全策略的精确成员开放。
	OutboxArchive OutboxArchiveConfig `yaml:"outbox_archive"`
	// RealtimeOutboxWorker 控制红包等实时 UI 事件到独立 MQ topic 的补偿投递。
	RealtimeOutboxWorker OutboxWorkerConfig `yaml:"realtime_outbox_worker"`
	// ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费。
	ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"`
	// MySQLAutoMigrate 保留给显式本地迁移；线上 schema 由发布流程或 initdb 管理。
	MySQLAutoMigrate bool        `yaml:"mysql_auto_migrate"`
	Log              logx.Config `yaml:"log"`
}

// RedPacketExpiryWorkerConfig 保存红包过期退款 worker 策略。
type RedPacketExpiryWorkerConfig struct {
	Enabled      bool          `yaml:"enabled"`
	PollInterval time.Duration `yaml:"poll_interval"`
	BatchSize    int32         `yaml:"batch_size"`
}

// RocketMQConfig 描述 wallet-service 发布账务事实的 MQ 连接。
type RocketMQConfig struct {
	Enabled          bool          `yaml:"enabled"`
	NameServers      []string      `yaml:"name_servers"`
	NameServerDomain string        `yaml:"name_server_domain"`
	AccessKey        string        `yaml:"access_key"`
	SecretKey        string        `yaml:"secret_key"`
	SecurityToken    string        `yaml:"security_token"`
	Namespace        string        `yaml:"namespace"`
	VIPChannel       bool          `yaml:"vip_channel"`
	SendTimeout      time.Duration `yaml:"send_timeout"`
	Retry            int           `yaml:"retry"`
	// WalletEventTagMode 是 wallet topic producer 的两阶段发布开关：
	// legacy 用统一 Tag 保证旧消费者可见，event_type 仅在所有消费者完成双订阅后启用。
	WalletEventTagMode WalletEventTagMode   `yaml:"wallet_event_tag_mode"`
	WalletOutbox       WalletOutboxMQConfig `yaml:"wallet_outbox"`
	RealtimeOutbox     WalletOutboxMQConfig `yaml:"realtime_outbox"`
}

// WalletEventTagMode 控制钱包事实的 Broker 路由 Tag，不改变消息体协议。
type WalletEventTagMode string

const (
	// WalletEventTagModeLegacy 是安全默认值；旧配置缺字段时不能因普通滚动发布提前切 typed Tag。
	WalletEventTagModeLegacy WalletEventTagMode = "legacy"
	// WalletEventTagModeEventType 在 Phase B 使用消息 event_type 作为 Tag，让 Broker 精确过滤。
	WalletEventTagModeEventType WalletEventTagMode = "event_type"
)

// WalletOutboxMQConfig 保存 wallet_outbox topic 的生产者配置。
type WalletOutboxMQConfig struct {
	Enabled       bool   `yaml:"enabled"`
	Topic         string `yaml:"topic"`
	ProducerGroup string `yaml:"producer_group"`
}

// OutboxWorkerConfig 控制 wallet_outbox pending 事件的 MQ fanout。
type OutboxWorkerConfig struct {
	Enabled bool `yaml:"enabled"`
	// AppCodes 是可投递租户 allowlist；空值才自动发现，便于积压回放按 App 分阶段放量。
	AppCodes       []string      `yaml:"app_codes"`
	PollInterval   time.Duration `yaml:"poll_interval"`
	BatchSize      int           `yaml:"batch_size"`
	Concurrency    int           `yaml:"concurrency"`
	PublishTimeout time.Duration `yaml:"publish_timeout"`
	MaxRetryCount  int           `yaml:"max_retry_count"`
	InitialBackoff time.Duration `yaml:"initial_backoff"`
	MaxBackoff     time.Duration `yaml:"max_backoff"`
	EventTypes     []string      `yaml:"event_types"`
}

// OutboxArchiveMode 区分无写入的容量评估与可验证的 COS 归档。
type OutboxArchiveMode string

const (
	OutboxArchiveModeDryRun      OutboxArchiveMode = "dry_run"
	OutboxArchiveModeArchiveOnly OutboxArchiveMode = "archive_only"
)

// OutboxArchiveConfig 控制 delivered wallet_outbox 的归档与受控清理链路。
type OutboxArchiveConfig struct {
	Enabled bool              `yaml:"enabled"`
	Mode    OutboxArchiveMode `yaml:"mode"`
	// PurgeEnabled 只有在归档、真实恢复演练凭证和租户 allowlist 同时满足时才允许启动。
	PurgeEnabled bool `yaml:"purge_enabled"`
	// PurgeAppCodes 永远是显式 allowlist；清理不能像归档一样自动发现新租户。
	PurgeAppCodes []string `yaml:"purge_app_codes"`
	// PurgeMaxAge 与归档 cutoff 分离，防止调快归档任务时意外同步缩短热数据保留期。
	PurgeMaxAge       time.Duration `yaml:"purge_max_age"`
	PurgePollInterval time.Duration `yaml:"purge_poll_interval"`
	PurgeBatchSize    int           `yaml:"purge_batch_size"`
	// PurgeReceiptGrace 强制归档回执先稳定一段时间，避免刚完成上传就立即删除热表事实。
	PurgeReceiptGrace time.Duration `yaml:"purge_receipt_grace"`
	// PurgePolicyVersion 对应代码内正向事件类型清单；未知的新事件默认永不删除。
	PurgePolicyVersion string `yaml:"purge_policy_version"`
	// PurgeRestoreProofs 把一次真实 RESTORE_VERIFIED 结果绑定到 App 和归档 manifest；生产库还必须有相同授权记录。
	PurgeRestoreProofs []OutboxArchivePurgeRestoreProofConfig `yaml:"purge_restore_proofs"`
	MaxAge             time.Duration                          `yaml:"max_age"`
	PollInterval       time.Duration                          `yaml:"poll_interval"`
	QueryBatchSize     int                                    `yaml:"query_batch_size"`
	SegmentMaxRows     int                                    `yaml:"segment_max_rows"`
	// SegmentMaxUncompressedBytes caps one in-memory/archive artifact independently of row count because payload sizes vary.
	SegmentMaxUncompressedBytes int64                  `yaml:"segment_max_uncompressed_bytes"`
	RoundMaxRows                int                    `yaml:"round_max_rows"`
	BatchPause                  time.Duration          `yaml:"batch_pause"`
	QueryTimeout                time.Duration          `yaml:"query_timeout"`
	OperationTimeout            time.Duration          `yaml:"operation_timeout"`
	ObjectPrefix                string                 `yaml:"object_prefix"`
	TempDir                     string                 `yaml:"temp_dir"`
	COS                         OutboxArchiveCOSConfig `yaml:"cos"`
}

// OutboxArchivePurgeRestoreProofConfig 是自动删除的双重确认材料，不包含任何 COS 或数据库密钥。
type OutboxArchivePurgeRestoreProofConfig struct {
	AppCode        string `yaml:"app_code"`
	BatchID        string `yaml:"batch_id"`
	ManifestSHA256 string `yaml:"manifest_sha256"`
}

// OutboxArchiveCOSConfig 只允许服务端环境注入私有桶凭证；归档对象不生成公网 URL。
type OutboxArchiveCOSConfig struct {
	CredentialSource string        `yaml:"credential_source"`
	CAMRoleName      string        `yaml:"cam_role_name"`
	SecretID         string        `yaml:"secret_id"`
	SecretKey        string        `yaml:"secret_key"`
	SessionToken     string        `yaml:"session_token"`
	Bucket           string        `yaml:"bucket"`
	Region           string        `yaml:"region"`
	HTTPTimeout      time.Duration `yaml:"http_timeout"`
}

// ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。
type ProjectionWorkerConfig struct {
	Enabled                   bool          `yaml:"enabled"`
	PollInterval              time.Duration `yaml:"poll_interval"` // Deprecated: runtime projection is MQ-driven.
	BatchSize                 int           `yaml:"batch_size"`
	LockTTL                   time.Duration `yaml:"lock_ttl"`
	ConsumerGroup             string        `yaml:"consumer_group"`
	ConsumerMaxReconsumeTimes int32         `yaml:"consumer_max_reconsume_times"`
}

// GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。
type GooglePlayConfig struct {
	Enabled            bool          `yaml:"enabled"`
	PackageName        string        `yaml:"package_name"`
	ServiceAccountFile string        `yaml:"service_account_file"`
	ServiceAccountJSON string        `yaml:"service_account_json"`
	APIBaseURL         string        `yaml:"api_base_url"`
	TokenURL           string        `yaml:"token_url"`
	HTTPTimeout        time.Duration `yaml:"http_timeout"`
	ConsumeEnabled     bool          `yaml:"consume_enabled"`
}

// GooglePaidSyncWorkerConfig polls unsynchronized payment_orders independently from the request goroutine.
type GooglePaidSyncWorkerConfig struct {
	Enabled        bool          `yaml:"enabled"`
	PollInterval   time.Duration `yaml:"poll_interval"`
	BatchSize      int           `yaml:"batch_size"`
	RequestTimeout time.Duration `yaml:"request_timeout"`
}

// MifaPayConfig 保存 MiFaPay 商户签名、验签和网关配置。
type MifaPayConfig struct {
	Enabled           bool                  `yaml:"enabled"`
	Environment       string                `yaml:"environment"`
	EnvironmentTest   MifaPayEndpointConfig `yaml:"environment_test"`
	EnvironmentProd   MifaPayEndpointConfig `yaml:"environment_prod"`
	MerAccount        string                `yaml:"mer_account"`
	MerNo             string                `yaml:"mer_no"`
	MerchantPublicKey string                `yaml:"merchant_public_key"`
	PrivateKey        string                `yaml:"private_key"`
	PlatformPublicKey string                `yaml:"platform_public_key"`
	APIBaseURL        string                `yaml:"api_base_url"`
	HTTPTimeout       time.Duration         `yaml:"http_timeout"`
}

// MifaPayEndpointConfig 保存某一个 MiFaPay 环境的商户和网关配置；密钥仍由配置文件或环境注入，不在代码里伪造。
type MifaPayEndpointConfig struct {
	MerAccount        string        `yaml:"mer_account"`
	MerNo             string        `yaml:"mer_no"`
	MerchantPublicKey string        `yaml:"merchant_public_key"`
	PrivateKey        string        `yaml:"private_key"`
	PlatformPublicKey string        `yaml:"platform_public_key"`
	APIBaseURL        string        `yaml:"api_base_url"`
	HTTPTimeout       time.Duration `yaml:"http_timeout"`
}

// V5PayConfig 保存 V5Pay 商户签名、验签和网关配置。
type V5PayConfig struct {
	Enabled         bool                `yaml:"enabled"`
	Environment     string              `yaml:"environment"`
	EnvironmentTest V5PayEndpointConfig `yaml:"environment_test"`
	EnvironmentProd V5PayEndpointConfig `yaml:"environment_prod"`
	MerchantNo      string              `yaml:"merchant_no"`
	MerNo           string              `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。
	AppKey          string              `yaml:"app_key"`
	SecretKey       string              `yaml:"secret_key"`
	APIBaseURL      string              `yaml:"api_base_url"`
	HTTPTimeout     time.Duration       `yaml:"http_timeout"`
}

// V5PayEndpointConfig 保存 V5Pay 某一环境的商户应用配置；secret_key 只能来自环境或配置注入。
type V5PayEndpointConfig struct {
	MerchantNo  string        `yaml:"merchant_no"`
	MerNo       string        `yaml:"mer_no"` // Deprecated: 兼容早期截图里的 mer_no 写法。
	AppKey      string        `yaml:"app_key"`
	SecretKey   string        `yaml:"secret_key"`
	APIBaseURL  string        `yaml:"api_base_url"`
	HTTPTimeout time.Duration `yaml:"http_timeout"`
}

// TronGridConfig 保存 TRON HTTP 兼容接口配置；base_url 可替换成自有代理。
type TronGridConfig struct {
	Enabled             bool          `yaml:"enabled"`
	APIBaseURL          string        `yaml:"api_base_url"`
	APIKey              string        `yaml:"api_key"`
	USDTContractAddress string        `yaml:"usdt_contract_address"`
	HTTPTimeout         time.Duration `yaml:"http_timeout"`
}

// BSCUSDTConfig 保存 BSC JSON-RPC 只读配置；不包含私钥，也不发起转账。
type BSCUSDTConfig struct {
	Enabled             bool          `yaml:"enabled"`
	RPCURL              string        `yaml:"rpc_url"`
	USDTContractAddress string        `yaml:"usdt_contract_address"`
	HTTPTimeout         time.Duration `yaml:"http_timeout"`
}

// BinanceConfig 保存 Binance 只读查询配置；不同 App 独立 key，避免跨账套查错收款记录。
type BinanceConfig struct {
	Enabled     bool                            `yaml:"enabled"`
	APIBaseURL  string                          `yaml:"api_base_url"`
	RecvWindow  int64                           `yaml:"recv_window"`
	HTTPTimeout time.Duration                   `yaml:"http_timeout"`
	Accounts    map[string]BinanceAccountConfig `yaml:"accounts"`
	// LegacyAccounts 泛化接收旧版 bian.<app_code> 顶层账号；不枚举产品名，归一化后统一并入 Accounts。
	LegacyAccounts map[string]BinanceAccountConfig `yaml:",inline"`
}

// BinanceAccountConfig 是单个 Binance 账号的只读 API 凭证；日志和错误都不能输出这些字段。
type BinanceAccountConfig struct {
	APIKey       string `yaml:"api_key"`
	APISecretKey string `yaml:"api_secret_key"`
}

// Account 按 appCode 选择对应 Binance 账号；未知 app 明确返回 false，避免落入其它租户账套。
func (cfg BinanceConfig) Account(appCode string) (BinanceAccountConfig, bool) {
	account, exists := cfg.Accounts[strings.ToLower(strings.TrimSpace(appCode))]
	if !exists {
		return BinanceAccountConfig{}, false
	}
	return account, binanceAccountReady(account)
}

// HasAnyAccount 用于启动装配判断是否需要注入 Binance client；缺失某个 App key 时仍让业务返回明确配置错误。
func (cfg BinanceConfig) HasAnyAccount() bool {
	for _, account := range cfg.Accounts {
		if binanceAccountReady(account) {
			return true
		}
	}
	return false
}

func binanceAccountReady(account BinanceAccountConfig) bool {
	return strings.TrimSpace(account.APIKey) != "" && strings.TrimSpace(account.APISecretKey) != ""
}

// ExternalRechargeConfig 保存外部充值页面和支付回调所需的公开配置。
type ExternalRechargeConfig struct {
	USDTTRC20Enabled   bool              `yaml:"usdt_trc20_enabled"`
	USDTTRC20Address   string            `yaml:"usdt_trc20_address"`
	USDTTRC20Addresses map[string]string `yaml:"usdt_trc20_addresses"`
	USDTBEP20Enabled   bool              `yaml:"usdt_bep20_enabled"`
	USDTBEP20Address   string            `yaml:"usdt_bep20_address"`
	USDTBEP20Addresses map[string]string `yaml:"usdt_bep20_addresses"`
	MifaPayNotifyURL   string            `yaml:"mifapay_notify_url"`
	MifaPayReturnURL   string            `yaml:"mifapay_return_url"`
	V5PayNotifyURL     string            `yaml:"v5pay_notify_url"`
	V5PayReturnURL     string            `yaml:"v5pay_return_url"`
}

// ExternalRechargeReconcileWorkerConfig 保存 H5 外部充值查单补偿策略。
type ExternalRechargeReconcileWorkerConfig struct {
	Enabled      bool          `yaml:"enabled"`
	PollInterval time.Duration `yaml:"poll_interval"`
	BatchSize    int           `yaml:"batch_size"`
}

// ResourceEquipmentCleanupWorkerConfig 控制失效装备的单写者、游标式物理清理。
type ResourceEquipmentCleanupWorkerConfig struct {
	Enabled             bool          `yaml:"enabled"`
	PollInterval        time.Duration `yaml:"poll_interval"`
	QueryTimeout        time.Duration `yaml:"query_timeout"`
	BatchPause          time.Duration `yaml:"batch_pause"`
	ScanBatchSize       int           `yaml:"scan_batch_size"`
	DeleteBatchSize     int           `yaml:"delete_batch_size"`
	RoundMaxScannedRows int           `yaml:"round_max_scanned_rows"`
}

// Default 返回本地开发默认配置。
func Default() Config {
	return Config{
		ServiceName:         "wallet-service",
		NodeID:              "wallet-local",
		Environment:         "local",
		GRPCAddr:            ":13004",
		HealthHTTPAddr:      ":13104",
		MySQLDSN:            "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC",
		ActivityServiceAddr: "127.0.0.1:13006",
		RedPacketExpiryWorker: RedPacketExpiryWorkerConfig{
			Enabled:      true,
			PollInterval: 5 * time.Second,
			BatchSize:    50,
		},
		RocketMQ: defaultRocketMQConfig(),
		GooglePlay: GooglePlayConfig{
			Enabled:        false,
			APIBaseURL:     "https://androidpublisher.googleapis.com",
			TokenURL:       "https://oauth2.googleapis.com/token",
			HTTPTimeout:    10 * time.Second,
			ConsumeEnabled: true,
		},
		GooglePaidSyncWorker: GooglePaidSyncWorkerConfig{
			Enabled:        true,
			PollInterval:   15 * time.Second,
			BatchSize:      100,
			RequestTimeout: 2 * time.Minute,
		},
		MifaPay: MifaPayConfig{
			Enabled:     true,
			APIBaseURL:  "https://platformtest.xqdmipay.com",
			HTTPTimeout: 10 * time.Second,
		},
		V5Pay: V5PayConfig{
			Enabled:     true,
			APIBaseURL:  "https://api-uat.v5pay.com",
			HTTPTimeout: 10 * time.Second,
		},
		TronGrid: TronGridConfig{
			Enabled:             true,
			APIBaseURL:          "https://api.trongrid.io",
			USDTContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
			HTTPTimeout:         10 * time.Second,
		},
		BSCUSDT: BSCUSDTConfig{
			Enabled:             false,
			RPCURL:              "https://bsc-dataseed.binance.org",
			USDTContractAddress: "0x55d398326f99059fF775485246999027B3197955",
			HTTPTimeout:         10 * time.Second,
		},
		Binance: BinanceConfig{
			Enabled:     true,
			APIBaseURL:  "https://api.binance.com",
			RecvWindow:  10000,
			HTTPTimeout: 10 * time.Second,
		},
		ExternalRecharge: ExternalRechargeConfig{
			USDTTRC20Enabled: true,
			USDTBEP20Enabled: false,
		},
		ExternalRechargeReconcileWorker: ExternalRechargeReconcileWorkerConfig{
			Enabled:      true,
			PollInterval: 30 * time.Second,
			BatchSize:    50,
		},
		ResourceEquipmentCleanupWorker: ResourceEquipmentCleanupWorkerConfig{
			Enabled:             true,
			PollInterval:        30 * time.Second,
			QueryTimeout:        3 * time.Second,
			BatchPause:          25 * time.Millisecond,
			ScanBatchSize:       200,
			DeleteBatchSize:     50,
			RoundMaxScannedRows: 2000,
		},
		OutboxWorker: OutboxWorkerConfig{
			Enabled:        false,
			PollInterval:   time.Second,
			BatchSize:      100,
			Concurrency:    1,
			PublishTimeout: 3 * time.Second,
			MaxRetryCount:  10,
			InitialBackoff: 5 * time.Second,
			MaxBackoff:     5 * time.Minute,
		},
		OutboxArchive: OutboxArchiveConfig{
			Enabled:                     false,
			Mode:                        OutboxArchiveModeDryRun,
			PurgeEnabled:                false,
			PurgeMaxAge:                 30 * 24 * time.Hour,
			PurgePollInterval:           5 * time.Second,
			PurgeBatchSize:              100,
			PurgeReceiptGrace:           time.Hour,
			PurgePolicyVersion:          "financial-v1",
			MaxAge:                      30 * 24 * time.Hour,
			PollInterval:                5 * time.Minute,
			QueryBatchSize:              500,
			SegmentMaxRows:              10000,
			SegmentMaxUncompressedBytes: 64 << 20,
			RoundMaxRows:                50000,
			BatchPause:                  50 * time.Millisecond,
			QueryTimeout:                10 * time.Second,
			OperationTimeout:            2 * time.Minute,
			ObjectPrefix:                "wallet-outbox/v1",
			COS: OutboxArchiveCOSConfig{
				CredentialSource: "static",
				HTTPTimeout:      30 * time.Second,
			},
		},
		RealtimeOutboxWorker: OutboxWorkerConfig{
			Enabled:        false,
			PollInterval:   time.Second,
			BatchSize:      50,
			Concurrency:    2,
			PublishTimeout: 3 * time.Second,
			MaxRetryCount:  10,
			InitialBackoff: 2 * time.Second,
			MaxBackoff:     time.Minute,
			EventTypes:     defaultRealtimeOutboxEventTypes(),
		},
		ProjectionWorker: ProjectionWorkerConfig{
			Enabled:                   false,
			PollInterval:              60 * time.Second,
			BatchSize:                 10,
			LockTTL:                   30 * time.Second,
			ConsumerGroup:             "hyapp-wallet-projection",
			ConsumerMaxReconsumeTimes: 16,
		},
		MySQLAutoMigrate: false,
		Log: logx.Config{
			Level:           "info",
			Format:          "json",
			MaxPayloadBytes: 2048,
		},
	}
}

func defaultRocketMQConfig() RocketMQConfig {
	return RocketMQConfig{
		Enabled:            false,
		SendTimeout:        3 * time.Second,
		Retry:              2,
		WalletEventTagMode: WalletEventTagModeLegacy,
		WalletOutbox: WalletOutboxMQConfig{
			Enabled:       false,
			Topic:         "hyapp_wallet_outbox",
			ProducerGroup: "hyapp-wallet-outbox-producer",
		},
		RealtimeOutbox: WalletOutboxMQConfig{
			Enabled:       false,
			Topic:         "hyapp_wallet_realtime_outbox",
			ProducerGroup: "hyapp-wallet-realtime-outbox-producer",
		},
	}
}

// Load 从独立 config.yaml 读取 wallet-service 配置。
func Load(path string) (Config, error) {
	cfg := Default()
	if path == "" {
		return cfg, nil
	}

	if err := loadRuntimeEnv(path); err != nil {
		return Config{}, err
	}
	if err := configx.LoadYAMLWithEnv(path, &cfg); err != nil {
		return Config{}, err
	}

	cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
	if cfg.ServiceName == "" {
		cfg.ServiceName = "wallet-service"
	}
	cfg.NodeID = strings.TrimSpace(cfg.NodeID)
	if cfg.NodeID == "" {
		cfg.NodeID = cfg.ServiceName
	}
	cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
	if cfg.Environment == "" {
		cfg.Environment = "local"
	}
	cfg.HealthHTTPAddr = strings.TrimSpace(cfg.HealthHTTPAddr)
	if cfg.HealthHTTPAddr == "" {
		cfg.HealthHTTPAddr = ":13104"
	}
	cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr)
	if cfg.ActivityServiceAddr == "" {
		cfg.ActivityServiceAddr = "127.0.0.1:13006"
	}
	if cfg.RedPacketExpiryWorker.PollInterval <= 0 {
		cfg.RedPacketExpiryWorker.PollInterval = Default().RedPacketExpiryWorker.PollInterval
	}
	if cfg.RedPacketExpiryWorker.BatchSize <= 0 {
		cfg.RedPacketExpiryWorker.BatchSize = Default().RedPacketExpiryWorker.BatchSize
	}
	rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
	if err != nil {
		return Config{}, err
	}
	cfg.RocketMQ = rocketMQ
	cfg.GooglePlay.PackageName = strings.TrimSpace(cfg.GooglePlay.PackageName)
	cfg.GooglePlay.ServiceAccountFile = strings.TrimSpace(cfg.GooglePlay.ServiceAccountFile)
	cfg.GooglePlay.ServiceAccountJSON = strings.TrimSpace(cfg.GooglePlay.ServiceAccountJSON)
	cfg.GooglePlay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GooglePlay.APIBaseURL), "/")
	if cfg.GooglePlay.APIBaseURL == "" {
		cfg.GooglePlay.APIBaseURL = Default().GooglePlay.APIBaseURL
	}
	cfg.GooglePlay.TokenURL = strings.TrimSpace(cfg.GooglePlay.TokenURL)
	if cfg.GooglePlay.TokenURL == "" {
		cfg.GooglePlay.TokenURL = Default().GooglePlay.TokenURL
	}
	if cfg.GooglePlay.HTTPTimeout <= 0 {
		cfg.GooglePlay.HTTPTimeout = Default().GooglePlay.HTTPTimeout
	}
	if cfg.GooglePlay.Enabled {
		if cfg.GooglePlay.PackageName == "" {
			return Config{}, errors.New("google_play package_name is required")
		}
		if cfg.GooglePlay.ServiceAccountFile == "" && cfg.GooglePlay.ServiceAccountJSON == "" {
			return Config{}, errors.New("google_play service account is required")
		}
	}
	if cfg.GooglePaidSyncWorker.PollInterval <= 0 {
		cfg.GooglePaidSyncWorker.PollInterval = Default().GooglePaidSyncWorker.PollInterval
	}
	if cfg.GooglePaidSyncWorker.BatchSize <= 0 {
		cfg.GooglePaidSyncWorker.BatchSize = Default().GooglePaidSyncWorker.BatchSize
	}
	if cfg.GooglePaidSyncWorker.BatchSize > 1000 {
		cfg.GooglePaidSyncWorker.BatchSize = 1000
	}
	if cfg.GooglePaidSyncWorker.RequestTimeout <= 0 {
		cfg.GooglePaidSyncWorker.RequestTimeout = Default().GooglePaidSyncWorker.RequestTimeout
	}
	cfg.MifaPay = normalizeMifaPayConfig(cfg.MifaPay)
	// MiFaPay 功能开关默认打开；商户密钥属于环境敏感配置，允许本地缺省启动，启动装配时再按凭证完整性决定是否接入真实网关。
	cfg.V5Pay = normalizeV5PayConfig(cfg.V5Pay)
	// V5Pay 功能开关默认打开；merchantNo/appKey/secretKey 是环境敏感配置，缺失时不注入真实网关。
	cfg.TronGrid.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.TronGrid.APIBaseURL), "/")
	if cfg.TronGrid.APIBaseURL == "" {
		cfg.TronGrid.APIBaseURL = Default().TronGrid.APIBaseURL
	}
	cfg.TronGrid.APIKey = strings.TrimSpace(cfg.TronGrid.APIKey)
	cfg.TronGrid.USDTContractAddress = strings.TrimSpace(cfg.TronGrid.USDTContractAddress)
	if cfg.TronGrid.USDTContractAddress == "" {
		cfg.TronGrid.USDTContractAddress = Default().TronGrid.USDTContractAddress
	}
	if cfg.TronGrid.HTTPTimeout <= 0 {
		cfg.TronGrid.HTTPTimeout = Default().TronGrid.HTTPTimeout
	}
	cfg.BSCUSDT.RPCURL = strings.TrimRight(strings.TrimSpace(cfg.BSCUSDT.RPCURL), "/")
	if cfg.BSCUSDT.RPCURL == "" {
		cfg.BSCUSDT.RPCURL = Default().BSCUSDT.RPCURL
	}
	cfg.BSCUSDT.USDTContractAddress = strings.TrimSpace(cfg.BSCUSDT.USDTContractAddress)
	if cfg.BSCUSDT.USDTContractAddress == "" {
		cfg.BSCUSDT.USDTContractAddress = Default().BSCUSDT.USDTContractAddress
	}
	if cfg.BSCUSDT.HTTPTimeout <= 0 {
		cfg.BSCUSDT.HTTPTimeout = Default().BSCUSDT.HTTPTimeout
	}
	cfg.Binance = normalizeBinanceConfig(cfg.Binance)
	cfg.ExternalRecharge.USDTTRC20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTTRC20Address)
	cfg.ExternalRecharge.USDTTRC20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTTRC20Addresses)
	cfg.ExternalRecharge.USDTBEP20Address = strings.TrimSpace(cfg.ExternalRecharge.USDTBEP20Address)
	cfg.ExternalRecharge.USDTBEP20Addresses = normalizeExternalRechargeAddressMap(cfg.ExternalRecharge.USDTBEP20Addresses)
	cfg.ExternalRecharge.MifaPayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayNotifyURL)
	cfg.ExternalRecharge.MifaPayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.MifaPayReturnURL)
	cfg.ExternalRecharge.V5PayNotifyURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayNotifyURL)
	cfg.ExternalRecharge.V5PayReturnURL = strings.TrimSpace(cfg.ExternalRecharge.V5PayReturnURL)
	// USDT 功能开关默认打开；未配置收款地址时 H5 不展示 USDT 下单入口，但配置本身不被静默改成关闭。
	cfg.ExternalRechargeReconcileWorker = normalizeExternalRechargeReconcileWorkerConfig(cfg.ExternalRechargeReconcileWorker, Default().ExternalRechargeReconcileWorker)
	cfg.ResourceEquipmentCleanupWorker = normalizeResourceEquipmentCleanupWorkerConfig(
		cfg.ResourceEquipmentCleanupWorker,
		Default().ResourceEquipmentCleanupWorker,
	)
	cfg.OutboxWorker = normalizeOutboxWorkerConfig(cfg.OutboxWorker, Default().OutboxWorker)
	if cfg.OutboxWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
		return Config{}, errors.New("outbox_worker requires rocketmq.wallet_outbox.enabled")
	}
	outboxArchive, err := normalizeOutboxArchiveConfig(cfg.OutboxArchive, Default().OutboxArchive)
	if err != nil {
		return Config{}, err
	}
	cfg.OutboxArchive = outboxArchive
	cfg.RealtimeOutboxWorker = normalizeOutboxWorkerConfig(cfg.RealtimeOutboxWorker, Default().RealtimeOutboxWorker)
	if len(cfg.RealtimeOutboxWorker.EventTypes) == 0 {
		cfg.RealtimeOutboxWorker.EventTypes = defaultRealtimeOutboxEventTypes()
	}
	if cfg.RealtimeOutboxWorker.Enabled && !cfg.RocketMQ.RealtimeOutbox.Enabled {
		return Config{}, errors.New("realtime_outbox_worker requires rocketmq.realtime_outbox.enabled")
	}
	if cfg.ProjectionWorker.PollInterval <= 0 {
		cfg.ProjectionWorker.PollInterval = Default().ProjectionWorker.PollInterval
	}
	if cfg.ProjectionWorker.BatchSize <= 0 {
		cfg.ProjectionWorker.BatchSize = Default().ProjectionWorker.BatchSize
	}
	if cfg.ProjectionWorker.BatchSize > 100 {
		cfg.ProjectionWorker.BatchSize = 100
	}
	if cfg.ProjectionWorker.LockTTL <= 0 {
		cfg.ProjectionWorker.LockTTL = Default().ProjectionWorker.LockTTL
	}
	if cfg.ProjectionWorker.ConsumerGroup = strings.TrimSpace(cfg.ProjectionWorker.ConsumerGroup); cfg.ProjectionWorker.ConsumerGroup == "" {
		cfg.ProjectionWorker.ConsumerGroup = Default().ProjectionWorker.ConsumerGroup
	}
	if cfg.ProjectionWorker.ConsumerMaxReconsumeTimes <= 0 {
		cfg.ProjectionWorker.ConsumerMaxReconsumeTimes = Default().ProjectionWorker.ConsumerMaxReconsumeTimes
	}
	if cfg.ProjectionWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
		return Config{}, errors.New("projection_worker requires rocketmq.wallet_outbox.enabled")
	}

	return cfg, nil
}

func loadRuntimeEnv(configPath string) error {
	seen := make(map[string]struct{})
	for _, start := range runtimeEnvSearchRoots(configPath) {
		for _, path := range dotEnvCandidates(start) {
			if _, ok := seen[path]; ok {
				continue
			}
			seen[path] = struct{}{}
			if err := configx.LoadDotEnvFile(path); err != nil {
				return err
			}
		}
	}
	return nil
}

func runtimeEnvSearchRoots(configPath string) []string {
	roots := make([]string, 0, 2)
	if cwd, err := os.Getwd(); err == nil {
		roots = append(roots, cwd)
	}
	if absConfigPath, err := filepath.Abs(configPath); err == nil {
		roots = append(roots, filepath.Dir(absConfigPath))
	}
	return roots
}

func dotEnvCandidates(start string) []string {
	start, err := filepath.Abs(start)
	if err != nil {
		return nil
	}
	candidates := make([]string, 0, 4)
	for {
		candidates = append(candidates, filepath.Join(start, ".env"))
		if _, err := os.Stat(filepath.Join(start, ".git")); err == nil {
			return candidates
		}
		parent := filepath.Dir(start)
		if parent == start {
			return candidates
		}
		start = parent
	}
}

func normalizeExternalRechargeReconcileWorkerConfig(cfg ExternalRechargeReconcileWorkerConfig, defaults ExternalRechargeReconcileWorkerConfig) ExternalRechargeReconcileWorkerConfig {
	if cfg.PollInterval <= 0 {
		cfg.PollInterval = defaults.PollInterval
	}
	if cfg.BatchSize <= 0 {
		cfg.BatchSize = defaults.BatchSize
	}
	if cfg.BatchSize > 500 {
		cfg.BatchSize = 500
	}
	return cfg
}

func normalizeMifaPayConfig(cfg MifaPayConfig) MifaPayConfig {
	cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
	// 本地联调可以同时保留 test/prod 两套商户配置；显式 environment 决定真实注入哪套，避免测试网和正式网字段互相覆盖。
	switch cfg.Environment {
	case "test":
		cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentTest)
	case "prod", "production":
		cfg.Environment = "prod"
		cfg = applyMifaPayEndpointConfig(cfg, cfg.EnvironmentProd)
	default:
		cfg.Environment = ""
	}
	cfg.MerAccount = strings.TrimSpace(cfg.MerAccount)
	cfg.MerNo = strings.TrimSpace(cfg.MerNo)
	cfg.MerchantPublicKey = strings.TrimSpace(cfg.MerchantPublicKey)
	cfg.PrivateKey = strings.TrimSpace(cfg.PrivateKey)
	cfg.PlatformPublicKey = strings.TrimSpace(cfg.PlatformPublicKey)
	cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
	if cfg.APIBaseURL == "" {
		cfg.APIBaseURL = Default().MifaPay.APIBaseURL
	}
	if cfg.HTTPTimeout <= 0 {
		cfg.HTTPTimeout = Default().MifaPay.HTTPTimeout
	}
	return cfg
}

func normalizeV5PayConfig(cfg V5PayConfig) V5PayConfig {
	cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
	// V5Pay 和 MiFaPay 一样支持 test/prod 两套环境；只在环境块里真实配置了字段时覆盖 flat 字段。
	switch cfg.Environment {
	case "test":
		cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentTest)
	case "prod", "production":
		cfg.Environment = "prod"
		cfg = applyV5PayEndpointConfig(cfg, cfg.EnvironmentProd)
	default:
		cfg.Environment = ""
	}
	cfg.MerchantNo = firstNonEmpty(strings.TrimSpace(cfg.MerchantNo), strings.TrimSpace(cfg.MerNo))
	cfg.MerNo = strings.TrimSpace(cfg.MerNo)
	cfg.AppKey = strings.TrimSpace(cfg.AppKey)
	cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
	cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
	if cfg.APIBaseURL == "" {
		cfg.APIBaseURL = Default().V5Pay.APIBaseURL
	}
	if cfg.HTTPTimeout <= 0 {
		cfg.HTTPTimeout = Default().V5Pay.HTTPTimeout
	}
	return cfg
}

func normalizeBinanceConfig(cfg BinanceConfig) BinanceConfig {
	defaults := Default().Binance
	cfg.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
	if cfg.APIBaseURL == "" {
		cfg.APIBaseURL = defaults.APIBaseURL
	}
	if cfg.RecvWindow <= 0 {
		cfg.RecvWindow = defaults.RecvWindow
	}
	if cfg.HTTPTimeout <= 0 {
		cfg.HTTPTimeout = defaults.HTTPTimeout
	}
	// 新 accounts 节点覆盖同名旧顶层账号；两种格式都只经过通用 app_code map，
	// 线上旧 Nacos/YAML 可滚动升级，新增租户则无需扩展结构体或业务分支。
	accounts := make(map[string]BinanceAccountConfig, len(cfg.LegacyAccounts)+len(cfg.Accounts))
	mergeAccounts := func(source map[string]BinanceAccountConfig) {
		for appCode, account := range source {
			appCode = strings.ToLower(strings.TrimSpace(appCode))
			account = normalizeBinanceAccountConfig(account)
			if appCode == "" || !binanceAccountReady(account) {
				continue
			}
			accounts[appCode] = account
		}
	}
	mergeAccounts(cfg.LegacyAccounts)
	mergeAccounts(cfg.Accounts)
	cfg.Accounts = accounts
	cfg.LegacyAccounts = nil
	return cfg
}

func normalizeBinanceAccountConfig(account BinanceAccountConfig) BinanceAccountConfig {
	account.APIKey = strings.TrimSpace(account.APIKey)
	account.APISecretKey = strings.TrimSpace(account.APISecretKey)
	return account
}

func normalizeExternalRechargeAddressMap(values map[string]string) map[string]string {
	if len(values) == 0 {
		return nil
	}
	normalized := make(map[string]string, len(values))
	for appCode, address := range values {
		appCode = strings.ToLower(strings.TrimSpace(appCode))
		address = strings.TrimSpace(address)
		if appCode == "" || address == "" {
			continue
		}
		normalized[appCode] = address
	}
	if len(normalized) == 0 {
		return nil
	}
	return normalized
}

func applyV5PayEndpointConfig(cfg V5PayConfig, endpoint V5PayEndpointConfig) V5PayConfig {
	if value := firstNonEmpty(strings.TrimSpace(endpoint.MerchantNo), strings.TrimSpace(endpoint.MerNo)); value != "" {
		cfg.MerchantNo = value
	}
	if value := strings.TrimSpace(endpoint.MerNo); value != "" {
		cfg.MerNo = value
	}
	if value := strings.TrimSpace(endpoint.AppKey); value != "" {
		cfg.AppKey = value
	}
	if value := strings.TrimSpace(endpoint.SecretKey); value != "" {
		cfg.SecretKey = value
	}
	if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" {
		cfg.APIBaseURL = value
	}
	if endpoint.HTTPTimeout > 0 {
		cfg.HTTPTimeout = endpoint.HTTPTimeout
	}
	return cfg
}

func applyMifaPayEndpointConfig(cfg MifaPayConfig, endpoint MifaPayEndpointConfig) MifaPayConfig {
	// 环境块只在字段真实配置时覆盖扁平字段；这样旧的 flat 配置仍能启动，新配置也能按 test/prod 切换。
	if value := strings.TrimSpace(endpoint.MerAccount); value != "" {
		cfg.MerAccount = value
	}
	if value := strings.TrimSpace(endpoint.MerNo); value != "" {
		cfg.MerNo = value
	}
	if value := strings.TrimSpace(endpoint.MerchantPublicKey); value != "" {
		cfg.MerchantPublicKey = value
	}
	if value := strings.TrimSpace(endpoint.PrivateKey); value != "" {
		cfg.PrivateKey = value
	}
	if value := strings.TrimSpace(endpoint.PlatformPublicKey); value != "" {
		cfg.PlatformPublicKey = value
	}
	if value := strings.TrimSpace(endpoint.APIBaseURL); value != "" {
		cfg.APIBaseURL = value
	}
	if endpoint.HTTPTimeout > 0 {
		cfg.HTTPTimeout = endpoint.HTTPTimeout
	}
	return cfg
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" {
			return value
		}
	}
	return ""
}

func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
	defaults := defaultRocketMQConfig()
	cfg.NameServers = normalizeStringSlice(cfg.NameServers)
	cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain)
	cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
	cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
	cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken)
	cfg.Namespace = strings.TrimSpace(cfg.Namespace)
	if cfg.SendTimeout <= 0 {
		cfg.SendTimeout = defaults.SendTimeout
	}
	if cfg.Retry <= 0 {
		cfg.Retry = defaults.Retry
	}
	cfg.WalletEventTagMode = WalletEventTagMode(strings.ToLower(strings.TrimSpace(string(cfg.WalletEventTagMode))))
	if cfg.WalletEventTagMode == "" {
		// 配置升级必须显式进入 Phase B；历史线上 YAML 缺少新字段时始终继续发布 legacy Tag。
		cfg.WalletEventTagMode = defaults.WalletEventTagMode
	}
	switch cfg.WalletEventTagMode {
	case WalletEventTagModeLegacy, WalletEventTagModeEventType:
	default:
		return RocketMQConfig{}, errors.New("rocketmq.wallet_event_tag_mode must be legacy or event_type")
	}
	if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
		cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
	}
	if cfg.WalletOutbox.ProducerGroup = strings.TrimSpace(cfg.WalletOutbox.ProducerGroup); cfg.WalletOutbox.ProducerGroup == "" {
		cfg.WalletOutbox.ProducerGroup = defaults.WalletOutbox.ProducerGroup
	}
	if cfg.RealtimeOutbox.Topic = strings.TrimSpace(cfg.RealtimeOutbox.Topic); cfg.RealtimeOutbox.Topic == "" {
		cfg.RealtimeOutbox.Topic = defaults.RealtimeOutbox.Topic
	}
	if cfg.RealtimeOutbox.ProducerGroup = strings.TrimSpace(cfg.RealtimeOutbox.ProducerGroup); cfg.RealtimeOutbox.ProducerGroup == "" {
		cfg.RealtimeOutbox.ProducerGroup = defaults.RealtimeOutbox.ProducerGroup
	}
	if cfg.WalletOutbox.Enabled || cfg.RealtimeOutbox.Enabled {
		cfg.Enabled = true
	}
	if cfg.Enabled {
		if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" {
			return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required")
		}
		if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
			return RocketMQConfig{}, errors.New("rocketmq access_key and secret_key must be configured together")
		}
	}
	return cfg, nil
}

func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig, defaults OutboxWorkerConfig) OutboxWorkerConfig {
	cfg.AppCodes = outboxpartition.Normalize(cfg.AppCodes, false)
	if cfg.PollInterval <= 0 {
		cfg.PollInterval = defaults.PollInterval
	}
	if cfg.BatchSize <= 0 {
		cfg.BatchSize = defaults.BatchSize
	}
	if cfg.BatchSize > 500 {
		cfg.BatchSize = 500
	}
	if cfg.Concurrency <= 0 {
		cfg.Concurrency = defaults.Concurrency
	}
	if cfg.Concurrency > 64 {
		cfg.Concurrency = 64
	}
	if cfg.PublishTimeout <= 0 {
		cfg.PublishTimeout = defaults.PublishTimeout
	}
	if cfg.MaxRetryCount <= 0 {
		cfg.MaxRetryCount = defaults.MaxRetryCount
	}
	if cfg.InitialBackoff <= 0 {
		cfg.InitialBackoff = defaults.InitialBackoff
	}
	if cfg.MaxBackoff <= 0 {
		cfg.MaxBackoff = defaults.MaxBackoff
	}
	if cfg.MaxBackoff < cfg.InitialBackoff {
		cfg.MaxBackoff = cfg.InitialBackoff
	}
	cfg.EventTypes = normalizeStringSlice(cfg.EventTypes)
	return cfg
}

func normalizeResourceEquipmentCleanupWorkerConfig(cfg ResourceEquipmentCleanupWorkerConfig, defaults ResourceEquipmentCleanupWorkerConfig) ResourceEquipmentCleanupWorkerConfig {
	if cfg.PollInterval <= 0 {
		cfg.PollInterval = defaults.PollInterval
	}
	if cfg.QueryTimeout <= 0 {
		cfg.QueryTimeout = defaults.QueryTimeout
	}
	if cfg.BatchPause < 0 {
		cfg.BatchPause = defaults.BatchPause
	}
	if cfg.ScanBatchSize <= 0 {
		cfg.ScanBatchSize = defaults.ScanBatchSize
	}
	if cfg.ScanBatchSize > 1000 {
		cfg.ScanBatchSize = 1000
	}
	if cfg.DeleteBatchSize <= 0 {
		cfg.DeleteBatchSize = defaults.DeleteBatchSize
	}
	if cfg.DeleteBatchSize > cfg.ScanBatchSize {
		cfg.DeleteBatchSize = cfg.ScanBatchSize
	}
	if cfg.RoundMaxScannedRows <= 0 {
		cfg.RoundMaxScannedRows = defaults.RoundMaxScannedRows
	}
	if cfg.RoundMaxScannedRows < cfg.ScanBatchSize {
		cfg.RoundMaxScannedRows = cfg.ScanBatchSize
	}
	if cfg.RoundMaxScannedRows > 100_000 {
		cfg.RoundMaxScannedRows = 100_000
	}
	return cfg
}

func normalizeOutboxArchiveConfig(cfg OutboxArchiveConfig, defaults OutboxArchiveConfig) (OutboxArchiveConfig, error) {
	cfg.Mode = OutboxArchiveMode(strings.ToLower(strings.TrimSpace(string(cfg.Mode))))
	if cfg.Mode == "" {
		cfg.Mode = defaults.Mode
	}
	switch cfg.Mode {
	case OutboxArchiveModeDryRun, OutboxArchiveModeArchiveOnly:
	default:
		return OutboxArchiveConfig{}, errors.New("outbox_archive.mode must be dry_run or archive_only")
	}
	if cfg.MaxAge <= 0 {
		cfg.MaxAge = defaults.MaxAge
	}
	if cfg.Enabled && cfg.MaxAge < 24*time.Hour {
		return OutboxArchiveConfig{}, errors.New("outbox_archive.max_age must be at least 24h")
	}
	cfg.PurgeAppCodes = outboxpartition.Normalize(cfg.PurgeAppCodes, false)
	if cfg.PurgeMaxAge <= 0 {
		cfg.PurgeMaxAge = defaults.PurgeMaxAge
	}
	if cfg.PurgePollInterval <= 0 {
		cfg.PurgePollInterval = defaults.PurgePollInterval
	}
	if cfg.PurgeBatchSize <= 0 {
		cfg.PurgeBatchSize = defaults.PurgeBatchSize
	}
	if cfg.PurgeReceiptGrace <= 0 {
		cfg.PurgeReceiptGrace = defaults.PurgeReceiptGrace
	}
	cfg.PurgePolicyVersion = strings.ToLower(strings.TrimSpace(cfg.PurgePolicyVersion))
	if cfg.PurgePolicyVersion == "" {
		cfg.PurgePolicyVersion = defaults.PurgePolicyVersion
	}
	proofsByApp := make(map[string]OutboxArchivePurgeRestoreProofConfig, len(cfg.PurgeRestoreProofs))
	for _, proof := range cfg.PurgeRestoreProofs {
		proof.AppCode = strings.ToLower(strings.TrimSpace(proof.AppCode))
		proof.BatchID = strings.ToLower(strings.TrimSpace(proof.BatchID))
		proof.ManifestSHA256 = strings.ToLower(strings.TrimSpace(proof.ManifestSHA256))
		if proof.AppCode == "" || !isSHA256Hex(proof.BatchID) || !isSHA256Hex(proof.ManifestSHA256) {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge restore proof requires app_code and 64-character hex batch/manifest hashes")
		}
		if _, duplicated := proofsByApp[proof.AppCode]; duplicated {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge restore proofs must contain each app_code once")
		}
		proofsByApp[proof.AppCode] = proof
	}
	cfg.PurgeRestoreProofs = cfg.PurgeRestoreProofs[:0]
	for _, appCode := range cfg.PurgeAppCodes {
		proof, ok := proofsByApp[appCode]
		if !ok && cfg.PurgeEnabled {
			return OutboxArchiveConfig{}, errors.New("outbox_archive every purge app_code requires an exact restore proof")
		}
		if ok {
			cfg.PurgeRestoreProofs = append(cfg.PurgeRestoreProofs, proof)
			delete(proofsByApp, appCode)
		}
	}
	if cfg.PurgeEnabled {
		// 删除开关必须 fail closed：只能在持续 COS 归档模式下，对显式租户逐小批运行。
		if !cfg.Enabled || cfg.Mode != OutboxArchiveModeArchiveOnly {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge requires enabled archive_only mode")
		}
		if len(cfg.PurgeAppCodes) == 0 || len(proofsByApp) != 0 {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge requires a non-empty app allowlist with no unmatched restore proofs")
		}
		// 租户范围由显式 allowlist 和逐 App 恢复证明共同约束，不在代码里固化产品名称。
		if cfg.PurgeMaxAge < 30*24*time.Hour || cfg.PurgeMaxAge < cfg.MaxAge {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge_max_age must be at least 30d and not shorter than archive max_age")
		}
		if cfg.PurgePollInterval < 5*time.Second {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge_poll_interval must be at least 5s")
		}
		if cfg.PurgeReceiptGrace < time.Hour {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge_receipt_grace must be at least 1h")
		}
		if cfg.PurgePolicyVersion != "financial-v1" {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge_policy_version must be financial-v1")
		}
		if cfg.PurgeBatchSize > 500 {
			return OutboxArchiveConfig{}, errors.New("outbox_archive purge_batch_size must not exceed 500")
		}
	}
	if cfg.PollInterval <= 0 {
		cfg.PollInterval = defaults.PollInterval
	}
	if cfg.QueryBatchSize <= 0 {
		cfg.QueryBatchSize = defaults.QueryBatchSize
	}
	if cfg.QueryBatchSize > 500 {
		cfg.QueryBatchSize = 500
	}
	if cfg.SegmentMaxRows <= 0 {
		cfg.SegmentMaxRows = defaults.SegmentMaxRows
	}
	if cfg.SegmentMaxRows > 50000 {
		cfg.SegmentMaxRows = 50000
	}
	if cfg.SegmentMaxUncompressedBytes <= 0 {
		cfg.SegmentMaxUncompressedBytes = defaults.SegmentMaxUncompressedBytes
	}
	if cfg.SegmentMaxUncompressedBytes > 256<<20 {
		cfg.SegmentMaxUncompressedBytes = 256 << 20
	}
	if cfg.RoundMaxRows <= 0 {
		cfg.RoundMaxRows = defaults.RoundMaxRows
	}
	if cfg.RoundMaxRows < cfg.SegmentMaxRows {
		cfg.RoundMaxRows = cfg.SegmentMaxRows
	}
	if cfg.RoundMaxRows > 250000 {
		cfg.RoundMaxRows = 250000
	}
	if cfg.BatchPause < 0 {
		cfg.BatchPause = defaults.BatchPause
	}
	if cfg.QueryTimeout <= 0 {
		cfg.QueryTimeout = defaults.QueryTimeout
	}
	if cfg.OperationTimeout <= 0 {
		cfg.OperationTimeout = defaults.OperationTimeout
	}
	cfg.ObjectPrefix = strings.Trim(strings.TrimSpace(cfg.ObjectPrefix), "/")
	if cfg.ObjectPrefix == "" {
		cfg.ObjectPrefix = defaults.ObjectPrefix
	}
	cfg.TempDir = strings.TrimSpace(cfg.TempDir)
	cfg.COS.SecretID = strings.TrimSpace(cfg.COS.SecretID)
	cfg.COS.SecretKey = strings.TrimSpace(cfg.COS.SecretKey)
	cfg.COS.SessionToken = strings.TrimSpace(cfg.COS.SessionToken)
	cfg.COS.Bucket = strings.TrimSpace(cfg.COS.Bucket)
	cfg.COS.Region = strings.TrimSpace(cfg.COS.Region)
	cfg.COS.CredentialSource = strings.ToLower(strings.TrimSpace(cfg.COS.CredentialSource))
	if cfg.COS.CredentialSource == "" {
		cfg.COS.CredentialSource = defaults.COS.CredentialSource
	}
	cfg.COS.CAMRoleName = strings.TrimSpace(cfg.COS.CAMRoleName)
	if cfg.COS.HTTPTimeout <= 0 {
		cfg.COS.HTTPTimeout = defaults.COS.HTTPTimeout
	}
	if cfg.Enabled && cfg.Mode == OutboxArchiveModeArchiveOnly {
		if cfg.COS.Bucket == "" || cfg.COS.Region == "" {
			return OutboxArchiveConfig{}, errors.New("outbox_archive archive_only mode requires private COS bucket and region")
		}
		switch cfg.COS.CredentialSource {
		case "static":
			if cfg.COS.SecretID == "" || cfg.COS.SecretKey == "" {
				return OutboxArchiveConfig{}, errors.New("outbox_archive static COS credential source requires secret_id and secret_key")
			}
		case "cvm_role":
			if cfg.COS.CAMRoleName == "" {
				return OutboxArchiveConfig{}, errors.New("outbox_archive cvm_role COS credential source requires cam_role_name")
			}
		default:
			return OutboxArchiveConfig{}, errors.New("outbox_archive.cos.credential_source must be static or cvm_role")
		}
	}
	return cfg, nil
}

func isSHA256Hex(value string) bool {
	if len(value) != 64 {
		return false
	}
	decoded, err := hex.DecodeString(value)
	return err == nil && len(decoded) == 32
}

func normalizeStringSlice(values []string) []string {
	normalized := make([]string, 0, len(values))
	seen := make(map[string]struct{}, len(values))
	for _, value := range values {
		if value = strings.TrimSpace(value); value != "" {
			if _, exists := seen[value]; exists {
				continue
			}
			seen[value] = struct{}{}
			normalized = append(normalized, value)
		}
	}
	return normalized
}

func defaultRealtimeOutboxEventTypes() []string {
	return []string{
		"WalletRedPacketCreated",
		"WalletRedPacketClaimed",
		"WalletRedPacketRefunded",
	}
}
