// Package invite stores invitation codes, relations, counters and recharge progress in MySQL.
package invite

import (
	"context"
	"crypto/rand"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"slices"
	"strings"
	"time"

	mysqldriver "github.com/go-sql-driver/mysql"
	"hyapp/pkg/appcode"
	"hyapp/pkg/idgen"
	"hyapp/pkg/xerr"
	invitedomain "hyapp/services/user-service/internal/domain/invite"
	"hyapp/services/user-service/internal/storage/mysql/shared"
)

const generatedCodeLength = 8

const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"

// Repository owns invitation persistence inside the user-service database.
type Repository struct {
	db *sql.DB
}

// New creates an invitation repository backed by the shared user-service MySQL pool.
func New(db *sql.DB) *Repository {
	return &Repository{db: db}
}

// EnsurePrimaryCodeForUser creates the current user's own shareable invite code inside the caller transaction.
func EnsurePrimaryCodeForUser(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64, nowMs int64) (string, error) {
	code, ok, err := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID)
	if err != nil {
		return "", err
	}
	if ok {
		return code, nil
	}

	for range 16 {
		candidate := generateInviteCode(generatedCodeLength)
		_, err := tx.ExecContext(ctx, `
			INSERT INTO user_invite_codes (app_code, owner_user_id, code, code_type, status, created_at_ms, updated_at_ms)
			VALUES (?, ?, ?, ?, ?, ?, ?)
		`, appcode.Normalize(appCode), ownerUserID, candidate, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive, nowMs, nowMs)
		if err == nil {
			return candidate, nil
		}
		if !isDuplicate(err) {
			return "", err
		}
		// Owner-level duplicate means another code was created earlier in this transaction path; reuse it.
		if code, ok, lookupErr := primaryCodeForUpdate(ctx, tx, appCode, ownerUserID); lookupErr != nil {
			return "", lookupErr
		} else if ok {
			return code, nil
		}
	}

	return "", xerr.New(xerr.Internal, "invite code allocation failed")
}

// BindRelation validates an optional invite code and binds the invited user once inside the caller transaction.
func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand) (invitedomain.Binding, error) {
	command.AppCode = appcode.Normalize(command.AppCode)
	command.InviteCode = invitedomain.NormalizeCode(command.InviteCode)
	command.Source = strings.TrimSpace(command.Source)
	command.RequestID = strings.TrimSpace(command.RequestID)
	if command.InviteCode == "" {
		return invitedomain.Binding{}, nil
	}
	if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) {
		return invitedomain.Binding{}, invalidInviteCode()
	}
	if command.BoundAtMs <= 0 {
		command.BoundAtMs = time.Now().UnixMilli()
	}

	code, err := resolveActiveCodeForUpdate(ctx, tx, command.AppCode, command.InviteCode)
	if err != nil {
		return invitedomain.Binding{}, err
	}
	if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" {
		return invitedomain.Binding{}, invalidInviteCode()
	}
	if code.OwnerRegionID != command.InvitedRegionID {
		// 邀请归因按区域隔离；注册、资料补全和 H5 后补绑定都必须共享同一条写入边界。
		return invitedomain.Binding{}, invalidInviteCode()
	}

	existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
	if err != nil {
		return invitedomain.Binding{}, err
	}
	if exists {
		// 邀请归因只能首次绑定；重复提交不改写原邀请人，也不重复增加计数。
		return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
	}

	policy, err := currentPolicyForUpdate(ctx, tx, command.AppCode, command.InvitedRegionID, command.BoundAtMs)
	if err != nil {
		return invitedomain.Binding{}, err
	}

	_, err = tx.ExecContext(ctx, `
		INSERT INTO user_invite_relations (
			app_code, invited_user_id, inviter_user_id, invite_code_id, invite_code, source, request_id, bound_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
	`, command.AppCode, command.InvitedUserID, code.OwnerUserID, code.InviteCodeID, code.Code, command.Source, command.RequestID, command.BoundAtMs, command.BoundAtMs, command.BoundAtMs)
	if err != nil {
		if isDuplicate(err) {
			existing, exists, lookupErr := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
			if lookupErr != nil {
				return invitedomain.Binding{}, lookupErr
			}
			if exists {
				return invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}, nil
			}
			return invitedomain.Binding{}, nil
		}
		return invitedomain.Binding{}, err
	}
	if err := insertProgress(ctx, tx, command, code.OwnerUserID, policy); err != nil {
		return invitedomain.Binding{}, err
	}
	if err := incrementInviteCount(ctx, tx, command.AppCode, code.OwnerUserID, command.BoundAtMs); err != nil {
		return invitedomain.Binding{}, err
	}
	if err := insertUserOutbox(ctx, tx, command.AppCode, invitedomain.EventTypeUserInvited, "user_invite_relation", command.InvitedUserID, command.BoundAtMs, map[string]any{
		"invited_user_id": command.InvitedUserID,
		"inviter_user_id": code.OwnerUserID,
		"invite_code":     code.Code,
		"request_id":      command.RequestID,
		"source":          command.Source,
		"bound_at_ms":     command.BoundAtMs,
	}); err != nil {
		return invitedomain.Binding{}, err
	}

	return invitedomain.Binding{Bound: true, InviteCode: code.Code, InviterUserID: code.OwnerUserID}, nil
}

