// Command audit-wheel-rewards performs a read-only, item-by-item reconciliation
// between unsettled WheelRewardSettlement facts and wallet owner receipts.
package main

import (
	"context"
	"crypto/sha256"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"log"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"

	_ "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/services/activity-service/internal/config"
	domain "hyapp/services/activity-service/internal/domain/wheel"
)

type outboxRow struct {
	AppCode    string
	OutboxID   string
	Payload    []byte
	Status     string
	RetryCount int
	CreatedMS  int64
}

type rewardPayload struct {
	AppCode         string              `json:"app_code"`
	DrawID          string              `json:"draw_id"`
	DrawIDs         []string            `json:"draw_ids"`
	CommandID       string              `json:"command_id"`
	UserID          int64               `json:"user_id"`
	WheelID         string              `json:"wheel_id"`
	SelectedTierID  string              `json:"selected_tier_id"`
	RewardType      string              `json:"reward_type"`
	RewardID        string              `json:"reward_id"`
	RewardCount     int64               `json:"reward_count"`
	RewardCoins     int64               `json:"reward_coins"`
	MetadataJSON    string              `json:"metadata_json"`
	Rewards         []domain.DrawReward `json:"rewards"`
	VisibleRegionID int64               `json:"visible_region_id"`
}

type itemAudit struct {
	CommandID string `json:"command_id"`
	Type      string `json:"type"`
	State     string `json:"state"`
	ReceiptID string `json:"receipt_id,omitempty"`
	Reason    string `json:"reason,omitempty"`
}

type rowAudit struct {
	AppCode  string      `json:"app_code"`
	OutboxID string      `json:"outbox_id"`
	Status   string      `json:"outbox_status"`
	State    string      `json:"state"`
	Items    []itemAudit `json:"items"`
}

type auditSummary struct {
	Rows          int    `json:"rows"`
	FullyPaid     int    `json:"fully_paid"`
	FullyUnpaid   int    `json:"fully_unpaid"`
	PartiallyPaid int    `json:"partially_paid"`
	Conflict      int    `json:"conflict"`
	PaidItems     int    `json:"paid_items"`
	UnpaidItems   int    `json:"unpaid_items"`
	ConflictItems int    `json:"conflict_items"`
	AuditSHA256   string `json:"audit_sha256"`
}

// outboxAuditIndex 是 FORCE INDEX 唯一允许接受的封闭枚举。information_schema 返回的是数据库数据，
// 不能把其中的任意字符串直接拼接进 SQL；查询构造器会再次按用途校验枚举并只输出下列编译期常量。
type outboxAuditIndex uint8

const (
	outboxAuditIndexUnknown outboxAuditIndex = iota
	outboxAuditIndexEventRetry
	outboxAuditIndexLuckyRetry
	outboxAuditIndexEventLock
	outboxAuditIndexLuckyLock
)

type outboxAuditIndexes struct {
	Retry outboxAuditIndex
	Lock  outboxAuditIndex
}

type appCodeFlags []string

func (values *appCodeFlags) String() string {
	return strings.Join(*values, ",")
}

func (values *appCodeFlags) Set(value string) error {
	value = strings.TrimSpace(value)
	if value == "" {
		return fmt.Errorf("app-code must not be empty")
	}
	*values = append(*values, value)
	return nil
}

