package scratch_test

import (
	"encoding/binary"
	"testing"

	"github.com/drjones/quantum-arcade/pkg/fair"
	"github.com/drjones/quantum-arcade/pkg/scratch"
)

func TestCatalogIsCoherent(t *testing.T) {
	for _, ticket := range scratch.Catalog {
		if err := ticket.Validate(); err != nil {
			t.Errorf("%s: %v", ticket.ID, err)
		}
	}
}

// The published RTP must be a genuine 98%, not a rounded claim.
func TestPublishedRTPIsHonest(t *testing.T) {
	for _, ticket := range scratch.Catalog {
		rtp := ticket.RTPBasisPoints()
		if rtp != 9900 {
			t.Errorf("%s: RTP = %d bp, want 9900", ticket.ID, rtp)
		}
	}
}

// The core honesty test: the odds table shown to players must match the
// frequencies the generator actually produces. If someone edits a weight to
// tighten the game without updating the table, this fails.
func TestObservedFrequenciesMatchPublishedOdds(t *testing.T) {
	const trials = 2_000_000

	for _, ticket := range scratch.Catalog {
		counts := map[string]int{}
		for i := 0; i < trials; i++ {
			var seed [32]byte
			binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
			out := ticket.Play(seed, 1000)
			counts[out.TierName]++
		}

		for _, row := range ticket.Odds() {
			expected := float64(row.Weight) / float64(row.Denominator)
			observed := float64(counts[row.TierName]) / trials
			// Allow a relative tolerance that scales with rarity, since rare
			// tiers have proportionally more sampling noise.
			tolerance := 0.02 + 3/(expected*trials+1)
			if expected > 0 {
				diff := (observed - expected) / expected
				if diff < -tolerance || diff > tolerance {
					t.Errorf("%s/%s: published %.6f, observed %.6f (%.1f%% off)",
						ticket.ID, row.TierName, expected, observed, diff*100)
				}
			}
		}
	}
}

// Empirical return must match the published RTP, which is the claim that
// actually matters to a player.
func TestEmpiricalReturnMatchesPublishedRTP(t *testing.T) {
	const trials = 2_000_000
	const stake = 10_000

	for _, ticket := range scratch.Catalog {
		var paid int64
		for i := 0; i < trials; i++ {
			var seed [32]byte
			binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
			paid += ticket.Play(seed, stake).PayoutMsat
		}
		staked := int64(trials) * stake
		observedBP := paid * 10000 / staked
		published := int64(ticket.RTPBasisPoints())
		if observedBP < published-150 || observedBP > published+150 {
			t.Errorf("%s: observed RTP %d bp, published %d bp",
				ticket.ID, observedBP, published)
		}
	}
}

func TestOutcomeIsDeterministic(t *testing.T) {
	ticket := scratch.Catalog[0]
	var seed [32]byte
	copy(seed[:], "a-fixed-seed-for-this-ticket-abc")
	first := ticket.Play(seed, 5000)
	for i := 0; i < 100; i++ {
		if got := ticket.Play(seed, 5000); got.TierName != first.TierName ||
			got.PayoutMsat != first.PayoutMsat {
			t.Fatal("scratch outcome is not deterministic")
		}
	}
}

// The revealed cells must never contradict the payout: a winning ticket shows
// three of a kind, a losing ticket does not.
func TestRevealedCellsAgreeWithPayout(t *testing.T) {
	for _, ticket := range scratch.Catalog {
		for i := 0; i < 20000; i++ {
			var seed [32]byte
			binary.BigEndian.PutUint64(seed[0:8], uint64(i)*2654435761)
			for j := 8; j < 32; j++ {
				seed[j] = byte(i*j + j)
			}
			out := ticket.Play(seed, 1000)

			counts := map[int]int{}
			for _, c := range out.Cells {
				counts[c]++
			}
			hasThree := false
			for _, n := range counts {
				if n >= 3 {
					hasThree = true
					break
				}
			}
			if out.PayoutBP > 0 && !hasThree {
				t.Fatalf("%s: winning ticket (%s) shows no three-of-a-kind: %v",
					ticket.ID, out.TierName, out.Cells)
			}
			if out.PayoutBP == 0 && hasThree {
				t.Fatalf("%s: losing ticket shows three-of-a-kind: %v",
					ticket.ID, out.Cells)
			}
		}
	}
}

// Scratch tickets must be verifiable by the same commit-reveal path as the
// crash games.
func TestPlayFromRoundIsVerifiable(t *testing.T) {
	ticket := scratch.Catalog[0]
	server := fair.NewServerSeed()
	commitment := server.Commitment()
	pubkey := []byte("player-pubkey")

	out, proof := scratch.PlayFromRound(ticket, server, pubkey, 3, 1000)

	if !fair.VerifyCommitment(commitment, server) {
		t.Fatal("commitment does not verify")
	}
	// Independently recompute the outcome the way a client would.
	seed := fair.RoundSeed(server, fair.ClientSeed([][]byte{pubkey}), 3)
	if recomputed := ticket.Play(seed, 1000); recomputed.TierName != out.TierName {
		t.Fatalf("recomputed %q, server said %q", recomputed.TierName, out.TierName)
	}
	if proof.Nonce != 3 {
		t.Fatalf("proof nonce = %d, want 3", proof.Nonce)
	}
}
