package robot

import (
	"math"
	"sort"
	"testing"
	"time"

	"scrabble/backend/internal/engine"
)

// TestPlayToWinDistribution checks the once-per-game decision is fixed per seed
// and lands near the 40% target over many games.
func TestPlayToWinDistribution(t *testing.T) {
	const n = 20000
	wins := 0
	for seed := int64(1); seed <= n; seed++ {
		if playToWin(seed) {
			wins++
		}
		if playToWin(seed) != playToWin(seed) {
			t.Fatalf("playToWin not deterministic for seed %d", seed)
		}
	}
	pct := float64(wins) / float64(n) * 100
	if pct < 37 || pct > 43 {
		t.Errorf("play-to-win rate = %.1f%%, want ~40%% (37-43)", pct)
	}
}

// TestMoveDelayBoundsAndDeterminism checks every sampled delay stays in the hard
// bounds [1min, 90min] and is reproducible for a (seed, moveCount).
func TestMoveDelayBoundsAndDeterminism(t *testing.T) {
	for seed := int64(1); seed <= 200; seed++ {
		for mc := 0; mc < 50; mc++ {
			d := moveDelay(seed, mc)
			if d < 1*time.Minute || d > 90*time.Minute {
				t.Fatalf("delay %s out of [1m,90m] for seed=%d mc=%d", d, seed, mc)
			}
			if moveDelay(seed, mc) != d {
				t.Fatalf("delay not deterministic for seed=%d mc=%d", seed, mc)
			}
		}
	}
}

// TestMoveDelayGrowsWithMoveCount checks the delay band shifts up over a game: the
// first move lives in the short [1,5]min band, a late move in the long [10,90]min
// band, so the median think time rises with the move count.
func TestMoveDelayGrowsWithMoveCount(t *testing.T) {
	median := func(mc int) float64 {
		const n = 4000
		xs := make([]float64, n)
		for s := 0; s < n; s++ {
			xs[s] = moveDelay(int64(s+1), mc).Minutes()
		}
		sort.Float64s(xs)
		return xs[n/2]
	}
	for s := int64(1); s <= 500; s++ {
		if d := moveDelay(s, 0).Minutes(); d < 3 || d > 10 {
			t.Fatalf("first-move delay %.2f out of [3,10] for seed %d", d, s)
		}
		if d := moveDelay(s, 40).Minutes(); d < 10 || d > 90 {
			t.Fatalf("late-move delay %.2f out of [10,90] for seed %d", d, s)
		}
	}
	if early, late := median(0), median(30); early >= late {
		t.Errorf("median should grow with move count: move0=%.1f move30=%.1f", early, late)
	}
}

// TestMoveDelaySkew checks the late-game distribution is right-skewed at a fixed move
// count: short delays are frequent (median near the band floor) and the mean sits
// above the median, with a tail toward the cap.
func TestMoveDelaySkew(t *testing.T) {
	const n = 20000
	mins := make([]float64, 0, n)
	var sum float64
	for s := 0; s < n; s++ {
		m := moveDelay(int64(s+1), 28).Minutes() // late band [10,90]
		mins = append(mins, m)
		sum += m
	}
	sort.Float64s(mins)
	median := mins[n/2]
	mean := sum / float64(n)
	if median < 12 || median > 20 {
		t.Errorf("late median delay = %.1f min, want ~15 (12-20)", median)
	}
	if mean <= median {
		t.Errorf("mean %.1f should exceed median %.1f (right skew)", mean, median)
	}
}

// TestSelectMovePlayToWinKeepsLeadSmall checks the winning robot prefers an
// in-band move with the smallest resulting lead.
func TestSelectMovePlayToWinKeepsLeadSmall(t *testing.T) {
	cands := plays(50, 20, 5, 2) // margins 50,20,5,2 with scores even
	d := selectMove(cands, 100, 100, true, marginBand{1, 30}, nil, 0)
	if d.kind != decidePlay || d.move.Score != 2 {
		t.Errorf("got kind=%d score=%d, want play score=2 (smallest in-band lead)", d.kind, d.move.Score)
	}
}

// TestSelectMovePlayToLoseKeepsDeficitSmall checks the losing robot prefers the
// in-band move with the smallest deficit.
func TestSelectMovePlayToLoseKeepsDeficitSmall(t *testing.T) {
	cands := plays(50, 20, 15, 5) // myScore 80, opp 100 → margins 30,0,-5,-15
	d := selectMove(cands, 80, 100, false, marginBand{1, 30}, nil, 0)
	if d.kind != decidePlay || d.move.Score != 15 {
		t.Errorf("got kind=%d score=%d, want play score=15 (smallest deficit in band)", d.kind, d.move.Score)
	}
}