func main() {
	configPath := flag.String("config", "services/activity-service/configs/config.yaml", "path to activity-service config")
	walletDatabase := flag.String("wallet-database", "hyapp_wallet", "wallet owner database on the same MySQL cluster")
	limit := flag.Int("limit", 500, "maximum unsettled rows, 1..500")
	details := flag.Bool("details", false, "emit one JSON reconciliation record per outbox row")
	var requestedAppCodes appCodeFlags
	flag.Var(&requestedAppCodes, "app-code", "required App partition to audit; repeat for multiple Apps, for example --app-code lalu --app-code fami")
	flag.Parse()

	if *limit <= 0 || *limit > 500 {
		log.Fatal("limit must be between 1 and 500")
	}
	walletDB := normalizedDatabaseName(*walletDatabase)
	if walletDB == "" {
		log.Fatal("wallet-database contains invalid characters")
	}
	appCodes, err := normalizeAuditAppCodes(requestedAppCodes)
	if err != nil {
		log.Fatal(err)
	}
	cfg, err := config.Load(*configPath)
	if err != nil {
		log.Fatal(err)
	}
	db, err := sql.Open("mysql", cfg.MySQLDSN)
	if err != nil {
		log.Fatal(err)
	}
	defer func() { _ = db.Close() }()
	db.SetMaxOpenConns(2)
	db.SetMaxIdleConns(1)

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		log.Fatal(err)
	}
	indexes, err := discoverOutboxAuditIndexes(ctx, db)
	if err != nil {
		log.Fatal(err)
	}
	rows, err := loadUnsettledRows(ctx, db, indexes, appCodes, *limit)
	if err != nil {
		log.Fatal(err)
	}
	audits := make([]rowAudit, 0, len(rows))
	for _, row := range rows {
		audit, err := auditRow(ctx, db, walletDB, row)
		if err != nil {
			log.Fatalf("audit app_code=%s outbox_id=%s: %v", row.AppCode, row.OutboxID, err)
		}
		audits = append(audits, audit)
	}
	summary := summarize(audits)
	if *details {
		encoder := json.NewEncoder(os.Stdout)
		for _, audit := range audits {
			if err := encoder.Encode(audit); err != nil {
				log.Fatal(err)
			}
		}
	}
	encoded, err := json.Marshal(summary)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(string(encoded))
}

func loadUnsettledRows(ctx context.Context, db *sql.DB, indexes outboxAuditIndexes, requestedAppCodes []string, limit int) ([]outboxRow, error) {
	if limit <= 0 || limit > 500 {
		return nil, fmt.Errorf("limit must be between 1 and 500")
	}
	appCodes, err := normalizeAuditAppCodes(requestedAppCodes)
	if err != nil {
		return nil, err
	}
	retryQuery, err := unsettledRetryQuery(indexes.Retry)
	if err != nil {
		return nil, err
	}
	deliveringQuery, err := unsettledDeliveringQuery(indexes.Lock)
	if err != nil {
		return nil, err
	}
	// activity_outbox 的线上索引都以 app_code 开头。每个 App 拆成 retry/lock 两个等值分区查询，
	// 既能强制使用对应索引，也避免 status OR 让优化器退化为全表扫描。每个分支只取 global remaining，
	// 合并出该 App 最早的一页后再进入下一 App，因此返回量严格不超过 limit，数据库返回行数也被约束在约 2*limit 内。
	result := make([]outboxRow, 0, limit)
	for _, appCodeValue := range appCodes {
		remaining := limit - len(result)
		if remaining <= 0 {
			break
		}
		branches := []struct {
			query string
			args  []any
		}{
			{
				query: retryQuery,
				args: []any{
					appCodeValue,
					domain.EventTypeWheelRewardSettlement,
					domain.OutboxStatusPending,
					domain.OutboxStatusRetryable,
					remaining,
				},
			},
			{
				query: deliveringQuery,
				args: []any{
					appCodeValue,
					domain.EventTypeWheelRewardSettlement,
					domain.OutboxStatusDelivering,
					remaining,
				},
			},
		}
		appCandidates := make([]outboxRow, 0, remaining)
		seen := make(map[string]struct{}, remaining)
		for _, branch := range branches {
			rows, err := loadUnsettledBranch(ctx, db, branch.query, branch.args...)
			if err != nil {
				return nil, fmt.Errorf("load app_code=%s: %w", appCodeValue, err)
			}
			for _, row := range rows {
				key := row.AppCode + "\x00" + row.OutboxID
				if _, exists := seen[key]; exists {
					continue
				}
				seen[key] = struct{}{}
				appCandidates = append(appCandidates, row)
			}
		}
		sort.Slice(appCandidates, func(i, j int) bool {
			if appCandidates[i].CreatedMS != appCandidates[j].CreatedMS {
				return appCandidates[i].CreatedMS < appCandidates[j].CreatedMS
			}
			if appCandidates[i].OutboxID != appCandidates[j].OutboxID {
				return appCandidates[i].OutboxID < appCandidates[j].OutboxID
			}
			return appCandidates[i].Status < appCandidates[j].Status
		})
		if len(appCandidates) > remaining {
			appCandidates = appCandidates[:remaining]
		}
		result = append(result, appCandidates...)
	}
	return result, nil
}

