//go:build integration

package codes_test

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"strconv"
	"sync"
	"testing"
	"time"

	// MySQL driver registration.
	_ "github.com/go-sql-driver/mysql"
	"github.com/redis/go-redis/v9"
	"github.com/testcontainers/testcontainers-go"
	tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
	tcredis "github.com/testcontainers/testcontainers-go/modules/redis"

	"github.com/wangjia/pangolin/server/internal/codes"
)

// --------------------------------------------------------------------------
// Container setup helpers
// --------------------------------------------------------------------------

func setupMySQL(t *testing.T) *sql.DB {
	t.Helper()
	ctx := context.Background()

	ctr, err := tcmysql.Run(ctx, "mysql:8.0",
		tcmysql.WithDatabase("pangolin_test"),
		tcmysql.WithUsername("root"),
		tcmysql.WithPassword("test"),
	)
	testcontainers.CleanupContainer(t, ctr)
	if err != nil {
		t.Fatalf("mysql container: %v", err)
	}

	dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
	if err != nil {
		t.Fatalf("mysql dsn: %v", err)
	}

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		t.Fatalf("open mysql: %v", err)
	}
	t.Cleanup(func() { db.Close() })

	if err := applySchema(db); err != nil {
		t.Fatalf("schema: %v", err)
	}
	return db
}

func setupRedis(t *testing.T) *redis.Client {
	t.Helper()
	ctx := context.Background()

	ctr, err := tcredis.Run(ctx, "redis:7-alpine")
	testcontainers.CleanupContainer(t, ctr)
	if err != nil {
		t.Fatalf("redis container: %v", err)
	}

	addr, err := ctr.ConnectionString(ctx)
	if err != nil {
		t.Fatalf("redis addr: %v", err)
	}
	// Remove the redis:// scheme prefix if present.
	addr = stripScheme(addr)

	rdb := redis.NewClient(&redis.Options{Addr: addr})
	t.Cleanup(func() { rdb.Close() })
	return rdb
}

func stripScheme(s string) string {
	for _, prefix := range []string{"redis://", "rediss://"} {
		if len(s) > len(prefix) && s[:len(prefix)] == prefix {
			return s[len(prefix):]
		}
	}
	return s
}

