package mysql

import (
	"context"
	"testing"
	"time"

	"github.com/DATA-DOG/go-sqlmock"
	"hyapp/pkg/appcode"
	"hyapp/pkg/xerr"
	domain "hyapp/services/activity-service/internal/domain/activitytemplate"
)

var activityTemplateRewardLeaseTestColumns = []string{
	"app_code", "reward_job_id", "template_id", "template_code", "version_no", "board_type", "period_key",
	"rank_no", "user_id", "score", "resource_group_id", "reward_snapshot_id", "reward_snapshot_json",
	"wallet_command_id", "wallet_grant_id", "status", "failure_reason", "attempt_count", "created_at_ms",
	"updated_at_ms", "granted_at_ms", "max_attempts", "next_retry_at_ms", "locked_by", "lock_until_ms",
}

var activityTemplateRankPeriodLeaseTestColumns = []string{
	"app_code", "template_id", "template_code", "version_no", "board_type", "period_key",
	"period_start_ms", "period_end_ms", "settle_after_ms", "leaderboard_size", "status", "settled_at_ms",
}

func TestClaimDueActivityTemplateRankPeriodsExcludesMaterializedBlockedJobsBeforePending(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	const nowMS = int64(100_000)
	lockUntilMS := nowMS + time.Minute.Milliseconds()

	mock.ExpectBegin()
	mock.ExpectQuery(`(?s)FROM activity_template_rank_periods p.+p.status = \?.+p.status = \?.+NOT EXISTS \(.+FROM activity_template_reward_jobs j.+LIMIT \? FOR UPDATE SKIP LOCKED`).
		WithArgs("hyapp", nowMS, domain.PeriodStatusPending, domain.PeriodStatusSettling, nowMS, 50).
		WillReturnRows(sqlmock.NewRows(activityTemplateRankPeriodLeaseTestColumns).
			AddRow("hyapp", "pending-after-50-blocked", "summer", 3, domain.BoardTypeTotal, "total", 1, 2, 3, 100, domain.PeriodStatusPending, 0))
	mock.ExpectExec(`(?s)UPDATE activity_template_rank_periods.+SET status = \?, locked_by = \?, lock_until_ms = \?.+WHERE app_code = \?`).
		WithArgs(domain.PeriodStatusSettling, "settler", lockUntilMS, nowMS, "hyapp", "pending-after-50-blocked", int64(3), domain.BoardTypeTotal, "total").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	periods, err := repository.ClaimDueActivityTemplateRankPeriods(ctx, "settler", nowMS, time.Minute, 50)
	if err != nil {
		t.Fatalf("ClaimDueActivityTemplateRankPeriods(): %v", err)
	}
	if len(periods) != 1 || periods[0].TemplateID != "pending-after-50-blocked" {
		t.Fatalf("claimed periods = %+v, want only actionable pending period", periods)
	}
	if len(periods) == 50 {
		t.Fatal("dead/backoff periods must not fill the discovery batch")
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestClaimFinalizableActivityTemplateRankPeriodsOnlySelectsAllGrantedRewardingPeriods(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	const nowMS = int64(200_000)

	mock.ExpectBegin()
	mock.ExpectQuery(`(?s)FROM activity_template_rank_periods p.+p.status = \?.+p.status = \? AND EXISTS \(.+FROM activity_template_reward_jobs materialized.+NOT EXISTS \(.+FROM activity_template_reward_jobs j.+j.status <> \?.+LIMIT \? FOR UPDATE SKIP LOCKED`).
		WithArgs("hyapp", nowMS, domain.PeriodStatusRewarding, domain.PeriodStatusSettling, domain.ClaimStatusGranted, 50).
		WillReturnRows(sqlmock.NewRows(activityTemplateRankPeriodLeaseTestColumns))
	mock.ExpectCommit()

	periods, err := repository.ClaimFinalizableActivityTemplateRankPeriods(ctx, "finalizer", nowMS, time.Minute, 50)
	if err != nil {
		t.Fatalf("ClaimFinalizableActivityTemplateRankPeriods(): %v", err)
	}
	if len(periods) != 0 {
		t.Fatalf("finalizable periods = %+v, want none while jobs are blocked", periods)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestClaimActivityTemplateRewardJobsUsesFiniteLeasesAndRecoversExpiredRunning(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	nowMS := int64(1_000)
	lockUntilMS := nowMS + (30 * time.Second).Milliseconds()

	mock.ExpectBegin()
	rows := sqlmock.NewRows(activityTemplateRewardLeaseTestColumns).
		AddRow("hyapp", "job_failed", "tpl_1", "summer", 2, "daily", "2026-07-15", 1, 1001, 900, 81, "", "null", "wallet-stable-failed", "", domain.ClaimStatusFailed, "wallet unavailable", 1, 100, 900, 0, 8, 900, "", 0).
		AddRow("hyapp", "job_crashed", "tpl_1", "summer", 2, "daily", "2026-07-15", 2, 1002, 800, 82, "", "null", "wallet-stable-crashed", "", domain.ClaimStatusRunning, "", 2, 100, 900, 0, 8, 0, "dead-worker", 999).
		AddRow("hyapp", "job_exhausted", "tpl_1", "summer", 2, "daily", "2026-07-15", 3, 1003, 700, 83, "", "null", "wallet-stable-exhausted", "", domain.ClaimStatusRunning, "", 8, 100, 900, 0, 8, 0, "dead-worker", 999)
	mock.ExpectQuery(`(?s)SELECT app_code, reward_job_id.+FROM activity_template_reward_jobs.+FOR UPDATE SKIP LOCKED`).
		WithArgs("hyapp", domain.ClaimStatusPending, domain.ClaimStatusFailed, nowMS, domain.ClaimStatusRunning, nowMS, 3).
		WillReturnRows(rows)
	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+attempt_count = attempt_count \+ 1.+WHERE app_code = \? AND reward_job_id = \?`).
		WithArgs(domain.ClaimStatusRunning, "settler-a", lockUntilMS, nowMS, "hyapp", "job_failed").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+attempt_count = attempt_count \+ 1.+WHERE app_code = \? AND reward_job_id = \?`).
		WithArgs(domain.ClaimStatusRunning, "settler-a", lockUntilMS, nowMS, "hyapp", "job_crashed").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+failure_reason = CASE WHEN failure_reason = ''.+WHERE app_code = \? AND reward_job_id = \?`).
		WithArgs(activityTemplateRewardJobStatusDead, "maximum delivery attempts exhausted", nowMS, "hyapp", "job_exhausted").
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	jobs, err := repository.ClaimActivityTemplateRewardJobs(ctx, "settler-a", nowMS, 30*time.Second, 3)
	if err != nil {
		t.Fatalf("ClaimActivityTemplateRewardJobs(): %v", err)
	}
	if len(jobs) != 2 {
		t.Fatalf("claimed jobs = %+v, want failed and expired-running jobs only", jobs)
	}
	if jobs[0].RewardJobID != "job_failed" || jobs[0].AttemptCount != 2 || jobs[0].WalletCommandID != "wallet-stable-failed" {
		t.Fatalf("failed retry claim = %+v", jobs[0])
	}
	if jobs[1].RewardJobID != "job_crashed" || jobs[1].AttemptCount != 3 || jobs[1].WalletCommandID != "wallet-stable-crashed" {
		t.Fatalf("expired running recovery = %+v", jobs[1])
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMarkActivityTemplateRewardJobFailedWithLeaseSchedulesStableBackoff(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	nowMS := int64(20_000)
	nextRetryAtMS := activityTemplateRewardRetryAtMS("job_retry", 2, nowMS)

	mock.ExpectBegin()
	mock.ExpectQuery(`(?s)SELECT attempt_count, max_attempts FROM activity_template_reward_jobs.+FOR UPDATE`).
		WithArgs("hyapp", "job_retry", domain.ClaimStatusRunning, "settler-b", nowMS).
		WillReturnRows(sqlmock.NewRows([]string{"attempt_count", "max_attempts"}).AddRow(2, 8))
	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+next_retry_at_ms = \?.+locked_by = ''.+lock_until_ms = 0.+locked_by = \?.+lock_until_ms > \?`).
		WithArgs(domain.ClaimStatusFailed, "wallet timeout", nextRetryAtMS, nowMS, "hyapp", "job_retry", domain.ClaimStatusRunning, "settler-b", nowMS).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	if err := repository.MarkActivityTemplateRewardJobFailedWithLease(ctx, "job_retry", "settler-b", "wallet timeout", nowMS); err != nil {
		t.Fatalf("MarkActivityTemplateRewardJobFailedWithLease(): %v", err)
	}
	if nextRetryAtMS <= nowMS+activityTemplateRewardJobRetryBase.Milliseconds() {
		t.Fatalf("next retry = %d, want exponential delay plus jitter after %d", nextRetryAtMS, nowMS)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestMarkActivityTemplateRewardJobGrantedRejectsLostLeaseOwner(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	nowMS := int64(50_000)

	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+status = \?.+locked_by = ''.+WHERE app_code = \?.+locked_by = \?.+lock_until_ms > \?`).
		WithArgs(domain.ClaimStatusGranted, "grant_1", nowMS, nowMS, "hyapp", "job_taken_over", domain.ClaimStatusRunning, "stale-worker", nowMS).
		WillReturnResult(sqlmock.NewResult(0, 0))
	err = repository.MarkActivityTemplateRewardJobGrantedWithLease(ctx, "job_taken_over", "stale-worker", "grant_1", nowMS)
	if xerr.CodeOf(err) != xerr.Conflict {
		t.Fatalf("lost owner error = %v, want conflict", err)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestClaimActivityTemplateRewardJobForManualRetryRevivesDeadWithoutChangingWalletCommand(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("sqlmock.New(): %v", err)
	}
	t.Cleanup(func() { _ = db.Close() })
	repository := &Repository{db: db}
	ctx := appcode.WithContext(context.Background(), "hyapp")
	nowMS := int64(80_000)
	lockUntilMS := nowMS + time.Minute.Milliseconds()

	mock.ExpectBegin()
	rows := sqlmock.NewRows(activityTemplateRewardLeaseTestColumns).
		AddRow("hyapp", "job_dead", "tpl_2", "anniversary", 4, "total", "total", 1, 2001, 9_000, 91, "", "null", "wallet-command-never-changes", "", activityTemplateRewardJobStatusDead, "attempts exhausted", 8, 100, 79_000, 0, 8, 0, "", 0)
	mock.ExpectQuery(`(?s)SELECT app_code, reward_job_id.+FROM activity_template_reward_jobs.+FOR UPDATE`).
		WithArgs("hyapp", "job_dead").WillReturnRows(rows)
	mock.ExpectExec(`(?s)UPDATE activity_template_reward_jobs.+max_attempts = \?.+locked_by = \?.+lock_until_ms = \?.+status IN \(\?, \?\)`).
		WithArgs(domain.ClaimStatusRunning, int32(9), "admin-retry-90001", lockUntilMS, nowMS, "hyapp", "job_dead", domain.ClaimStatusFailed, activityTemplateRewardJobStatusDead).
		WillReturnResult(sqlmock.NewResult(0, 1))
	mock.ExpectCommit()

	job, err := repository.ClaimActivityTemplateRewardJobForManualRetry(ctx, "tpl_2", "job_dead", "admin-retry-90001", nowMS, time.Minute)
	if err != nil {
		t.Fatalf("ClaimActivityTemplateRewardJobForManualRetry(): %v", err)
	}
	if job.Status != domain.ClaimStatusRunning || job.AttemptCount != 9 || job.MaxAttempts != 9 || job.NextRetryAtMS != 0 ||
		job.WalletCommandID != "wallet-command-never-changes" {
		t.Fatalf("manual retry job = %+v", job)
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("sql expectations: %v", err)
	}
}

func TestActivityTemplateRewardRetryBackoffIsDeterministicBoundedAndIncreasing(t *testing.T) {
	nowMS := int64(1_000_000)
	previous := nowMS
	for attempt := int32(1); attempt <= activityTemplateRewardJobDefaultAttempts; attempt++ {
		first := activityTemplateRewardRetryAtMS("job-stable", attempt, nowMS)
		second := activityTemplateRewardRetryAtMS("job-stable", attempt, nowMS)
		if first != second {
			t.Fatalf("attempt %d jitter is not deterministic: %d != %d", attempt, first, second)
		}
		if first <= previous {
			t.Fatalf("attempt %d retry %d did not increase after %d", attempt, first, previous)
		}
		if first-nowMS > activityTemplateRewardJobRetryHardCap.Milliseconds() {
			t.Fatalf("attempt %d delay %d exceeds hard cap", attempt, first-nowMS)
		}
		previous = first
	}
	if capped := activityTemplateRewardRetryAtMS("job-stable", 100, nowMS) - nowMS; capped > activityTemplateRewardJobRetryHardCap.Milliseconds() {
		t.Fatalf("high-attempt delay %d exceeds hard cap", capped)
	}
}