func discoverOutboxAuditIndexes(ctx context.Context, db *sql.DB) (outboxAuditIndexes, error) {
	rows, err := db.QueryContext(ctx, outboxAuditIndexDiscoveryQuery())
	if err != nil {
		return outboxAuditIndexes{}, fmt.Errorf("discover activity_outbox audit indexes: %w", err)
	}
	defer rows.Close()

	type indexPrefix struct {
		columns [3]string
		seen    [3]bool
		invalid bool
	}
	prefixes := make(map[outboxAuditIndex]*indexPrefix, 4)
	for rows.Next() {
		var indexName, columnName string
		var sequence int
		if err := rows.Scan(&indexName, &sequence, &columnName); err != nil {
			return outboxAuditIndexes{}, fmt.Errorf("scan activity_outbox audit index: %w", err)
		}
		index, ok := outboxAuditIndexByName(indexName)
		if !ok || sequence < 1 || sequence > 3 {
			continue
		}
		prefix := prefixes[index]
		if prefix == nil {
			prefix = &indexPrefix{}
			prefixes[index] = prefix
		}
		position := sequence - 1
		if prefix.seen[position] {
			prefix.invalid = true
			continue
		}
		prefix.seen[position] = true
		prefix.columns[position] = strings.ToLower(strings.TrimSpace(columnName))
	}
	if err := rows.Err(); err != nil {
		return outboxAuditIndexes{}, fmt.Errorf("iterate activity_outbox audit indexes: %w", err)
	}

	validPrefix := func(index outboxAuditIndex) bool {
		prefix := prefixes[index]
		if prefix == nil || prefix.invalid {
			return false
		}
		expected := [3]string{"app_code", "event_type", "status"}
		return prefix.seen == [3]bool{true, true, true} && prefix.columns == expected
	}
	pick := func(preferred, legacy outboxAuditIndex) outboxAuditIndex {
		if validPrefix(preferred) {
			return preferred
		}
		if validPrefix(legacy) {
			return legacy
		}
		return outboxAuditIndexUnknown
	}
	indexes := outboxAuditIndexes{
		Retry: pick(outboxAuditIndexEventRetry, outboxAuditIndexLuckyRetry),
		Lock:  pick(outboxAuditIndexEventLock, outboxAuditIndexLuckyLock),
	}
	// 缺失或列前缀不匹配时必须中止。核账命令不能静默退回无索引查询，否则线上只读审计也可能拖垮主库。
	if indexes.Retry == outboxAuditIndexUnknown || indexes.Lock == outboxAuditIndexUnknown {
		return outboxAuditIndexes{}, fmt.Errorf("activity_outbox requires whitelisted retry and lock indexes with prefix (app_code,event_type,status)")
	}
	return indexes, nil
}

func outboxAuditIndexDiscoveryQuery() string {
	return `
		SELECT index_name, seq_in_index, column_name
		FROM information_schema.statistics
		WHERE table_schema = DATABASE()
		  AND table_name = 'activity_outbox'
		  AND index_name IN (
		      'idx_activity_outbox_event_retry',
		      'idx_activity_outbox_lucky_retry',
		      'idx_activity_outbox_event_lock',
		      'idx_activity_outbox_lucky_lock'
		  )
		  AND seq_in_index <= 3
		ORDER BY index_name, seq_in_index`
}

func outboxAuditIndexByName(value string) (outboxAuditIndex, bool) {
	switch strings.TrimSpace(value) {
	case "idx_activity_outbox_event_retry":
		return outboxAuditIndexEventRetry, true
	case "idx_activity_outbox_lucky_retry":
		return outboxAuditIndexLuckyRetry, true
	case "idx_activity_outbox_event_lock":
		return outboxAuditIndexEventLock, true
	case "idx_activity_outbox_lucky_lock":
		return outboxAuditIndexLuckyLock, true
	default:
		return outboxAuditIndexUnknown, false
	}
}

