package hangman

import (
	"encoding/json"
	"math/rand/v2"
	"strings"
	"testing"
)

// tierShort is the tier most tests play on: base 2.6.
func tierShort(t *testing.T) Tier {
	t.Helper()
	tr, err := TierBySlug("short")
	if err != nil {
		t.Fatal(err)
	}
	return tr
}

// play runs a sequence of single-letter guesses against a pinned phrase.
func play(t *testing.T, phrase string, bet int64, rake float64, guesses ...string) State {
	t.Helper()
	s, _, err := start(bet, tierShort(t), phrase, rake)
	if err != nil {
		t.Fatal(err)
	}
	for _, g := range guesses {
		next, _, err := ApplyMove(s, Move{Letter: g})
		if err != nil {
			t.Fatalf("guess %q: %v", g, err)
		}
		s = next
	}
	return s
}

func TestStartShowsScaffoldingOnly(t *testing.T) {
	s, evs, err := start(100, tierShort(t), "Insert Coin", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	if got, want := s.Masked(), "______ ____"; got != want {
		t.Errorf("masked = %q, want %q — the space is scaffolding and shows, the letters don't", got, want)
	}
	if len(evs) != 1 || evs[0].Kind != "start" {
		t.Errorf("events = %+v, want one start", evs)
	}
	if s.Lives() != MaxWrong {
		t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong)
	}
}

