Ctrl K
ComunidadGuías…

internal/domain/star_gift.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package domain

import (
	"encoding/base64"
	"errors"
	"fmt"
	"regexp"
	"strconv"
	"strings"
	"time"
)

// Star gift (payments.sendStarsForm + inputInvoiceStarGift) domain model. The catalog and
// immutable revisions persist in star_gift_catalog(_revisions); gift instances received by
// a peer land in peer_star_gifts. Works with the Stars ledger: sending a gift debits,
// converting back to Stars credits.

// StarGift is a purchasable gift catalog entry. RevisionID identifies the immutable
// title/price/animation snapshot.
type StarGift struct {
	ID            int64
	RevisionID    int64
	Stars         int64    // purchase price (Stars)
	ConvertStars  int64    // Stars the recipient can convert back
	UpgradeStars  int64    // Stars required to upgrade to a unique gift; 0 means not currently upgradable
	UpgradeTotal  int      // total unique gifts the currently published attribute pool may issue
	UpgradeIssued int      // number currently issued
	Title         string   // optional title
	Sticker       Document // gift sticker snapshot (the tg projection must be a valid Document with a sticker attribute, or clients drop it)

	// Layer 228 regular-gift shape. Static release facts live in the immutable
	// catalog revision; AvailabilityRemains/AvailabilityResale are the current
	// inventory projection maintained on the catalog aggregate.
	Limited              bool
	SoldOut              bool
	Birthday             bool
	RequirePremium       bool
	SupportOnly          bool
	LimitedPerUser       bool
	PeerColorAvailable   bool
	Auction              bool
	AvailabilityRemains  int
	AvailabilityTotal    int
	AvailabilityResale   int64
	FirstSaleDate        int
	LastSaleDate         int
	ResellMinStars       int64
	ReleasedBy           Peer
	PerUserTotal         int
	PerUserRemains       int
	LockedUntilDate      int
	AuctionSlug          string
	GiftsPerRound        int
	AuctionStartDate     int
	AuctionRoundDuration int
	UpgradeVariants      int
	Background           *StarGiftBackground
}

// StarGiftBackground is the release-level palette used by auction cards and
// gift previews before a collectible backdrop is selected.
type StarGiftBackground struct {
	CenterColor int
	EdgeColor   int
	TextColor   int
}

// SavedStarGift is one received gift instance (a peer_star_gifts row).
type SavedStarGift struct {
	ID                       int64
	Owner                    Peer  // recipient peer (user/channel)
	FromUserID               int64 // sender (the real value is kept for the ledger even when anonymous; dispatch decides visibility by NameHidden)
	GiftID                   int64 // → StarGift.ID
	RevisionID               int64 // → star_gift_catalog_revisions.id; historical queries must project by this revision
	MsgID                    int   // private-chat msg_id for user gifts; channel gifts do not enter history and are fixed at 0
	SavedID                  int64 // inputSavedStarGiftChat.saved_id for channel gifts; 0 for user gifts
	Date                     int   // receive time, Unix seconds
	NameHidden               bool  // sender requested the name be hidden
	Unsaved                  bool  // not shown on the profile (toggled by saveStarGift)
	Converted                bool  // converted back to Stars (terminal; excluded from lists)
	LifecycleStatus          StarGiftLifecycleStatus
	ConvertStars             int64  // Stars refundable by conversion
	PrepaidUpgradeStars      int64  // unique-gift upgrade amount the sender prepaid with the gift
	PrepaidUpgradeHash       string // one-time entitlement for a third party paying for an upgrade separately
	GiftNum                  int    // auction-acquired release number for regular gifts
	PaidStars                int64  // actual transaction price (auction winning bid); 0 means project at the catalog revision price
	Message                  string // attached message (optional)
	MessageEntities          []MessageEntity
	UniqueGiftID             int64 // non-zero means upgraded to a unique gift; mutually exclusive with Converted
	TransferStars            int64
	CanExportAt              int
	CanTransferAt            int
	CanResellAt              int
	DropOriginalDetailsStars int64
	CanCraftAt               int
	UpgradeMsgID             int   // id of the current owner-side message carrying messageActionStarGiftUnique; updated with the new message on ownership transfer
	PinnedOrder              int   // >0 means profile pin order
	CollectionIDs            []int // collections it currently belongs to; returned stably in collection order
	Unique                   *UniqueStarGift
}

type StarGiftLifecycleStatus string

const (
	StarGiftLifecycleActive    StarGiftLifecycleStatus = "active"
	StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted"
	StarGiftLifecycleBurned    StarGiftLifecycleStatus = "burned"
	StarGiftLifecycleExported  StarGiftLifecycleStatus = "exported"
	// StarGiftLifecycleDeleted is the terminal state of payments.deleteStarGift: the gift
	// leaves the profile page, but the history row is retained (upgrade commands, offers
	// and transfer records all reference it, so it cannot be hard-deleted).
	StarGiftLifecycleDeleted StarGiftLifecycleStatus = "deleted"
)

func (s StarGiftLifecycleStatus) Live() bool {
	return s == StarGiftLifecycleActive
}

// StarGiftCollectibleAttributeKind is the three required attribute slots of a unique gift.
type StarGiftCollectibleAttributeKind string

const (
	StarGiftCollectibleModel    StarGiftCollectibleAttributeKind = "model"
	StarGiftCollectiblePattern  StarGiftCollectibleAttributeKind = "pattern"
	StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
)

// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only
// kind eligible for a regular upgrade draw; named rarities are currently used by
// craft-only models and must still be preserved in the published attribute directory.
type StarGiftAttributeRarityKind string

const (
	StarGiftRarityPermille  StarGiftAttributeRarityKind = "permille"
	StarGiftRarityUncommon  StarGiftAttributeRarityKind = "uncommon"
	StarGiftRarityRare      StarGiftAttributeRarityKind = "rare"
	StarGiftRarityEpic      StarGiftAttributeRarityKind = "epic"
	StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary"
)

func (k StarGiftAttributeRarityKind) Valid() bool {
	switch k {
	case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare,
		StarGiftRarityEpic, StarGiftRarityLegendary:
		return true
	default:
		return false
	}
}

// StarGiftCollectibleAttribute is one entry of the published attribute pool. RarityKind/
// RarityPermille are client-visible facts; a regular upgrade treats non-crafted permille
// values as relative weights and does not require them to sum to 1000. Each kind must still
// provide at least two client-distinguishable regular upgrade attributes, or TDesktop's
// upgrade spinning cannot finish.
type StarGiftCollectibleAttribute struct {
	ID                    int64
	CollectibleRevisionID int64
	Kind                  StarGiftCollectibleAttributeKind
	Name                  string
	Document              *Document
	BackdropID            int
	CenterColor           int
	EdgeColor             int
	PatternColor          int
	TextColor             int
	RarityKind            StarGiftAttributeRarityKind
	RarityPermille        int
	Crafted               bool
	OfficialDocumentID    int64
	SortOrder             int
	Animation             *StarGiftAnimation
	Blob                  *FileBlob
}

// StarGiftCollectibleRevision is an immutable, publishable attribute pool for one regular gift.
type StarGiftCollectibleRevision struct {
	ID                   int64
	GiftID               int64
	Revision             int
	UpgradeStars         int64
	SupplyTotal          int
	Issued               int
	SlugPrefix           string
	Published            bool
	Models               []StarGiftCollectibleAttribute
	Patterns             []StarGiftCollectibleAttribute
	Backdrops            []StarGiftCollectibleAttribute
	CreatedBy            string
	CreatedAt            time.Time
	PublishedAt          time.Time
	OfficialGiftID       int64
	SourceManifestSHA256 []byte
}