func unsettledRetryQuery(index outboxAuditIndex) (string, error) {
	var indexName string
	switch index {
	case outboxAuditIndexEventRetry:
		indexName = "idx_activity_outbox_event_retry"
	case outboxAuditIndexLuckyRetry:
		indexName = "idx_activity_outbox_lucky_retry"
	default:
		return "", fmt.Errorf("retry FORCE INDEX is not whitelisted")
	}
	return fmt.Sprintf(`
		SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms
		FROM activity_outbox FORCE INDEX (%s)
		WHERE app_code = ? AND event_type = ? AND status IN (?, ?)
		ORDER BY created_at_ms, outbox_id
		LIMIT ?`, indexName), nil
}

func unsettledDeliveringQuery(index outboxAuditIndex) (string, error) {
	var indexName string
	switch index {
	case outboxAuditIndexEventLock:
		indexName = "idx_activity_outbox_event_lock"
	case outboxAuditIndexLuckyLock:
		indexName = "idx_activity_outbox_lucky_lock"
	default:
		return "", fmt.Errorf("lock FORCE INDEX is not whitelisted")
	}
	return fmt.Sprintf(`
		SELECT app_code, outbox_id, payload, status, retry_count, created_at_ms
		FROM activity_outbox FORCE INDEX (%s)
		WHERE app_code = ? AND event_type = ? AND status = ?
		ORDER BY created_at_ms, outbox_id
		LIMIT ?`, indexName), nil
}

func loadUnsettledBranch(ctx context.Context, db *sql.DB, query string, args ...any) ([]outboxRow, error) {
	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	result := make([]outboxRow, 0)
	for rows.Next() {
		var row outboxRow
		if err := rows.Scan(&row.AppCode, &row.OutboxID, &row.Payload, &row.Status, &row.RetryCount, &row.CreatedMS); err != nil {
			return nil, err
		}
		result = append(result, row)
	}
	return result, rows.Err()
}

func normalizeAuditAppCodes(values []string) ([]string, error) {
	if len(values) == 0 {
		return nil, fmt.Errorf("at least one --app-code is required")
	}
	const maxAuditAppCodes = 16
	seen := make(map[string]struct{}, len(values))
	result := make([]string, 0, len(values))
	for _, value := range values {
		if strings.TrimSpace(value) == "" {
			return nil, fmt.Errorf("app-code must not be empty")
		}
		value = appcode.Normalize(value)
		if _, exists := seen[value]; exists {
			continue
		}
		seen[value] = struct{}{}
		result = append(result, value)
	}
	if len(result) == 0 {
		return nil, fmt.Errorf("at least one --app-code is required")
	}
	if len(result) > maxAuditAppCodes {
		return nil, fmt.Errorf("at most %d app-code values are allowed", maxAuditAppCodes)
	}
	sort.Strings(result)
	return result, nil
}

func auditRow(ctx context.Context, db *sql.DB, walletDB string, row outboxRow) (rowAudit, error) {
	var payload rewardPayload
	if err := json.Unmarshal(row.Payload, &payload); err != nil {
		return rowAudit{}, fmt.Errorf("decode immutable payload: %w", err)
	}
	row.AppCode = appcode.Normalize(row.AppCode)
	if payload.AppCode != "" && appcode.Normalize(payload.AppCode) != row.AppCode {
		return rowAudit{}, fmt.Errorf("payload app_code does not match outbox partition")
	}
	payload.DrawID = strings.TrimSpace(payload.DrawID)
	payload.WheelID = strings.TrimSpace(payload.WheelID)
	if payload.DrawID == "" || payload.WheelID == "" || payload.UserID <= 0 {
		return rowAudit{}, fmt.Errorf("payload identity is incomplete")
	}
	rewards := payload.Rewards
	if len(rewards) == 0 {
		rewards = []domain.DrawReward{{
			SelectedTierID: payload.SelectedTierID,
			RewardType:     payload.RewardType,
			RewardID:       payload.RewardID,
			RewardCount:    payload.RewardCount,
			RewardCoins:    payload.RewardCoins,
			MetadataJSON:   payload.MetadataJSON,
		}}
	}
	if len(rewards) == 0 {
		return rowAudit{}, fmt.Errorf("payload reward snapshot is empty")
	}

	audit := rowAudit{AppCode: row.AppCode, OutboxID: row.OutboxID, Status: row.Status, Items: make([]itemAudit, 0, len(rewards))}
	for index, reward := range rewards {
		reward.RewardType = strings.ToLower(strings.TrimSpace(reward.RewardType))
		var item itemAudit
		var err error
		switch reward.RewardType {
		case domain.RewardTypeCoin:
			item, err = auditCoin(ctx, db, walletDB, row.AppCode, payload, reward, index, len(rewards))
		case domain.RewardTypeGift, domain.RewardTypeProp, domain.RewardTypeDress:
			item, err = auditResource(ctx, db, walletDB, row.AppCode, payload, reward, index, len(rewards))
		default:
			item = itemAudit{Type: reward.RewardType, State: "conflict", Reason: "unsupported reward type"}
		}
		if err != nil {
			return rowAudit{}, err
		}
		audit.Items = append(audit.Items, item)
	}
	audit.State = aggregateRowState(audit.Items)
	return audit, nil
}

