// Command movegen emits golden conformance fixtures for the client-side move
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
// committed so the TypeScript parity tests run without a Go toolchain.
//
// For each small sample dictionary (English and Russian — the latter reaches
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
//
//   - sample_<tag>.dawg           the serialized dictionary (the reader/cursor fixture)
//   - sample_<tag>.words.json     the stored words + their alphabet indexes
//   - sample_<tag>.gen.json       ranked move-generation results from the real solver,
//     for a handful of positions, plus the ruleset the TS
//     side rebuilds to score identically
//
// Positions are built with only the solver's public API: an empty board, and
// two-ply positions reached by applying the solver's own top move (so no internal
// encoding is needed). Regenerate with:
//
//	go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"log"
	"os"
	"path/filepath"
	"strings"

	"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"
	dawg "github.com/iliadenisov/dafsa"
)

// sampleWordsEN is the English sample dictionary, in strictly increasing
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
var sampleWordsEN = []string{
	"a", "an", "and", "ant",
	"car", "care", "cared", "cares", "cars", "cat", "cats",
	"do", "doe", "does", "dog", "dogs", "done", "dot",
}

// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
// order. It deliberately includes words starting with я (index 32) so the ported
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}

// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
// knows the exact word set (as alphabet indexes) to expect from enumeration.
type sampleFixture struct {
	Alphabet string   `json:"alphabet"`
	NumAdded int      `json:"numAdded"`
	Words    []string `json:"words"`
	Indexes  [][]int  `json:"indexes"`
}

// genFixture is the move-generation golden set for one sample dictionary.
type genFixture struct {
	Ruleset genRuleset `json:"ruleset"`
	Cases   []genCase  `json:"cases"`
}

// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
type genRuleset struct {
	Size       int     `json:"size"`
	Cols       int     `json:"cols"`
	Center     int     `json:"center"`
	RackSize   int     `json:"rackSize"`
	Bingo      int     `json:"bingo"`
	Values     []int   `json:"values"`
	LetterMult [][]int `json:"letterMult"`
	WordMult   [][]int `json:"wordMult"`
}

// genTile is one placed tile (a board tile or a move placement).
type genTile struct {
	Row    int  `json:"row"`
	Col    int  `json:"col"`
	Letter int  `json:"letter"`
	Blank  bool `json:"blank"`
}

// genRack is a rack as a multiset of letter indexes plus a blank count.
type genRack struct {
	Letters []int `json:"letters"`
	Blanks  int   `json:"blanks"`
}

// genMove is one ranked generated play: its orientation, placed tiles and total score.
type genMove struct {
	Dir   int       `json:"dir"`
	Tiles []genTile `json:"tiles"`
	Score int       `json:"score"`
}

// genCase is one generation position: the tiles already on the board (empty when
// none), the rack, the mode/rule and the ranked moves the solver returns.
type genCase struct {
	Name             string    `json:"name"`
	Placed           []genTile `json:"placed"`
	Rack             genRack   `json:"rack"`
	Mode             int       `json:"mode"`
	IgnoreCrossWords bool      `json:"ignoreCrossWords"`
	Moves            []genMove `json:"moves"`
}

func main() {
	out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
	dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
	flag.Parse()

	if err := os.MkdirAll(*out, 0o755); err != nil {
		log.Fatalf("movegen: mkdir %s: %v", *out, err)
	}

	if *dawgDir != "" {
		buildReal(*dawgDir, *out)
		return
	}

	emitRulesets()

	buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
		emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
		emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
		emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
		emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
	})

	buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
		emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
	})
}

// buildSample writes the dawg, the word fixture and the generation golden set for
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
// the first non-empty result is applied, then generation runs again on the new rack.
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
	idx := rs.Alphabet
	b := dawg.New(idx)
	indexes := make([][]int, 0, len(words))
	for _, w := range words {
		if err := b.Add(w); err != nil {
			log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
		}
		enc, err := idx.Encode(w)
		if err != nil {
			log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
		}
		ints := make([]int, len(enc))
		for i, x := range enc {
			ints[i] = int(x)
		}
		indexes = append(indexes, ints)
	}
	finder := b.Finish()

	writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
		Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
	})
	dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
	if _, err := finder.Save(dawgPath); err != nil {
		log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
	}

	s := scrabble.NewSolver(rs, finder)
	for i := range cases {
		runCase(s, rs, &cases[i], nil)
	}
	// A two-ply position from the first standard case that produced a move.
	if two := twoPly(s, rs, cases); two != nil {
		cases = append(cases, *two)
	}

	writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
		Ruleset: rulesetOf(rs), Cases: cases,
	})
	log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
}

// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
// kept small by the anchors around the already-placed word.
type realVariant struct {
	file, variant, smallRack, blankRack, fullRack string
	rs                                            *rules.Ruleset
}

// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
// the full alphabets and deep graphs the tiny samples cannot reach — one
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
func buildReal(dawgDir, out string) {
	reals := []realVariant{
		{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
		{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
		{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
	}
	for _, v := range reals {
		data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
		if err != nil {
			log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
		}
		finder, err := dawg.Read(bytes.NewReader(data), 0)
		if err != nil {
			log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
		}
		s := scrabble.NewSolver(v.rs, finder)

		cases := []genCase{
			emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
			emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
		}
		for i := range cases {
			runCase(s, v.rs, &cases[i], nil)
		}
		// A deep 7-tile mid-game: place the top first move, then generate again. The
		// anchors around the placed word bound the list while still exercising a full rack,
		// deep left/right extension and wide cross-sets over the real graph.
		full := encRack(v.rs, v.fullRack, 0)
		b := board.New(v.rs.Rows, v.rs.Cols)
		if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
			mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
			runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
			cases = append(cases, mid)
		}

		writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
		total := 0
		for _, c := range cases {
			total += len(c.Moves)
		}
		_ = finder.Close()
		log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
	}
}

// encRack encodes a rack given as the variant's letters (plus a blank count) into the
// index-based genRack the fixtures carry.
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
	enc, err := rs.Alphabet.Encode(letters)
	if err != nil {
		log.Fatalf("movegen: encode rack %q: %v", letters, err)
	}
	idx := make([]int, len(enc))
	for i, b := range enc {
		idx[i] = int(b)
	}
	return genRack{Letters: idx, Blanks: blanks}
}

// runCase fills a case's Moves by generating on a board holding the given placed
// tiles (nil = empty board).
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
	bd := board.New(rs.Rows, rs.Cols)
	for _, t := range placed {
		bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
	}
	c.Placed = placed
	rk := toRack(rs.Size(), c.Rack)
	moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
	c.Moves = movesOf(moves)
}

// twoPly reaches a mid-game position by applying the top move of the first standard
// case that generated one, then generates again with a fresh rack of the same tiles.
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
	for _, c := range cases {
		if c.IgnoreCrossWords || len(c.Moves) == 0 {
			continue
		}
		placed := c.Moves[0].Tiles
		next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
		runCase(s, rs, &next, placed)
		return &next
	}
	return nil
}

// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
// to the solver module and cannot be imported.
func cellByte(letter int, blank bool) byte {
	v := byte(letter+1) & 0x3f
	if blank {
		v |= 0x80
	}
	return v
}

func toRack(size int, r genRack) rack.Rack {
	rk := rack.New(size)
	for _, l := range r.Letters {
		rk.Add(byte(l))
	}
	for i := 0; i < r.Blanks; i++ {
		rk.AddBlank()
	}
	return rk
}

func rulesetOf(rs *rules.Ruleset) genRuleset {
	lm := make([][]int, rs.Rows)
	wm := make([][]int, rs.Rows)
	for r := 0; r < rs.Rows; r++ {
		lm[r] = make([]int, rs.Cols)
		wm[r] = make([]int, rs.Cols)
		for c := 0; c < rs.Cols; c++ {
			p := rs.Premium(r, c)
			lm[r][c] = p.LetterMult()
			wm[r][c] = p.WordMult()
		}
	}
	return genRuleset{
		Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
		Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
	}
}

func movesOf(ms []scrabble.Move) []genMove {
	out := make([]genMove, len(ms))
	for i, m := range ms {
		out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
	}
	return out
}

func tilesOf(ps []scrabble.Placement) []genTile {
	out := make([]genTile, len(ps))
	for i, p := range ps {
		out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
	}
	return out
}

// emptyCase builds an empty-board case (Moves filled later by runCase).
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
	return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
}

// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
func englishRack(letters string, blanks int) genRack {
	idx := make([]int, 0, len(letters))
	for _, ch := range letters {
		idx = append(idx, int(ch-'a'))
	}
	return genRack{Letters: idx, Blanks: blanks}
}

// russianRack builds a rack from the Russian sample letters used above.
func russianRack(letters string, blanks int) genRack {
	m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
	idx := make([]int, 0, len([]rune(letters)))
	for _, ch := range letters {
		i, ok := m[ch]
		if !ok {
			log.Fatalf("movegen: russianRack: no index for %q", string(ch))
		}
		idx = append(idx, i)
	}
	return genRack{Letters: idx, Blanks: blanks}
}

// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
func emitRulesets() {
	type rsFix struct {
		Size     int      `json:"size"`
		RackSize int      `json:"rackSize"`
		Bingo    int      `json:"bingo"`
		Blanks   int      `json:"blanks"`
		Values   []int    `json:"values"`
		Counts   []int    `json:"counts"`
		Letters  []string `json:"letters"`
	}
	out := map[string]rsFix{}
	for _, v := range []struct {
		name string
		rs   *rules.Ruleset
	}{
		{"scrabble_en", rules.English()},
		{"scrabble_ru", rules.RussianScrabble()},
		{"erudit_ru", rules.Erudit()},
	} {
		letters := make([]string, v.rs.Size())
		for i := range letters {
			ch, err := v.rs.Alphabet.Character(byte(i))
			if err != nil {
				log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
			}
			letters[i] = strings.ToUpper(ch)
		}
		out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
	}
	dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		log.Fatalf("movegen: mkdir %s: %v", dir, err)
	}
	writeJSON(filepath.Join(dir, "rulesets.json"), out)
	log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
}

func writeJSON(path string, v any) {
	data, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		log.Fatalf("movegen: marshal %s: %v", path, err)
	}
	if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
		log.Fatalf("movegen: write %s: %v", path, err)
	}
}
