// Package scratch implements instant scratch-ticket games.
//
// The defining property here is that the odds table shown to the player is
// derived from the same data that generates outcomes. There is no separate
// "marketing" table that could drift from reality: Odds() and Play() read the
// identical tier list, and a test asserts that observed frequencies match the
// published ones.
package scratch

import (
	"encoding/binary"
	"fmt"

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

// Denominator is the resolution of the odds table. Every tier's weight is
// expressed out of this, so odds are exact rationals rather than rounded
// percentages.
const Denominator uint64 = 1_000_000

// Tier is one prize band.
type Tier struct {
	// Name is what the player sees when they win it.
	Name string
	// Weight is the number of outcomes out of Denominator that land here.
	Weight uint64
	// PayoutBP is the prize as basis points of the stake: 10000 = 1x stake.
	PayoutBP uint64
}

// Ticket is a scratch game definition.
type Ticket struct {
	ID    string
	Name  string
	Blurb string
	// Cells is how many squares the player scratches, for presentation.
	Cells int
	Tiers []Tier
}

// Outcome is the result of one ticket.
type Outcome struct {
	TicketID   string `json:"ticket_id"`
	TierName   string `json:"tier_name"`
	PayoutBP   uint64 `json:"payout_bp"`
	PayoutMsat int64  `json:"payout_msat"`
	// Roll is the raw draw, published so the player can check the mapping.
	Roll uint64 `json:"roll"`
	// Cells are the revealed symbols, derived from the same seed.
	Cells []int `json:"cells"`
}

// OddsRow is one line of the published odds table.
type OddsRow struct {
	TierName    string `json:"tier"`
	PayoutBP    uint64 `json:"payout_bp"`
	Weight      uint64 `json:"weight"`
	Denominator uint64 `json:"denominator"`
	// OneIn is the human-readable "1 in N" figure, zero for the losing tier.
	OneIn uint64 `json:"one_in"`
}

// Validate reports whether the tier weights are coherent. A ticket whose
// weights do not sum to exactly Denominator would have undefined outcomes.
func (t Ticket) Validate() error {
	var sum uint64
	for _, tier := range t.Tiers {
		sum += tier.Weight
	}
	if sum != Denominator {
		return fmt.Errorf("scratch: ticket %q weights sum to %d, want %d",
			t.ID, sum, Denominator)
	}
	return nil
}

// Odds returns the published odds table, derived from the generating tiers.
func (t Ticket) Odds() []OddsRow {
	rows := make([]OddsRow, 0, len(t.Tiers))
	for _, tier := range t.Tiers {
		row := OddsRow{
			TierName:    tier.Name,
			PayoutBP:    tier.PayoutBP,
			Weight:      tier.Weight,
			Denominator: Denominator,
		}
		if tier.Weight > 0 {
			row.OneIn = Denominator / tier.Weight
		}
		rows = append(rows, row)
	}
	return rows
}

// RTPBasisPoints is the return to player in basis points, computed from the
// same tiers that generate outcomes. 9800 means 98%.
func (t Ticket) RTPBasisPoints() uint64 {
	var total uint64
	for _, tier := range t.Tiers {
		total += tier.Weight * tier.PayoutBP
	}
	return total / Denominator
}

// Play resolves one ticket from a round seed.
func (t Ticket) Play(seed [32]byte, stakeMsat int64) Outcome {
	// The first 8 bytes select the tier; later bytes decorate the cells, so
	// presentation can never alter the prize.
	roll := binary.BigEndian.Uint64(seed[0:8]) % Denominator

	var chosen Tier
	var cumulative uint64
	for _, tier := range t.Tiers {
		cumulative += tier.Weight
		if roll < cumulative {
			chosen = tier
			break
		}
	}

	return Outcome{
		TicketID:   t.ID,
		TierName:   chosen.Name,
		PayoutBP:   chosen.PayoutBP,
		PayoutMsat: stakeMsat * int64(chosen.PayoutBP) / 10000,
		Roll:       roll,
		Cells:      t.revealCells(seed, chosen),
	}
}

// symbolCount is the number of distinct symbols on a ticket face. With six
// symbols, a losing face can always be filled without an accidental
// three-of-a-kind for any ticket up to twelve cells.
const symbolCount = 6

// revealCells produces the symbols the player scratches off.
//
// The prize is already fixed by the roll; the cells are presentation. But they
// must never contradict the result, so a winning face always shows a genuine
// three-of-a-kind and a losing face never does.
func (t Ticket) revealCells(seed [32]byte, won Tier) []int {
	cells := make([]int, t.Cells)
	for i := range cells {
		cells[i] = -1
	}
	counts := make([]int, symbolCount)

	// A winning face gets its match placed first, on three adjacent positions
	// so they cannot collide with one another.
	winSym := -1
	if won.PayoutBP > 0 && t.Cells >= 3 {
		winSym = int(seed[31]) % symbolCount
		start := int(seed[30]) % t.Cells
		for k := 0; k < 3; k++ {
			cells[(start+k)%t.Cells] = winSym
			counts[winSym]++
		}
	}

	// Fill the remainder, never letting a non-winning symbol reach three.
	for i := range cells {
		if cells[i] != -1 {
			continue
		}
		pick := int(seed[(i+8)%32]) % symbolCount
		for attempts := 0; attempts < symbolCount; attempts++ {
			if pick != winSym && counts[pick] >= 2 {
				pick = (pick + 1) % symbolCount
				continue
			}
			if pick == winSym && winSym == -1 {
				pick = (pick + 1) % symbolCount
				continue
			}
			break
		}
		cells[i] = pick
		counts[pick]++
	}
	return cells
}

// Catalog is the set of tickets offered. Each is validated at startup.
var Catalog = []Ticket{
	{
		ID:    "nebula-nine",
		Name:  "Nebula Nine",
		Blurb: "Nine cells. Match three. Frequent small wins, modest top prize.",
		Cells: 9,
		// Weights are out of 1,000,000 and sum to it exactly. The weighted
		// payout sums to 9.9e9, which is an RTP of exactly 99%.
		Tiers: []Tier{
			{Name: "No win", Weight: 458_400, PayoutBP: 0},
			{Name: "Stake back", Weight: 350_000, PayoutBP: 10_000},
			{Name: "Double", Weight: 155_000, PayoutBP: 20_000},
			{Name: "Five times", Weight: 30_000, PayoutBP: 50_000},
			{Name: "Twenty times", Weight: 6_000, PayoutBP: 200_000},
			{Name: "Nebula jackpot", Weight: 600, PayoutBP: 1_000_000},
		},
	},
	{
		ID:    "singularity",
		Name:  "Singularity",
		Blurb: "Six cells. Rarely pays, but the top prize is five hundred times.",
		Cells: 6,
		// Same 99% RTP as Nebula Nine, but concentrated in the rare tiers:
		// you lose far more often, and the top prize is 500x.
		Tiers: []Tier{
			{Name: "No win", Weight: 865_530, PayoutBP: 0},
			{Name: "Stake back", Weight: 60_000, PayoutBP: 10_000},
			{Name: "Triple", Weight: 45_000, PayoutBP: 30_000},
			{Name: "Ten times", Weight: 26_000, PayoutBP: 100_000},
			{Name: "Hundred times", Weight: 3_000, PayoutBP: 1_000_000},
			{Name: "Singularity", Weight: 470, PayoutBP: 5_000_000},
		},
	},
}

// ByID looks up a ticket in the catalog.
func ByID(id string) (Ticket, bool) {
	for _, t := range Catalog {
		if t.ID == id {
			return t, true
		}
	}
	return Ticket{}, false
}

// PlayFromRound is the entry point used by the server: it derives the seed
// through the same commit-reveal machinery the crash games use, so scratch
// tickets are verifiable by exactly the same method.
func PlayFromRound(t Ticket, server fair.ServerSeed, pubkey []byte, nonce uint64, stakeMsat int64) (Outcome, fair.Proof) {
	client := fair.ClientSeed([][]byte{pubkey})
	seed := fair.RoundSeed(server, client, nonce)
	return t.Play(seed, stakeMsat), fair.BuildProof(server, [][]byte{pubkey}, nonce)
}