// applySchema runs the minimum DDL required by the codes package.
func applySchema(db *sql.DB) error {
	stmts := []string{
		`CREATE TABLE IF NOT EXISTS plans (
			id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			code         ENUM('free','pro','team') NOT NULL UNIQUE,
			max_devices  INT NOT NULL DEFAULT 1,
			daily_minutes INT NULL,
			ad_gate      BOOLEAN NOT NULL DEFAULT FALSE
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS code_batches (
			id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			channel    ENUM('store','tg','line','manual') NOT NULL,
			created_by VARCHAR(64) NOT NULL,
			note       VARCHAR(255) NULL,
			created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS codes (
			id            BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			code_hash     CHAR(64) NOT NULL UNIQUE,
			plan_id       BIGINT UNSIGNED NOT NULL,
			duration_days INT NOT NULL,
			batch_id      BIGINT UNSIGNED NOT NULL,
			status        ENUM('unused','redeemed','void') NOT NULL DEFAULT 'unused',
			redeemed_by   BIGINT UNSIGNED NULL,
			redeemed_at   DATETIME(6) NULL,
			FOREIGN KEY (plan_id) REFERENCES plans(id),
			FOREIGN KEY (batch_id) REFERENCES code_batches(id),
			INDEX idx_status (status)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS subscriptions (
			id         BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			user_id    BIGINT UNSIGNED NOT NULL,
			plan_id    BIGINT UNSIGNED NOT NULL,
			expires_at DATETIME(6) NOT NULL,
			source     ENUM('trial','code') NOT NULL,
			created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
			FOREIGN KEY (plan_id) REFERENCES plans(id),
			INDEX idx_user_exp (user_id, expires_at)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		`CREATE TABLE IF NOT EXISTS audit_log (
			id     BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
			actor  VARCHAR(64) NOT NULL,
			action VARCHAR(64) NOT NULL,
			target VARCHAR(128) NOT NULL,
			meta   JSON NULL,
			at     DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
			INDEX idx_at (at)
		) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,

		// Seed plan rows.
		`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
		 VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
	}
	for _, stmt := range stmts {
		if _, err := db.Exec(stmt); err != nil {
			return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
		}
	}
	return nil
}

// insertCode inserts a test code directly into the DB and returns it.
func insertCode(t *testing.T, db *sql.DB, plaintext string, plan codes.PlanCode, durationDays int) {
	t.Helper()
	canonical, err := codes.Canonicalize(plaintext)
	if err != nil {
		t.Fatalf("Canonicalize: %v", err)
	}
	hash := codes.Hash(canonical)

	var planID int64
	if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, string(plan)).Scan(&planID); err != nil {
		t.Fatalf("plan lookup: %v", err)
	}

	_, err = db.Exec(
		`INSERT INTO code_batches (channel, created_by, created_at) VALUES ('manual','test',UTC_TIMESTAMP(6))`,
	)
	if err != nil {
		t.Fatalf("batch insert: %v", err)
	}
	var batchID int64
	db.QueryRow(`SELECT LAST_INSERT_ID()`).Scan(&batchID)

	_, err = db.Exec(
		`INSERT INTO codes (code_hash, plan_id, duration_days, batch_id) VALUES (?,?,?,?)`,
		hash, planID, durationDays, batchID,
	)
	if err != nil {
		t.Fatalf("code insert: %v", err)
	}
}

// --------------------------------------------------------------------------
// Integration tests
// --------------------------------------------------------------------------

// TestRedeemConcurrentSingleWinner verifies that when N goroutines try to
// redeem the same code simultaneously, exactly 1 succeeds and N-1 fail.
func TestRedeemConcurrentSingleWinner(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	const N = 20
	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanPro, 30)

	results := make([]bool, N) // true = success
	var wg sync.WaitGroup
	wg.Add(N)
	for i := 0; i < N; i++ {
		go func(idx int) {
			defer wg.Done()
			userID := int64(1000 + idx) // different user for each goroutine
			_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
				UserID: userID,
				Code:   code,
			})
			results[idx] = (apiErr == nil)
		}(i)
	}
	wg.Wait()

	winners := 0
	for _, ok := range results {
		if ok {
			winners++
		}
	}
	if winners != 1 {
		t.Errorf("expected exactly 1 winner, got %d", winners)
	}
}

// TestRedeemIdempotent verifies that the same user re-submitting the same
// code gets a 200 with Idempotent=true.
func TestRedeemIdempotent(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanPro, 30)

	const userID = int64(2001)

	// First redemption.
	r1, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
	if apiErr != nil {
		t.Fatalf("first redeem failed: %v", apiErr)
	}
	if r1.Idempotent {
		t.Error("first redeem should not be idempotent")
	}

	// Second redemption – same user, same code.
	r2, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: userID, Code: code})
	if apiErr2 != nil {
		t.Fatalf("second redeem failed: %v", apiErr2)
	}
	if !r2.Idempotent {
		t.Error("second redeem should be idempotent")
	}
}

// TestRedeemOtherUserFails verifies that a different user trying to redeem an
// already-redeemed code gets CODE_REDEEMED and that the failure is counted.
func TestRedeemOtherUserFails(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanPro, 30)

	// User A redeems.
	_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3001, Code: code})
	if apiErr != nil {
		t.Fatalf("user A redeem: %v", apiErr)
	}

	// User B tries the same code.
	_, apiErr2 := svc.Redeem(context.Background(), codes.RedeemRequest{UserID: 3002, Code: code})
	if apiErr2 == nil {
		t.Fatal("user B should have failed")
	}
	if apiErr2.Code != "CODE_REDEEMED" {
		t.Errorf("expected CODE_REDEEMED, got %s", apiErr2.Code)
	}
}

// TestRedeemFailLock verifies that 5 consecutive failures lock the account for
// 1 hour and subsequent attempts return ACCOUNT_LOCKED.
func TestRedeemFailLock(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	// Use a very short lock for the test (we won't wait).
	svc := codes.NewService(store, rdb, 5, time.Hour)

	const userID = int64(4001)
	// Attempt 5 redemptions with invalid codes.
	for i := 0; i < 5; i++ {
		fakeCode, _ := codes.GenerateCode() // valid format but not in DB
		_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
			UserID: userID,
			Code:   fakeCode,
		})
		if apiErr == nil {
			t.Fatalf("attempt %d: expected failure", i+1)
		}
		if apiErr.Code == "ACCOUNT_LOCKED" {
			t.Fatalf("locked too early at attempt %d", i+1)
		}
	}

	// 6th attempt should return ACCOUNT_LOCKED.
	fakeCode, _ := codes.GenerateCode()
	_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
		UserID: userID,
		Code:   fakeCode,
	})
	if apiErr == nil || apiErr.Code != "ACCOUNT_LOCKED" {
		t.Errorf("expected ACCOUNT_LOCKED, got %v", apiErr)
	}
}

// TestSamePlanExtension verifies that redeeming a pro code when the user
// already has a pro subscription extends the existing subscription.
func TestSamePlanExtension(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	const userID = int64(5001)

	// Pre-insert a pro subscription expiring in 10 days.
	var planID int64
	db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&planID)
	initial := time.Now().UTC().AddDate(0, 0, 10)
	db.Exec(
		`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,'trial')`,
		userID, planID, initial,
	)

	// Redeem a 30-day pro code.
	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanPro, 30)

	result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
		UserID: userID,
		Code:   code,
	})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}

	// expires_at should be initial + 30 days (since initial > now).
	wantExpiry := initial.AddDate(0, 0, 30)
	diff := result.ExpiresAt.Sub(wantExpiry)
	if diff < -2*time.Second || diff > 2*time.Second {
		t.Errorf("expires_at = %v, want ≈%v (diff=%v)", result.ExpiresAt, wantExpiry, diff)
	}
}

// TestCrossPlanNewSubscription verifies that redeeming a team code when the
// user only has a pro subscription creates a new team subscription.
func TestCrossPlanNewSubscription(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	const userID = int64(6001)

	// Pre-insert a pro subscription.
	var proID int64
	db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID)
	db.Exec(
		`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,'2099-01-01','trial')`,
		userID, proID,
	)

	// Redeem a 30-day team code.
	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanTeam, 30)

	result, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
		UserID: userID,
		Code:   code,
	})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}

	// Should have created a new team subscription starting from now + 30 days.
	wantMin := time.Now().UTC().AddDate(0, 0, 29)
	wantMax := time.Now().UTC().AddDate(0, 0, 31)
	if result.ExpiresAt.Before(wantMin) || result.ExpiresAt.After(wantMax) {
		t.Errorf("team expires_at = %v, want within [%v, %v]", result.ExpiresAt, wantMin, wantMax)
	}

	// Confirm the new row is a team subscription.
	var count int
	var teamID int64
	db.QueryRow(`SELECT id FROM plans WHERE code='team'`).Scan(&teamID)
	db.QueryRow(
		`SELECT COUNT(1) FROM subscriptions WHERE user_id=? AND plan_id=? AND source='code'`,
		userID, teamID,
	).Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 new team subscription, got %d", count)
	}
}

// TestAuditLogWritten verifies that every successful redemption produces an
// audit_log row.
func TestAuditLogWritten(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	const userID = int64(7001)
	code, _ := codes.GenerateCode()
	insertCode(t, db, code, codes.PlanPro, 30)

	_, apiErr := svc.Redeem(context.Background(), codes.RedeemRequest{
		UserID: userID,
		Code:   code,
	})
	if apiErr != nil {
		t.Fatalf("redeem: %v", apiErr)
	}

	var count int
	db.QueryRow(
		`SELECT COUNT(1) FROM audit_log WHERE action='redeem' AND actor=?`,
		fmt.Sprintf("user:%d", userID),
	).Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 audit_log row, got %d", count)
	}
}

// TestCSVExportNoPlaintextInDB verifies that after a batch generation and
// CSV export, no plaintext code is stored in the database.
func TestCSVExportNoPlaintextInDB(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	svc := codes.NewService(store, rdb, 5, time.Hour)

	result, err := svc.CreateBatch(context.Background(), codes.BatchRequest{
		PlanCode:     codes.PlanPro,
		DurationDays: 30,
		Count:        10,
		Channel:      codes.ChannelManual,
		Note:         "integration test",
		CreatedBy:    "test",
	})
	if err != nil {
		t.Fatalf("CreateBatch: %v", err)
	}

	if len(result.Codes) != 10 {
		t.Fatalf("expected 10 codes, got %d", len(result.Codes))
	}

	// Write CSV.
	var buf bytes.Buffer
	rows := codes.BatchResultToCSVRows(result, time.Now())
	if err := codes.ExportCSV(&buf, rows); err != nil {
		t.Fatalf("ExportCSV: %v", err)
	}

	// Verify no plaintext appears in the codes table.
	for _, c := range result.Codes {
		var count int
		// The table stores only code_hash; search for the plaintext as a string.
		db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, c).Scan(&count)
		if count > 0 {
			t.Errorf("plaintext code %s found in codes.code_hash column!", c)
		}
		// Also verify the hash IS present.
		h := codes.Hash(c)
		db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, h).Scan(&count)
		if count != 1 {
			t.Errorf("hash for code %s not found in DB", c)
		}
	}
}

// TestWebhookNonceReplay verifies that replaying a webhook nonce is rejected
// with status 200 (duplicate_ignored) and does NOT create a second DB row.
func TestWebhookNonceReplay(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	secret := "integration-secret"

	h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)

	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)
	nonce := fmt.Sprintf("unique-nonce-%d", time.Now().UnixNano())

	makeReq := func() *http.Request {
		r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
		r.Header.Set("Content-Type", "application/json")
		r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
		r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
		r.Header.Set("X-Pangolin-Nonce", nonce)
		return r
	}

	// First request: should succeed with 201.
	w1 := httptest.NewRecorder()
	h.ServeHTTP(w1, makeReq())
	if w1.Code != http.StatusCreated {
		t.Errorf("first request: expected 201, got %d body=%s", w1.Code, w1.Body.String())
	}

	// Second request with same nonce: should return 200 duplicate_ignored.
	w2 := httptest.NewRecorder()
	h.ServeHTTP(w2, makeReq())
	if w2.Code != http.StatusOK {
		t.Errorf("replay request: expected 200, got %d body=%s", w2.Code, w2.Body.String())
	}

	// Only one row should exist in codes.
	hash := codes.Hash(code)
	var count int
	db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 code row, got %d", count)
	}
}

// TestWebhookSameHashIdempotent verifies that pushing the same code_hash twice
// (different nonce) is idempotent: no duplicate row is created.
func TestWebhookSameHashIdempotent(t *testing.T) {
	db := setupMySQL(t)
	rdb := setupRedis(t)
	store := codes.NewStore(db)
	secret := "integration-secret-2"

	h := codes.NewWebhookHandler(store, rdb, secret, 5*time.Minute, 15*time.Minute)

	code, _ := codes.GenerateCode()
	payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30}
	body, _ := json.Marshal(payload)

	sendReq := func(nonce string) int {
		r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body))
		r.Header.Set("Content-Type", "application/json")
		r.Header.Set("X-Pangolin-Signature", signBody(secret, body))
		r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
		r.Header.Set("X-Pangolin-Nonce", nonce)
		w := httptest.NewRecorder()
		h.ServeHTTP(w, r)
		return w.Code
	}

	status1 := sendReq(fmt.Sprintf("nonce-a-%d", time.Now().UnixNano()))
	if status1 != http.StatusCreated {
		t.Fatalf("first request: expected 201, got %d", status1)
	}

	status2 := sendReq(fmt.Sprintf("nonce-b-%d", time.Now().UnixNano()))
	if status2 != http.StatusOK {
		t.Fatalf("second (same hash) request: expected 200, got %d", status2)
	}

	// Still only one row.
	hash := codes.Hash(code)
	var count int
	db.QueryRow(`SELECT COUNT(1) FROM codes WHERE code_hash=?`, hash).Scan(&count)
	if count != 1 {
		t.Errorf("expected 1 code row after idempotent push, got %d", count)
	}
}
