// Command validategen produces golden conformance fixtures for the TypeScript
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
// greedy games with the authoritative scrabble-solver engine to build realistic
// board positions, then records a battery of candidate plays — the engine's own
// top move, letter-mutated variants, random scatters and (on the empty board) an
// off-centre translation — each paired with the ground-truth result of
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
// replays these and must agree exactly.
//
// It is a development tool (not built into any service), analogous to
// cmd/dictgen. Run it from the repository root:
//
//	go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"

	"gitea.iliadenisov.ru/developer/scrabble-solver/board"
	"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
	"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
	"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
	"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
	dawg "github.com/iliadenisov/dafsa"
)

// blankTile marks a blank tile in a drawn hand (matches selfplay).
const blankTile byte = 0xff

// variantSpec pairs a variant label with its ruleset and dawg file.
type variantSpec struct {
	name  string
	rules *rules.Ruleset
	dawg  string
}

// cell is an occupied board square or a placement (alphabet-index letter).
type cell struct {
	R, C, Letter int
	Blank        bool
}

// word mirrors scrabble.Word in index space.
type word struct {
	Row, Col, Dir int
	Letters       []int
	Blanks        []bool
	Score         int
}

// fixture is one candidate play with the engine's ground-truth verdict.
type fixture struct {
	Board            int    `json:"board"` // index into the boards list
	Dir              int    `json:"dir"`
	IgnoreCrossWords bool   `json:"ignoreCrossWords"`
	Tiles            []cell `json:"tiles"`
	Legal            bool   `json:"legal"`
	Score            int    `json:"score"`
	Bonus            int    `json:"bonus"`
	Main             *word  `json:"main,omitempty"`
	Cross            []word `json:"cross,omitempty"`
}

// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
	Index  int    `json:"index"`
	Letter string `json:"letter"`
	Value  int    `json:"value"`
}

// variantFile is the whole conformance payload for one variant.
type variantFile struct {
	Variant  string       `json:"variant"`
	Rows     int          `json:"rows"`
	Cols     int          `json:"cols"`
	Center   int          `json:"center"`
	RackSize int          `json:"rackSize"`
	Bingo    int          `json:"bingo"`
	Values   []int        `json:"values"`
	Premiums []int        `json:"premiums"` // row-major rules.Premium codes
	Alphabet []alphaEntry `json:"alphabet"`
	Boards   [][]cell     `json:"boards"`
	Fixtures []fixture    `json:"fixtures"`
}

func main() {
	dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
	outDir := flag.String("out", "", "output directory for the fixture files (required)")
	games := flag.Int("games", 6, "self-play games per (variant, rule)")
	plies := flag.Int("plies", 40, "maximum plies captured per game")
	flag.Parse()
	if *outDir == "" {
		fail("-out is required")
	}
	if err := os.MkdirAll(*outDir, 0o755); err != nil {
		fail("mkdir out: %v", err)
	}

	specs := []variantSpec{
		{"scrabble_en", rules.English(), "en_sowpods.dawg"},
		{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
		{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
	}
	for _, sp := range specs {
		if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
			fail("%s: %v", sp.name, err)
		}
	}
}

func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
	data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
	if err != nil {
		return err
	}
	finder, err := dawg.Read(bytes.NewReader(data), 0)
	if err != nil {
		return fmt.Errorf("read dawg: %w", err)
	}
	defer finder.Close()

	rs := sp.rules
	solver := scrabble.NewSolver(rs, finder)

	out := variantFile{
		Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
		RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
		Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
	}

	// Capture under both the standard rule and the single-word rule, building the
	// board with the same rule so positions are reachable under it.
	for _, ignore := range []bool{false, true} {
		opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
		for g := range games {
			seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
			playAndCapture(&out, rs, solver, opts, seed, plies)
		}
	}

	b, err := json.Marshal(&out)
	if err != nil {
		return err
	}
	if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
		return err
	}
	fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
	return nil
}