// TestSelectMoveFallbackBehind checks that when even the best play cannot reach
// the band the winning robot takes the highest-scoring move (best catch-up).
func TestSelectMoveFallbackBehind(t *testing.T) {
	cands := plays(10, 5) // myScore 50, opp 100 → margins -40,-45, both below band
	d := selectMove(cands, 50, 100, true, marginBand{1, 30}, nil, 0)
	if d.move.Score != 10 {
		t.Errorf("got score=%d, want 10 (closest to band from below)", d.move.Score)
	}
}

// TestSelectMoveFallbackOvershoot checks that when every play overshoots the band
// the winning robot takes the lowest-scoring move (keeps the lead near the cap).
func TestSelectMoveFallbackOvershoot(t *testing.T) {
	cands := plays(40, 10) // myScore 100, opp 50 → margins 90,60, both above band
	d := selectMove(cands, 100, 50, true, marginBand{1, 30}, nil, 0)
	if d.move.Score != 10 {
		t.Errorf("got score=%d, want 10 (closest to band from above)", d.move.Score)
	}
}

// TestSelectMoveNoPlay checks the exchange-or-pass fallback.
func TestSelectMoveNoPlay(t *testing.T) {
	rack := []string{"A", "B", "C"}
	if d := selectMove(nil, 0, 0, true, defaultBand, rack, 5); d.kind != decideExchange || len(d.exchange) != 3 {
		t.Errorf("with a refillable bag want exchange of 3, got kind=%d n=%d", d.kind, len(d.exchange))
	}
	if d := selectMove(nil, 0, 0, true, defaultBand, rack, 2); d.kind != decidePass {
		t.Errorf("with a short bag want pass, got kind=%d", d.kind)
	}
	if d := selectMove(nil, 0, 0, true, defaultBand, nil, 9); d.kind != decidePass {
		t.Errorf("with an empty rack want pass, got kind=%d", d.kind)
	}
}

// TestSleepDriftBounds checks the drift stays within ±3h and is deterministic.
func TestSleepDriftBounds(t *testing.T) {
	for seed := int64(1); seed <= 5000; seed++ {
		d := sleepDrift(seed)
		if d < -3*time.Hour || d > 3*time.Hour {
			t.Fatalf("drift %s out of ±3h for seed %d", d, seed)
		}
		if sleepDrift(seed) != d {
			t.Fatalf("drift not deterministic for seed %d", seed)
		}
	}
}

// TestAsleep covers the window, the drift shift, a real timezone and the
// midnight wrap.
func TestAsleep(t *testing.T) {
	at := func(tz string, y int, mo time.Month, d, h int) time.Time {
		loc, err := time.LoadLocation(tz)
		if err != nil {
			t.Fatalf("load %s: %v", tz, err)
		}
		return time.Date(y, mo, d, h, 0, 0, 0, loc)
	}
	cases := []struct {
		name  string
		tz    string
		drift time.Duration
		now   time.Time
		want  bool
	}{
		{"utc night", "UTC", 0, at("UTC", 2024, 1, 1, 3), true},
		{"utc day", "UTC", 0, at("UTC", 2024, 1, 1, 12), false},
		{"utc edge end", "UTC", 0, at("UTC", 2024, 1, 1, 7), false},
		{"drift+3 shifts earlier", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 22), true},
		{"drift+3 awake midday", "UTC", 3 * time.Hour, at("UTC", 2024, 1, 1, 5), false},
		{"drift-3 shifts later", "UTC", -3 * time.Hour, at("UTC", 2024, 1, 1, 9), true},
		{"tokyo asleep", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 18), true}, // 03:00 JST
		{"tokyo awake", "Asia/Tokyo", 0, at("UTC", 2024, 1, 1, 0), false},  // 09:00 JST
		{"bad tz falls back to utc", "Nowhere/Bad", 0, at("UTC", 2024, 1, 1, 3), true},
	}
	for _, c := range cases {
		if got := asleep(c.tz, c.drift, c.now); got != c.want {
			t.Errorf("%s: asleep = %v, want %v", c.name, got, c.want)
		}
	}
}

// TestMixDeterministic checks the mixer is stable (across calls, and so across
// restarts) and salt-sensitive.
func TestMixDeterministic(t *testing.T) {
	if mix(7, "win") != mix(7, "win") {
		t.Error("mix not stable for the same inputs")
	}
	if mix(7, "win") == mix(7, "delay") {
		t.Error("mix should differ by salt")
	}
	if mix(7, "delay", 1) == mix(7, "delay", 2) {
		t.Error("mix should differ by move index")
	}
}