// StarGiftCollectibleWrite is the protocol-agnostic input for creating/publishing an attribute pool from the backend.
type StarGiftCollectibleWrite struct {
	GiftID               int64
	UpgradeStars         int64
	SupplyTotal          int
	SlugPrefix           string
	Models               []StarGiftCollectibleAttribute
	Patterns             []StarGiftCollectibleAttribute
	Backdrops            []StarGiftCollectibleAttribute
	Actor                string
	CommandID            string
	OfficialGiftID       int64
	SourceManifestSHA256 []byte
}

// UniqueStarGift is one already-issued unique gift. Its attributes, number and slug are permanent once created.
type UniqueStarGift struct {
	ID                      int64
	GiftID                  int64
	CollectibleRevisionID   int64
	SourceSavedGiftID       int64
	Title                   string
	Slug                    string
	Num                     int
	Owner                   Peer
	RequirePremium          bool
	ResaleTonOnly           bool
	ThemeAvailable          bool
	Burned                  bool
	Crafted                 bool
	OwnerName               string
	OwnerAddress            string
	GiftAddress             string
	ResellAmount            *StarGiftAmount
	ResellVersion           int64
	ReleasedBy              Peer
	ValueAmount             int64
	ValueCurrency           string
	ValueUSD                int64
	ThemePeer               Peer
	Host                    Peer
	OfferMinStars           int
	CraftChancePermille     int
	LastSaleDate            int
	LastSaleAmount          *StarGiftAmount
	Model                   StarGiftCollectibleAttribute
	Pattern                 StarGiftCollectibleAttribute
	Backdrop                StarGiftCollectibleAttribute
	AvailabilityIssued      int
	AvailabilityTotal       int
	KeepOriginalDetails     bool
	OriginalFromUserID      int64
	OriginalOwner           Peer
	OriginalDate            int
	OriginalMessage         string
	OriginalMessageEntities []MessageEntity
	OriginalNameHidden      bool
	Unsaved                 bool // true when the source saved gift is hidden from the owner's profile
	CreatedAt               time.Time
}

// StarGiftClaimChallenge binds a short-lived, single-use TON Proof payload to
// one Gramsrv user and one already exported collectible.
type StarGiftClaimChallenge struct {
	Payload   string
	UserID    int64
	Unique    UniqueStarGift
	ExpiresAt int
}

type StarGiftOnChainClaim struct {
	Payload      string
	UserID       int64
	UniqueGiftID int64
	// ExpectedPreviousWallet is the last wallet observed by the database before
	// the TON proof was verified.  CommitClaim uses it as a compare-and-swap
	// guard so a transfer/reconciliation racing the claim cannot overwrite a
	// newer owner projection.
	ExpectedPreviousWallet string
	WalletAddress          string
	GiftAddress            string
	ClaimedAt              int
}

type StarGiftOnChainClaimResult struct {
	Gift            UniqueStarGift
	PreviousWallet  string
	ProfileUsername string
}

// StarGiftAdminExport marks a collectible as owned by a wallet purely in the
// database. It is a test-agent facility: no TON transaction is sent, so the
// projection is display-only and the ownership sync leaves it untouched.
type StarGiftAdminExport struct {
	UniqueGiftID  int64
	WalletName    string
	WalletAddress string
	ExportedBy    int64
	Date          int
}

// WHY: the panel addresses a gift by slug, NFT address or id and may host it on
// a chosen profile, so UniqueGiftID may be zero while Ref carries the lookup and
// HostPeer* overrides the relayer default the Mini App path relies on.
type StarGiftWalletBind struct {
	UniqueGiftID  int64
	Ref           string
	WalletName    string
	WalletAddress string
	ActorUserID   int64
	HostPeerType  string
	HostPeerID    int64
}

// CollectibleEmojiStatus projects an immutable unique gift into the complete
// status shape consumed by Telegram clients.  Ownership/lifecycle validation
// is intentionally performed by the caller because it depends on the actor;
// this helper validates only the immutable renderable facts.
func CollectibleEmojiStatus(g UniqueStarGift) (EmojiStatusCollectible, bool) {
	status := EmojiStatusCollectible{
		CollectibleID: g.ID,
		Title:         g.Title,
		Slug:          g.Slug,
		CenterColor:   g.Backdrop.CenterColor,
		EdgeColor:     g.Backdrop.EdgeColor,
		PatternColor:  g.Backdrop.PatternColor,
		TextColor:     g.Backdrop.TextColor,
	}
	if g.Model.Document != nil {
		status.DocumentID = g.Model.Document.ID
	}
	if g.Pattern.Document != nil {
		status.PatternDocumentID = g.Pattern.Document.ID
	}
	return status, status.Valid()
}

type StarGiftCurrency string

const (
	StarGiftCurrencyStars StarGiftCurrency = "XTR"
	StarGiftCurrencyTON   StarGiftCurrency = "TON"
)

// MaxStarGiftAuctionBidStars caps a single auction bid so the bid ladder stays
// inside the range official clients can represent. Telegram Desktop derives its
// bid-slider maximum from the current top bid in 32-bit arithmetic — int(top * 1.2)
// in payments_reaction_box.cpp — and asserts that the result is at least 2. A top
// bid above roughly 1.79e9 overflows that int, the assertion fails, and the client
// dies for everyone who opens the auction, not just the bidder who set the price.
// Since every bid must clear the one before it, an uncapped auction escalates into
// that range on its own.
const MaxStarGiftAuctionBidStars int64 = 10_000_000

// StarGiftResaleFloorMultiple is the maximum magnitude a listing may exceed the
// gift's base release price and still count toward the dynamic resale floor
// (resell_min_stars / floor_price). The floor is computed as the cheapest active
// listing for the gift type, but a single overpriced "troll" listing (set far
// above the ~base price on purpose) would otherwise hijack that MIN and become
// the enforced floor for every future listing of the same gift. Listings above
// multiple×base are ignored by the floor projection while still remaining
// visible and purchasable — the floor stays market-driven and per-gift, never an
// absolute hardcoded minimum.
const StarGiftResaleFloorMultiple int64 = 10

type StarGiftAmount struct {
	Currency StarGiftCurrency
	Amount   int64
	Nanos    int
}

func (a StarGiftAmount) Valid() bool {
	if a.Amount <= 0 {
		return false
	}
	switch a.Currency {
	case StarGiftCurrencyStars:
		return a.Nanos >= -999999999 && a.Nanos <= 999999999
	case StarGiftCurrencyTON:
		return a.Nanos == 0
	default:
		return false
	}
}

// StarGiftUpgradePreview is the current price and sample attributes needed by the client's upgrade dialog.
type StarGiftUpgradePreview struct {
	GiftID       int64
	Revision     int
	UpgradeStars int64
	SupplyTotal  int
	Issued       int
	SlugPrefix   string
	Models       []StarGiftCollectibleAttribute
	Patterns     []StarGiftCollectibleAttribute
	Backdrops    []StarGiftCollectibleAttribute
}

// StarGiftCollectibleAvailability is the lightweight current-pool projection used
// when rendering historical saved gifts. The saved gift keeps its immutable catalog
// revision for appearance and prices, while upgrade availability follows the pool
// currently published for the logical gift ID.
type StarGiftCollectibleAvailability struct {
	UpgradeStars int64
	SupplyTotal  int
	Issued       int
}

