// Package payments is the in-game currency, wallet, benefit and catalog domain.
//
// It owns its own Postgres schema (payments) and is the only backend package
// that issues SQL against it — an import-boundary test forbids any other package
// from importing the payments jet code, which keeps the domain extractable into
// its own database or process later. There is no cross-schema foreign key to the
// account schema: an account id is a plain uuid here, kept referentially honest
// in code.
//
// Money is carried exclusively by [Money] — an exact integer amount in a
// currency's minor units — so no floating-point value ever reaches a monetary
// amount, and a whole-unit currency (Vote, Star, chip) can never hold a
// fraction. This file is the data-foundation layer: the currency value type; the
// wallet mechanics build on the schema and this package later.
package payments

import (
	"fmt"
	"math/big"
)

// Currency identifies the unit a monetary amount is denominated in.
type Currency string

const (
	// CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100).
	CurrencyRUB Currency = "RUB"
	// CurrencyVote is the VK Vote — a whole unit with no sub-unit.
	CurrencyVote Currency = "VOTE"
	// CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit.
	CurrencyStar Currency = "XTR"
	// CurrencyChip is the in-game chip, the unit a value's price is quoted in —
	// a whole unit with no sub-unit.
	CurrencyChip Currency = "CHIP"
)

// minorPerUnit reports how many minor units make one major unit of the currency.
// Every currency except the rouble is a whole-unit currency (scale 1), so an
// amount in it structurally cannot carry a fraction.
func (c Currency) minorPerUnit() int64 {
	if c == CurrencyRUB {
		return 100
	}
	return 1
}

// Valid reports whether the currency is one of the known units.
func (c Currency) Valid() bool {
	switch c {
	case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
		return true
	default:
		return false
	}
}

// Money is an exact monetary amount: a signed integer count of a currency's
// minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole
// carrier of money in the payments domain — construction, arithmetic and
// formatting all go through it, so no float ever reaches an amount and a
// whole-unit currency can never hold a fraction. The zero value has an empty,
// invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or
// [ParseMoney].
type Money struct {
	minor    int64
	currency Currency
}

// MoneyFromMinor builds a Money from a raw count of the currency's minor units
// (kopecks for the rouble, whole units otherwise). It errors on an unknown
// currency.
func MoneyFromMinor(minor int64, c Currency) (Money, error) {
	if !c.Valid() {
		return Money{}, fmt.Errorf("payments: unknown currency %q", c)
	}
	return Money{minor: minor, currency: c}, nil
}

// MoneyFromMajor builds a Money from a whole number of major units (roubles,
// Votes, Stars, chips). It errors on an unknown currency or on overflow.
func MoneyFromMajor(major int64, c Currency) (Money, error) {
	if !c.Valid() {
		return Money{}, fmt.Errorf("payments: unknown currency %q", c)
	}
	scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit()))
	if !scaled.IsInt64() {
		return Money{}, fmt.Errorf("payments: %d %s overflows", major, c)
	}
	return Money{minor: scaled.Int64(), currency: c}, nil
}

// ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency,
// exactly and without floating point (via math/big). It rejects a value with
// finer precision than the currency allows — in particular, any fractional part
// for a whole-unit currency — which is the gate that keeps a fraction out of an
// integer currency.
func ParseMoney(text string, c Currency) (Money, error) {
	if !c.Valid() {
		return Money{}, fmt.Errorf("payments: unknown currency %q", c)
	}
	r, ok := new(big.Rat).SetString(text)
	if !ok {
		return Money{}, fmt.Errorf("payments: %q is not a valid amount", text)
	}
	scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit()))
	if !scaled.IsInt() {
		return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c)
	}
	num := scaled.Num() // the denominator is 1 once scaled to an integer
	if !num.IsInt64() {
		return Money{}, fmt.Errorf("payments: %q overflows %s", text, c)
	}
	return Money{minor: num.Int64(), currency: c}, nil
}

// Minor returns the amount as a count of the currency's minor units — the value
// persisted to an amount column.
func (m Money) Minor() int64 { return m.minor }

// Currency returns the amount's currency.
func (m Money) Currency() Currency { return m.currency }

// IsZero reports whether the amount is zero.
func (m Money) IsZero() bool { return m.minor == 0 }

// Add returns the sum of the two amounts. It errors when the currencies differ.
func (m Money) Add(o Money) (Money, error) {
	if m.currency != o.currency {
		return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency)
	}
	return Money{minor: m.minor + o.minor, currency: m.currency}, nil
}

// Cmp compares the two amounts, returning -1, 0 or +1. It errors when the
// currencies differ.
func (m Money) Cmp(o Money) (int, error) {
	if m.currency != o.currency {
		return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency)
	}
	switch {
	case m.minor < o.minor:
		return -1, nil
	case m.minor > o.minor:
		return 1, nil
	default:
		return 0, nil
	}
}

// Major renders the amount as a decimal string without the currency, with the currency's
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
// field (Robokassa OutSum) takes.
func (m Money) Major() string {
	scale := m.currency.minorPerUnit()
	if scale == 1 {
		return fmt.Sprintf("%d", m.minor)
	}
	neg := m.minor < 0
	abs := m.minor
	if neg {
		abs = -abs
	}
	width := 0
	for s := scale; s > 1; s /= 10 {
		width++
	}
	sign := ""
	if neg {
		sign = "-"
	}
	return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
}

// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
	return m.Major() + " " + string(m.currency)
}