func TestPunctuationAndDigitsAreScaffoldingOrNot(t *testing.T) {
	// Punctuation shows from the start; a digit is guessable like a letter.
	s, _, err := start(100, tierShort(t), "Level 9!", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	if got, want := s.Masked(), "_____ _!"; got != want {
		t.Fatalf("masked = %q, want %q", got, want)
	}
	next, _, err := ApplyMove(s, Move{Letter: "9"})
	if err != nil {
		t.Fatalf("guessing a digit: %v", err)
	}
	if got, want := next.Masked(), "_____ 9!"; got != want {
		t.Errorf("masked = %q, want %q — a digit is a guess", got, want)
	}
}

func TestHitRevealsEveryOccurrence(t *testing.T) {
	// "Blue Shell" has an l at 1, 8 and 9. One guess turns over all three.
	s := play(t, "Blue Shell", 100, 0.05, "l")
	if got, want := s.Masked(), "_l__ ___ll"; got != want {
		t.Errorf("masked = %q, want %q — every l turns over, not just the first", got, want)
	}
	if s.Lives() != MaxWrong {
		t.Errorf("a hit cost a life: lives = %d", s.Lives())
	}
}

func TestMissCostsALifeAndTheMultiple(t *testing.T) {
	tr := tierShort(t)
	s := play(t, "Insert Coin", 100, 0.05, "z")
	if s.Lives() != MaxWrong-1 {
		t.Errorf("lives = %d, want %d", s.Lives(), MaxWrong-1)
	}
	want := tr.Base * 0.9
	if got := s.Multiple(); got != want {
		t.Errorf("multiple = %v, want %v — one wrong guess is a tenth of the base", got, want)
	}
}

func TestRepeatedLetterIsRefusedNotPunished(t *testing.T) {
	s := play(t, "Insert Coin", 100, 0.05, "z")
	_, _, err := ApplyMove(s, Move{Letter: "z"})
	if err != ErrAlreadyTried {
		t.Fatalf("err = %v, want ErrAlreadyTried", err)
	}
	// And the state the caller holds is untouched: a mis-click is not a life.
	if s.Lives() != MaxWrong-1 {
		t.Errorf("the refused move moved the state: lives = %d", s.Lives())
	}
}

func TestSixWrongHangsYouAndPaysNothing(t *testing.T) {
	s := play(t, "Insert Coin", 100, 0.05, "z", "x", "q", "y", "w", "k")
	if s.Phase != PhaseDone || s.Outcome != OutcomeHung {
		t.Fatalf("phase/outcome = %s/%s, want done/hung", s.Phase, s.Outcome)
	}
	if s.Payout != 0 {
		t.Errorf("payout = %d, want 0", s.Payout)
	}
	if s.Net() != -100 {
		t.Errorf("net = %d, want -100", s.Net())
	}
	if strings.Contains(s.Masked(), "_") {
		t.Error("a lost game must still show the phrase — being hung without being told the answer is the one thing hangman can't do")
	}
}

func TestFillingTheLastLetterWinsCleanAtFullMultiple(t *testing.T) {
	// "Blue Shell" — every distinct letter, no misses.
	s := play(t, "Blue Shell", 100, 0, "b", "l", "u", "e", "s", "h")
	if s.Outcome != OutcomeFilled {
		t.Fatalf("outcome = %s, want filled", s.Outcome)
	}
	// Base 2.6, no wrong guesses, no rake: 100 -> 260.
	if s.Payout != 260 {
		t.Errorf("payout = %d, want 260", s.Payout)
	}
	if s.Net() != 160 {
		t.Errorf("net = %d, want 160", s.Net())
	}
}

func TestSolveOutrightWinsAtTheMultipleYouStillHold(t *testing.T) {
	s, _, err := start(100, tierShort(t), "Insert Coin", 0)
	if err != nil {
		t.Fatal(err)
	}
	// Two wrong first: 2.6 -> 2.08.
	for _, g := range []string{"z", "x"} {
		s, _, err = ApplyMove(s, Move{Letter: g})
		if err != nil {
			t.Fatal(err)
		}
	}
	s, _, err = ApplyMove(s, Move{Solve: "insert coin"})
	if err != nil {
		t.Fatal(err)
	}
	if s.Outcome != OutcomeSolved {
		t.Fatalf("outcome = %s, want solved", s.Outcome)
	}
	if s.Payout != 208 {
		t.Errorf("payout = %d, want 208 — solving pays the multiple you still hold, not the base", s.Payout)
	}
}

func TestSolveIgnoresCaseAndPunctuation(t *testing.T) {
	s, _, err := start(100, tierShort(t), "How are you gentlemen!!", 0)
	if err != nil {
		t.Fatal(err)
	}
	s, _, err = ApplyMove(s, Move{Solve: "HOW ARE YOU GENTLEMEN"})
	if err != nil {
		t.Fatal(err)
	}
	if s.Outcome != OutcomeSolved {
		t.Errorf("outcome = %s — a solve shouldn't turn on shouting or the exclamation marks", s.Outcome)
	}
}

func TestWrongSolveCostsALife(t *testing.T) {
	s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	s, _, err = ApplyMove(s, Move{Solve: "insert quarter"})
	if err != nil {
		t.Fatal(err)
	}
	if s.Lives() != MaxWrong-1 {
		t.Errorf("lives = %d, want %d — a free solve is a solve you spam until it lands", s.Lives(), MaxWrong-1)
	}
	if s.Phase != PhasePlaying {
		t.Errorf("phase = %s, want playing", s.Phase)
	}
}

func TestAWinNeverReturnsLessThanTheStake(t *testing.T) {
	// Long pays 1.6, and five wrong guesses would take it to 0.8 — under water.
	long, err := TierBySlug("long")
	if err != nil {
		t.Fatal(err)
	}
	s, _, err := start(100, long, "the quick brown fox jumps over the lazy dog and keeps going", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	// Five clean misses. Every letter in the pangram is in the pangram, so the
	// only things guaranteed to miss it are digits.
	for _, g := range []string{"1", "2", "3", "4", "5"} {
		s, _, err = ApplyMove(s, Move{Letter: g})
		if err != nil {
			t.Fatal(err)
		}
	}
	if got := s.Multiple(); got != 1 {
		t.Fatalf("multiple = %v, want 1 — the floor", got)
	}
	s, _, err = ApplyMove(s, Move{Solve: "the quick brown fox jumps over the lazy dog and keeps going"})
	if err != nil {
		t.Fatal(err)
	}
	if s.Payout != 100 {
		t.Errorf("payout = %d, want 100 — a win hands back the stake at worst, never less", s.Payout)
	}
	if s.Rake != 0 {
		t.Errorf("rake = %d, want 0 — there was no profit to take a cut of", s.Rake)
	}
}

func TestRakeComesOutOfWinningsNeverTheStake(t *testing.T) {
	s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
	// Total 260, profit 160, rake 5% = 8, so 252 comes back.
	if s.Rake != 8 {
		t.Errorf("rake = %d, want 8 (5%% of the 160 profit)", s.Rake)
	}
	if s.Payout != 252 {
		t.Errorf("payout = %d, want 252", s.Payout)
	}
	if s.Payout < s.Bet {
		t.Error("the rake ate into the stake")
	}
}

func TestWhatTheFeltQuotesIsWhatTheHousePays(t *testing.T) {
	// The table shows Pays() while the game is still running. If that number and
	// the one settle() lands on ever came apart, the felt would be advertising a
	// payout the house doesn't honour. Walk a game and check they agree at every
	// step — including after a miss, which is where they'd drift.
	for _, wrong := range []int{0, 1, 2, 3} {
		s, _, err := start(200, tierShort(t), "Blue Shell", 0.05)
		if err != nil {
			t.Fatal(err)
		}
		misses := []string{"z", "x", "q", "y"}
		for i := 0; i < wrong; i++ {
			s, _, err = ApplyMove(s, Move{Letter: misses[i]})
			if err != nil {
				t.Fatal(err)
			}
		}
		quoted := s.Pays() // what the felt is telling the player right now

		s, _, err = ApplyMove(s, Move{Solve: "blue shell"})
		if err != nil {
			t.Fatal(err)
		}
		if s.Payout != quoted {
			t.Errorf("%d wrong: felt quoted %d, house paid %d", wrong, quoted, s.Payout)
		}
		if s.Payout != s.Bet+s.Net() {
			t.Errorf("%d wrong: payout %d doesn't square with net %d on a %d bet", wrong, s.Payout, s.Net(), s.Bet)
		}
	}
}

func TestMoveOnAFinishedGameIsRefused(t *testing.T) {
	s := play(t, "Blue Shell", 100, 0.05, "b", "l", "u", "e", "s", "h")
	if _, _, err := ApplyMove(s, Move{Letter: "z"}); err != ErrGameOver {
		t.Errorf("err = %v, want ErrGameOver", err)
	}
}

func TestGarbageGuessesAreRefused(t *testing.T) {
	s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	for _, m := range []Move{{Letter: "ab"}, {Letter: "!"}, {Letter: " "}, {}} {
		if _, _, err := ApplyMove(s, m); err == nil {
			t.Errorf("move %+v was accepted", m)
		}
	}
}

func TestApplyMoveDoesNotTouchTheCallersState(t *testing.T) {
	s, _, err := start(100, tierShort(t), "Insert Coin", 0.05)
	if err != nil {
		t.Fatal(err)
	}
	before := s.Masked()
	if _, _, err := ApplyMove(s, Move{Letter: "i"}); err != nil {
		t.Fatal(err)
	}
	if s.Masked() != before {
		t.Errorf("applying a move mutated the state it was given: %q -> %q", before, s.Masked())
	}
	if len(s.Tried) != 0 {
		t.Errorf("tried = %v, want empty — the caller's state was written through", s.Tried)
	}
}

func TestStateSurvivesASerializationRoundTrip(t *testing.T) {
	// A redeploy mid-game is a JSON round-trip. It has to come back playable.
	s := play(t, "Insert Coin", 100, 0.05, "i", "z")
	blob, err := json.Marshal(s)
	if err != nil {
		t.Fatal(err)
	}
	var back State
	if err := json.Unmarshal(blob, &back); err != nil {
		t.Fatal(err)
	}
	if back.Masked() != s.Masked() || back.Lives() != s.Lives() || back.Multiple() != s.Multiple() {
		t.Fatalf("round trip changed the game: %+v", back)
	}
	next, _, err := ApplyMove(back, Move{Solve: "insert coin"})
	if err != nil {
		t.Fatal(err)
	}
	if next.Outcome != OutcomeSolved {
		t.Errorf("a game restored from JSON couldn't be finished: %s", next.Outcome)
	}
}

func TestNewIsReproducibleFromItsSeed(t *testing.T) {
	// The seed is in the audit log so a disputed game can be replayed. That is
	// only true if the phrase comes back the same.
	one, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
	if err != nil {
		t.Fatal(err)
	}
	two, _, err := New(100, tierShort(t), 0.05, rand.New(rand.NewPCG(7, 9)))
	if err != nil {
		t.Fatal(err)
	}
	if one.Phrase != two.Phrase {
		t.Errorf("same seed dealt different phrases: %q vs %q", one.Phrase, two.Phrase)
	}
}

func TestNewDrawsFromTheRightShelf(t *testing.T) {
	rng := rand.New(rand.NewPCG(1, 2))
	for _, tr := range Tiers {
		for i := 0; i < 50; i++ {
			s, _, err := New(100, tr, 0.05, rng)
			if err != nil {
				t.Fatalf("%s: %v", tr.Slug, err)
			}
			if n := len([]rune(s.Phrase)); n < tr.Min || n > tr.Max {
				t.Fatalf("%s drew %q (%d chars), outside %d-%d", tr.Slug, s.Phrase, n, tr.Min, tr.Max)
			}
		}
	}
}

func TestEveryTierHasAShelfWorthPlaying(t *testing.T) {
	// If someone edits phrases.txt and empties a tier, the game 500s at the
	// table rather than here. Catch it here.
	for _, tr := range Tiers {
		if n := Shelf(tr.Slug); n < 20 {
			t.Errorf("tier %s has %d phrases — too few to not repeat", tr.Slug, n)
		}
	}
}