// StarGiftUpgradeRequest is one idempotent user-owned upgrade command. Paid
// invoice upgrades set ChargeStars; the direct payments.upgradeStarGift path
// sets RequirePrepaid and charges zero at upgrade time.
type StarGiftUpgradeRequest struct {
	UserID              int64
	Ref                 SavedStarGiftRef
	KeepOriginalDetails bool
	ChargeStars         int64
	RequirePrepaid      bool
	FormID              int64
	CommandKey          string
	Date                int
	OriginAuthKeyID     [8]byte
	OriginSessionID     int64

	// Admin-controlled attribute overrides. When non-zero these pin the specific
	// collectible model/pattern/backdrop instead of the random pool draw. They
	// are only honoured on the admin grant path; the DB FK (attribute must belong
	// to the revision) remains the source of truth. The collectible number is
	// always assigned automatically (sequential).
	ModelAttributeID    int64
	PatternAttributeID  int64
	BackdropAttributeID int64
}

// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from the official system account 777000 at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically.
type AdminStarGiftGrant struct {
	SenderID            int64
	Recipient           Peer
	GiftID              int64
	HideName            bool
	Message             string
	Upgrade             bool
	CommandKey          string
	Date                int
	RecipientBlocked    bool
	RecipientUnsaved    bool
	ModelAttributeID    int64
	PatternAttributeID  int64
	BackdropAttributeID int64
}

// AdminStarGiftGrantResult is the committed direct collectible assignment.
// The saved gift, unique issuance, private message and replay receipt are one
// aggregate transaction.
type AdminStarGiftGrantResult struct {
	Saved     SavedStarGift
	Unique    UniqueStarGift
	Send      SendPrivateTextResult
	Duplicate bool
}

type StarGiftPurchaseRequest struct {
	BuyerUserID      int64
	BuyerPremium     bool
	BuyerIsBot       bool
	To               Peer
	GiftID           int64
	RevisionID       int64
	IncludeUpgrade   bool
	HideName         bool
	Message          string
	MessageEntities  []MessageEntity
	ChargeStars      int64
	FormID           int64
	CommandKey       string
	Date             int
	RecipientBlocked bool
	RecipientUnsaved bool
	OriginAuthKeyID  [8]byte
	OriginSessionID  int64
}

// StarGiftPurchaseForm is the server-issued, short-lived payment intent that
// binds payments.getPaymentForm to one later payments.sendStarsForm call. A
// fresh form represents a fresh purchase even when every invoice field is the
// same; retrying one form represents the same purchase command.
type StarGiftPurchaseForm struct {
	FormID          int64
	BuyerUserID     int64
	To              Peer
	GiftID          int64
	RevisionID      int64
	IncludeUpgrade  bool
	HideName        bool
	Message         string
	MessageEntities []MessageEntity
	ChargeStars     int64
	IssuedAt        int
	ExpiresAt       int
}

type StarGiftPurchaseResult struct {
	Gift      StarGift
	Saved     SavedStarGift
	Balance   StarsBalance
	Send      SendPrivateTextResult
	Duplicate bool
}

// StarGiftConvertRequest identifies one owner-scoped regular gift conversion.
// ActorUserID is the authenticated user who owns the user gift or administers
// the channel gift; authorization is checked again at the RPC boundary.
type StarGiftConvertRequest struct {
	ActorUserID int64
	Ref         SavedStarGiftRef
	Date        int
}

// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is
// the post-credit balance of either the user or the channel internal Stars
// ledger selected by Saved.Owner.
type StarGiftConvertResult struct {
	Saved        SavedStarGift
	OwnerBalance int64
}

type StarGiftUpgradeResult struct {
	Saved       SavedStarGift
	Unique      UniqueStarGift
	Balance     StarsBalance
	Send        SendPrivateTextResult
	SourceEdits []EditedMessageForUser
	Duplicate   bool
}

// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a
// committed upgrade after the saved gift has entered its unique terminal state.
// In particular, a paid replay must not be rebound to a later catalog price.
type StarGiftUpgradeReceipt struct {
	UserID              int64
	SourceSavedGiftID   int64
	FormID              int64
	UniqueGiftID        int64
	ChargeStars         int64
	BalanceAfter        int64
	SourceEditPts       int
	RequirePrepaid      bool
	KeepOriginalDetails bool
}

type StarGiftPrepaidUpgradeRequest struct {
	PayerUserID     int64
	Owner           Peer
	Hash            string
	ChargeStars     int64
	FormID          int64
	CommandKey      string
	Date            int
	OriginAuthKeyID [8]byte
	OriginSessionID int64
}

type StarGiftPrepaidUpgradeResult struct {
	Saved     SavedStarGift
	Balance   StarsBalance
	Send      SendPrivateTextResult
	Duplicate bool
}

type StarGiftDropOriginalDetailsRequest struct {
	UserID      int64
	Ref         SavedStarGiftRef
	ChargeStars int64
	FormID      int64
	CommandKey  string
	Date        int
}

type StarGiftDropOriginalDetailsResult struct {
	Saved     SavedStarGift
	Unique    UniqueStarGift
	Balance   StarsBalance
	Duplicate bool
}

// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular
// gift becomes collectible. It deliberately contains no wallet/node/provider
// configuration: TON remains only a currency unit in the local ledger.
type StarGiftLifecyclePolicy struct {
	TransferStars            int64
	DropOriginalDetailsStars int64
	OfferMinStars            int
	ExportDelaySeconds       int
	TransferDelaySeconds     int
	ResellDelaySeconds       int
	CraftDelaySeconds        int
	CraftChancePermille      int
}

func (p StarGiftLifecyclePolicy) Valid() bool {
	return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 &&
		p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 &&
		p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000
}

// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator.
// Proceeds permille is the seller share; the remainder is recorded as platform
// commission. TON is still only a unit in the local ledger.
type StarGiftMarketPolicy struct {
	StarsProceedsPermille int
	TONProceedsPermille   int
}

func (p StarGiftMarketPolicy) Valid() bool {
	return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 &&
		p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000
}

type StarGiftResaleFilter struct {
	GiftID      int64
	SortByPrice bool
	SortByNum   bool
	ForCraft    bool
	StarsOnly   bool
	ModelIDs    []int64
	PatternIDs  []int64
	BackdropIDs []int64
	Offset      string
	Limit       int
}

type StarGiftResalePage struct {
	Gifts      []UniqueStarGift
	Count      int
	NextOffset string
}

type StarGiftValueInfo struct {
	Currency         string
	Value            int64
	ValueIsAverage   bool
	InitialSaleDate  int
	InitialSaleStars int64
	InitialSalePrice int64
	LastSaleDate     int
	LastSalePrice    int64
	FloorPrice       int64
	AveragePrice     int64
	ListedCount      int
}

type StarGiftTransferRequest struct {
	ActorUserID      int64
	Ref              SavedStarGiftRef
	To               Peer
	ChargeStars      int64
	FormID           int64
	CommandKey       string
	Date             int
	RecipientUnsaved bool
	OriginAuthKeyID  [8]byte
	OriginSessionID  int64
}

type StarGiftTransferResult struct {
	Saved     SavedStarGift
	Unique    UniqueStarGift
	Balance   StarsBalance
	Send      SendPrivateTextResult
	Duplicate bool
}

type StarGiftListingRequest struct {
	ActorUserID int64
	Ref         SavedStarGiftRef
	Amount      *StarGiftAmount
	Date        int
}

type StarGiftResalePurchaseRequest struct {
	BuyerUserID      int64
	Slug             string
	To               Peer
	Amount           StarGiftAmount
	FormID           int64
	CommandKey       string
	Date             int
	RecipientUnsaved bool
	OriginAuthKeyID  [8]byte
	OriginSessionID  int64
}

