package engine

import (
	"errors"
	"testing"

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

// TestSingleWordRuleWiring confirms Options.MultipleWordsPerTurn reaches the solver. The
// single-word game ignores perpendicular cross-words, so move generation from a shared
// position is a superset of the standard game's; the standard game does not relax them.
func TestSingleWordRuleWiring(t *testing.T) {
	const seed = 7
	mk := func(multipleWords bool) *Game {
		g, err := New(testReg, Options{
			Variant:              VariantEnglish,
			Version:              testVersion,
			Players:              2,
			Seed:                 seed,
			MultipleWordsPerTurn: multipleWords,
		})
		if err != nil {
			t.Fatalf("new game: %v", err)
		}
		return g
	}
	std, single := mk(true), mk(false)
	if std.playOpts().IgnoreCrossWords {
		t.Error("standard game must not ignore cross-words")
	}
	if !single.playOpts().IgnoreCrossWords {
		t.Error("single-word game must ignore cross-words")
	}

	// Play the same opening (the standard game's top move) in both games. Both share the
	// seed, so the next rack is identical and both still have legal replies. The single-word
	// rule is not a superset of the standard one — it forbids parallel plays the standard
	// rule allows and admits in-line plays whose cross-words are invalid — so here the two
	// move sets only need to be non-empty; their rule-specific differences are covered by the
	// cross-word and connectivity tests.
	hint, ok := std.HintView()
	if !ok {
		t.Fatal("opening game has no hint")
	}
	if _, err := std.SubmitPlay(hint.Tiles); err != nil {
		t.Fatalf("standard opening: %v", err)
	}
	if _, err := single.SubmitPlay(hint.Tiles); err != nil {
		t.Fatalf("single-word opening: %v", err)
	}
	if len(std.GenerateMoves()) == 0 || len(single.GenerateMoves()) == 0 {
		t.Error("both games should have legal replies after the opening")
	}
}

// setupSingleWordKran builds an Erudit position that reproduces the test-contour
// bug. It replaces the bag-dealt rack with к/а/н and places the existing Р the play
// bridges plus perpendicular neighbours (г, е, н) so that each of the three new
// tiles of the vertical КРАН forms an *invalid* cross-word (гк, еа, нн). The
// multipleWords argument selects the rule. It returns the game and the decoded КРАН
// placement (the three new tiles К, А, Н around the existing Р).
func setupSingleWordKran(t *testing.T, multipleWords bool) (*Game, []TileRecord) {
	t.Helper()
	g, err := New(testReg, Options{
		Variant:              VariantErudit,
		Version:              testVersion,
		Players:              2,
		Seed:                 1,
		MultipleWordsPerTurn: multipleWords,
	})
	if err != nil {
		t.Fatalf("new erudit game: %v", err)
	}
	idx := func(s string) byte {
		i, err := g.rules.Alphabet.Index(s)
		if err != nil {
			t.Fatalf("index %q: %v", s, err)
		}
		return i
	}
	scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
		{Row: 5, Col: 8, Letter: idx("р")}, // the existing tile the play bridges
		{Row: 4, Col: 7, Letter: idx("г")}, // left of К(4,8): cross-word "гк"
		{Row: 6, Col: 7, Letter: idx("е")}, // left of А(6,8): cross-word "еа"
		{Row: 7, Col: 7, Letter: idx("н")}, // left of Н(7,8): cross-word "нн"
	}})
	g.hands[0] = []byte{idx("к"), idx("а"), idx("н")}
	tiles := []TileRecord{
		{Row: 4, Col: 8, Letter: "к"},
		{Row: 6, Col: 8, Letter: "а"},
		{Row: 7, Col: 8, Letter: "н"},
	}
	return g, tiles
}

