package engine

import (
	"fmt"

	"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"
)

// scorelessLimit is the number of consecutive scoreless turns (passes and
// exchanges) that ends a game, per docs/ARCHITECTURE.md §6.
const scorelessLimit = 6

// EndReason explains why a game finished.
type EndReason uint8

const (
	// EndNotOver marks a game still in progress.
	EndNotOver EndReason = iota
	// EndOutOfTiles fires when the bag is empty and a player empties their rack.
	EndOutOfTiles
	// EndScoreless fires after scorelessLimit consecutive passes/exchanges.
	EndScoreless
	// EndResign fires when a player resigns.
	EndResign
	// EndAborted fires when a committed game can no longer be reconstructed from its
	// journal — a recorded move became illegal under tightened rules — and is closed as a
	// draw rather than left unopenable. See (*Game).Abort.
	EndAborted
)

// String renders the end reason for logs and diagnostics.
func (r EndReason) String() string {
	switch r {
	case EndNotOver:
		return "not_over"
	case EndOutOfTiles:
		return "out_of_tiles"
	case EndScoreless:
		return "scoreless"
	case EndResign:
		return "resign"
	case EndAborted:
		return "aborted"
	}
	return "unknown"
}

// DropoutTiles is the per-game disposition of a dropped-out player's rack when
// they resign or time out of a game with three or more seats: the tiles are
// either removed from play or returned to the bag. It is agreed at game creation
// (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on
// the first drop-out. In both dispositions the leaver's rack is never revealed to
// the remaining players.
type DropoutTiles uint8

const (
	// DropoutRemove removes the dropped player's tiles from play; this is the
	// default, so the zero value matches it.
	DropoutRemove DropoutTiles = iota
	// DropoutReturn returns the dropped player's tiles to the bag, where the
	// remaining players may draw them.
	DropoutReturn
)

// String renders the disposition as the stable label the game domain persists.
func (d DropoutTiles) String() string {
	if d == DropoutReturn {
		return "return"
	}
	return "remove"
}

// ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting
// ErrUnknownDropoutTiles for an unrecognised value.
func ParseDropoutTiles(s string) (DropoutTiles, error) {
	switch s {
	case "remove":
		return DropoutRemove, nil
	case "return":
		return DropoutReturn, nil
	}
	return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s)
}

// Options configures a new game.
type Options struct {
	// Variant selects the rules and dictionary.
	Variant Variant
	// Version pins the dictionary version; empty selects the registry's latest.
	Version string
	// Players is the number of seats, 2 to 4.
	Players int
	// Seed seeds the tile bag, making the game reproducible.
	Seed int64
	// DropoutTiles is the disposition of a dropped-out player's tiles in a game
	// with three or more seats; the zero value removes them from play.
	DropoutTiles DropoutTiles
	// MultipleWordsPerTurn selects standard Scrabble when true: every cross-word a
	// play forms must be a valid word and is scored. When false the game uses the
	// "single word per turn" rule — only the main word is validated and scored and
	// perpendicular cross-words are ignored. Callers always set this explicitly; the
	// zero value (false) is the single-word rule.
	MultipleWordsPerTurn bool
	// NoRepeatWords enables the "Эрудит" rule under which a word already laid on the
	// board cannot be laid again (see norepeat.go). It is pinned per game rather than
	// derived from the variant, so a game started before the rule existed replays and
	// scores unchanged; the zero value (false) is the unrestricted rule.
	NoRepeatWords bool
}

// Game is the in-memory state of a single match and the pure rules engine over
// it. It owns the board, the bag, each player's hand, the running scores, whose
// turn it is and the decoded move log, and it detects the end of the game. It
// performs no scheduling, persistence or I/O and is not safe for concurrent use.
type Game struct {
	solver  *scrabble.Solver
	rules   *rules.Ruleset
	variant Variant
	version string

	board         *board.Board
	bag           *Bag
	hands         [][]byte // per player, alphabet-index bytes with blankTile for blanks
	scores        []int
	toMove        int
	scorelessRun  int
	over          bool
	reason        EndReason
	resigned      []bool       // per seat; a resigned seat is skipped and cannot win
	dropoutTiles  DropoutTiles // disposition of a resigned seat's tiles
	multipleWords bool         // false = single-word rule (perpendicular cross-words ignored)
	noRepeatWords bool         // true = a word already on the board cannot be laid again
	log           []MoveRecord
}