// GetOverview returns the precomputed invite read model for my-page aggregation.
func (r *Repository) GetOverview(ctx context.Context, appCode string, userID int64, regionID int64, nowMs int64) (invitedomain.Overview, error) {
	if r == nil || r.db == nil {
		return invitedomain.Overview{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
	}
	if nowMs <= 0 {
		nowMs = time.Now().UnixMilli()
	}

	var overview invitedomain.Overview
	err := r.db.QueryRowContext(ctx, `
		SELECT code
		FROM user_invite_codes
		WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
		ORDER BY invite_code_id DESC
		LIMIT 1
	`, appcode.Normalize(appCode), userID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&overview.MyInviteCode)
	if err != nil && err != sql.ErrNoRows {
		return invitedomain.Overview{}, err
	}
	overview.InviteEnabled = overview.MyInviteCode != ""

	err = r.db.QueryRowContext(ctx, `
		SELECT invite_count, valid_invite_count
		FROM user_invite_counters
		WHERE app_code = ? AND user_id = ?
	`, appcode.Normalize(appCode), userID).Scan(&overview.InviteCount, &overview.ValidInviteCount)
	if err != nil && err != sql.ErrNoRows {
		return invitedomain.Overview{}, err
	}

	policy, err := currentPolicy(ctx, r.db, appCode, regionID, nowMs)
	if err == nil {
		overview.ValidInviteThresholdCoin = policy.RequiredRechargeCoinAmount
	} else if err != sql.ErrNoRows {
		return invitedomain.Overview{}, err
	}

	return overview, nil
}

// GetAttribution 返回被邀请用户的邀请归因快照；没有绑定关系是正常业务分支，调用方可直接跳过奖励累计。
func (r *Repository) GetAttribution(ctx context.Context, appCode string, invitedUserID int64) (invitedomain.Attribution, error) {
	if r == nil || r.db == nil {
		return invitedomain.Attribution{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
	}
	appCode = appcode.Normalize(appCode)
	if invitedUserID <= 0 {
		return invitedomain.Attribution{}, xerr.New(xerr.InvalidArgument, "invited_user_id is required")
	}
	var item invitedomain.Attribution
	var validAt sql.NullInt64
	err := r.db.QueryRowContext(ctx, `
		SELECT r.app_code, r.invited_user_id, r.inviter_user_id, r.invite_code, r.bound_at_ms,
		       COALESCE(p.status, ''), COALESCE(p.accumulated_recharge_coin_amount, 0),
		       COALESCE(p.required_recharge_coin_amount, 0), p.valid_at_ms
		FROM user_invite_relations r
		LEFT JOIN user_invite_recharge_progress p
		  ON p.app_code = r.app_code AND p.invited_user_id = r.invited_user_id
		WHERE r.app_code = ? AND r.invited_user_id = ?
		LIMIT 1`,
		appCode, invitedUserID,
	).Scan(
		&item.AppCode,
		&item.InvitedUserID,
		&item.InviterUserID,
		&item.InviteCode,
		&item.BoundAtMS,
		&item.ValidityStatus,
		&item.AccumulatedRechargeCoinAmount,
		&item.RequiredRechargeCoinAmount,
		&validAt,
	)
	if err == sql.ErrNoRows {
		return invitedomain.Attribution{AppCode: appCode, InvitedUserID: invitedUserID}, nil
	}
	if err != nil {
		return invitedomain.Attribution{}, err
	}
	item.Found = true
	if validAt.Valid {
		item.ValidAtMS = validAt.Int64
	}
	return item, nil
}

// ApplyRechargeEvent consumes one wallet recharge fact and advances invite validity idempotently.
func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain.RechargeEvent) (invitedomain.RechargeApplyResult, error) {
	if r == nil || r.db == nil {
		return invitedomain.RechargeApplyResult{}, xerr.New(xerr.Unavailable, "invite repository is not configured")
	}
	event.AppCode = appcode.Normalize(event.AppCode)
	event.EventID = strings.TrimSpace(event.EventID)
	event.EventType = strings.TrimSpace(event.EventType)
	event.TransactionID = strings.TrimSpace(event.TransactionID)
	event.AssetType = strings.TrimSpace(event.AssetType)
	event.RechargeType = normalizeRechargeType(event.RechargeType)
	if event.RechargeType == "" {
		event.RechargeType = invitedomain.RechargeTypeCoinSellerTransfer
	}
	if event.EventID == "" || event.TransactionID == "" || event.InvitedUserID <= 0 {
		return invitedomain.RechargeApplyResult{}, xerr.New(xerr.InvalidArgument, "wallet recharge event identity is required")
	}
	if event.OccurredAtMs <= 0 {
		event.OccurredAtMs = time.Now().UnixMilli()
	}

	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return invitedomain.RechargeApplyResult{}, err
	}
	defer tx.Rollback()

	if err := insertEventConsumption(ctx, tx, event); err != nil {
		if isDuplicate(err) {
			return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_consumed"}, nil
		}
		return invitedomain.RechargeApplyResult{}, err
	}
	if event.EventType != invitedomain.RechargeEventWalletRecorded || event.AssetType != "COIN" || event.RechargeCoinAmount <= 0 {
		if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "not_eligible_wallet_event", event.OccurredAtMs); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
		return invitedomain.RechargeApplyResult{Skipped: true, Reason: "not_eligible_wallet_event"}, tx.Commit()
	}

	progress, exists, err := progressForInvitedForUpdate(ctx, tx, event.AppCode, event.InvitedUserID)
	if err != nil {
		return invitedomain.RechargeApplyResult{}, err
	}
	if !exists {
		if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "no_invite_relation", event.OccurredAtMs); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
		return invitedomain.RechargeApplyResult{Skipped: true, Reason: "no_invite_relation"}, tx.Commit()
	}
	if progress.Status == invitedomain.ProgressStatusValid {
		if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "already_valid", event.OccurredAtMs); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
		return invitedomain.RechargeApplyResult{Skipped: true, Reason: "already_valid"}, tx.Commit()
	}
	if !eligibleRechargeType(progress.EligibleRechargeTypes, event.RechargeType) {
		if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "skipped", "ineligible_recharge_type", event.OccurredAtMs); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
		return invitedomain.RechargeApplyResult{Skipped: true, Reason: "ineligible_recharge_type"}, tx.Commit()
	}

	newAccumulated := progress.AccumulatedRechargeCoinAmount + event.RechargeCoinAmount
	becameValid := newAccumulated >= progress.RequiredRechargeCoinAmount
	status := invitedomain.ProgressStatusPending
	var validAt any
	if becameValid {
		status = invitedomain.ProgressStatusValid
		validAt = event.OccurredAtMs
	}
	_, err = tx.ExecContext(ctx, `
		UPDATE user_invite_recharge_progress
		SET accumulated_recharge_coin_amount = ?, status = ?, valid_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND invited_user_id = ?
	`, newAccumulated, status, validAt, event.OccurredAtMs, event.AppCode, event.InvitedUserID)
	if err != nil {
		return invitedomain.RechargeApplyResult{}, err
	}
	if becameValid {
		if err := incrementValidInviteCount(ctx, tx, event.AppCode, progress.InviterUserID, event.OccurredAtMs); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
		if err := insertUserOutbox(ctx, tx, event.AppCode, invitedomain.EventTypeUserInviteBecameValid, "user_invite_relation", event.InvitedUserID, event.OccurredAtMs, map[string]any{
			"invited_user_id":                  event.InvitedUserID,
			"inviter_user_id":                  progress.InviterUserID,
			"transaction_id":                   event.TransactionID,
			"accumulated_recharge_coin_amount": newAccumulated,
			"required_recharge_coin_amount":    progress.RequiredRechargeCoinAmount,
			"valid_at_ms":                      event.OccurredAtMs,
		}); err != nil {
			return invitedomain.RechargeApplyResult{}, err
		}
	}
	if err := finishConsumption(ctx, tx, event.AppCode, event.EventID, "consumed", "", event.OccurredAtMs); err != nil {
		return invitedomain.RechargeApplyResult{}, err
	}

	return invitedomain.RechargeApplyResult{Consumed: true, Valid: becameValid}, tx.Commit()
}

