// Package fees computes the operator's cut and the rounding residue.
//
// Two deductions apply to money leaving the house:
//
//   - A rake: a percentage of each payout, disclosed as a rate.
//   - Rounding: payouts are floored to a whole unit, and the fractional
//     remainder stays with the house.
//
// Neither is hidden, and neither can be hidden. Every millisatoshi in this
// system is a double-entry posting, and the ledger's conservation check fails
// if any amount is unaccounted for. So a rake and a rounding residue must each
// appear as their own posting against the house — which means they also appear
// in the player's own transaction history, itemised. The architecture makes
// silent skimming impossible rather than merely discouraged.
//
// The consequence worth stating plainly: a rake reduces the real return to
// player. A game advertising 99% that then takes 1% of wins does not return
// 99%. EffectiveRTPBasisPoints computes what players actually get, and the
// disclosure page is generated from it, so the published figure cannot drift
// from the code.
package fees

import "fmt"

// Schedule is the operator's fee configuration.
type Schedule struct {
	// RakeBP is taken from each payout, in basis points. 100 = 1%.
	RakeBP int64

	// RoundToMsat floors payouts to a multiple of this. 1000 rounds to whole
	// satoshis. Set to 1 (or 0) to disable rounding entirely.
	RoundToMsat int64

	// MinPayoutMsat is the smallest payout worth making. Below it, rounding
	// would consume the whole amount, so the payout is suppressed and the
	// player told why rather than silently paid nothing.
	MinPayoutMsat int64
}

// DefaultSchedule is deliberately small. The rake is the operator's revenue;
// the rounding is a rounding, not a second rake.
func DefaultSchedule() Schedule {
	return Schedule{
		RakeBP:        100,   // 1% of winnings
		RoundToMsat:   1_000, // whole satoshis
		MinPayoutMsat: 1_000,
	}
}

// NoFees disables both deductions, for testing and for a house that wants to
// run the arcade at cost.
func NoFees() Schedule {
	return Schedule{RakeBP: 0, RoundToMsat: 1, MinPayoutMsat: 0}
}

// Split is the breakdown of a single payout.
type Split struct {
	// GrossMsat is what the player won before deductions.
	GrossMsat int64
	// RakeMsat is the operator's percentage.
	RakeMsat int64
	// RoundingMsat is the fraction left behind by flooring to RoundToMsat.
	RoundingMsat int64
	// NetMsat is what the player actually receives.
	NetMsat int64
}

// HouseMsat is everything the operator keeps from this payout.
func (s Split) HouseMsat() int64 { return s.RakeMsat + s.RoundingMsat }

// Valid reports whether the split accounts for every millisatoshi. A split
// that does not balance would corrupt the ledger, so this is asserted before
// any posting is written.
func (s Split) Valid() bool {
	return s.NetMsat+s.RakeMsat+s.RoundingMsat == s.GrossMsat &&
		s.NetMsat >= 0 && s.RakeMsat >= 0 && s.RoundingMsat >= 0
}

// Apply splits a gross payout into the player's share and the house's.
func (sch Schedule) Apply(grossMsat int64) Split {
	if grossMsat <= 0 {
		return Split{}
	}

	rake := mulDivFloor(grossMsat, sch.RakeBP, 10000)
	afterRake := grossMsat - rake

	unit := sch.RoundToMsat
	if unit < 1 {
		unit = 1
	}
	net := afterRake / unit * unit
	rounding := afterRake - net

	// Below the minimum, paying out costs more in dust than it delivers.
	// Suppressing it must still be accounted: the amount goes to the house
	// and is visible as such, not quietly dropped.
	if net < sch.MinPayoutMsat {
		rounding += net
		net = 0
	}

	s := Split{
		GrossMsat:    grossMsat,
		RakeMsat:     rake,
		RoundingMsat: rounding,
		NetMsat:      net,
	}
	if !s.Valid() {
		// Unreachable by construction; a panic here beats a silent imbalance
		// that would be discovered later as missing money.
		panic(fmt.Sprintf("fees: split does not balance: %+v", s))
	}
	return s
}

// mulDivFloor computes v * num / den without overflowing int64.
//
// The direct form overflows for large payouts: a gross of 2.3e18 times a rake
// of 10000 basis points is 2.3e22, far past int64. Splitting the value into
// whole and remainder parts keeps every intermediate inside the range while
// producing the identical floored result.
func mulDivFloor(v, num, den int64) int64 {
	if den == 0 {
		return 0
	}
	return (v/den)*num + (v%den)*num/den
}

// EffectiveRTPBasisPoints is the real return to player, given a game's own RTP
// before fees.
//
// This is the number that belongs on the disclosure page. A game whose maths
// return 9900 and whose operator takes a 1% rake does not return 99%: winners
// hand back a percentage of what they win, so the realised return is lower.
//
// The rake applies only to payouts, so it scales the returned portion:
//
//	effective = gameRTP * (1 - rake)
//
// Rounding is excluded here because its size depends on the payout amounts a
// player actually hits, and overstating it would be its own dishonesty. It is
// disclosed separately, in units, which is a claim that can be checked.
func (sch Schedule) EffectiveRTPBasisPoints(gameRTPBasisPoints int64) int64 {
	return mulDivFloor(gameRTPBasisPoints, 10000-sch.RakeBP, 10000)
}

// Disclosure is the machine-readable statement of what the operator takes.
// The public page is rendered from this, so the published terms are generated
// from the same values the code charges.
type Disclosure struct {
	RakePercent       string `json:"rake_percent"`
	RoundingUnit      string `json:"rounding_unit"`
	MinPayout         string `json:"minimum_payout"`
	GameRTPPercent    string `json:"game_rtp_percent"`
	EffectiveRTP      string `json:"effective_rtp_percent"`
	WorstCaseRounding string `json:"worst_case_rounding_per_payout"`
}

// Describe renders the schedule for publication.
func (sch Schedule) Describe(gameRTPBasisPoints int64) Disclosure {
	unit := sch.RoundToMsat
	if unit < 1 {
		unit = 1
	}
	return Disclosure{
		RakePercent:  fmt.Sprintf("%.2f%%", float64(sch.RakeBP)/100),
		RoundingUnit: fmt.Sprintf("%d msat (%.3f sats)", unit, float64(unit)/1000),
		MinPayout:    fmt.Sprintf("%d msat", sch.MinPayoutMsat),
		GameRTPPercent: fmt.Sprintf("%.2f%%",
			float64(gameRTPBasisPoints)/100),
		EffectiveRTP: fmt.Sprintf("%.2f%%",
			float64(sch.EffectiveRTPBasisPoints(gameRTPBasisPoints))/100),
		// The most a single payout can lose to rounding is one unit less one.
		WorstCaseRounding: fmt.Sprintf("%d msat (%.3f sats)",
			unit-1, float64(unit-1)/1000),
	}
}