type StarGiftOfferRequest struct {
	BuyerUserID     int64
	Owner           Peer
	Slug            string
	Price           StarGiftAmount
	Duration        int
	RandomID        int64
	Date            int
	OriginAuthKeyID [8]byte
	OriginSessionID int64
}

type StarGiftOffer struct {
	ID           int64
	BuyerUserID  int64
	Owner        Peer
	UniqueGiftID int64
	Price        StarGiftAmount
	RandomID     int64
	OfferMsgID   int
	BuyerMsgID   int
	Status       string
	CreatedAt    int
	ExpiresAt    int
	ResolvedAt   int
	Gift         UniqueStarGift
}

type StarGiftOfferResult struct {
	Offer     StarGiftOffer
	Saved     SavedStarGift
	Unique    UniqueStarGift
	Balance   StarsBalance
	Send      SendPrivateTextResult
	Duplicate bool
}

type StarGiftResolveOfferRequest struct {
	OwnerUserID     int64
	OfferMsgID      int
	Decline         bool
	Date            int
	OriginAuthKeyID [8]byte
	OriginSessionID int64
}

type StarGiftCraftRequest struct {
	UserID          int64
	Refs            []SavedStarGiftRef
	CommandKey      string
	Date            int
	OriginAuthKeyID [8]byte
	OriginSessionID int64
}

// StarGiftCraftSource is loaded by the server from owned collectible gifts.
type StarGiftCraftSource struct {
	UniqueID      int64
	GiftID        int64
	ModelID       int64
	ModelName     string
	AnimationJSON []byte
	Backdrop      StarGiftCollectibleAttribute
}

type StarGiftCraftResult struct {
	Success     bool
	Chance      int
	Gift        *UniqueStarGift
	Send        SendPrivateTextResult
	SourceEdits []EditedMessageForUser
	Duplicate   bool
}

type StarGiftAuction struct {
	Gift          StarGift
	Version       int
	StartDate     int
	EndDate       int
	MinBidAmount  int64
	NextRoundAt   int
	LastGiftNum   int
	GiftsLeft     int
	CurrentRound  int
	TotalRounds   int
	RoundDuration int
	BidLevels     []StarGiftAuctionBidLevel
	TopBidders    []int64
	UserState     StarGiftAuctionUserState
	Finished      bool
	AveragePrice  int64
	ListedCount   int
}

type StarGiftAuctionBidLevel struct {
	Pos    int
	Amount int64
	Date   int
}

type StarGiftAuctionUserState struct {
	Returned      bool
	BidAmount     int64
	BidDate       int
	MinBidAmount  int64
	BidPeer       Peer
	AcquiredCount int
	// BidVersion is this bidder's own generation counter for this auction. It is
	// bumped by every write to their bid row — placing it, raising it, winning a
	// round, being refunded — and by nothing anyone else does. Payment forms are
	// bound to it so a bid placed after the previous one was settled can never
	// reuse the settled bid's durable payment receipt. It is deliberately kept
	// after the bid goes inactive, because that is exactly the case the binding
	// has to separate. Not projected to clients.
	BidVersion int64
}

type StarGiftAuctionBidRequest struct {
	UserID    int64
	GiftID    int64
	Peer      Peer
	BidAmount int64
	HideName  bool
	Message   string
	UpdateBid bool
	FormID    int64
	Date      int
}

type StarGiftAuctionAcquired struct {
	Peer       Peer
	Date       int
	BidAmount  int64
	Round      int
	Pos        int
	Message    string
	GiftNum    int
	NameHidden bool
}

type StarGiftWithdrawalRequest struct {
	UserID int64
	Ref    SavedStarGiftRef
	Date   int
}

// StarGiftDeleteRequest is the input of payments.deleteStarGift: the caller wants to delete
// the regular gift they received. Upgraded collectibles, limited/auction gifts and gifts
// that still carry Stars cannot be deleted.
type StarGiftDeleteRequest struct {
	UserID int64
	Ref    SavedStarGiftRef
	Date   int
}

// StarGiftDeleteResult reports the deletion result. A gift upgraded to a collectible (NFT)
// cannot be deleted, so UniqueGiftID is always 0.
type StarGiftDeleteResult struct {
	SavedGiftID  int64
	UniqueGiftID int64
	Deleted      bool
}

type StarGiftWithdrawal struct {
	ProviderRequestID string
	URL               string
	ExpiresAt         int
	Status            string
	Gift              UniqueStarGift
}

// ChannelRevenueCurrency identifies the two isolated channel revenue ledgers.
// TON here is telesrv's internal nanoton balance, not an on-chain transfer.
type ChannelRevenueCurrency string

const (
	ChannelRevenueStars ChannelRevenueCurrency = "stars"
	ChannelRevenueTON   ChannelRevenueCurrency = "ton"
)

func (c ChannelRevenueCurrency) Valid() bool {
	return c == ChannelRevenueStars || c == ChannelRevenueTON
}

// ChannelRevenueWithdrawalRequest is the immutable, bearer-token-backed claim
// command persisted when payments.getStarsRevenueWithdrawalUrl succeeds.
// TokenDigest is SHA-256(token); plaintext bearer tokens never cross the store
// boundary or enter the database.
type ChannelRevenueWithdrawalRequest struct {
	ChannelID              int64
	CreatorUserID          int64
	Currency               ChannelRevenueCurrency
	Amount                 int64 // zero means bind the full balance at issuance
	PasswordChangedAt      time.Time
	AuthKeyID              [8]byte
	AuthorizationCreatedAt time.Time
	TokenDigest            []byte
	Date                   int
	ExpiresAt              int
}

// ChannelRevenuePasswordStateChangedError means payout admission observed a
// different password state than the one whose SRP proof was checked. It carries
// no password material; callers use it only to return the official freshness
// or missing-password error while the store transaction fails closed.
type ChannelRevenuePasswordStateChangedError struct {
	HasPassword       bool
	PasswordChangedAt time.Time
}

func (e *ChannelRevenuePasswordStateChangedError) Error() string {
	return "channel revenue: password state changed during withdrawal admission"
}

// ChannelRevenueAuthorizationStateChangedError means payout admission no
// longer observes the exact fully-authorized session whose age the RPC checked.
// The public bearer command is not persisted when this error is returned.
type ChannelRevenueAuthorizationStateChangedError struct {
	HasAuthorization bool
	OwnerMatches     bool
	PasswordPending  bool
	CreatedAt        time.Time
}

func (e *ChannelRevenueAuthorizationStateChangedError) Error() string {
	return "channel revenue: authorization state changed during withdrawal admission"
}

// ChannelRevenueWithdrawal is both the confirmation-page projection and the
// durable completion receipt. Completed requests are exact, idempotent replays.
type ChannelRevenueWithdrawal struct {
	ID                   int64
	URL                  string
	ChannelID            int64
	CreatorUserID        int64
	Currency             ChannelRevenueCurrency
	Amount               int64
	Status               string
	ExpiresAt            int
	CreatedAt            int
	CompletedAt          int
	ChannelTransactionID int64
	UserTransactionID    int64
	ChannelBalanceAfter  int64
	UserBalanceAfter     int64
}

