package game

import (
	"sync"
	"testing"
	"time"

	"github.com/google/uuid"

	"scrabble/backend/internal/engine"
)

func TestPayloadPlayRoundTrip(t *testing.T) {
	rec := engine.MoveRecord{
		Action: engine.ActionPlay, Dir: engine.Vertical, MainRow: 3, MainCol: 4,
		Tiles: []engine.TileRecord{{Row: 3, Col: 4, Letter: "q", Blank: true}, {Row: 4, Col: 4, Letter: "i"}},
		Words: []string{"qi"},
	}
	s, err := buildPayload(rec, []string{"q", "i", "?"}, nil).marshal()
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	p, err := parsePayload(s)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if p.direction() != engine.Vertical || p.MainRow != 3 || p.MainCol != 4 {
		t.Errorf("dir/anchor = %v/(%d,%d)", p.direction(), p.MainRow, p.MainCol)
	}
	tiles := p.tileRecords()
	if len(tiles) != 2 || tiles[0].Letter != "q" || !tiles[0].Blank || tiles[1].Letter != "i" {
		t.Errorf("tiles = %+v", tiles)
	}
	if len(p.Rack) != 3 || p.Rack[2] != "?" {
		t.Errorf("rack = %v", p.Rack)
	}
}

func TestPayloadExchangeRoundTrip(t *testing.T) {
	rec := engine.MoveRecord{Action: engine.ActionExchange, Count: 2}
	s, err := buildPayload(rec, []string{"a", "b", "c"}, []string{"a", "b"}).marshal()
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	p, err := parsePayload(s)
	if err != nil {
		t.Fatalf("parse: %v", err)
	}
	if len(p.Exchanged) != 2 || p.Exchanged[0] != "a" {
		t.Errorf("exchanged = %v", p.Exchanged)
	}
	if len(p.Tiles) != 0 || p.Dir != "" {
		t.Errorf("exchange payload carried play fields: %+v", p)
	}
}

func TestHintsRemaining(t *testing.T) {
	cases := []struct{ allowance, used, want int }{
		{1, 0, 1},
		{1, 1, 0},
		{1, 2, 0}, // used past allowance clamps to 0
		{3, 1, 2},
		{0, 0, 0},
	}
	for _, c := range cases {
		if got := hintsRemaining(c.allowance, c.used); got != c.want {
			t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
		}
	}
}

func TestHintUnlockLeftSeconds(t *testing.T) {
	now := time.Now()
	// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
	gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
	cases := []struct {
		name string
		g    Game
		seat int
		want int
	}{
		{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
		{"not the caller's turn is open", gated, 1, 0},
		{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
		{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
		{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
	}
	for _, c := range cases {
		if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
			t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
		}
	}
}

func TestAllowedTimeout(t *testing.T) {
	if !allowedTimeout(24 * time.Hour) {
		t.Error("24h must be allowed")
	}
	if !allowedTimeout(5 * time.Minute) {
		t.Error("5m must be allowed")
	}
	if allowedTimeout(7 * time.Minute) {
		t.Error("7m must not be allowed")
	}
	if allowedTimeout(0) {
		t.Error("zero must not be allowed")
	}
}

func TestNormalizeWord(t *testing.T) {
	if got := normalizeWord("  CaT \n"); got != "cat" {
		t.Errorf("normalizeWord = %q, want cat", got)
	}
}

func TestGameCacheEviction(t *testing.T) {
	cur := time.Unix(1_700_000_000, 0)
	cache := newGameCache(time.Hour, func() time.Time { return cur })
	id := uuid.New()
	cache.put(id, nil, "scrabble_en", nil)
	if _, _, ok := cache.get(id); !ok {
		t.Fatal("game must be resident after put")
	}
	cur = cur.Add(30 * time.Minute)
	cache.get(id) // refresh idle timer
	cur = cur.Add(90 * time.Minute)
	if n := cache.sweep(); n != 1 {
		t.Errorf("sweep evicted %d, want 1", n)
	}
	if _, _, ok := cache.get(id); ok {
		t.Error("game must be evicted after idle TTL")
	}
	if cache.size() != 0 {
		t.Errorf("cache size = %d, want 0", cache.size())
	}
}

func TestKeyedMutexSerializes(t *testing.T) {
	km := newKeyedMutex()
	id := uuid.New()
	var counter int
	var wg sync.WaitGroup
	for i := 0; i < 200; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			unlock := km.lock(id)
			counter++ // serialised; -race would flag a missing lock
			unlock()
		}()
	}
	wg.Wait()
	if counter != 200 {
		t.Errorf("counter = %d, want 200", counter)
	}
	km.mu.Lock()
	left := len(km.locks)
	km.mu.Unlock()
	if left != 0 {
		t.Errorf("lock map not cleaned up: %d entries left", left)
	}
}
