package main

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"regexp"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	domain "hyapp/services/activity-service/internal/domain/wheel"
)

func TestAuditRowCoinStates(t *testing.T) {
	tests := []struct {
		name      string
		prepare   func(sqlmock.Sqlmock, rewardPayload)
		wantState string
		wantItem  string
	}{
		{
			name: "fully paid",
			prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) {
				metadata, _ := json.Marshal(map[string]any{
					"target_user_id":    payload.UserID,
					"amount":            int64(200),
					"draw_id":           payload.DrawID,
					"wheel_id":          payload.WheelID,
					"selected_tier_id":  "coin-200",
					"reason":            "wheel_reward",
					"visible_region_id": payload.VisibleRegionID,
				})
				mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_reward:draw-1").
					WillReturnRows(coinAuditRows().AddRow("wtx-1", "wheel_reward", "succeeded", string(metadata)))
			},
			wantState: "fully_paid",
			wantItem:  "paid",
		},
		{
			name: "fully unpaid uses errors Is",
			prepare: func(mock sqlmock.Sqlmock, _ rewardPayload) {
				mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_reward:draw-1").
					WillReturnError(fmt.Errorf("wrapped wallet miss: %w", sql.ErrNoRows))
			},
			wantState: "fully_unpaid",
			wantItem:  "unpaid",
		},
		{
			name: "conflict on immutable amount",
			prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) {
				metadata, _ := json.Marshal(map[string]any{
					"target_user_id":    payload.UserID,
					"amount":            int64(201),
					"draw_id":           payload.DrawID,
					"wheel_id":          payload.WheelID,
					"selected_tier_id":  "coin-200",
					"reason":            "wheel_reward",
					"visible_region_id": payload.VisibleRegionID,
				})
				mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_reward:draw-1").
					WillReturnRows(coinAuditRows().AddRow("wtx-conflict", "wheel_reward", "succeeded", string(metadata)))
			},
			wantState: "conflict",
			wantItem:  "conflict",
		},
		{
			name: "conflict on biz type",
			prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) {
				metadata := matchingCoinMetadata(payload)
				mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_reward:draw-1").
					WillReturnRows(coinAuditRows().AddRow("wtx-wrong-biz", "task_reward", "succeeded", metadata))
			},
			wantState: "conflict",
			wantItem:  "conflict",
		},
		{
			name: "conflict on visible region",
			prepare: func(mock sqlmock.Sqlmock, payload rewardPayload) {
				metadata := matchingCoinMetadata(payload)
				var values map[string]any
				_ = json.Unmarshal([]byte(metadata), &values)
				values["visible_region_id"] = int64(99)
				mismatch, _ := json.Marshal(values)
				mock.ExpectQuery(regexp.QuoteMeta(coinAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_reward:draw-1").
					WillReturnRows(coinAuditRows().AddRow("wtx-wrong-region", "wheel_reward", "succeeded", string(mismatch)))
			},
			wantState: "conflict",
			wantItem:  "conflict",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("create sqlmock: %v", err)
			}
			defer db.Close()
			payload := rewardPayload{
				AppCode:         "fami",
				DrawID:          "draw-1",
				UserID:          1001,
				WheelID:         "classic",
				VisibleRegionID: 9,
				Rewards:         []domain.DrawReward{{SelectedTierID: "coin-200", RewardType: domain.RewardTypeCoin, RewardCoins: 200}},
			}
			test.prepare(mock, payload)
			audit, err := auditRow(context.Background(), db, "hyapp_wallet", auditOutboxRow(t, payload))
			if err != nil {
				t.Fatalf("auditRow returned error: %v", err)
			}
			if audit.State != test.wantState || len(audit.Items) != 1 || audit.Items[0].State != test.wantItem {
				t.Fatalf("unexpected audit: %+v", audit)
			}
			if err := mock.ExpectationsWereMet(); err != nil {
				t.Fatalf("unmet SQL expectations: %v", err)
			}
		})
	}
}