// StarGiftCollection is a gift collection on a peer's profile; a gift may belong to several collections.
type StarGiftCollection struct {
	Owner        Peer
	CollectionID int
	Title        string
	GiftIDs      []int64 // peer_star_gifts.id, in collection order
	Hash         int64
	SortOrder    int
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

// StarGiftCollectionPatch describes a partial update of updateStarGiftCollection.
type StarGiftCollectionPatch struct {
	Title     *string
	DeleteIDs []int64
	AddIDs    []int64
	Order     []int64
}

// StarGiftAnimationFormat is the backend import source format. The server always stores normalized TGS.
type StarGiftAnimationFormat string

const (
	StarGiftAnimationTGS    StarGiftAnimationFormat = "tgs"
	StarGiftAnimationLottie StarGiftAnimationFormat = "lottie"
)

// StarGiftAnimation is a normalized and validated animation. JSON is used for backend playback, TGS for clients.
type StarGiftAnimation struct {
	SourceName   string
	SourceFormat StarGiftAnimationFormat
	JSON         []byte
	TGS          []byte
	SHA256       []byte
	Width        int
	Height       int
	FrameRate    float64
	InPoint      float64
	OutPoint     float64
}

// StarGiftCatalogWrite is the protocol-agnostic data the store needs to atomically create a catalog revision.
type StarGiftCatalogWrite struct {
	GiftID               int64 // 0 creates a new gift; non-zero creates a new revision for that gift
	Title                string
	Stars                int64
	ConvertStars         int64
	Enabled              bool
	SortOrder            int
	Document             Document
	Blob                 FileBlob
	Animation            StarGiftAnimation
	Actor                string
	CommandID            string
	OfficialGiftID       int64
	SourceManifestSHA256 []byte
	OfficialSourceJSON   []byte
	Limited              bool
	SoldOut              bool
	Birthday             bool
	RequirePremium       bool
	SupportOnly          bool
	LimitedPerUser       bool
	PeerColorAvailable   bool
	Auction              bool
	AvailabilityRemains  int
	AvailabilityTotal    int
	AvailabilityResale   int64
	FirstSaleDate        int
	LastSaleDate         int
	ResellMinStars       int64
	ReleasedBy           Peer
	PerUserTotal         int
	LockedUntilDate      int
	AuctionSlug          string
	GiftsPerRound        int
	AuctionStartDate     int
	AuctionRoundDuration int
	UpgradeVariants      int
	Background           *StarGiftBackground
}

// ValidateLifecycleAuthoring validates the optional auction and scheduled-release
// parameters an operator may attach when authoring a catalog revision through the
// admin panel. Zero values describe an ordinary gift and always pass; this is the
// authoring boundary for the auction panel and the scheduled-release surfaces. now
// is the current time in Unix seconds. The official-gift import path builds its
// bundle directly from Telegram's snapshot and does not go through this check, so
// the rules here may be stricter than the official model.
func (w StarGiftCatalogWrite) ValidateLifecycleAuthoring(now int) error {
	if w.GiftsPerRound < 0 || w.AvailabilityTotal < 0 || w.AuctionStartDate < 0 ||
		w.LockedUntilDate < 0 || w.AuctionRoundDuration < 0 {
		return fmt.Errorf("%w: negative field", ErrStarGiftLifecycleInvalid)
	}
	if w.Auction {
		if strings.TrimSpace(w.AuctionSlug) == "" {
			return fmt.Errorf("%w: auction requires a slug", ErrStarGiftLifecycleInvalid)
		}
		if w.GiftsPerRound <= 0 {
			return fmt.Errorf("%w: auction requires gifts_per_round > 0", ErrStarGiftLifecycleInvalid)
		}
		if w.AvailabilityTotal <= 0 {
			return fmt.Errorf("%w: auction requires availability_total > 0", ErrStarGiftLifecycleInvalid)
		}
		if w.GiftsPerRound > w.AvailabilityTotal {
			return fmt.Errorf("%w: gifts_per_round exceeds availability_total", ErrStarGiftLifecycleInvalid)
		}
		if w.AuctionStartDate != 0 && w.AuctionStartDate < now {
			return fmt.Errorf("%w: auction start date is in the past", ErrStarGiftLifecycleInvalid)
		}
		if w.LockedUntilDate != 0 {
			return fmt.Errorf("%w: auctions do not use a scheduled-release time", ErrStarGiftLifecycleInvalid)
		}
		// The gift price is the auction's opening bid (ensureStarGiftAuction seeds
		// min_bid_amount from it), so it is bound by the same ceiling as a bid.
		// Rejecting it here means an auction cannot be authored into a state where
		// no legal bid exists.
		if w.Stars > MaxStarGiftAuctionBidStars {
			return fmt.Errorf("%w: auction opening bid exceeds %d stars", ErrStarGiftLifecycleInvalid, MaxStarGiftAuctionBidStars)
		}
		return nil
	}
	// Non-auction gift: reject stray auction-only parameters, but allow
	// LockedUntilDate, which is exactly the scheduled-release gate.
	if strings.TrimSpace(w.AuctionSlug) != "" || w.GiftsPerRound != 0 ||
		w.AuctionStartDate != 0 || w.AuctionRoundDuration != 0 {
		return fmt.Errorf("%w: auction parameters set on a non-auction gift", ErrStarGiftLifecycleInvalid)
	}
	if w.LockedUntilDate != 0 && w.LockedUntilDate <= now {
		return fmt.Errorf("%w: scheduled-release time must be in the future", ErrStarGiftLifecycleInvalid)
	}
	return nil
}

// NormalizeLifecycleAuthoring derives the revision fields that the catalog's own
// CHECK constraints require but that an operator never states directly. Call it
// after ValidateLifecycleAuthoring, on the same write.
//
// star_gift_catalog_revision_auction_check demands that an auction be limited,
// carry a non-empty slug, and have gifts_per_round > 0 and auction_start_date > 0;
// the supply check separately demands "limited AND availability_total > 0". So an
// auction is by definition a limited gift, and a zero start date — which the
// validator accepts as "start now", and which ensureStarGiftAuction resolves the
// same way — has to become a concrete timestamp or the INSERT is rejected.
// AvailabilityRemains seeds the client's "N left" projection; auction settlement
// keeps it in step with gifts_left afterwards.
func (w *StarGiftCatalogWrite) NormalizeLifecycleAuthoring(now int) {
	if w.AvailabilityTotal > 0 {
		w.Limited = true
	}
	if w.Auction {
		w.Limited = true
		if w.AuctionStartDate <= 0 {
			w.AuctionStartDate = now
		}
	}
	// Zero on an existing identity can mean exhausted, never uninitialized.
	// The store preserves its inventory while holding the same lock as purchases.
	// Negative values must not be silently repaired either.
	if w.GiftID == 0 && w.Limited && !w.SoldOut && w.AvailabilityRemains == 0 {
		w.AvailabilityRemains = w.AvailabilityTotal
	}
}

// PreserveCatalogInventory applies an existing identity's inventory to a new
// revision. Call at the write boundary while holding the catalog lock.
// Replacing a revision edits presentation/pricing, not its original issuance
// cap. A different cap or sales mode requires a new gift identity.
func (w *StarGiftCatalogWrite) PreserveCatalogInventory(current StarGift) error {
	if w.GiftID == 0 || w.GiftID != current.ID {
		return fmt.Errorf("%w: inventory identity mismatch", ErrStarGiftLifecycleInvalid)
	}
	if current.AvailabilityRemains < 0 || current.AvailabilityTotal < 0 ||
		(current.Limited && (current.AvailabilityTotal == 0 || current.AvailabilityRemains > current.AvailabilityTotal)) ||
		(!current.Limited && (current.AvailabilityTotal != 0 || current.AvailabilityRemains != 0)) {
		return fmt.Errorf("%w: invalid persisted inventory", ErrStarGiftLifecycleInvalid)
	}
	if w.AvailabilityTotal < 0 || (w.AvailabilityTotal != 0 && w.AvailabilityTotal != current.AvailabilityTotal) ||
		(w.Limited && !current.Limited) || w.Auction != current.Auction {
		return fmt.Errorf("%w: existing gift supply and sales mode are immutable; create a new gift", ErrStarGiftLifecycleInvalid)
	}
	// Omitted authoring fields mean preserve, not convert to unlimited.
	w.Limited, w.SoldOut = current.Limited, current.SoldOut
	w.AvailabilityTotal, w.AvailabilityRemains = current.AvailabilityTotal, current.AvailabilityRemains
	w.AvailabilityResale, w.ResellMinStars = current.AvailabilityResale, current.ResellMinStars
	w.FirstSaleDate, w.LastSaleDate = current.FirstSaleDate, current.LastSaleDate
	return nil
}

// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional
// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID.
type StarGiftCatalogBundleWrite struct {
	Catalog     StarGiftCatalogWrite
	Collectible *StarGiftCollectibleWrite
}

type StarGiftCatalogBundleResult struct {
	Catalog     StarGiftCatalogEntry
	Collectible *StarGiftCollectibleRevision
}

// StarGiftCatalogEntry is the admin backend catalog view.
type StarGiftCatalogEntry struct {
	Gift          StarGift
	Enabled       bool
	SortOrder     int
	Revision      int
	SourceName    string
	SourceFormat  StarGiftAnimationFormat
	AnimationSHA  []byte
	AnimationSize int64
	Width         int
	Height        int
	FrameRate     float64
	ReceivedCount int64
	CreatedBy     string
	UpdatedAt     time.Time
}

// SavedStarGiftRef is the protocol-neutral reference for
// payments.getSavedStarGift/saveStarGift/convertStarGift. User gifts use
// inputSavedStarGiftUser.msg_id; channel gifts use inputSavedStarGiftChat.peer + saved_id;
// an upgraded unique gift may also use the official inputSavedStarGiftSlug.slug. The three
// identities must be mutually exclusive.
type SavedStarGiftRef struct {
	Owner   Peer
	MsgID   int
	SavedID int64
	Slug    string
}

// Valid reports whether the reference has the identity required by its owner kind.
func (r SavedStarGiftRef) Valid() bool {
	slug := strings.TrimSpace(r.Slug)
	validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0
	switch r.Owner.Type {
	case PeerTypeUser:
		return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "")
	case PeerTypeChannel:
		return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "")
	default:
		return false
	}
}