// New starts a game described by opts over a dictionary from reg. It resolves
// the solver (failing with ErrUnknownVariant/ErrUnknownVersion), builds an empty
// board and a seeded bag, and deals each player a full rack.
func New(reg *Registry, opts Options) (*Game, error) {
	if opts.Players < 2 || opts.Players > 4 {
		return nil, fmt.Errorf("engine: players must be between 2 and 4, got %d", opts.Players)
	}
	var (
		solver  *scrabble.Solver
		version = opts.Version
		err     error
	)
	if version == "" {
		version, solver, err = reg.Latest(opts.Variant)
	} else {
		solver, err = reg.Solver(opts.Variant, version)
	}
	if err != nil {
		return nil, err
	}

	rs := solver.Rules()
	g := &Game{
		solver:        solver,
		rules:         rs,
		variant:       opts.Variant,
		version:       version,
		board:         board.New(rs.Rows, rs.Cols),
		bag:           NewBag(rs, opts.Seed),
		hands:         make([][]byte, opts.Players),
		scores:        make([]int, opts.Players),
		resigned:      make([]bool, opts.Players),
		dropoutTiles:  opts.DropoutTiles,
		multipleWords: opts.MultipleWordsPerTurn,
		noRepeatWords: opts.NoRepeatWords,
	}
	for i := range g.hands {
		g.hands[i] = g.bag.Draw(rs.RackSize)
	}
	return g, nil
}

// playOpts returns the solver play options for this game's rules. Under the single-word
// rule (multipleWords false) the solver ignores perpendicular cross-words: only the main
// word is validated and scored, and move generation is not constrained by cross-words.
func (g *Game) playOpts() scrabble.PlayOptions {
	return scrabble.PlayOptions{IgnoreCrossWords: !g.multipleWords}
}

// Play validates and applies the current player's placement of tiles forming a
// word in direction dir. It scores the play, refills the rack from the bag,
// advances the turn and may end the game. It returns ErrTilesNotOnRack when the
// player does not hold the tiles, ErrIllegalPlay when the solver rejects the
// play, and ErrGameOver on a finished game.
func (g *Game) Play(dir scrabble.Direction, tiles []scrabble.Placement) (MoveRecord, error) {
	if g.over {
		return MoveRecord{}, ErrGameOver
	}
	player := g.toMove
	if err := g.checkHolds(player, placementTiles(tiles)); err != nil {
		return MoveRecord{}, err
	}
	move, err := g.solver.ValidatePlayOpts(g.board, dir, tiles, g.playOpts())
	if err != nil {
		return MoveRecord{}, fmt.Errorf("%w: %v", ErrIllegalPlay, err)
	}
	move, err = g.noRepeat(move, g.playedWords())
	if err != nil {
		return MoveRecord{}, err
	}

	scrabble.Apply(g.board, move)
	g.removeFromHand(player, placementTiles(tiles))
	g.scores[player] += move.Score
	g.refill(player)
	g.scorelessRun = 0

	rec := g.recordPlay(player, move)
	g.log = append(g.log, rec)

	if len(g.hands[player]) == 0 && g.bag.Len() == 0 {
		g.finish(EndOutOfTiles)
	} else {
		g.advance()
	}
	return rec, nil
}

// Pass forfeits the current player's turn. It extends the scoreless run, which
// may end the game (EndScoreless), and otherwise advances the turn.
func (g *Game) Pass() (MoveRecord, error) {
	if g.over {
		return MoveRecord{}, ErrGameOver
	}
	player := g.toMove
	g.scorelessRun++
	rec := MoveRecord{Player: player, Action: ActionPass, Total: g.scores[player]}
	g.log = append(g.log, rec)
	g.endTurnAfterScoreless()
	return rec, nil
}

// Exchange swaps the current player's tiles (alphabet-index bytes, blankTile for
// blanks) for fresh ones. It is legal only while the bag holds at least a full
// rack. The fresh tiles are drawn before the swapped ones return, so a player
// cannot draw back their own tiles. It extends the scoreless run, which may end
// the game (EndScoreless).
func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
	if g.over {
		return MoveRecord{}, ErrGameOver
	}
	if len(tiles) == 0 {
		return MoveRecord{}, ErrNothingToExchange
	}
	if g.bag.Len() < g.rules.RackSize {
		return MoveRecord{}, ErrNotEnoughTilesToExchange
	}
	player := g.toMove
	if err := g.checkHolds(player, tiles); err != nil {
		return MoveRecord{}, err
	}

	g.removeFromHand(player, tiles)
	g.hands[player] = append(g.hands[player], g.bag.Draw(len(tiles))...)
	g.bag.Return(tiles)
	g.scorelessRun++

	rec := MoveRecord{Player: player, Action: ActionExchange, Count: len(tiles), Total: g.scores[player]}
	g.log = append(g.log, rec)
	g.endTurnAfterScoreless()
	return rec, nil
}