func TestAuditRowResourceStates(t *testing.T) {
	tests := []struct {
		name      string
		prepare   func(sqlmock.Sqlmock)
		wantState string
		wantItem  string
	}{
		{
			name: "fully paid",
			prepare: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_resource:draw-1").
					WillReturnRows(resourceAuditRows().AddRow("rgr-1", "succeeded", int64(1001), "wheel_reward", "resource", "326", "wheel_reward", int64(1001), int64(2), int64(5000), int64(326)))
			},
			wantState: "fully_paid",
			wantItem:  "paid",
		},
		{
			name: "fully unpaid uses errors Is",
			prepare: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_resource:draw-1").
					WillReturnError(fmt.Errorf("wrapped resource miss: %w", sql.ErrNoRows))
			},
			wantState: "fully_unpaid",
			wantItem:  "unpaid",
		},
		{
			name: "conflict rejects legacy active status",
			prepare: func(mock sqlmock.Sqlmock) {
				mock.ExpectQuery(regexp.QuoteMeta(resourceAuditQuery("hyapp_wallet"))).
					WithArgs("fami", "wheel_resource:draw-1").
					WillReturnRows(resourceAuditRows().AddRow("rgr-conflict", "active", int64(1001), "wheel_reward", "resource", "326", "wheel_reward", int64(1001), int64(2), int64(5000), int64(326)))
			},
			wantState: "conflict",
			wantItem:  "conflict",
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("create sqlmock: %v", err)
			}
			defer db.Close()
			payload := rewardPayload{
				AppCode: "fami",
				DrawID:  "draw-1",
				UserID:  1001,
				WheelID: "classic",
				Rewards: []domain.DrawReward{{
					SelectedTierID: "gift-326",
					RewardType:     domain.RewardTypeGift,
					RewardID:       "326",
					RewardCount:    2,
					MetadataJSON:   `{"resource_id":326,"duration_ms":5000}`,
				}},
			}
			test.prepare(mock)
			audit, err := auditRow(context.Background(), db, "hyapp_wallet", auditOutboxRow(t, payload))
			if err != nil {
				t.Fatalf("auditRow returned error: %v", err)
			}
			if audit.State != test.wantState || len(audit.Items) != 1 || audit.Items[0].State != test.wantItem {
				t.Fatalf("unexpected audit: %+v", audit)
			}
			if err := mock.ExpectationsWereMet(); err != nil {
				t.Fatalf("unmet SQL expectations: %v", err)
			}
		})
	}
}

func TestLoadUnsettledRowsUsesAppScopedIndexesAndGlobalLimit(t *testing.T) {
	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatalf("create sqlmock: %v", err)
	}
	defer db.Close()
	const limit = 3
	columns := []string{"app_code", "outbox_id", "payload", "status", "retry_count", "created_at_ms"}
	indexes := outboxAuditIndexes{Retry: outboxAuditIndexLuckyRetry, Lock: outboxAuditIndexLuckyLock}
	retryQuery, err := unsettledRetryQuery(indexes.Retry)
	if err != nil {
		t.Fatalf("build retry query: %v", err)
	}
	deliveringQuery, err := unsettledDeliveringQuery(indexes.Lock)
	if err != nil {
		t.Fatalf("build delivering query: %v", err)
	}

	// normalizeAuditAppCodes sorts and deduplicates, so SQL order is deterministic regardless of flag order.
	mock.ExpectQuery(regexp.QuoteMeta(retryQuery)).
		WithArgs("fami", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusPending, domain.OutboxStatusRetryable, limit).
		WillReturnRows(sqlmock.NewRows(columns).
			AddRow("fami", "fami-pending", []byte(`{}`), domain.OutboxStatusPending, 0, int64(20)))
	mock.ExpectQuery(regexp.QuoteMeta(deliveringQuery)).
		WithArgs("fami", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusDelivering, limit).
		WillReturnRows(sqlmock.NewRows(columns).
			AddRow("fami", "fami-delivering", []byte(`{}`), domain.OutboxStatusDelivering, 1, int64(10)))
	mock.ExpectQuery(regexp.QuoteMeta(retryQuery)).
		WithArgs("lalu", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusPending, domain.OutboxStatusRetryable, 1).
		WillReturnRows(sqlmock.NewRows(columns).
			AddRow("lalu", "lalu-oldest", []byte(`{}`), domain.OutboxStatusRetryable, 2, int64(5)))
	mock.ExpectQuery(regexp.QuoteMeta(deliveringQuery)).
		WithArgs("lalu", domain.EventTypeWheelRewardSettlement, domain.OutboxStatusDelivering, 1).
		WillReturnRows(sqlmock.NewRows(columns).
			AddRow("lalu", "lalu-delivering", []byte(`{}`), domain.OutboxStatusDelivering, 1, int64(15)))

	rows, err := loadUnsettledRows(context.Background(), db, indexes, []string{"lalu", "fami", "fami"}, limit)
	if err != nil {
		t.Fatalf("loadUnsettledRows returned error: %v", err)
	}
	if len(rows) != limit {
		t.Fatalf("global limit mismatch: got=%d want=%d rows=%+v", len(rows), limit, rows)
	}
	wantIDs := []string{"fami-delivering", "fami-pending", "lalu-oldest"}
	for index, wantID := range wantIDs {
		if rows[index].OutboxID != wantID {
			t.Fatalf("deterministic order[%d]=%q want=%q rows=%+v", index, rows[index].OutboxID, wantID, rows)
		}
	}
	if err := mock.ExpectationsWereMet(); err != nil {
		t.Fatalf("unmet SQL expectations: %v", err)
	}
}