// SavedStarGiftPage is one page of received gifts plus a keyset pagination cursor.
type SavedStarGiftPage struct {
	Gifts      []SavedStarGift
	NextOffset string // empty = no more pages (must be omitted on the last page; clients stop paging on it)
	Count      int    // total (unconverted, after excludeUnsaved filtering)
}

// SavedStarGiftListCursor is the composite keyset cursor for the profile gift
// order: pinned gifts first by PinnedOrder, then unpinned gifts by ID DESC.
// PinnedOrder == 0 identifies the unpinned segment.
type SavedStarGiftListCursor struct {
	Date        int
	PinnedOrder int
	ID          int64
}

// SavedStarGiftFilter describes the client-visible filters supported by
// payments.getSavedStarGifts. CollectionID is the collection membership filter;
// zero means all collections. The current catalog is used only to decide whether
// a regular gift remains upgradable, while its rendered gift snapshot still comes
// from RevisionID.
type SavedStarGiftFilter struct {
	Owner               Peer
	ExcludeUnsaved      bool
	ExcludeSaved        bool
	ExcludeUnlimited    bool
	ExcludeUnique       bool
	ExcludeUpgradable   bool
	ExcludeUnupgradable bool
	// ExcludeHosted corresponds to exclude_hosted: keep only gifts a peer owns/received
	// itself, excluding collectibles hosted on-chain on the profile by a host.
	ExcludeHosted bool
	// SortByValue corresponds to sort_by_value: sort by price rather than receive time.
	SortByValue bool
	// PeerColorAvailable corresponds to peer_color_available: return only collectibles
	// whose palette can be used as the collectible-message palette.
	PeerColorAvailable bool
	CollectionID       int
	Offset             string
	Limit              int
}

// Star gift boundary constants.
const (
	// MaxSavedStarGiftsLimit is the per-page cap of getSavedStarGifts.
	MaxSavedStarGiftsLimit = 100
	// MaxStarGiftMessageRunes bounds the attached-message length (aligned with the
	// stargifts_message_length_max magnitude).
	MaxStarGiftMessageRunes = 255
	// MaxStarGiftsOffsetBytes is the maximum length of a keyset cursor string.
	MaxStarGiftsOffsetBytes = 64
	// MaxStarGiftTGSBytes bounds compressed animations imported from the backend, so an
	// admin-panel upload cannot become a capacity bypass.
	MaxStarGiftTGSBytes int64 = 512 << 10
	// MaxStarGiftLottieBytes bounds the decompressed Lottie JSON.
	MaxStarGiftLottieBytes int64 = 4 << 20
	// MaxStarGiftAnimationFrameRate / Seconds bound the admin backend player and the
	// client animation timeline.
	MaxStarGiftAnimationFrameRate = 120
	MaxStarGiftAnimationSeconds   = 30
	// MaxStarGiftCatalogSize is the bounded cap of the current regular gift catalog.
	MaxStarGiftCatalogSize                  = 500
	MaxStarGiftTitleRunes                   = 128
	MaxStarGiftSlugBytes                    = 255
	MaxStarGiftCollectibleAttributesPerKind = 512
	MaxStarGiftCollectionTitleRunes         = 12
	MaxStarGiftCollectionsPerPeer           = 100
	MaxStarGiftCollectionItems              = 1000
	// MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to
	// official clients. Pin requests are complete replacement vectors.
	MaxPinnedStarGifts = 6
	// MinStarGiftResaleStars is the floor of a Stars resale price. It equals the
	// clients' built-in stars_stargift_resale_amount_min default and is
	// advertised under that key in help.getAppConfig. It is deliberately flat: a
	// floor derived from the cheapest listing only ever ratchets up.
	MinStarGiftResaleStars int64 = 125
)

