package repository

import (
	"database/sql"
	"errors"
	"fmt"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"hyapp-admin-server/internal/model"

	"github.com/DATA-DOG/go-sqlmock"
	mysqlDriver "github.com/go-sql-driver/mysql"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestListWithdrawalApplicationsFiltersFinanceStageAndSorts(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	like := "%TRC20%"
	mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*operations_status IN").
		WithArgs("lalu", like, like, like, like, like, like, model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*operations_status IN").
		WithArgs("lalu", like, like, like, like, like, like, model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped, 50, 50).
		WillReturnRows(sqlmock.NewRows([]string{
			"id",
			"app_code",
			"user_id",
			"withdraw_amount",
			"withdraw_method",
			"withdraw_address",
			"status",
			"operations_status",
			"approver_user_id",
			"approver_name",
			"approved_at_ms",
			"created_at_ms",
			"updated_at_ms",
		}).AddRow(3, "lalu", "10003", "22.50", "USDT-TRC20", "addr-3", "approved", "approved", 9, "财务", int64(1700000200000), int64(1700000100000), int64(1700000200000)))

	items, total, err := store.ListWithdrawalApplications(WithdrawalApplicationListOptions{
		AppCode:  " lalu ",
		Keyword:  " TRC20 ",
		Page:     2,
		PageSize: 50,
		Stage:    WithdrawalApplicationReviewStageFinance,
	})
	if err != nil {
		t.Fatalf("list withdrawal applications failed: %v", err)
	}
	if total != 2 || len(items) != 1 {
		t.Fatalf("withdrawal list page mismatch: total=%d items=%+v", total, items)
	}
	if items[0].AppCode != "lalu" || items[0].UserID != "10003" || items[0].WithdrawMethod != "USDT-TRC20" {
		t.Fatalf("withdrawal row mismatch: %+v", items[0])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestSameOptionalDecimalTreatsEquivalentMySQLScaleAsReplay(t *testing.T) {
	t.Parallel()
	existing := "84.50"
	replay := "84.5"
	if !sameOptionalDecimal(&existing, &replay) {
		t.Fatal("equivalent decimal scales must remain idempotent after MySQL DECIMAL normalization")
	}
	changed := "84.51"
	if sameOptionalDecimal(&existing, &changed) {
		t.Fatal("different balance snapshot must be rejected as a conflicting replay")
	}
	if !sameOptionalDecimal(nil, &changed) {
		t.Fatal("legacy row without balance snapshot must remain replay compatible")
	}
	existingMinor, replayMinor, changedMinor := int64(6700), int64(6700), int64(6699)
	if !sameOptionalInt64(&existingMinor, &replayMinor) || sameOptionalInt64(&existingMinor, &changedMinor) || !sameOptionalInt64(nil, &changedMinor) {
		t.Fatal("optional minor-unit replay comparison must preserve legacy nil and reject changed persisted facts")
	}
}

func TestValidateExternalWithdrawalReplayProtectsPayoutSnapshot(t *testing.T) {
	t.Parallel()
	ratio, charge, actual, actualMinor := "20.0000", "17.00", "67.00", int64(6700)
	existing := model.UserWithdrawalApplication{
		UserID: "10001", SalaryAssetType: "SALARY", WithdrawAmountMinor: 8400,
		ServiceChargeRatio: &ratio, ServiceCharge: &charge, ActualAmount: &actual, ActualAmountMinor: &actualMinor,
		WithdrawMethod: "usdt_trc20", WithdrawAddress: "T-address",
	}
	ratioReplay, chargeReplay, actualReplay, actualMinorReplay := "20", "17", "67", int64(6700)
	replay := existing
	replay.ServiceChargeRatio = &ratioReplay
	replay.ServiceCharge = &chargeReplay
	replay.ActualAmount = &actualReplay
	replay.ActualAmountMinor = &actualMinorReplay
	if err := validateExternalWithdrawalReplay(existing, replay); err != nil {
		t.Fatalf("equivalent MySQL decimal scales must remain idempotent: %v", err)
	}
	changedActual := "66.99"
	replay.ActualAmount = &changedActual
	if err := validateExternalWithdrawalReplay(existing, replay); err == nil {
		t.Fatal("changed payout snapshot must conflict with the persisted source application")
	}
	legacy := existing
	legacy.ServiceChargeRatio = nil
	legacy.ServiceCharge = nil
	legacy.ActualAmount = nil
	legacy.ActualAmountMinor = nil
	replay.ActualAmount = &actualReplay
	if err := validateExternalWithdrawalReplay(legacy, replay); err != nil {
		t.Fatalf("legacy row without payout columns must remain replay compatible: %v", err)
	}
}

func TestApprovedWithdrawalStatsPrefersActualPayoutSnapshot(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectQuery("(?s)SELECT.*WHEN actual_amount_minor IS NOT NULL THEN actual_amount_minor.*FROM `admin_user_withdrawal_applications`.*status = \\?.*app_code = \\?.*approved_at_ms >= \\?.*approved_at_ms < \\?").
		WithArgs(model.WithdrawalApplicationStatusApproved, "yumi", int64(1700000000000), int64(1700086400000)).
		WillReturnRows(sqlmock.NewRows([]string{"approved_count", "approved_usd_minor"}).AddRow(2, 6700))
	stats, err := store.ApprovedWithdrawalStats(" yumi ", 1700000000000, 1700086400000)
	if err != nil {
		t.Fatalf("aggregate approved withdrawal stats failed: %v", err)
	}
	if stats.ApprovedCount != 2 || stats.ApprovedUSDMinor != 6700 {
		t.Fatalf("approved withdrawal payout stats mismatch: %+v", stats)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestListWithdrawalApplicationsOperationsStageExcludesLegacySkipped(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*operations_status <> \\?").
		WithArgs("fami", model.WithdrawalOperationsStatusSkipped).
		WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*operations_status <> \\?").
		WithArgs("fami", model.WithdrawalOperationsStatusSkipped, 20).
		WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))

	items, total, err := store.ListWithdrawalApplications(WithdrawalApplicationListOptions{
		AppCode: "fami", Page: 1, PageSize: 20, Stage: WithdrawalApplicationReviewStageOperations,
	})
	if err != nil {
		t.Fatalf("list operations withdrawal applications failed: %v", err)
	}
	if total != 1 || len(items) != 1 || items[0].OperationsStatus != model.WithdrawalOperationsStatusPending {
		t.Fatalf("operations withdrawal list mismatch: total=%d items=%+v", total, items)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestReviewWithdrawalApplicationOperationsApprovalKeepsOverallPending(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("fami", uint(77), 1).
		WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
	mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	callbackCalls := 0
	item, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{
		Stage:          WithdrawalApplicationReviewStageOperations,
		Decision:       model.WithdrawalApplicationStatusApproved,
		ApproverUserID: 8,
		ApproverName:   "运营",
		AuditRemark:    "资料无误",
		ApprovedAtMS:   1700000300000,
	}, func(application model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) {
		callbackCalls++
		if application.OperationsStatus != model.WithdrawalOperationsStatusPending {
			t.Fatalf("callback must observe operations pending row under lock: %+v", application)
		}
		return WithdrawalApplicationAuditEffect{}, nil
	})
	if err != nil {
		t.Fatalf("approve operations withdrawal failed: %v", err)
	}
	if callbackCalls != 1 || item.Status != model.WithdrawalApplicationStatusPending || item.OperationsStatus != model.WithdrawalOperationsStatusApproved || item.OperationsReviewerName != "运营" {
		t.Fatalf("operations approval state mismatch: calls=%d item=%+v", callbackCalls, item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestReviewWithdrawalApplicationRefusesOperationsRejectionWithoutDurableClaim(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	callbackCalled := false
	_, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected,
		ApproverUserID: 8, ApprovedAtMS: 1700000300000,
	}, func(model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) {
		callbackCalled = true
		return WithdrawalApplicationAuditEffect{}, nil
	})
	if err == nil || !strings.Contains(err.Error(), "durable claim") || callbackCalled {
		t.Fatalf("operations rejection must use durable claim path: err=%v callback=%t", err, callbackCalled)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("operations rejection bypass touched database: %v", err)
	}
}

func TestClaimAndFinalizeWithdrawalOperationsRejectionPersistsDurableIntermediateState(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	// claim 必须先独立提交 rejecting 和固定审核上下文，外部 wallet/notice 此后失败也不能把行回滚到 pending。
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("fami", uint(77), 1).
		WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
	mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	validatorCalls := 0
	claimed, err := store.ClaimWithdrawalOperationsRejectionForApp("fami", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected,
		ApproverUserID: 8, ApproverName: "运营", AuditRemark: "资料不符",
		AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: 1700000300000,
	}, func(application model.UserWithdrawalApplication) error {
		validatorCalls++
		if application.FreezeTransactionID != "tx-freeze" {
			t.Fatalf("claim validator must observe locked freeze snapshot: %+v", application)
		}
		return nil
	})
	if err != nil {
		t.Fatalf("claim operations rejection failed: %v", err)
	}
	if validatorCalls != 1 || claimed.Status != model.WithdrawalApplicationStatusPending || claimed.OperationsStatus != model.WithdrawalOperationsStatusRejecting || claimed.OperationsAuditCommandID != "salary-withdrawal:77:operations" {
		t.Fatalf("operations rejection claim mismatch: validator_calls=%d item=%+v", validatorCalls, claimed)
	}

	// wallet 与 notice 成功后只允许相同 command 把 rejecting 收敛成双重 rejected，并保存实际 release transaction。
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("fami", uint(77), 1).
		WillReturnRows(withdrawalApplicationRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "运营", "资料不符"))
	mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	item, err := store.FinalizeWithdrawalOperationsRejectionForApp("fami", 77, "salary-withdrawal:77:operations", "release-tx", 1700000300001)
	if err != nil {
		t.Fatalf("finalize operations rejection failed: %v", err)
	}
	if item.Status != model.WithdrawalApplicationStatusRejected || item.OperationsStatus != model.WithdrawalOperationsStatusRejected || item.OperationsAuditTransactionID != "release-tx" || item.OperationsAuditRemark != "资料不符" {
		t.Fatalf("operations rejection terminal state mismatch: %+v", item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestClaimWithdrawalOperationsRejectionRetryPreservesFirstAuditContext(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("fami", uint(77), 1).
		WillReturnRows(withdrawalApplicationRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "第一次运营", "第一次原因"))
	mock.ExpectCommit()

	item, err := store.ClaimWithdrawalOperationsRejectionForApp("fami", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected,
		ApproverUserID: 18, ApproverName: "重试运营", AuditRemark: "重试原因不能覆盖",
		AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: 1700000400000,
	}, nil)
	if err != nil {
		t.Fatalf("retry operations rejection claim failed: %v", err)
	}
	if item.OperationsReviewerUserID == nil || *item.OperationsReviewerUserID != 8 || item.OperationsReviewerName != "第一次运营" || item.OperationsAuditRemark != "第一次原因" {
		t.Fatalf("retry claim overwrote first audit context: %+v", item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestExternalFinanceClaimPersistsFirstDecisionAndRejectsOppositeRetry(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	commandID := "salary-withdrawal:77:finance"
	firstImage := "https://example.com/first-proof.png"
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("yumi", uint(77), 1).
		WillReturnRows(externalFinanceWithdrawalRows("", "", 0, "", "", "", "", ""))
	mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	claimed, err := store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved,
		ApproverUserID: 9, ApproverName: "first-finance", AuditRemark: "first remark", AuditImageURL: firstImage,
		AuditCommandID: commandID, ApprovedAtMS: 1700000300000,
	}, nil)
	if err != nil {
		t.Fatalf("claim external finance decision failed: %v", err)
	}
	if claimed.ExternalFinanceClaimStatus == nil || *claimed.ExternalFinanceClaimStatus != model.WithdrawalExternalFinanceClaimStatusClaimed ||
		claimed.ExternalFinanceClaimDecision == nil || *claimed.ExternalFinanceClaimDecision != model.WithdrawalApplicationStatusApproved ||
		claimed.ApproverUserID == nil || *claimed.ApproverUserID != 9 || claimed.AuditImageURL != firstImage {
		t.Fatalf("external finance claim snapshot mismatch: %+v", claimed)
	}

	// 同决策换人重试只返回第一次快照，不执行 UPDATE。
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("yumi", uint(77), 1).
		WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
	mock.ExpectCommit()
	retried, err := store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved,
		ApproverUserID: 19, ApproverName: "retry-finance", AuditRemark: "changed remark", AuditImageURL: "https://example.com/changed.png",
		AuditCommandID: commandID, ApprovedAtMS: 1700000400000,
	}, nil)
	if err != nil {
		t.Fatalf("retry external finance claim failed: %v", err)
	}
	if retried.ApproverUserID == nil || *retried.ApproverUserID != 9 || retried.ApproverName != "first-finance" || retried.AuditRemark != "first remark" || retried.AuditImageURL != firstImage {
		t.Fatalf("retry overwrote first external finance claim: %+v", retried)
	}

	// 相反决策在来源回调之前拒绝，不允许复用同一 command 改写首次 claim。
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("yumi", uint(77), 1).
		WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "first remark", firstImage, commandID, ""))
	mock.ExpectRollback()
	_, err = store.ClaimExternalWithdrawalFinanceDecisionForApp("yumi", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusRejected,
		ApproverUserID: 19, ApproverName: "retry-finance", AuditRemark: "reject instead",
		AuditCommandID: commandID, ApprovedAtMS: 1700000400000,
	}, nil)
	if !errors.Is(err, ErrWithdrawalApplicationStageNotReviewable) {
		t.Fatalf("opposite external finance decision must be blocked, got %v", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestFinalizeExternalFinanceDecisionUsesClaimedTerminalContext(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	commandID := "salary-withdrawal:77:finance"
	transactionID := "external:" + commandID
	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("yumi", uint(77), 1).
		WillReturnRows(externalFinanceWithdrawalRows(model.WithdrawalExternalFinanceClaimStatusClaimed, model.WithdrawalApplicationStatusApproved, 9, "first-finance", "paid", "https://example.com/proof.png", commandID, ""))
	mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	item, err := store.FinalizeExternalWithdrawalFinanceDecisionForApp("yumi", 77, model.WithdrawalApplicationStatusApproved, commandID, transactionID, 1700000300001)
	if err != nil {
		t.Fatalf("finalize external finance decision failed: %v", err)
	}
	if item.Status != model.WithdrawalApplicationStatusApproved || item.ExternalFinanceClaimStatus == nil || *item.ExternalFinanceClaimStatus != model.WithdrawalExternalFinanceClaimStatusFinalized || item.AuditTransactionID != transactionID || item.ApproverUserID == nil || *item.ApproverUserID != 9 {
		t.Fatalf("external finance terminal snapshot mismatch: %+v", item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestWithdrawalOperationsRejectionClaimSerializesConcurrentFirstAndRetryRealMySQL(t *testing.T) {
	baseDSN := strings.TrimSpace(os.Getenv("WITHDRAWAL_REVIEW_MYSQL_TEST_DSN"))
	if baseDSN == "" {
		t.Skip("set WITHDRAWAL_REVIEW_MYSQL_TEST_DSN to run the real MySQL rejection-claim concurrency test")
	}
	db := newWithdrawalReviewMySQLTestDB(t, baseDSN)
	if _, err := db.Exec(`CREATE TABLE admin_user_withdrawal_applications (
		id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
		app_code VARCHAR(32) NOT NULL,
		user_id VARCHAR(64) NOT NULL,
		salary_asset_type VARCHAR(64) NOT NULL DEFAULT '',
		withdraw_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
		withdraw_amount_minor BIGINT NOT NULL DEFAULT 0,
		freeze_transaction_id VARCHAR(128) NOT NULL DEFAULT '',
		status VARCHAR(32) NOT NULL DEFAULT 'pending',
		operations_status VARCHAR(32) NOT NULL DEFAULT 'pending',
		operations_reviewer_user_id BIGINT UNSIGNED NULL,
		operations_reviewer_name VARCHAR(64) NOT NULL DEFAULT '',
		operations_audit_remark TEXT NULL,
		operations_audit_command_id VARCHAR(128) NOT NULL DEFAULT '',
		operations_audit_transaction_id VARCHAR(128) NOT NULL DEFAULT '',
		operations_reviewed_at_ms BIGINT NULL,
		created_at_ms BIGINT NOT NULL,
		updated_at_ms BIGINT NOT NULL,
		PRIMARY KEY (id)
	) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil {
		t.Fatalf("create withdrawal claim fixture table: %v", err)
	}
	if _, err := db.Exec(`INSERT INTO admin_user_withdrawal_applications
		(id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms)
		VALUES (77, 'lalu', '42001', 'HOST_SALARY_USD', 50.00, 5000, 'freeze-tx', 'pending', 'pending', 1700000000000, 1700000000000)`); err != nil {
		t.Fatalf("seed withdrawal claim fixture: %v", err)
	}
	gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: db, SkipInitializeWithVersion: true}), &gorm.Config{})
	if err != nil {
		t.Fatalf("open withdrawal claim gorm connection: %v", err)
	}
	store := New(gormDB)

	type claimResult struct {
		item *model.UserWithdrawalApplication
		err  error
	}
	start := make(chan struct{})
	results := make(chan claimResult, 2)
	var workers sync.WaitGroup
	for index, reviewerID := range []uint{8, 18} {
		index, reviewerID := index, reviewerID
		workers.Add(1)
		go func() {
			defer workers.Done()
			<-start
			item, err := store.ClaimWithdrawalOperationsRejectionForApp("lalu", 77, WithdrawalApplicationAuditInput{
				Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected,
				ApproverUserID: reviewerID, ApproverName: fmt.Sprintf("operator-%d", reviewerID), AuditRemark: fmt.Sprintf("reason-%d", reviewerID),
				AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: int64(1700000300000 + index),
			}, nil)
			results <- claimResult{item: item, err: err}
		}()
	}
	close(start)
	workers.Wait()
	close(results)

	var persistedReviewerID uint
	for result := range results {
		if result.err != nil {
			t.Fatalf("concurrent rejection claim failed: %v", result.err)
		}
		if result.item == nil || result.item.OperationsStatus != model.WithdrawalOperationsStatusRejecting || result.item.OperationsReviewerUserID == nil {
			t.Fatalf("concurrent rejection claim result mismatch: %+v", result.item)
		}
		if persistedReviewerID == 0 {
			persistedReviewerID = *result.item.OperationsReviewerUserID
		} else if persistedReviewerID != *result.item.OperationsReviewerUserID {
			t.Fatalf("concurrent retry observed different claim owners: first=%d result=%+v", persistedReviewerID, result.item)
		}
	}

	// 两个请求都可能在外部幂等动作后并发 finalize；相同行锁和 command/transaction 校验必须让二者返回同一终态。
	finalizeResults := make(chan error, 2)
	start = make(chan struct{})
	workers = sync.WaitGroup{}
	for range 2 {
		workers.Add(1)
		go func() {
			defer workers.Done()
			<-start
			_, err := store.FinalizeWithdrawalOperationsRejectionForApp("lalu", 77, "salary-withdrawal:77:operations", "release-tx", 1700000400000)
			finalizeResults <- err
		}()
	}
	close(start)
	workers.Wait()
	close(finalizeResults)
	for err := range finalizeResults {
		if err != nil {
			t.Fatalf("concurrent rejection finalize failed: %v", err)
		}
	}
	final, err := store.GetWithdrawalApplicationForApp("lalu", 77)
	if err != nil {
		t.Fatalf("read final rejection state: %v", err)
	}
	if final.Status != model.WithdrawalApplicationStatusRejected || final.OperationsStatus != model.WithdrawalOperationsStatusRejected || final.OperationsAuditTransactionID != "release-tx" || final.OperationsReviewerUserID == nil || *final.OperationsReviewerUserID != persistedReviewerID {
		t.Fatalf("concurrent rejection terminal mismatch: %+v", final)
	}
}

func TestReviewWithdrawalApplicationFinanceRejectsOperationsPendingBeforeAction(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectBegin()
	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE").
		WithArgs("fami", uint(77), 1).
		WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending))
	mock.ExpectRollback()

	callbackCalled := false
	_, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{
		Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved,
		ApproverUserID: 9, ApprovedAtMS: 1700000300000,
	}, func(model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) {
		callbackCalled = true
		return WithdrawalApplicationAuditEffect{}, nil
	})
	if !errors.Is(err, ErrWithdrawalApplicationStageNotReviewable) || callbackCalled {
		t.Fatalf("finance must reject operations-pending row before callback: err=%v callback=%t", err, callbackCalled)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func TestWithdrawalApplicationReviewErrorAllowsLegacySkippedInFinanceStage(t *testing.T) {
	t.Parallel()

	err := withdrawalApplicationReviewError(model.UserWithdrawalApplication{
		Status:           model.WithdrawalApplicationStatusPending,
		OperationsStatus: model.WithdrawalOperationsStatusSkipped,
	}, WithdrawalApplicationReviewStageFinance)
	if err != nil {
		t.Fatalf("legacy skipped application must remain finance-reviewable: %v", err)
	}
}

func TestGetWithdrawalApplicationForAppScopesByAppCode(t *testing.T) {
	store, mock, closeStore := newRepositorySQLMock(t)
	defer closeStore()

	mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications` WHERE app_code = \\? AND id = \\?").
		WithArgs("huwaa", uint(77), 1).
		WillReturnRows(sqlmock.NewRows([]string{
			"id",
			"app_code",
			"user_id",
			"salary_asset_type",
			"withdraw_amount",
			"withdraw_amount_minor",
			"withdraw_method",
			"withdraw_address",
			"freeze_command_id",
			"freeze_transaction_id",
			"audit_command_id",
			"audit_transaction_id",
			"status",
			"approver_user_id",
			"approver_name",
			"audit_remark",
			"audit_image_url",
			"approved_at_ms",
			"created_at_ms",
			"updated_at_ms",
		}).AddRow(
			77,
			"huwaa",
			"42001",
			"POINT",
			"9.50",
			int64(1000000),
			"usdt_trc20",
			"TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF",
			"cmd-point-freeze",
			"tx-freeze",
			"",
			"",
			"pending",
			nil,
			"",
			"",
			"",
			nil,
			int64(1700000000000),
			int64(1700000000000),
		))

	item, err := store.GetWithdrawalApplicationForApp("huwaa", 77)
	if err != nil {
		t.Fatalf("get withdrawal application for app failed: %v", err)
	}
	if item.ID != 77 || item.AppCode != "huwaa" || item.SalaryAssetType != "POINT" || item.FreezeCommandID != "cmd-point-freeze" {
		t.Fatalf("withdrawal app-scoped row mismatch: %+v", item)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations mismatch: %v", err)
	}
}

func newWithdrawalReviewMySQLTestDB(t *testing.T, baseDSN string) *sql.DB {
	t.Helper()
	baseConfig, err := mysqlDriver.ParseDSN(baseDSN)
	if err != nil {
		t.Fatalf("parse withdrawal review MySQL test DSN: %v", err)
	}
	databaseName := fmt.Sprintf("hy_admin_withdrawal_review_%d", time.Now().UnixNano())
	adminConfig := baseConfig.Clone()
	adminConfig.DBName = ""
	adminDB, err := sql.Open("mysql", adminConfig.FormatDSN())
	if err != nil {
		t.Fatalf("open withdrawal review MySQL admin connection: %v", err)
	}
	if _, err := adminDB.Exec("CREATE DATABASE `" + databaseName + "` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
		_ = adminDB.Close()
		t.Fatalf("create withdrawal review MySQL test database: %v", err)
	}
	testConfig := baseConfig.Clone()
	testConfig.DBName = databaseName
	db, err := sql.Open("mysql", testConfig.FormatDSN())
	if err != nil {
		_, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`")
		_ = adminDB.Close()
		t.Fatalf("open withdrawal review MySQL test connection: %v", err)
	}
	t.Cleanup(func() {
		_ = db.Close()
		_, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`")
		_ = adminDB.Close()
	})
	return db
}

func withdrawalApplicationRows(status string, operationsStatus string) *sqlmock.Rows {
	return withdrawalApplicationRowsWithOperationsContext(status, operationsStatus, 0, "", "")
}

func withdrawalApplicationRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows {
	var storedReviewerID any
	var reviewedAtMS any
	operationsCommandID := ""
	if reviewerID > 0 {
		storedReviewerID = reviewerID
		reviewedAtMS = int64(1700000300000)
		operationsCommandID = "salary-withdrawal:77:operations"
	}
	return sqlmock.NewRows([]string{
		"id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor",
		"withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id",
		"audit_command_id", "audit_transaction_id", "status", "operations_status",
		"approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms",
		"operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark",
		"operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms",
		"created_at_ms", "updated_at_ms",
	}).AddRow(
		77, "fami", "42001", "POINT", "9.50", int64(1000000),
		"usdt_trc20", "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", "cmd-point-freeze", "tx-freeze",
		"", "", status, operationsStatus, nil, "", "", "", nil,
		storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS,
		int64(1700000000000), int64(1700000000000),
	)
}

func externalFinanceWithdrawalRows(claimStatus string, claimDecision string, reviewerID uint, reviewerName string, remark string, imageURL string, commandID string, transactionID string) *sqlmock.Rows {
	var storedClaimStatus any
	var storedClaimDecision any
	var storedReviewerID any
	var approvedAtMS any
	if claimStatus != "" {
		storedClaimStatus = claimStatus
		storedClaimDecision = claimDecision
		storedReviewerID = reviewerID
		approvedAtMS = int64(1700000300000)
	}
	return sqlmock.NewRows([]string{
		"id", "app_code", "source_system", "source_application_id", "user_id", "salary_asset_type",
		"withdraw_amount", "withdraw_amount_minor", "withdraw_method", "withdraw_address", "status", "operations_status",
		"external_finance_claim_decision", "external_finance_claim_status", "approver_user_id", "approver_name",
		"audit_remark", "audit_image_url", "audit_command_id", "audit_transaction_id", "approved_at_ms", "created_at_ms", "updated_at_ms",
	}).AddRow(
		77, "yumi", "chatapp3", "88001", "42001", "SALARY", "84.00", int64(8400), "usdt_trc20", "TRON-address",
		model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusApproved,
		storedClaimDecision, storedClaimStatus, storedReviewerID, reviewerName, remark, imageURL, commandID, transactionID, approvedAtMS,
		int64(1700000000000), int64(1700000000000),
	)
}