type inviteCodeRow struct {
	InviteCodeID  int64
	Code          string
	OwnerUserID   int64
	OwnerRegionID int64
	OwnerStatus   string
}

type relationRow struct {
	InviterUserID int64
	InviteCode    string
}

type policyRow struct {
	PolicyID                   int64
	PolicyVersion              string
	RequiredRechargeCoinAmount int64
	EligibleRechargeTypes      []string
}

type progressRow struct {
	InviterUserID                 int64
	PolicyID                      int64
	PolicyVersion                 string
	RequiredRechargeCoinAmount    int64
	AccumulatedRechargeCoinAmount int64
	Status                        string
	EligibleRechargeTypes         []string
}

func primaryCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, ownerUserID int64) (string, bool, error) {
	var code string
	err := tx.QueryRowContext(ctx, `
		SELECT code
		FROM user_invite_codes
		WHERE app_code = ? AND owner_user_id = ? AND code_type = ? AND status = ?
		ORDER BY invite_code_id DESC
		LIMIT 1
		FOR UPDATE
	`, appcode.Normalize(appCode), ownerUserID, invitedomain.CodeTypePrimary, invitedomain.CodeStatusActive).Scan(&code)
	if err == sql.ErrNoRows {
		return "", false, nil
	}
	if err != nil {
		return "", false, err
	}
	return code, true, nil
}