// Resign drops the current player out of the game. The resigner always forfeits
// the win and keeps their accumulated score (it is neither zeroed nor docked a
// rack adjustment), and their rack is disposed of per the game's DropoutTiles
// setting without ever being revealed to the remaining players. In a game with
// three or more seats the others play on with the resigned seat skipped, until
// one active seat is left (it wins) or the game ends by the ordinary conditions;
// the game finishes with EndResign only once a single active seat remains. A
// two-player game therefore ends on the first resignation, the other player
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
	return g.ResignSeat(g.toMove)
}

// ResignSeat resigns a specific seat regardless of whose turn it is, so a player
// may forfeit on the opponent's turn. The resigning seat always loses (winner()
// skips resigned seats). The turn cursor only advances when the seat that resigned
// was the one to move; resigning an off-turn seat leaves the current player's turn
// intact. It returns ErrGameOver on a finished game or for an out-of-range or
// already-resigned seat.
func (g *Game) ResignSeat(seat int) (MoveRecord, error) {
	if g.over {
		return MoveRecord{}, ErrGameOver
	}
	if seat < 0 || seat >= len(g.hands) || g.resigned[seat] {
		return MoveRecord{}, ErrGameOver
	}
	g.resigned[seat] = true
	g.disposeHand(seat)
	rec := MoveRecord{Player: seat, Action: ActionResign, Total: g.scores[seat]}
	g.log = append(g.log, rec)
	if g.activeCount() <= 1 {
		g.finish(EndResign)
	} else if seat == g.toMove {
		g.advance()
	}
	return rec, nil
}

// GenerateMoves returns every legal play for the current player's rack, ranked
// by descending score. It is empty when the player has no legal play.
func (g *Game) GenerateMoves() []scrabble.Move {
	return g.noRepeatFilter(g.solver.GenerateMovesOpts(g.board, g.rackOf(g.toMove), scrabble.Both, g.playOpts()))
}

// Hint returns the highest-scoring legal play for the current player and true,
// or the zero move and false when there is none. It is the top-1 move the
// one-per-game hint reveals.
func (g *Game) Hint() (scrabble.Move, bool) {
	moves := g.GenerateMoves()
	if len(moves) == 0 {
		return scrabble.Move{}, false
	}
	return moves[0], true
}

// Variant returns the variant the game is played under.
func (g *Game) Variant() Variant { return g.variant }

// Version returns the pinned dictionary version.
func (g *Game) Version() string { return g.version }

// Players returns the number of seats in the game.
func (g *Game) Players() int { return len(g.hands) }

// ToMove returns the index of the player whose turn it is. On a finished game it
// is the player who made the final move.
func (g *Game) ToMove() int { return g.toMove }

// Over reports whether the game has finished.
func (g *Game) Over() bool { return g.over }

// Reason returns why the game finished, or EndNotOver while it is in progress.
func (g *Game) Reason() EndReason { return g.reason }

// Score returns the current score of the player at index player.
func (g *Game) Score(player int) int { return g.scores[player] }

// BagLen returns the number of tiles left in the bag.
func (g *Game) BagLen() int { return g.bag.Len() }

// BoardClone returns a deep copy of the board, safe for the caller to read or
// mutate without affecting the game.
func (g *Game) BoardClone() *board.Board { return g.board.Clone() }

// Log returns a copy of the dictionary-independent move log.
func (g *Game) Log() []MoveRecord {
	out := make([]MoveRecord, len(g.log))
	copy(out, g.log)
	return out
}

// Result is the outcome of a finished game.
type Result struct {
	Over   bool
	Reason EndReason
	Scores []int
	// Winner is the index of the single highest score, or -1 on a tie or while
	// the game is unfinished.
	Winner int
}

// Result reports the current outcome. Final scores already include the standard
// end-game rack adjustment applied when the game finished.
func (g *Game) Result() Result {
	scores := make([]int, len(g.scores))
	copy(scores, g.scores)
	return Result{Over: g.over, Reason: g.reason, Scores: scores, Winner: g.winner()}
}

// finish marks the game over with reason and applies the end-game rack
// adjustment to the scores.
func (g *Game) finish(reason EndReason) {
	g.over = true
	g.reason = reason
	g.applyEndAdjustment(reason)
}

// Abort closes a still-running game as a draw with EndAborted and no rack adjustment. The
// service calls it when a committed game can no longer be reconstructed from its journal —
// a recorded move became illegal under tightened rules — so the game ends gracefully
// instead of being left unopenable. It is a no-op on an already-finished game.
func (g *Game) Abort() {
	if g.over {
		return
	}
	g.finish(EndAborted)
}