// TestNextMoveAt checks the exported schedule used by the admin ETA: the instant is never
// earlier than the sampled think-time delay, and it never lands while the robot is asleep
// (a delay that would fall in the sleep window is deferred to the wake time).
func TestNextMoveAt(t *testing.T) {
	base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	for seed := int64(1); seed <= 500; seed++ {
		for _, h := range []int{0, 2, 6, 9, 14, 23} { // turn starts across the day
			start := base.Add(time.Duration(h) * time.Hour)
			at := NextMoveAt(seed, 3, start, "UTC")
			if at.Before(start.Add(moveDelay(seed, 3))) {
				t.Fatalf("seed %d h %d: ETA %s earlier than the scheduled delay", seed, h, at)
			}
			if asleep("UTC", sleepDrift(seed), at) {
				t.Fatalf("seed %d h %d: ETA %s lands in the sleep window", seed, h, at)
			}
		}
	}
}

// TestPlayToWinExport checks the exported decision matches the internal one and the target.
func TestPlayToWinExport(t *testing.T) {
	for seed := int64(1); seed <= 200; seed++ {
		if PlayToWin(seed) != playToWin(seed) {
			t.Fatalf("PlayToWin(%d) != playToWin", seed)
		}
	}
	if PlayToWinTargetPercent != playToWinPercent {
		t.Errorf("PlayToWinTargetPercent = %d, want %d", PlayToWinTargetPercent, playToWinPercent)
	}
}

// TestDeviateProbTaper checks the deviation probability is deviateMaxProb while the
// bag holds at least deviateTaperTiles tiles, halves at the taper midpoint, is 0
// once the bag is empty, and stays within [0, deviateMaxProb] and non-decreasing.
func TestDeviateProbTaper(t *testing.T) {
	if p := deviateProb(0); p != 0 {
		t.Errorf("deviateProb(0) = %v, want 0 (strict endgame)", p)
	}
	if p := deviateProb(deviateTaperTiles); p != deviateMaxProb {
		t.Errorf("deviateProb(%d) = %v, want %v", deviateTaperTiles, p, deviateMaxProb)
	}
	if p := deviateProb(deviateTaperTiles + 50); p != deviateMaxProb {
		t.Errorf("deviateProb above the taper = %v, want %v (capped)", p, deviateMaxProb)
	}
	if p := deviateProb(deviateTaperTiles / 2); math.Abs(p-deviateMaxProb/2) > 1e-9 {
		t.Errorf("deviateProb at half taper = %v, want ~%v", p, deviateMaxProb/2)
	}
	prev := -1.0
	for bag := 0; bag <= deviateTaperTiles+5; bag++ {
		p := deviateProb(bag)
		if p < 0 || p > deviateMaxProb {
			t.Fatalf("deviateProb(%d) = %v out of [0,%v]", bag, p, deviateMaxProb)
		}
		if p < prev {
			t.Fatalf("deviateProb not non-decreasing: bag %d gives %v after %v", bag, p, prev)
		}
		prev = p
	}
}

// TestDeviatesNeverInEndgame checks the robot never deviates once the bag is empty,
// for every seed and move count, so the endgame follows the chosen strategy strictly.
func TestDeviatesNeverInEndgame(t *testing.T) {
	for seed := int64(1); seed <= 5000; seed++ {
		for mc := 0; mc < 40; mc++ {
			if deviates(seed, mc, 0) {
				t.Fatalf("deviates with an empty bag for seed=%d mc=%d", seed, mc)
			}
		}
	}
}

// TestDeviatesDeterministic checks the per-turn deviation draw is reproducible for a
// (seed, moveCount, bagLen), so the driver recomputes the same decision on every scan.
func TestDeviatesDeterministic(t *testing.T) {
	for seed := int64(1); seed <= 500; seed++ {
		for mc := 0; mc < 30; mc++ {
			got := deviates(seed, mc, deviateTaperTiles)
			if deviates(seed, mc, deviateTaperTiles) != got {
				t.Fatalf("deviates not deterministic for seed=%d mc=%d", seed, mc)
			}
		}
	}
}

// TestDeviatesDistribution checks the deviation rate over many games lands near
// deviateMaxProb while the bag is full (above the taper), at a fixed move count.
func TestDeviatesDistribution(t *testing.T) {
	const n = 20000
	hits := 0
	for seed := int64(1); seed <= n; seed++ {
		if deviates(seed, 3, deviateTaperTiles+20) {
			hits++
		}
	}
	pct := float64(hits) / float64(n) * 100
	want := deviateMaxProb * 100
	if pct < want-2 || pct > want+2 {
		t.Errorf("deviation rate = %.1f%%, want ~%.0f%% (±2)", pct, want)
	}
}