func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) {
	var row inviteCodeRow
	err := tx.QueryRowContext(ctx, `
		SELECT c.invite_code_id, c.code, c.owner_user_id, COALESCE(u.region_id, 0), u.status
		FROM user_invite_codes c
		INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id
		WHERE c.app_code = ? AND c.code = ? AND c.status = ?
		LIMIT 1
		FOR UPDATE
	`, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerRegionID, &row.OwnerStatus)
	if err == sql.ErrNoRows {
		return inviteCodeRow{}, invalidInviteCode()
	}
	if err != nil {
		return inviteCodeRow{}, err
	}
	return row, nil
}

func relationForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (relationRow, bool, error) {
	var row relationRow
	err := tx.QueryRowContext(ctx, `
		SELECT inviter_user_id, invite_code
		FROM user_invite_relations
		WHERE app_code = ? AND invited_user_id = ?
		FOR UPDATE
	`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.InviteCode)
	if err == sql.ErrNoRows {
		return relationRow{}, false, nil
	}
	if err != nil {
		return relationRow{}, false, err
	}
	return row, true, nil
}

func currentPolicyForUpdate(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, nowMs int64) (policyRow, error) {
	policy, err := currentPolicy(ctx, tx, appCode, regionID, nowMs)
	if err == sql.ErrNoRows {
		return policyRow{}, xerr.New(xerr.Internal, "invite validity policy is not configured")
	}
	return policy, err
}