func TestDiscoverOutboxAuditIndexes(t *testing.T) {
	indexColumns := []string{"index_name", "seq_in_index", "column_name"}
	addPrefix := func(rows *sqlmock.Rows, indexName string, columns ...string) *sqlmock.Rows {
		for position, column := range columns {
			rows.AddRow(indexName, position+1, column)
		}
		return rows
	}
	tests := []struct {
		name      string
		rows      func() *sqlmock.Rows
		want      outboxAuditIndexes
		wantError bool
	}{
		{
			name: "prefers current event indexes",
			rows: func() *sqlmock.Rows {
				rows := sqlmock.NewRows(indexColumns)
				addPrefix(rows, "idx_activity_outbox_lucky_retry", "app_code", "event_type", "status")
				addPrefix(rows, "idx_activity_outbox_event_retry", "app_code", "event_type", "status")
				addPrefix(rows, "idx_activity_outbox_lucky_lock", "app_code", "event_type", "status")
				addPrefix(rows, "idx_activity_outbox_event_lock", "app_code", "event_type", "status")
				return rows
			},
			want: outboxAuditIndexes{Retry: outboxAuditIndexEventRetry, Lock: outboxAuditIndexEventLock},
		},
		{
			name: "falls back to deployed lucky indexes",
			rows: func() *sqlmock.Rows {
				rows := sqlmock.NewRows(indexColumns)
				addPrefix(rows, "idx_activity_outbox_lucky_retry", "app_code", "event_type", "status")
				addPrefix(rows, "idx_activity_outbox_lucky_lock", "app_code", "event_type", "status")
				return rows
			},
			want: outboxAuditIndexes{Retry: outboxAuditIndexLuckyRetry, Lock: outboxAuditIndexLuckyLock},
		},
		{
			name: "rejects wrong prefix without an index-free fallback",
			rows: func() *sqlmock.Rows {
				rows := sqlmock.NewRows(indexColumns)
				addPrefix(rows, "idx_activity_outbox_event_retry", "event_type", "app_code", "status")
				addPrefix(rows, "idx_activity_outbox_event_lock", "app_code", "event_type", "status")
				return rows
			},
			wantError: true,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			db, mock, err := sqlmock.New()
			if err != nil {
				t.Fatalf("create sqlmock: %v", err)
			}
			defer db.Close()
			mock.ExpectQuery(regexp.QuoteMeta(outboxAuditIndexDiscoveryQuery())).WillReturnRows(test.rows())
			got, err := discoverOutboxAuditIndexes(context.Background(), db)
			if test.wantError {
				if err == nil {
					t.Fatalf("discoverOutboxAuditIndexes returned indexes=%+v, want error", got)
				}
			} else if err != nil || got != test.want {
				t.Fatalf("discoverOutboxAuditIndexes=(%+v, %v), want=(%+v, nil)", got, err, test.want)
			}
			if err := mock.ExpectationsWereMet(); err != nil {
				t.Fatalf("unmet SQL expectations: %v", err)
			}
		})
	}
}

func TestUnsettledQueriesRejectNonWhitelistedIndexes(t *testing.T) {
	if _, err := unsettledRetryQuery(outboxAuditIndexLuckyLock); err == nil {
		t.Fatal("retry query accepted a lock index")
	}
	if _, err := unsettledDeliveringQuery(outboxAuditIndexLuckyRetry); err == nil {
		t.Fatal("delivering query accepted a retry index")
	}
	if _, err := unsettledRetryQuery(outboxAuditIndex(255)); err == nil {
		t.Fatal("retry query accepted an arbitrary identifier enum")
	}
}

func TestNormalizeAuditAppCodesRequiresRepeatedExplicitScope(t *testing.T) {
	if _, err := normalizeAuditAppCodes(nil); err == nil {
		t.Fatal("missing --app-code must be rejected before opening or querying MySQL")
	}
	values, err := normalizeAuditAppCodes([]string{" LALU ", "fami", "lalu", "huwaa"})
	if err != nil {
		t.Fatalf("normalizeAuditAppCodes returned error: %v", err)
	}
	want := []string{"fami", "huwaa", "lalu"}
	if len(values) != len(want) {
		t.Fatalf("normalized app count=%d want=%d values=%+v", len(values), len(want), values)
	}
	for index := range want {
		if values[index] != want[index] {
			t.Fatalf("normalized app[%d]=%q want=%q", index, values[index], want[index])
		}
	}
}

func auditOutboxRow(t *testing.T, payload rewardPayload) outboxRow {
	t.Helper()
	body, err := json.Marshal(payload)
	if err != nil {
		t.Fatalf("marshal payload: %v", err)
	}
	return outboxRow{AppCode: payload.AppCode, OutboxID: "wheel_reward_" + payload.DrawID, Payload: body, Status: domain.OutboxStatusPending}
}

func resourceAuditRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{
		"grant_id",
		"status",
		"target_user_id",
		"grant_source",
		"grant_subject_type",
		"grant_subject_id",
		"reason",
		"operator_user_id",
		"quantity",
		"duration_ms",
		"resource_id",
	})
}

func coinAuditRows() *sqlmock.Rows {
	return sqlmock.NewRows([]string{"transaction_id", "biz_type", "status", "metadata_json"})
}

func matchingCoinMetadata(payload rewardPayload) string {
	body, _ := json.Marshal(map[string]any{
		"target_user_id":    payload.UserID,
		"amount":            int64(200),
		"draw_id":           payload.DrawID,
		"wheel_id":          payload.WheelID,
		"selected_tier_id":  "coin-200",
		"reason":            "wheel_reward",
		"visible_region_id": payload.VisibleRegionID,
	})
	return string(body)
}