// TestEvaluatePlayHonorsSingleWordRule is the regression for the contour bug: under
// the single-word rule the EvaluatePlay preview (the "what would this score, and is
// it legal?" tool) must honour the same rule as SubmitPlay and ignore perpendicular
// cross-words, so a play whose only flaw is invalid cross-words is reported legal and
// scored on its main word alone. Before the fix EvaluatePlay validated under standard
// rules and wrongly rejected it.
func TestEvaluatePlayHonorsSingleWordRule(t *testing.T) {
	// The main word must be a real Erudit word, so any rejection can only come from
	// the (ignored) cross-words rather than the main word itself.
	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)
	}

	t.Run("single-word rule accepts the cross-invalid play", func(t *testing.T) {
		g, tiles := setupSingleWordKran(t, false)
		rec, err := g.EvaluatePlay(tiles)
		if err != nil {
			t.Fatalf("evaluate under single-word rule: %v", err)
		}
		if len(rec.Words) != 1 || rec.Words[0] != "кран" {
			t.Errorf("words = %v, want [кран] only (cross-words ignored)", rec.Words)
		}
		if rec.Dir != Vertical {
			t.Errorf("dir = %v, want Vertical", rec.Dir)
		}
		if rec.Score <= 0 {
			t.Errorf("score = %d, want positive", rec.Score)
		}
	})

	t.Run("standard rules reject the same play", func(t *testing.T) {
		g, tiles := setupSingleWordKran(t, true)
		if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
			t.Errorf("evaluate under standard rules = %v, want ErrIllegalPlay", err)
		}
	})

	t.Run("evaluate agrees with submit under the single-word rule", func(t *testing.T) {
		g, tiles := setupSingleWordKran(t, false)
		if _, err := g.SubmitPlay(tiles); err != nil {
			t.Errorf("submit under single-word rule: %v", err)
		}
	})
}

// TestSingleWordRuleSingleTileDirection covers the single-tile half of the single-word
// rule: when a lone tile abuts the board on both axes, the engine picks the orientation that
// forms a real word, not the geometrically longer one. The lone 'о' spells the non-word
// "фоф" across (length 3) but the real word "до" down (length 2); the geometric resolver
// prefers the longer "фоф", so before the fix the play was wrongly rejected.
func TestSingleWordRuleSingleTileDirection(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)
	}
	if ok, _ := testReg.Lookup(VariantErudit, testVersion, "фоф"); ok {
		t.Fatal("precondition: \"фоф\" must not be a word")
	}

	g, err := New(testReg, Options{
		Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1, MultipleWordsPerTurn: false,
	})
	if err != nil {
		t.Fatalf("new erudit game: %v", err)
	}
	idx := func(s string) byte {
		i, err := g.rules.Alphabet.Index(s)
		if err != nil {
			t.Fatalf("index %q: %v", s, err)
		}
		return i
	}
	scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
		{Row: 4, Col: 8, Letter: idx("д")}, // above 'о': the vertical word "до"
		{Row: 5, Col: 7, Letter: idx("ф")}, // left of 'о': the across non-word "фоф"
		{Row: 5, Col: 9, Letter: idx("ф")}, // right of 'о'
	}})
	g.hands[0] = []byte{idx("о")}
	tiles := []TileRecord{{Row: 5, Col: 8, Letter: "о"}}

	rec, err := g.EvaluatePlay(tiles)
	if err != nil {
		t.Fatalf("evaluate the single tile under the single-word rule: %v", err)
	}
	if rec.Dir != Vertical {
		t.Errorf("dir = %v, want Vertical (the real word \"до\")", rec.Dir)
	}
	if len(rec.Words) != 1 || rec.Words[0] != "до" {
		t.Errorf("words = %v, want [до]", rec.Words)
	}
	if _, err := g.SubmitPlay(tiles); err != nil {
		t.Errorf("submit the single tile under the single-word rule: %v", err)
	}
}