// TestProactiveNudgeGap checks the proactive-nudge schedule: the first gap (refIdle 0) is
// ~60-90 min, every gap stays within [60 min, 6 h] and is deterministic, and the gap lengthens
// as the idle grows (the median at 12 h idle exceeds the median at the start).
func TestProactiveNudgeGap(t *testing.T) {
	for seed := int64(1); seed <= 1000; seed++ {
		if first := proactiveNudgeGap(0, seed); first < 9*time.Hour || first > 12*time.Hour {
			t.Fatalf("first gap %s out of [9h,12h] for seed %d", first, seed)
		}
		for _, idle := range []time.Duration{0, time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour, 24 * time.Hour} {
			g := proactiveNudgeGap(idle, seed)
			if g < 9*time.Hour || g > 12*time.Hour {
				t.Fatalf("gap %s out of [9h,12h] for seed %d idle %s", g, seed, idle)
			}
			if proactiveNudgeGap(idle, seed) != g {
				t.Fatalf("gap not deterministic for seed %d idle %s", seed, idle)
			}
		}
	}
	median := func(idle time.Duration) float64 {
		const n = 4000
		xs := make([]float64, n)
		for s := 0; s < n; s++ {
			xs[s] = proactiveNudgeGap(idle, int64(s+1)).Minutes()
		}
		sort.Float64s(xs)
		return xs[n/2]
	}
	// The window is flat: the gap distribution does not lengthen with idle time, so the median
	// stays near the band centre (10.5 h) and barely moves between a fresh turn and a long-idle one.
	early, late := median(0), median(12*time.Hour)
	for _, m := range []float64{early, late} {
		if m < 9*60 || m > 12*60 {
			t.Fatalf("median gap %.0f min out of [540,720]", m)
		}
	}
	if diff := math.Abs(early - late); diff > 30 {
		t.Errorf("median gap should not shift with idle (flat window): idle0=%.0f idle12h=%.0f", early, late)
	}
}

// TestEndgamePassDelayBoundsAndAnchor checks the shortened endgame think time: it always
// lands in [30s, 8min], collapses a slow human to the cap, floors a fast human, tracks a
// mid human inside [0.8,1.5]*oppLast, floors a clock-skew negative gap, and is
// deterministic per (seed, moveCount).
func TestEndgamePassDelayBoundsAndAnchor(t *testing.T) {
	const floor = 30 * time.Second
	const ceil = 8 * time.Minute
	cases := []struct {
		name    string
		oppLast time.Duration
		lo, hi  time.Duration // expected inclusive output range
	}{
		{"clock-skew negative floors", -time.Hour, floor, floor},
		{"zero floors", 0, floor, floor},
		{"very fast floors", 3 * time.Second, floor, floor},                        // [2.4s,4.5s] → floor
		{"fast tracks above floor", 30 * time.Second, floor, 45 * time.Second},     // [24s,45s] → [30s,45s]
		{"mid tracks in band", 2 * time.Minute, 96 * time.Second, 3 * time.Minute}, // [1.6m,3m]
		{"at cap boundary", 8 * time.Minute, 384 * time.Second, ceil},              // [6.4m,12m] → [6.4m,8m]
		{"slow caps", 3 * time.Hour, ceil, ceil},                                   // [2.4h,4.5h] → cap
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			t.Parallel()
			for seed := int64(1); seed <= 2000; seed++ {
				d := endgamePassDelay(seed, 30, c.oppLast)
				if d < floor || d > ceil {
					t.Fatalf("oppLast=%s seed=%d: delay %s out of hard [%s,%s]", c.oppLast, seed, d, floor, ceil)
				}
				if d < c.lo || d > c.hi {
					t.Fatalf("oppLast=%s seed=%d: delay %s out of expected [%s,%s]", c.oppLast, seed, d, c.lo, c.hi)
				}
				if endgamePassDelay(seed, 30, c.oppLast) != d {
					t.Fatalf("oppLast=%s seed=%d: not deterministic", c.oppLast, seed)
				}
			}
		})
	}
}

// TestEndgamePassDelayShrinksLateGame checks the endgame think time is always shorter than
// the normal late-game schedule (band floor 10min vs the 8min cap), so taking the min in the
// driver actually speeds the robot up rather than ever slowing it down.
func TestEndgamePassDelayShrinksLateGame(t *testing.T) {
	for seed := int64(1); seed <= 1000; seed++ {
		for mc := 28; mc <= 40; mc++ {
			eg := endgamePassDelay(seed, mc, 3*time.Hour) // worst case: a slow human, caps at 8min
			if nd := moveDelay(seed, mc); eg >= nd {
				t.Fatalf("seed=%d mc=%d: endgame %s not shorter than normal %s", seed, mc, eg, nd)
			}
		}
	}
}

// plays builds candidate plays carrying only the given scores (ranked as passed).
func plays(scores ...int) []engine.MoveRecord {
	out := make([]engine.MoveRecord, len(scores))
	for i, s := range scores {
		out[i] = engine.MoveRecord{Action: engine.ActionPlay, Score: s}
	}
	return out
}