func auditCoin(ctx context.Context, db *sql.DB, walletDB string, app string, payload rewardPayload, reward domain.DrawReward, index int, total int) (itemAudit, error) {
	commandID := rewardCommandID("wheel_reward", payload.DrawID, index, total)
	item := itemAudit{CommandID: commandID, Type: domain.RewardTypeCoin, State: "unpaid"}
	query := coinAuditQuery(walletDB)
	var transactionID, bizType, statusValue, metadataJSON string
	err := db.QueryRowContext(ctx, query, app, commandID).Scan(&transactionID, &bizType, &statusValue, &metadataJSON)
	if err != nil {
		// database/sql 和 driver/observer 包装都可能给 ErrNoRows 增加上下文；审计必须把这种稳定语义归为 unpaid，而不是中止整批核账。
		if errors.Is(err, sql.ErrNoRows) {
			return item, nil
		}
		return itemAudit{}, err
	}
	metadata := map[string]any{}
	if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
		item.State, item.Reason = "conflict", "wallet metadata is invalid"
		return item, nil
	}
	// wallet_transactions 和 resource_grants 共用 succeeded 终态；completed 从未是当前账本状态，不能把真实已发金币误判为冲突。
	if bizType != "wheel_reward" || statusValue != "succeeded" || jsonInt64(metadata, "target_user_id") != payload.UserID || jsonInt64(metadata, "amount") != reward.RewardCoins || jsonString(metadata, "draw_id") != payload.DrawID || jsonString(metadata, "wheel_id") != payload.WheelID || jsonString(metadata, "selected_tier_id") != strings.TrimSpace(reward.SelectedTierID) || jsonString(metadata, "reason") != "wheel_reward" || jsonInt64(metadata, "visible_region_id") != payload.VisibleRegionID {
		item.State, item.Reason = "conflict", "existing wallet transaction does not match immutable reward snapshot"
		return item, nil
	}
	item.State = "paid"
	item.ReceiptID = transactionID
	return item, nil
}

func coinAuditQuery(walletDB string) string {
	return fmt.Sprintf(`
		SELECT transaction_id, biz_type, status, COALESCE(CAST(metadata_json AS CHAR), '{}')
		FROM %s.wallet_transactions
		WHERE app_code = ? AND command_id = ?`, walletDB)
}

func auditResource(ctx context.Context, db *sql.DB, walletDB string, app string, payload rewardPayload, reward domain.DrawReward, index int, total int) (itemAudit, error) {
	commandID := rewardCommandID("wheel_resource", payload.DrawID, index, total)
	item := itemAudit{CommandID: commandID, Type: reward.RewardType, State: "unpaid"}
	resourceID := rewardResourceID(reward)
	quantity := reward.RewardCount
	if quantity <= 0 {
		quantity = 1
	}
	durationMS := metadataInt64(reward.MetadataJSON, "duration_ms", "durationMs", "validity_ms", "validityMs")
	query := resourceAuditQuery(walletDB)
	var grantID, statusValue, grantSource, subjectType, subjectID, reason string
	var targetUserID, operatorUserID, actualQuantity, actualDurationMS, actualResourceID int64
	err := db.QueryRowContext(ctx, query, app, commandID).Scan(&grantID, &statusValue, &targetUserID, &grantSource, &subjectType, &subjectID, &reason, &operatorUserID, &actualQuantity, &actualDurationMS, &actualResourceID)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return item, nil
		}
		return itemAudit{}, err
	}
	if resourceID <= 0 || statusValue != "succeeded" || targetUserID != payload.UserID || grantSource != "wheel_reward" || subjectType != "resource" || subjectID != strconv.FormatInt(resourceID, 10) || reason != "wheel_reward" || operatorUserID != payload.UserID || actualQuantity != quantity || actualDurationMS != durationMS || actualResourceID != resourceID {
		item.State, item.Reason = "conflict", "existing resource grant does not match immutable reward snapshot"
		return item, nil
	}
	item.State = "paid"
	item.ReceiptID = grantID
	return item, nil
}