func currentPolicy(ctx context.Context, q shared.Queryer, appCode string, regionID int64, nowMs int64) (policyRow, error) {
	var policy policyRow
	var eligibleJSON string
	err := q.QueryRowContext(ctx, `
		SELECT policy_id, policy_version, required_recharge_coin_amount, CAST(eligible_recharge_types AS CHAR)
		FROM user_invite_validity_policies
		WHERE app_code = ?
		  AND status = 'active'
		  AND (region_id = ? OR region_id = 0)
		  AND effective_from_ms <= ?
		  AND (effective_to_ms IS NULL OR effective_to_ms > ?)
		ORDER BY CASE WHEN region_id = ? THEN 0 ELSE 1 END, effective_from_ms DESC, policy_id DESC
		LIMIT 1
	`, appcode.Normalize(appCode), nullableRegion(regionID), nowMs, nowMs, nullableRegion(regionID)).Scan(&policy.PolicyID, &policy.PolicyVersion, &policy.RequiredRechargeCoinAmount, &eligibleJSON)
	if err != nil {
		return policyRow{}, err
	}
	policy.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
	return policy, nil
}

func insertProgress(ctx context.Context, tx *sql.Tx, command invitedomain.BindCommand, inviterUserID int64, policy policyRow) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_invite_recharge_progress (
			app_code, invited_user_id, inviter_user_id, policy_id, policy_version, required_recharge_coin_amount,
			accumulated_recharge_coin_amount, status, valid_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, ?, 0, ?, NULL, ?, ?)
		ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms
	`, command.AppCode, command.InvitedUserID, inviterUserID, policy.PolicyID, policy.PolicyVersion, policy.RequiredRechargeCoinAmount, invitedomain.ProgressStatusPending, command.BoundAtMs, command.BoundAtMs)
	return err
}

func incrementInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
		VALUES (?, ?, 1, 0, ?)
		ON DUPLICATE KEY UPDATE invite_count = invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
	`, appCode, inviterUserID, nowMs)
	return err
}