// Star gift sentinel errors (the rpc layer maps them to tgerr via errors.Is).
var (
	// ErrStarGiftInvalid means the gift id is not in the catalog.
	ErrStarGiftInvalid = errors.New("stargift: invalid gift id")
	// ErrStarGiftNotFound means the received gift instance could not be found.
	ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
	// ErrStarGiftAlreadyConverted means the gift was already converted back to Stars
	// (it cannot be converted again).
	ErrStarGiftAlreadyConverted       = errors.New("stargift: already converted")
	ErrStarGiftFileInvalid            = errors.New("stargift: invalid animation file")
	ErrStarGiftCatalogFull            = errors.New("stargift: catalog full")
	ErrStarGiftLifecycleInvalid       = errors.New("stargift: invalid auction or scheduled-release parameters")
	ErrStarGiftCollectibleUnavailable = errors.New("stargift: collectible upgrade unavailable")
	ErrStarGiftAlreadyUpgraded        = errors.New("stargift: already upgraded")
	ErrStarGiftCollectibleSoldOut     = errors.New("stargift: collectible supply exhausted")
	ErrStarGiftCollectibleInvalid     = errors.New("stargift: invalid collectible definition")
	ErrStarGiftCollectionNotFound     = errors.New("stargift: collection not found")
	ErrStarGiftCollectionsFull        = errors.New("stargift: collections full")
	ErrStarGiftUnavailable            = errors.New("stargift: unavailable")
	ErrStarGiftOwnerInvalid           = errors.New("stargift: owner invalid")
	ErrStarGiftTransferUnavailable    = errors.New("stargift: transfer unavailable")
	ErrStarGiftResaleUnavailable      = errors.New("stargift: resale unavailable")
	// ErrStarGiftDeleteUnavailable means this gift cannot currently be deleted: converted,
	// exported, upgraded to a collectible, limited or auction gift, still carrying a
	// conversion price or prepaid upgrade Stars, or already deleted.
	ErrStarGiftDeleteUnavailable = errors.New("stargift: delete unavailable")
	ErrStarGiftOfferInvalid      = errors.New("stargift: offer invalid")
	ErrStarGiftOfferExpired      = errors.New("stargift: offer expired")
	// ErrStarGiftRecipientUnavailable means the recipient appears as a deleted tombstone
	// to other users (a frozen account), so the gift can no longer be sent.
	ErrStarGiftRecipientUnavailable  = errors.New("stargift: recipient account unavailable")
	ErrStarGiftCraftUnavailable      = errors.New("stargift: craft unavailable")
	ErrStarGiftAuctionUnavailable    = errors.New("stargift: auction unavailable")
	ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable")
	// ErrStarGiftExportCooldown means can_export_at has not yet elapsed: the server clock
	// has not reached the exportable moment, and no client input can trigger the
	// withdrawal early.
	ErrStarGiftExportCooldown              = errors.New("stargift: export cooldown active")
	ErrChannelRevenueWithdrawalInvalid     = errors.New("channel revenue: withdrawal invalid")
	ErrChannelRevenueWithdrawalExpired     = errors.New("channel revenue: withdrawal expired")
	ErrChannelRevenueInsufficient          = errors.New("channel revenue: insufficient balance")
	ErrChannelRevenueWithdrawalUnavailable = errors.New("channel revenue: withdrawal unavailable")
	ErrStarGiftFormExpired                 = errors.New("stargift: payment form expired")
	ErrStarGiftFormPurposeInvalid          = errors.New("stargift: payment form purpose invalid")
	ErrStarGiftFormAmountMismatch          = errors.New("stargift: payment form amount mismatch")
	ErrStarGiftNotConvertible              = errors.New("stargift: not convertible")
	ErrStarGiftIdempotencyConflict         = errors.New("stargift: idempotency conflict")
)

var ErrStarGiftListed = errors.New("сначала снимите подарок с продажи")

var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)

// ValidateStarGiftCollectibleDraft validates the operator-authored definition before animation
// blobs/documents are allocated. This is the validation boundary used by admin dry-runs.
func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
	write.SlugPrefix = strings.TrimSpace(strings.ToLower(write.SlugPrefix))
	if write.GiftID <= 0 || write.UpgradeStars <= 0 || write.SupplyTotal <= 0 ||
		!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
		return ErrStarGiftCollectibleInvalid
	}
	if write.OfficialGiftID < 0 ||
		(write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) ||
		(write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) {
		return ErrStarGiftCollectibleInvalid
	}
	if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
		return err
	}
	if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, false); err != nil {
		return err
	}
	if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false); err != nil {
		return err
	}
	return validateStarGiftUpgradePreviewPool(write, false)
}

// ValidateStarGiftCollectibleWrite validates a complete publish command. Published pools are
// immutable, so partial definitions are rejected before any document/blob rows are written.
func ValidateStarGiftCollectibleWrite(write StarGiftCollectibleWrite) error {
	if err := ValidateStarGiftCollectibleDraft(write); err != nil {
		return err
	}
	if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, true); err != nil {
		return err
	}
	if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, true); err != nil {
		return err
	}
	if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true); err != nil {
		return err
	}
	return validateStarGiftUpgradePreviewPool(write, true)
}

// validateStarGiftUpgradePreviewPool protects the official-client animation contract. The
// preview response includes the target attribute plus the published selectable pool. A pool may
// intentionally contain one deterministic model or pattern; when it contains several entries,
// every entry must still have its own document identity so clients do not deduplicate the pool.
// Only attributes with a positive weight are selectable: the preview sample and the upgrade draw
// skip zero-weight attributes, so they cannot make up the two backdrops a preview needs.
func validateStarGiftUpgradePreviewPool(write StarGiftCollectibleWrite, requireStoredAsset bool) error {
	validateAnimated := func(kind StarGiftCollectibleAttributeKind, attributes []StarGiftCollectibleAttribute) error {
		selectable := 0
		documents := make(map[int64]struct{}, len(attributes))
		for _, attribute := range attributes {
			if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted || attribute.RarityPermille <= 0 {
				continue
			}
			selectable++
			if requireStoredAsset {
				if attribute.Document == nil {
					return fmt.Errorf("%w: %s preview attribute has no document", ErrStarGiftCollectibleInvalid, kind)
				}
				documents[attribute.Document.ID] = struct{}{}
			}
		}
		if selectable < 1 {
			return fmt.Errorf("%w: %s preview requires a selectable attribute", ErrStarGiftCollectibleInvalid, kind)
		}
		if requireStoredAsset && len(documents) != selectable {
			return fmt.Errorf("%w: %s preview attributes require distinct documents", ErrStarGiftCollectibleInvalid, kind)
		}
		return nil
	}
	if err := validateAnimated(StarGiftCollectibleModel, write.Models); err != nil {
		return err
	}
	if err := validateAnimated(StarGiftCollectiblePattern, write.Patterns); err != nil {
		return err
	}
	seenBackdropIDs := make(map[int]struct{}, len(write.Backdrops))
	selectableBackdrops := 0
	for _, attribute := range write.Backdrops {
		if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted {
			continue
		}
		if _, exists := seenBackdropIDs[attribute.BackdropID]; exists {
			return fmt.Errorf("%w: duplicate backdrop_id %d", ErrStarGiftCollectibleInvalid, attribute.BackdropID)
		}
		seenBackdropIDs[attribute.BackdropID] = struct{}{}
		if attribute.RarityPermille > 0 {
			selectableBackdrops++
		}
	}
	if selectableBackdrops < 2 {
		return fmt.Errorf("%w: backdrop preview requires at least two selectable attributes", ErrStarGiftCollectibleInvalid)
	}
	return nil
}

func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind StarGiftCollectibleAttributeKind, requireStoredAsset bool) error {
	if len(attributes) == 0 || len(attributes) > MaxStarGiftCollectibleAttributesPerKind {
		return ErrStarGiftCollectibleInvalid
	}
	seen := make(map[string]struct{}, len(attributes))
	selectable := 0
	for _, attribute := range attributes {
		name := strings.TrimSpace(attribute.Name)
		rarityKind := attribute.RarityKind
		if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() {
			return ErrStarGiftCollectibleInvalid
		}
		if rarityKind == StarGiftRarityPermille {
			if attribute.RarityPermille < 0 || attribute.RarityPermille > 1000 || attribute.Crafted {
				return ErrStarGiftCollectibleInvalid
			}
			// permille 0 is a legal weight: the attribute stays in the pool (it must be
			// retained to render existing collectibles) but is never drawn at random, so
			// it does not count toward the selectable total.
			if attribute.RarityPermille > 0 {
				selectable++
			}
		} else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel {
			return ErrStarGiftCollectibleInvalid
		}
		key := strings.ToLower(name)
		if _, ok := seen[key]; ok {
			return ErrStarGiftCollectibleInvalid
		}
		seen[key] = struct{}{}
		switch kind {
		case StarGiftCollectibleModel, StarGiftCollectiblePattern:
			if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
				len(attribute.Animation.TGS) == 0 || len(attribute.Animation.SHA256) != 32 {
				return ErrStarGiftCollectibleInvalid
			}
			if requireStoredAsset && (attribute.Document == nil ||
				!validStarGiftCollectibleDocument(*attribute.Document, kind) ||
				attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) {
				return ErrStarGiftCollectibleInvalid
			}
		case StarGiftCollectibleBackdrop:
			if attribute.BackdropID < 0 || attribute.Document != nil ||
				attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
				attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
				attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
				attribute.TextColor < 0 || attribute.TextColor > 0xffffff {
				return ErrStarGiftCollectibleInvalid
			}
		default:
			return ErrStarGiftCollectibleInvalid
		}
	}
	if selectable == 0 {
		return ErrStarGiftCollectibleInvalid
	}
	return nil
}

