package scrabble

import (
	"bufio"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"

	"gitea.iliadenisov.ru/developer/scrabble-solver/board"
	"gitea.iliadenisov.ru/developer/scrabble-solver/dictdawg"
	"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)

// TestScoreRealGames replays real tournament games recorded in GCG format and checks that
// our scoring reproduces, move for move, the score and running total written in the
// protocol. This validates the scoring engine against canonical play, not invented cases.
//
// The games come from cross-tables.com (annotated self-play) and are stored under
// testdata/. They use the standard English board and SOWPODS, so the test loads the
// committed dawg/en_sowpods.dawg.
func TestScoreRealGames(t *testing.T) {
	finder, err := dictdawg.Load("../dawg/en_sowpods.dawg")
	if err != nil {
		t.Skipf("need dawg/en_sowpods.dawg: %v", err)
	}
	s := NewSolver(rules.English(), finder)
	games, _ := filepath.Glob("testdata/*.gcg")
	if len(games) == 0 {
		t.Fatal("no GCG games in testdata/")
	}
	for _, g := range games {
		t.Run(filepath.Base(g), func(t *testing.T) { replayGCGOpts(t, s, g, PlayOptions{}) })
	}
}

// TestSingleWordGames replays single-word ("multiple words per turn" off) fixtures, in
// which some plays form invalid cross-words that the standard rules would reject. The
// replay (replayGCGOpts with IgnoreCrossWords) checks each move's single-word score against
// the trusted standard scorer's main word, so the rule is exercised end to end: scoring,
// validation and relaxed generation.
func TestSingleWordGames(t *testing.T) {
	finder, err := dictdawg.Load("../dawg/en_sowpods.dawg")
	if err != nil {
		t.Skipf("need dawg/en_sowpods.dawg: %v", err)
	}
	s := NewSolver(rules.English(), finder)
	games, _ := filepath.Glob("testdata/singleword/*.gcg")
	if len(games) == 0 {
		t.Fatal("no single-word GCG games in testdata/singleword/")
	}
	for _, g := range games {
		t.Run(filepath.Base(g), func(t *testing.T) { replayGCGOpts(t, s, g, PlayOptions{IgnoreCrossWords: true}) })
	}
}

// parsePos decodes a GCG coordinate into a 0-based square and orientation. A leading digit
// means an across (horizontal) play ("11J"), a leading letter means a down (vertical) one
// ("H7"); rows are 1..15 and columns A..O.
func parsePos(p string) (row, col int, dir Direction, ok bool) {
	if len(p) < 2 {
		return 0, 0, 0, false
	}
	if p[0] >= '1' && p[0] <= '9' { // number first -> horizontal (across)
		i := 0
		for i < len(p) && p[i] >= '0' && p[i] <= '9' {
			i++
		}
		if i+1 != len(p) || p[i] < 'A' || p[i] > 'O' {
			return 0, 0, 0, false
		}
		n, _ := strconv.Atoi(p[:i])
		return n - 1, int(p[i] - 'A'), Horizontal, true
	}
	if p[0] >= 'A' && p[0] <= 'O' { // letter first -> vertical (down)
		for i := 1; i < len(p); i++ {
			if p[i] < '0' || p[i] > '9' {
				return 0, 0, 0, false
			}
		}
		n, _ := strconv.Atoi(p[1:])
		return n - 1, int(p[0] - 'A'), Vertical, true
	}
	return 0, 0, 0, false
}

// parseRack splits a GCG rack ("ACEILRT", "?GOORRS") into lowercase letters and a blank
// count, ready for makeRack.
func parseRack(s string) (string, int) {
	var letters []rune
	blanks := 0
	for _, ch := range s {
		switch {
		case ch == '?':
			blanks++
		case ch >= 'A' && ch <= 'Z':
			letters = append(letters, ch+('a'-'A'))
		case ch >= 'a' && ch <= 'z':
			letters = append(letters, ch)
		}
	}
	return string(letters), blanks
}

// parseWord turns a GCG word into the newly-placed tiles starting at (row,col) along dir.
// "." marks an existing played-through tile (skipped); a lowercase letter is a blank.
func parseWord(word string, row, col int, dir Direction) []Placement {
	var ts []Placement
	for _, ch := range word {
		if ch != '.' {
			switch {
			case ch >= 'A' && ch <= 'Z':
				ts = append(ts, Placement{Row: row, Col: col, Letter: byte(ch - 'A')})
			case ch >= 'a' && ch <= 'z':
				ts = append(ts, Placement{Row: row, Col: col, Letter: byte(ch - 'a'), Blank: true})
			}
		}
		if dir == Horizontal {
			col++
		} else {
			row++
		}
	}
	return ts
}

func replayGCGOpts(t *testing.T, s *Solver, path string, opts PlayOptions) {
	f, err := os.Open(path)
	if err != nil {
		t.Fatal(err)
	}
	defer f.Close()

	b := board.New(s.rules.Rows, s.rules.Cols)
	total := map[string]int{}
	var last Move // the last applied play, undone by a phony withdrawal ("--")
	plays := 0
	sc := bufio.NewScanner(f)
	for sc.Scan() {
		line := sc.Text()
		if !strings.HasPrefix(line, ">") {
			continue // pragma or note
		}
		colon := strings.Index(line, ":")
		player := line[1:colon]
		toks := strings.Fields(line[colon+1:])
		if len(toks) < 2 {
			continue
		}

		// score = the +N/-N token; cumulative = the last token.
		want, _ := strconv.Atoi(strings.TrimPrefix(toks[len(toks)-2], "+"))
		cumul, _ := strconv.Atoi(toks[len(toks)-1])

		switch row, col, dir, ok := parsePos(toks[1]); {
		case ok: // a regular play: RACK POS WORD +SCORE CUMUL
			ts := parseWord(toks[2], row, col, dir)
			m, err := s.ScorePlayOpts(b, dir, ts, opts)
			if err != nil {
				t.Fatalf("%s: ScorePlay %q at %s: %v", path, toks[2], toks[1], err)
			}
			if m.Score != want {
				t.Errorf("%s: %q at %s scored %d, want %d", path, toks[2], toks[1], m.Score, want)
			}
			if opts.IgnoreCrossWords {
				// Single-word scoring must equal the standard scorer's main word (plus any
				// all-tiles bonus) and form no cross-words. Checking against the trusted
				// standard scorer keeps the fixture's numbers from drifting silently.
				std, _ := s.ScorePlay(b, dir, ts)
				if wantMain := std.Main.Score + std.Bonus; m.Score != wantMain {
					t.Errorf("%s: %q at %s single-word scored %d, want main+bonus %d", path, toks[2], toks[1], m.Score, wantMain)
				}
				if len(m.Cross) != 0 {
					t.Errorf("%s: %q at %s formed %d cross-words in single-word mode", path, toks[2], toks[1], len(m.Cross))
				}
			}
			// A dictionary-valid play (under the active rules) must also be produced by the
			// generator from the player's rack; phonies are correctly never generated.
			if _, verr := s.ValidatePlayOpts(b, dir, ts, opts); verr == nil {
				key, found := moveKey(dir, ts), false
				for _, mv := range s.GenerateMovesOpts(b, makeRack(parseRack(toks[0])), Both, opts) {
					if mv.Key() == key {
						found = true
						if mv.Score != want {
							t.Errorf("%s: generated %q at %s scored %d, want %d", path, toks[2], toks[1], mv.Score, want)
						}
						break
					}
				}
				if !found {
					t.Errorf("%s: generator did not produce %q at %s from rack %s", path, toks[2], toks[1], toks[0])
				}
			}
			Apply(b, m)
			last = m
			total[player] += m.Score
			plays++
		case toks[1] == "--": // a challenged-off phony: undo the previous play
			for _, p := range last.Tiles {
				b.Set(p.Row, p.Col, 0)
			}
			last = Move{}
			total[player] += want
		default: // pass, exchange, challenge bonus, time penalty, end-game rack adjustment
			total[player] += want
		}
		if total[player] != cumul {
			t.Errorf("%s: %s running total %d, want %d (after %q)", path, player, total[player], cumul, line)
		}
	}
	if err := sc.Err(); err != nil {
		t.Fatal(err)
	}
	if plays == 0 {
		t.Fatalf("%s: no plays parsed", path)
	}
	t.Logf("%s: %d scored plays, final totals %v", path, plays, total)
}