// playAndCapture greedily self-plays one game, recording candidate plays against
// each board position along the way.
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
	rng := rand.New(rand.NewSource(seed))
	bag := selfplay.NewBag(rs, seed)
	b := board.New(rs.Rows, rs.Cols)
	hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}

	passes := 0
	for turn := range plies {
		p := turn % 2
		rk := rackOf(hands[p], rs.Size())
		moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
		if len(moves) == 0 {
			if passes++; passes >= 4 {
				break
			}
			continue
		}
		passes = 0
		top := moves[0]

		boardIdx := len(out.Boards)
		out.Boards = append(out.Boards, boardCells(b))
		captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)

		scrabble.Apply(b, top)
		hands[p] = removeUsed(hands[p], top)
		if need := rs.RackSize - len(hands[p]); need > 0 {
			hands[p] = append(hands[p], bag.Draw(need)...)
		}
		if len(hands[p]) == 0 && bag.Len() == 0 {
			break
		}
	}
}

// captureCandidates records the engine's top move plus derived candidates for one
// board, each with its ValidatePlayOpts verdict.
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
	size := rs.Size()
	record := func(tiles []scrabble.Placement) {
		if len(tiles) == 0 {
			return
		}
		out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
	}

	// The engine's own top move (legal).
	record(top.Tiles)

	// Letter-mutated variants: usually reject on the dictionary, occasionally form
	// a different legal word.
	for range 3 {
		mut := clonePlacements(top.Tiles)
		i := rng.Intn(len(mut))
		mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
		record(mut)
	}

	// Random scatters: exercise geometry, dictionary and connectivity paths.
	for range 3 {
		record(randomScatter(b, size, 2+rng.Intn(4), rng))
	}

	// Single tiles abutting the board exercise the direction inference — a single
	// tile is ambiguous, its orientation resolved from which axis it extends.
	for range 3 {
		if t, ok := randomAdjacentSingle(b, size, rng); ok {
			record([]scrabble.Placement{t})
		}
	}

	// On the empty board, an off-centre translation of the first move exercises the
	// first-move centre rule.
	if b.IsEmpty() {
		shifted := clonePlacements(top.Tiles)
		ok := true
		for i := range shifted {
			shifted[i].Row++
			shifted[i].Col++
			if !b.InBounds(shifted[i].Row, shifted[i].Col) {
				ok = false
				break
			}
		}
		if ok {
			record(shifted)
		}
	}
}

// makeFixture validates a candidate against board b and serializes it with its
// ground truth. Word breakdown is recorded only for legal plays (the TS test
// checks words only then); an illegal play records legal=false alone.
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
	// Infer the orientation exactly as the backend evaluate does (dir-less), so the
	// fixture matches the real eval path and pins the client's ported inference.
	dir := playDirectionMirror(solver, b, tiles, opts)
	fx := fixture{
		Board:            boardIdx,
		Dir:              int(dir),
		IgnoreCrossWords: opts.IgnoreCrossWords,
		Tiles:            placementCells(tiles),
	}
	m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
	if err == nil {
		fx.Legal = true
		fx.Score = m.Score
		fx.Bonus = m.Bonus
		fx.Main = toWord(m.Main)
		for _, cw := range m.Cross {
			fx.Cross = append(fx.Cross, *toWord(cw))
		}
	}
	return fx
}

func placementCells(ts []scrabble.Placement) []cell {
	cs := make([]cell, len(ts))
	for i, t := range ts {
		cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
	}
	return cs
}

func toWord(w scrabble.Word) *word {
	letters := make([]int, len(w.Letters))
	for i, l := range w.Letters {
		letters[i] = int(l)
	}
	return &word{
		Row: w.Row, Col: w.Col, Dir: int(w.Dir),
		Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
	}
}

func alphabetOf(rs *rules.Ruleset) []alphaEntry {
	n := rs.Alphabet.Size()
	out := make([]alphaEntry, n)
	for i := range n {
		ch, _ := rs.Alphabet.Character(byte(i))
		out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
	}
	return out
}