func incrementValidInviteCount(ctx context.Context, tx *sql.Tx, appCode string, inviterUserID int64, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_invite_counters (app_code, user_id, invite_count, valid_invite_count, updated_at_ms)
		VALUES (?, ?, 0, 1, ?)
		ON DUPLICATE KEY UPDATE valid_invite_count = valid_invite_count + 1, updated_at_ms = VALUES(updated_at_ms)
	`, appCode, inviterUserID, nowMs)
	return err
}

func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventType string, aggregateType string, aggregateID int64, nowMs int64, payload any) error {
	payloadJSON, err := json.Marshal(payload)
	if err != nil {
		return err
	}
	_, err = tx.ExecContext(ctx, `
		INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
		VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, ?)
	`, appcode.Normalize(appCode), inviteEventID(eventType, aggregateID), eventType, aggregateType, aggregateID, string(payloadJSON), nowMs, nowMs)
	return err
}

func insertEventConsumption(ctx context.Context, tx *sql.Tx, event invitedomain.RechargeEvent) error {
	_, err := tx.ExecContext(ctx, `
		INSERT INTO user_invite_event_consumption (
			app_code, event_id, event_type, transaction_id, invited_user_id, status, skip_reason, consumed_at_ms, created_at_ms, updated_at_ms
		) VALUES (?, ?, ?, ?, ?, 'processing', '', NULL, ?, ?)
	`, event.AppCode, event.EventID, event.EventType, event.TransactionID, event.InvitedUserID, event.OccurredAtMs, event.OccurredAtMs)
	return err
}

func finishConsumption(ctx context.Context, tx *sql.Tx, appCode string, eventID string, status string, reason string, nowMs int64) error {
	_, err := tx.ExecContext(ctx, `
		UPDATE user_invite_event_consumption
		SET status = ?, skip_reason = ?, consumed_at_ms = ?, updated_at_ms = ?
		WHERE app_code = ? AND event_id = ?
	`, status, reason, nowMs, nowMs, appCode, eventID)
	return err
}

func progressForInvitedForUpdate(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (progressRow, bool, error) {
	var row progressRow
	var eligibleJSON string
	err := tx.QueryRowContext(ctx, `
		SELECT p.inviter_user_id, p.policy_id, p.policy_version, p.required_recharge_coin_amount,
		       p.accumulated_recharge_coin_amount, p.status, CAST(pol.eligible_recharge_types AS CHAR)
		FROM user_invite_recharge_progress p
		INNER JOIN user_invite_validity_policies pol ON pol.app_code = p.app_code AND pol.policy_id = p.policy_id
		WHERE p.app_code = ? AND p.invited_user_id = ?
		FOR UPDATE
	`, appCode, invitedUserID).Scan(&row.InviterUserID, &row.PolicyID, &row.PolicyVersion, &row.RequiredRechargeCoinAmount, &row.AccumulatedRechargeCoinAmount, &row.Status, &eligibleJSON)
	if err == sql.ErrNoRows {
		return progressRow{}, false, nil
	}
	if err != nil {
		return progressRow{}, false, err
	}
	row.EligibleRechargeTypes = parseEligibleTypes(eligibleJSON)
	return row, true, nil
}

func parseEligibleTypes(raw string) []string {
	var types []string
	if err := json.Unmarshal([]byte(raw), &types); err != nil || len(types) == 0 {
		return defaultEligibleRechargeTypes()
	}
	out := make([]string, 0, len(types))
	for index := range types {
		if rechargeType := normalizeRechargeType(types[index]); rechargeType != "" {
			out = append(out, rechargeType)
		}
	}
	if len(out) == 0 {
		return defaultEligibleRechargeTypes()
	}
	return out
}

func eligibleRechargeType(types []string, eventType string) bool {
	return slices.Contains(types, normalizeRechargeType(eventType))
}

func defaultEligibleRechargeTypes() []string {
	return []string{
		invitedomain.RechargeTypeCoinSellerTransfer,
		invitedomain.RechargeTypeGooglePlay,
		invitedomain.RechargeTypeGoogle,
		invitedomain.RechargeTypeMifaPay,
		invitedomain.RechargeTypeV5Pay,
		invitedomain.RechargeTypeUSDTTRC20,
	}
}

func normalizeRechargeType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "coin_seller", "coin_seller_transfer":
		return invitedomain.RechargeTypeCoinSellerTransfer
	case "google", "google_play", "googleplay":
		return invitedomain.RechargeTypeGooglePlay
	case "mifapay", "mifa_pay":
		return invitedomain.RechargeTypeMifaPay
	case "v5pay", "v5_pay":
		return invitedomain.RechargeTypeV5Pay
	case "usdt", "usdt_trc20", "trc20":
		return invitedomain.RechargeTypeUSDTTRC20
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

func inviteEventID(eventType string, aggregateID int64) string {
	return fmt.Sprintf("%s:%d", eventType, aggregateID)
}

func invalidInviteCode() error {
	return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
}

func nullableRegion(regionID int64) int64 {
	if regionID <= 0 {
		return 0
	}
	return regionID
}

func generateInviteCode(length int) string {
	if length <= 0 {
		length = generatedCodeLength
	}
	buf := make([]byte, length)
	if _, err := rand.Read(buf); err != nil {
		fallback := strings.ToUpper(strings.ReplaceAll(idgen.New("INV"), "_", ""))
		if len(fallback) >= length {
			return fallback[len(fallback)-length:]
		}
		return fmt.Sprintf("%0*s", length, fallback)
	}
	for index, value := range buf {
		buf[index] = inviteCodeAlphabet[int(value)%len(inviteCodeAlphabet)]
	}
	return string(buf)
}

func isDuplicate(err error) bool {
	var mysqlErr *mysqldriver.MySQLError
	return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}
