package engine

import (
	"errors"
	"testing"

	"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)

// newEruditNoRepeat builds a two-player Erudit game with the no-repeat-words rule set either
// way, plus a helper resolving a letter to its alphabet index.
func newEruditNoRepeat(t *testing.T, noRepeat bool) (*Game, func(string) byte) {
	t.Helper()
	g, err := New(testReg, Options{
		Variant:              VariantErudit,
		Version:              testVersion,
		Players:              2,
		Seed:                 1,
		MultipleWordsPerTurn: true,
		NoRepeatWords:        noRepeat,
	})
	if err != nil {
		t.Fatalf("new erudit game: %v", err)
	}
	return g, func(s string) byte {
		i, err := g.rules.Alphabet.Index(s)
		if err != nil {
			t.Fatalf("index %q: %v", s, err)
		}
		return i
	}
}

// playKotAtCentre commits the opening horizontal КОТ across the centre square, so that the word
// is genuinely in the game's move log rather than only on the board.
func playKotAtCentre(t *testing.T, g *Game, idx func(string) byte) (row, col int) {
	t.Helper()
	row, col = centre(g.rules)
	g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
	if _, err := g.Play(scrabble.Horizontal, placementsForWord(t, g.rules, row, col, scrabble.Horizontal, "кот")); err != nil {
		t.Fatalf("opening кот: %v", err)
	}
	return row, col
}

// TestNoRepeatRejectsAReplayedMainWord is the rule's headline case: КОТ laid once may not be laid
// again, here as a vertical play hooking the opening word's К. Without the rule the very same play
// is legal, which pins the rejection on the rule and not on the position.
func TestNoRepeatRejectsAReplayedMainWord(t *testing.T) {
	if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
		t.Fatalf("precondition: кот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
	}
	// The second play reuses the opening К and adds О and Т below it, forming КОТ downwards.
	// Neither new tile has a horizontal neighbour, so the main word is the only word formed.
	replay := func(t *testing.T, g *Game, idx func(string) byte, row, col int) error {
		t.Helper()
		g.hands[g.toMove] = []byte{idx("о"), idx("т")}
		_, err := g.Play(scrabble.Vertical, []scrabble.Placement{
			{Row: row + 1, Col: col, Letter: idx("о")},
			{Row: row + 2, Col: col, Letter: idx("т")},
		})
		return err
	}

	t.Run("with the rule the replayed word is illegal", func(t *testing.T) {
		g, idx := newEruditNoRepeat(t, true)
		row, col := playKotAtCentre(t, g, idx)
		if err := replay(t, g, idx, row, col); !errors.Is(err, ErrIllegalPlay) {
			t.Fatalf("replaying кот: err = %v, want ErrIllegalPlay", err)
		}
	})

	t.Run("without the rule the same play is accepted", func(t *testing.T) {
		g, idx := newEruditNoRepeat(t, false)
		row, col := playKotAtCentre(t, g, idx)
		if err := replay(t, g, idx, row, col); err != nil {
			t.Fatalf("replaying кот without the rule: %v", err)
		}
	})
}

// TestNoRepeatPreviewRejectsAReplayedMainWord pins the preview to the same answer as submitting:
// EvaluatePlay must reject what Play rejects, or the player would be shown a score for a move the
// server will refuse.
func TestNoRepeatPreviewRejectsAReplayedMainWord(t *testing.T) {
	g, idx := newEruditNoRepeat(t, true)
	row, col := playKotAtCentre(t, g, idx)
	g.hands[g.toMove] = []byte{idx("о"), idx("т")}
	_, err := g.EvaluatePlay([]TileRecord{
		{Row: row + 1, Col: col, Letter: "о"},
		{Row: row + 2, Col: col, Letter: "т"},
	})
	if !errors.Is(err, ErrIllegalPlay) {
		t.Fatalf("previewing a replayed кот: err = %v, want ErrIllegalPlay", err)
	}
}

// TestNoRepeatDropsTheScoreOfAReplayedCrossWord covers the other half of the rule: a play whose
// perpendicular cross-word is already on the board stays legal — the cross-word is incidental to
// laying the main word — but that cross-word scores nothing. The position places РОТ so that its
// Т completes a standing КО downwards into a second КОТ.
func TestNoRepeatDropsTheScoreOfAReplayedCrossWord(t *testing.T) {
	if ok, err := testReg.Lookup(VariantErudit, testVersion, "рот"); err != nil || !ok {
		t.Fatalf("precondition: рот must be in the Erudit dictionary (ok=%v, err=%v)", ok, err)
	}
	// The main word РОТ runs along row 12; only its last tile has vertical neighbours, so КОТ
	// (К and О standing at rows 10 and 11 of column 5) is the play's single cross-word.
	evaluate := func(t *testing.T, noRepeat bool) MoveRecord {
		t.Helper()
		g, idx := newEruditNoRepeat(t, noRepeat)
		playKotAtCentre(t, g, idx)
		applyScaffold(t, g, idx)
		g.hands[g.toMove] = []byte{idx("р"), idx("о"), idx("т")}
		rec, err := g.EvaluatePlay([]TileRecord{
			{Row: 12, Col: 3, Letter: "р"},
			{Row: 12, Col: 4, Letter: "о"},
			{Row: 12, Col: 5, Letter: "т"},
		})
		if err != nil {
			t.Fatalf("evaluating рот (noRepeat=%v): %v", noRepeat, err)
		}
		return rec
	}

	unrestricted, restricted := evaluate(t, false), evaluate(t, true)
	// Exact totals, not merely "less": the client port scores this very position in
	// ui/src/lib/dict/eval.norepeat.test.ts and is pinned to the same two numbers, so a scoring
	// divergence between the two engines breaks one of the two tests.
	if unrestricted.Score != 10 {
		t.Errorf("score without the rule = %d, want 10 (РОТ plus the КОТ cross-word)", unrestricted.Score)
	}
	if restricted.Score != 5 {
		t.Errorf("score with the rule = %d, want 5 (РОТ alone; the repeated cross-word earns nothing)", restricted.Score)
	}
	// The word is still formed and still reported — it simply earns nothing.
	want := []string{"рот", "кот"}
	if len(restricted.Words) != len(want) || restricted.Words[0] != want[0] || restricted.Words[1] != want[1] {
		t.Errorf("words with the rule = %v, want %v", restricted.Words, want)
	}
}

// TestNoRepeatFiltersGeneratedMoves keeps the robot and the hint honest: a play the rule forbids
// must never be generated, or the engine would offer a move it would then refuse.
func TestNoRepeatFiltersGeneratedMoves(t *testing.T) {
	g, idx := newEruditNoRepeat(t, true)
	playKotAtCentre(t, g, idx)
	g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}

	moves := g.GenerateMoves()
	if len(moves) == 0 {
		t.Fatal("no legal replies generated; the position cannot exercise the filter")
	}
	for _, m := range moves {
		if w := g.word(m.Main); w == "кот" {
			t.Fatalf("generated a play of кот, which is already on the board")
		}
	}
	// The list stays ranked by the adjusted score, which the hint relies on.
	for i := 1; i < len(moves); i++ {
		if moves[i-1].Score < moves[i].Score {
			t.Fatalf("generated moves not ranked: move %d scores %d, move %d scores %d",
				i-1, moves[i-1].Score, i, moves[i].Score)
		}
	}
}

// TestNoRepeatOffLeavesTheGameUnchanged is the compatibility guard for every game started before
// the rule existed (and for both Scrabble variants, which never take it): nothing is filtered,
// nothing is rescored.
func TestNoRepeatOffLeavesTheGameUnchanged(t *testing.T) {
	g, idx := newEruditNoRepeat(t, false)
	playKotAtCentre(t, g, idx)
	g.hands[g.toMove] = []byte{idx("к"), idx("о"), idx("т"), idx("а"), idx("н"), idx("с"), idx("л")}

	generated := g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts())
	if got, want := len(g.GenerateMoves()), len(generated); got != want {
		t.Errorf("generated %d moves, want the solver's %d untouched", got, want)
	}
}

// applyScaffold stands КО down column 5 above row 12, so a play across row 12 completes КОТ.
func applyScaffold(t *testing.T, g *Game, idx func(string) byte) {
	t.Helper()
	scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
		{Row: 10, Col: 5, Letter: idx("к")},
		{Row: 11, Col: 5, Letter: idx("о")},
	}})
}