// TestSingleWordRuleSingleTileBestScore covers the rest of rule (2): when a single tile
// forms a real word on BOTH axes, the engine keeps the higher-scoring orientation (and
// horizontal on a tie), overriding the geometric resolver's tie preference. The lone 'с'
// spells "ас" across and "юс" down; the engine must pick whichever scores more.
func TestSingleWordRuleSingleTileBestScore(t *testing.T) {
	for _, w := range []string{"ас", "юс"} {
		if ok, err := testReg.Lookup(VariantErudit, testVersion, w); err != nil || !ok {
			t.Fatalf("precondition: %q must be in the Erudit dictionary (ok=%v, err=%v)", w, ok, err)
		}
	}
	g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
	if err != nil {
		t.Fatalf("new erudit game: %v", err)
	}
	idx := func(s string) byte {
		i, err := g.rules.Alphabet.Index(s)
		if err != nil {
			t.Fatalf("index %q: %v", s, err)
		}
		return i
	}
	scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
		{Row: 5, Col: 7, Letter: idx("а")}, // left of 'с': the across word "ас"
		{Row: 4, Col: 8, Letter: idx("ю")}, // above 'с': the down word "юс"
	}})
	g.hands[0] = []byte{idx("с")}
	tiles := []TileRecord{{Row: 5, Col: 8, Letter: "с"}}
	ps := []scrabble.Placement{{Row: 5, Col: 8, Letter: idx("с")}}

	across, aerr := g.solver.ValidatePlayOpts(g.board, scrabble.Horizontal, ps, g.playOpts())
	down, derr := g.solver.ValidatePlayOpts(g.board, scrabble.Vertical, ps, g.playOpts())
	if aerr != nil || derr != nil {
		t.Fatalf("both orientations should be legal: across=%v down=%v", aerr, derr)
	}
	wantDir, wantScore := Horizontal, across.Score
	if down.Score > across.Score {
		wantDir, wantScore = Vertical, down.Score
	}

	rec, err := g.EvaluatePlay(tiles)
	if err != nil {
		t.Fatalf("evaluate the single tile: %v", err)
	}
	if rec.Dir != wantDir {
		t.Errorf("dir = %v, want %v (higher of \"ас\"=%d, \"юс\"=%d)", rec.Dir, wantDir, across.Score, down.Score)
	}
	if rec.Score != wantScore {
		t.Errorf("score = %d, want %d", rec.Score, wantScore)
	}
}

// TestSingleWordRuleRejectsPerpendicularOnlyContour is the backend regression for the
// reported contour bug: a multi-tile play whose main word is a real word but which touches
// the board only perpendicular to its own line — forming a cross-word, not a word along that
// line — does not connect under the single-word rule and is rejected by both the preview and
// the submit path. Existing "до" sits down column 8; "кот" laid across row 6 is all-new along
// its row and touches the board only through the 'о' below the existing 'о'.
func TestSingleWordRuleRejectsPerpendicularOnlyContour(t *testing.T) {
	if ok, err := testReg.Lookup(VariantErudit, testVersion, "кот"); err != nil || !ok {
		t.Fatalf("precondition: \"кот\" must be a real word (ok=%v, err=%v)", ok, err)
	}
	g, err := New(testReg, Options{Variant: VariantErudit, Version: testVersion, Players: 2, Seed: 1})
	if err != nil {
		t.Fatalf("new erudit game: %v", err)
	}
	idx := func(s string) byte {
		i, err := g.rules.Alphabet.Index(s)
		if err != nil {
			t.Fatalf("index %q: %v", s, err)
		}
		return i
	}
	scrabble.Apply(g.board, scrabble.Move{Tiles: []scrabble.Placement{
		{Row: 4, Col: 8, Letter: idx("д")},
		{Row: 5, Col: 8, Letter: idx("о")},
	}})
	g.hands[0] = []byte{idx("к"), idx("о"), idx("т")}
	tiles := []TileRecord{
		{Row: 6, Col: 7, Letter: "к"},
		{Row: 6, Col: 8, Letter: "о"},
		{Row: 6, Col: 9, Letter: "т"},
	}
	if _, err := g.EvaluatePlay(tiles); !errors.Is(err, ErrIllegalPlay) {
		t.Errorf("EvaluatePlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
	}
	if _, err := g.SubmitPlay(tiles); !errors.Is(err, ErrIllegalPlay) {
		t.Errorf("SubmitPlay = %v, want ErrIllegalPlay (connects only perpendicular)", err)
	}
}

// TestSingleWordRuleRobotCandidates proves the robot opponent never trips the same
// cross-word check while searching for its move: its move source, Candidates ->
// GenerateMovesOpts, already honours the rule. Under the single-word rule the bridged
// КРАН (whose cross-words are invalid) appears among the candidates the robot chooses
// from; under standard rules it is correctly absent. The robot submits its pick through
// SubmitPlay (covered above), so this holds both before and after the EvaluatePlay fix —
// the robot never uses EvaluatePlay.
func TestSingleWordRuleRobotCandidates(t *testing.T) {
	hasKran := func(cands []MoveRecord) bool {
		for _, c := range cands {
			if len(c.Words) > 0 && c.Words[0] == "кран" {
				return true
			}
		}
		return false
	}

	single, _ := setupSingleWordKran(t, false)
	if !hasKran(single.Candidates()) {
		t.Error("single-word candidates must include the bridged кран play the robot can pick")
	}
	std, _ := setupSingleWordKran(t, true)
	if hasKran(std.Candidates()) {
		t.Error("standard candidates must not include кран (its cross-words are invalid)")
	}
}