// applyEndAdjustment settles the unplayed racks. When a player goes out (bag
// empty, rack empty) they gain the sum of every opponent's rack value and each
// opponent loses their own. A scoreless stalemate forfeits each player's own
// rack value. A resignation freezes the scores: the win is decided by winner
// (which excludes the resigner), so no rack adjustment is applied and the
// resigner keeps their accumulated score.
func (g *Game) applyEndAdjustment(reason EndReason) {
	switch reason {
	case EndOutOfTiles:
		out := g.toMove
		var bonus int
		for i := range g.hands {
			if i == out {
				continue
			}
			v := g.rackValue(i)
			g.scores[i] -= v
			bonus += v
		}
		g.scores[out] += bonus
	case EndScoreless:
		for i := range g.hands {
			g.scores[i] -= g.rackValue(i)
		}
	}
}

// endTurnAfterScoreless ends the game when the scoreless run reaches the limit,
// otherwise advances the turn. Used by Pass and Exchange.
func (g *Game) endTurnAfterScoreless() {
	if g.scorelessRun >= scorelessLimit {
		g.finish(EndScoreless)
		return
	}
	g.advance()
}

// advance moves play to the next active (non-resigned) seat. While a game is in
// progress at least two seats are active, so a next active seat always exists;
// the loop leaves toMove unchanged in the degenerate all-but-one-resigned case,
// which Resign turns into a finished game instead.
func (g *Game) advance() {
	n := len(g.hands)
	for i := 1; i <= n; i++ {
		next := (g.toMove + i) % n
		if !g.resigned[next] {
			g.toMove = next
			return
		}
	}
}

// activeCount returns the number of seats that have not resigned.
func (g *Game) activeCount() int {
	n := 0
	for _, r := range g.resigned {
		if !r {
			n++
		}
	}
	return n
}

// disposeHand empties a resigned player's rack per the game's DropoutTiles
// setting: it returns the tiles to the bag or removes them from play. Either way
// the hand is cleared, so the end-game rack adjustment ignores the seat and the
// rack is never exposed.
func (g *Game) disposeHand(player int) {
	if g.dropoutTiles == DropoutReturn {
		g.bag.Return(g.hands[player])
	}
	g.hands[player] = nil
}

// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished. Resigned (dropped-out) seats are
// always excluded, so a two-player game returns the remaining player even when
// the resigner led on score, and a multi-player game never awards the win to a
// seat that left.
func (g *Game) winner() int {
	if !g.over {
		return -1
	}
	if g.reason == EndAborted {
		return -1 // an aborted game is a draw regardless of the running scores
	}
	best, tie := -1, false
	for i := range g.scores {
		if g.resigned[i] {
			continue
		}
		switch {
		case best == -1 || g.scores[i] > g.scores[best]:
			best, tie = i, false
		case g.scores[i] == g.scores[best]:
			tie = true
		}
	}
	if tie {
		return -1
	}
	return best
}

// rackOf builds a generation rack from player's hand.
func (g *Game) rackOf(player int) rack.Rack {
	r := rack.New(g.rules.Size())
	for _, t := range g.hands[player] {
		if t == blankTile {
			r.AddBlank()
		} else {
			r.Add(t)
		}
	}
	return r
}

// rackValue sums the tile values left on player's hand; blanks count zero.
func (g *Game) rackValue(player int) int {
	var v int
	for _, t := range g.hands[player] {
		if t != blankTile {
			v += g.rules.Values[t]
		}
	}
	return v
}

// checkHolds reports ErrTilesNotOnRack unless player holds every tile in want.
func (g *Game) checkHolds(player int, want []byte) error {
	avail := tileCounts(g.hands[player])
	for tile, n := range tileCounts(want) {
		if avail[tile] < n {
			return ErrTilesNotOnRack
		}
	}
	return nil
}

// removeFromHand takes one tile per entry of used off player's hand.
func (g *Game) removeFromHand(player int, used []byte) {
	hand := g.hands[player]
	for _, t := range used {
		for i, h := range hand {
			if h == t {
				hand = append(hand[:i], hand[i+1:]...)
				break
			}
		}
	}
	g.hands[player] = hand
}

// refill draws from the bag until player's hand is full or the bag is empty.
func (g *Game) refill(player int) {
	if need := g.rules.RackSize - len(g.hands[player]); need > 0 {
		g.hands[player] = append(g.hands[player], g.bag.Draw(need)...)
	}
}

// placementTiles maps placements to the tiles they consume (blankTile for blanks).
func placementTiles(tiles []scrabble.Placement) []byte {
	out := make([]byte, len(tiles))
	for i, p := range tiles {
		if p.Blank {
			out[i] = blankTile
		} else {
			out[i] = p.Letter
		}
	}
	return out
}

// tileCounts tallies a multiset of tiles by value.
func tileCounts(tiles []byte) map[byte]int {
	m := make(map[byte]int, len(tiles))
	for _, t := range tiles {
		m[t]++
	}
	return m
}