func premiumCodes(rs *rules.Ruleset) []int {
	codes := make([]int, rs.Rows*rs.Cols)
	for i := range codes {
		codes[i] = int(rs.PremiumAt(i))
	}
	return codes
}

func boardCells(b *board.Board) []cell {
	var cs []cell
	for r := 0; r < b.Rows(); r++ {
		for c := 0; c < b.Cols(); c++ {
			if b.Filled(r, c) {
				v := b.At(r, c)
				cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
			}
		}
	}
	return cs
}

func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
	return append([]scrabble.Placement(nil), ts...)
}

// randomScatter picks n distinct empty in-bounds squares with random letters.
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
	seen := map[[2]int]bool{}
	var ts []scrabble.Placement
	for tries := 0; tries < n*20 && len(ts) < n; tries++ {
		r := rng.Intn(b.Rows())
		c := rng.Intn(b.Cols())
		if seen[[2]int{r, c}] || b.Filled(r, c) {
			continue
		}
		seen[[2]int{r, c}] = true
		ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
	}
	return ts
}

// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
// filled square, with a random letter — a single-tile play whose orientation the
// inference must resolve. It returns ok=false on an empty board.
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
	var cands [][2]int
	for r := 0; r < b.Rows(); r++ {
		for c := 0; c < b.Cols(); c++ {
			if b.Filled(r, c) {
				continue
			}
			if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
				cands = append(cands, [2]int{r, c})
			}
		}
	}
	if len(cands) == 0 {
		return scrabble.Placement{}, false
	}
	rc := cands[rng.Intn(len(cands))]
	return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
}

// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
// resolution, except a single tile under the single-word rule tries both
// orientations through the solver and keeps the higher-scoring legal one (H wins
// ties). It reproduces the orientation the backend evaluate infers.
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
	geo := resolveDirectionMirror(b, placements)
	if len(placements) != 1 || !opts.IgnoreCrossWords {
		return geo
	}
	best, found, bestScore := geo, false, 0
	for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
		m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
		if err != nil {
			continue
		}
		if !found || m.Score > bestScore {
			best, found, bestScore = dir, true, m.Score
		}
	}
	return best
}

// resolveDirectionMirror mirrors engine.resolveDirection.
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
	if len(placements) >= 2 {
		row := placements[0].Row
		for _, p := range placements[1:] {
			if p.Row != row {
				return scrabble.Vertical
			}
		}
		return scrabble.Horizontal
	}
	if len(placements) == 1 {
		p := placements[0]
		h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
		v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
		if v >= 2 && v > h {
			return scrabble.Vertical
		}
		if h >= 2 {
			return scrabble.Horizontal
		}
		if v >= 2 {
			return scrabble.Vertical
		}
	}
	return scrabble.Horizontal
}

// runLengthMirror mirrors engine.runLength.
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
	dr, dc := 0, 1
	if dir == scrabble.Vertical {
		dr, dc = 1, 0
	}
	n := 1
	for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
		n++
	}
	for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
		n++
	}
	return n
}

// rackOf builds a generation rack from a hand of tiles (reimplemented from the
// unexported selfplay helper).
func rackOf(tiles []byte, size int) rack.Rack {
	r := rack.New(size)
	for _, t := range tiles {
		if t == blankTile {
			r.AddBlank()
		} else {
			r.Add(t)
		}
	}
	return r
}

// removeUsed returns the hand with the tiles consumed by m removed.
func removeUsed(tiles []byte, m scrabble.Move) []byte {
	out := append([]byte(nil), tiles...)
	for _, p := range m.Tiles {
		want := p.Letter
		if p.Blank {
			want = blankTile
		}
		for i, t := range out {
			if t == want {
				out = append(out[:i], out[i+1:]...)
				break
			}
		}
	}
	return out
}

func boolseed(b bool) int64 {
	if b {
		return 500000
	}
	return 0
}

func variantSeed(name string) int64 {
	var s int64
	for _, r := range name {
		s = s*131 + int64(r)
	}
	return s
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
	os.Exit(1)
}