// validStarGiftCollectibleDocument enforces the client-visible document roles
// materialized by the Star Gift write boundary. Models are ordinary stickers.
// Patterns are text-color custom emoji with an inline PhotoPathSize so Android
// can classify and tint the TGS before its full first frame is downloaded.
func validStarGiftCollectibleDocument(document Document, kind StarGiftCollectibleAttributeKind) bool {
	renderAttributes := 0
	validRenderAttribute := false
	for _, attribute := range document.Attributes {
		switch attribute.Kind {
		case DocAttrSticker:
			renderAttributes++
			validRenderAttribute = validRenderAttribute || kind == StarGiftCollectibleModel
		case DocAttrCustomEmoji:
			renderAttributes++
			validRenderAttribute = validRenderAttribute ||
				(kind == StarGiftCollectiblePattern && attribute.TextColor)
		}
	}
	if renderAttributes != 1 || !validRenderAttribute {
		return false
	}
	if kind == StarGiftCollectibleModel {
		return true
	}
	for _, thumb := range document.Thumbs {
		if thumb.Kind == PhotoSizeKindPath && strings.TrimSpace(thumb.Type) != "" && len(thumb.Bytes) > 0 {
			return true
		}
	}
	return false
}

// StarGiftCatalogHash folds the client-visible catalog fields into a stable hash for getStarGifts NotModified.
func StarGiftCatalogHash(catalog []StarGift) int {
	var h uint64
	for _, g := range catalog {
		h ^= uint64(g.ID)
		h = h*0x4f25 + uint64(g.ID)
		h = h*0x4f25 + uint64(g.RevisionID)
		h = h*0x4f25 + uint64(g.Stars)
		h = h*0x4f25 + uint64(g.ConvertStars)
		h = h*0x4f25 + uint64(g.UpgradeStars)
		h = h*0x4f25 + uint64(g.UpgradeTotal)
		h = h*0x4f25 + uint64(g.UpgradeIssued)
		h = h*0x4f25 + uint64(g.Sticker.ID)
		for _, r := range g.Title {
			h = h*131 + uint64(r)
		}
	}
	return int(h & 0x7fffffff)
}

// StarGiftCollectionsHash folds each collection's own stable hash in the server's return order.
func StarGiftCollectionsHash(collections []StarGiftCollection) int64 {
	var h uint64
	for _, collection := range collections {
		h = h*0x4f25 + uint64(collection.Hash)
	}
	return int64(h & 0x7fffffffffffffff)
}

// StarGiftCollectionHash returns the per-collection hash exposed by starGiftCollection.hash.
func StarGiftCollectionHash(title string, giftIDs []int64) int64 {
	h := uint64(0x534743)
	for _, r := range title {
		h = h*131 + uint64(r)
	}
	for _, id := range giftIDs {
		h = h*0x4f25 + uint64(id)
	}
	return int64(h & 0x7fffffffffffffff)
}

// EncodeSavedStarGiftListCursor encodes the exact profile-order key of the last
// visible gift. The version prefix keeps this cursor distinct from other star
// gift lists that are ordered only by instance ID.
func EncodeSavedStarGiftListCursor(pinnedOrder, date int, id int64) string {
	if pinnedOrder < 0 || date < 0 || id <= 0 {
		return ""
	}
	raw := "v2:" + strconv.Itoa(pinnedOrder) + ":" + strconv.Itoa(date) + ":" + strconv.FormatInt(id, 10)
	return base64.RawURLEncoding.EncodeToString([]byte(raw))
}

// DecodeSavedStarGiftListCursor decodes a profile gift list cursor. Invalid or
// obsolete cursor shapes are rejected instead of being normalized on read.
func DecodeSavedStarGiftListCursor(s string) (SavedStarGiftListCursor, bool) {
	if s == "" {
		return SavedStarGiftListCursor{}, false
	}
	raw, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return SavedStarGiftListCursor{}, false
	}
	parts := strings.Split(string(raw), ":")
	if len(parts) != 4 || parts[0] != "v2" {
		return SavedStarGiftListCursor{}, false
	}
	order, err := strconv.ParseInt(parts[1], 10, 32)
	if err != nil || order < 0 {
		return SavedStarGiftListCursor{}, false
	}
	date, err := strconv.ParseInt(parts[2], 10, 32)
	if err != nil || date < 0 {
		return SavedStarGiftListCursor{}, false
	}
	id, err := strconv.ParseInt(parts[3], 10, 64)
	if err != nil || id <= 0 {
		return SavedStarGiftListCursor{}, false
	}
	return SavedStarGiftListCursor{PinnedOrder: int(order), Date: int(date), ID: id}, true
}

// EncodeSavedStarGiftPriceCursor encodes the keyset cursor for sort_by_value ordering
// (price descending, ties by instance id descending). Price may be 0, so only id is validated.
func EncodeSavedStarGiftPriceCursor(price, id int64) string {
	if id <= 0 {
		return ""
	}
	raw := "v2:" + strconv.FormatInt(price, 10) + ":" + strconv.FormatInt(id, 10)
	return base64.RawURLEncoding.EncodeToString([]byte(raw))
}

// DecodeSavedStarGiftPriceCursor decodes a price cursor; returns ok=false when it cannot be parsed (including stale v1 cursors).
func DecodeSavedStarGiftPriceCursor(s string) (price int64, id int64, ok bool) {
	if s == "" {
		return 0, 0, false
	}
	raw, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return 0, 0, false
	}
	parts := strings.Split(string(raw), ":")
	if len(parts) != 3 || parts[0] != "v2" {
		return 0, 0, false
	}
	price, err = strconv.ParseInt(parts[1], 10, 64)
	if err != nil {
		return 0, 0, false
	}
	id, err = strconv.ParseInt(parts[2], 10, 64)
	if err != nil || id <= 0 {
		return 0, 0, false
	}
	return price, id, true
}

// EncodeStarGiftCursor / DecodeStarGiftCursor are simple instance-ID cursors
// used by star gift lists whose order is strictly ID DESC (for example craft).
func EncodeStarGiftCursor(id int64) string {
	return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}

// DecodeStarGiftCursor decodes a cursor; returns ok=false when it cannot be parsed (including the empty string), and the caller starts from the first page.
func DecodeStarGiftCursor(s string) (int64, bool) {
	if s == "" {
		return 0, false
	}
	raw, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil {
		return 0, false
	}
	id, err := strconv.ParseInt(string(raw), 10, 64)
	if err != nil || id <= 0 {
		return 0, false
	}
	return id, true
}