func resourceAuditQuery(walletDB string) string {
	// 每个 SELECT 列只出现一次且与 Scan 顺序一一对应；显式逐列排版避免核账命令在生产才暴露漏逗号或重复列。
	return fmt.Sprintf(`
		SELECT rg.grant_id,
		       rg.status,
		       rg.target_user_id,
		       rg.grant_source,
		       rg.grant_subject_type,
		       rg.grant_subject_id,
		       rg.reason,
		       rg.operator_user_id,
		       COALESCE(rgi.quantity, 0),
		       COALESCE(rgi.duration_ms, 0),
		       COALESCE(rgi.resource_id, 0)
		FROM %s.resource_grants rg
		LEFT JOIN %s.resource_grant_items rgi
		  ON rgi.app_code = rg.app_code AND rgi.grant_id = rg.grant_id
		WHERE rg.app_code = ? AND rg.command_id = ?
		ORDER BY rgi.grant_item_id
		LIMIT 1`, walletDB, walletDB)
}

func aggregateRowState(items []itemAudit) string {
	paid, unpaid, conflict := 0, 0, 0
	for _, item := range items {
		switch item.State {
		case "paid":
			paid++
		case "unpaid":
			unpaid++
		default:
			conflict++
		}
	}
	if conflict > 0 {
		return "conflict"
	}
	if paid == len(items) {
		return "fully_paid"
	}
	if unpaid == len(items) {
		return "fully_unpaid"
	}
	return "partially_paid"
}

func summarize(audits []rowAudit) auditSummary {
	summary := auditSummary{Rows: len(audits)}
	canonical := make([]string, 0, len(audits))
	for _, audit := range audits {
		switch audit.State {
		case "fully_paid":
			summary.FullyPaid++
		case "fully_unpaid":
			summary.FullyUnpaid++
		case "partially_paid":
			summary.PartiallyPaid++
		default:
			summary.Conflict++
		}
		for _, item := range audit.Items {
			switch item.State {
			case "paid":
				summary.PaidItems++
			case "unpaid":
				summary.UnpaidItems++
			default:
				summary.ConflictItems++
			}
		}
		encoded, _ := json.Marshal(audit)
		canonical = append(canonical, string(encoded))
	}
	sort.Strings(canonical)
	sum := sha256.Sum256([]byte(strings.Join(canonical, "\n")))
	summary.AuditSHA256 = hex.EncodeToString(sum[:])
	return summary
}

func rewardCommandID(prefix string, drawID string, index int, total int) string {
	if total <= 1 {
		return prefix + ":" + drawID
	}
	return fmt.Sprintf("%s:%s:%d", prefix, drawID, index+1)
}

func rewardResourceID(reward domain.DrawReward) int64 {
	if value := metadataInt64(reward.MetadataJSON, "resource_id", "resourceId"); value > 0 {
		return value
	}
	value, _ := strconv.ParseInt(strings.TrimSpace(reward.RewardID), 10, 64)
	return value
}

func metadataInt64(raw string, keys ...string) int64 {
	metadata := map[string]any{}
	if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
		return 0
	}
	for _, key := range keys {
		if value := jsonInt64(metadata, key); value > 0 {
			return value
		}
	}
	return 0
}

func jsonInt64(values map[string]any, key string) int64 {
	switch value := values[key].(type) {
	case float64:
		return int64(value)
	case json.Number:
		parsed, _ := value.Int64()
		return parsed
	case string:
		parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
		return parsed
	default:
		return 0
	}
}

func jsonString(values map[string]any, key string) string {
	value, _ := values[key].(string)
	return strings.TrimSpace(value)
}

func normalizedDatabaseName(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return ""
	}
	for _, char := range value {
		if char == '_' || unicode.IsDigit(char) || unicode.IsLetter(char) {
			continue
		}
		return ""
	}
	return value
}
