package payments

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"sync"
	"time"

	"github.com/google/uuid"
)

// Service is the payments domain's application layer — the narrow surface other domains depend
// on, keeping the schema reachable through one seam. Every read/gate method takes the trusted
// execution Context and the account's present identity sources (which segments are awake, §6);
// payments holds no cross-schema identity knowledge, so the caller supplies present. Reads are
// served from the store's in-process cache, so the steady-state hot path issues no query to the
// payments schema.
type Service struct {
	store *Store
	clock func() time.Time

	// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
	// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
	// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
	offerMu    sync.Mutex
	offerMD    string
	offerFresh bool
}

// NewService constructs a Service over store with a wall-clock time source.
func NewService(store *Store) *Service {
	return &Service{store: store, clock: func() time.Time { return time.Now().UTC() }}
}

// Ping reports whether the payments schema is reachable.
func (s *Service) Ping(ctx context.Context) error { return s.store.Ping(ctx) }

// Wallet returns the read model for the account in the execution context: the segments visible
// there (each with its chip count and whether it is spendable) plus the context-applicable
// benefits. In a store context only the same-named segment is shown; on web/native or an
// untrusted platform all attached segments are shown, spendable only when the gate allows.
func (s *Service) Wallet(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (WalletView, error) {
	st, err := s.store.state(ctx, accountID)
	if err != nil {
		return WalletView{}, err
	}
	now := s.clock()
	view := WalletView{Benefits: benefitView(st, cxt, present, now)}
	spendable := spendableSources(cxt, present)
	for _, src := range visibleSources(cxt) {
		if !has(present, src) {
			continue // only the account's own (attached) segments are shown
		}
		view.Segments = append(view.Segments, Segment{
			Source:    src,
			Chips:     st.chipsOf(src),
			Spendable: has(spendable, src),
		})
	}
	return view, nil
}

// Catalog returns the storefront for the execution context: every chip-priced value (shown in
// every context) plus the chip packs priced in the context's payment method. It is read straight
// from the catalog tables — small and rarely edited, so uncached. An untrusted context has no
// method and so shows values only; buying is gate-checked on Spend regardless (fail-closed).
func (s *Service) Catalog(ctx context.Context, cxt Context) (CatalogView, error) {
	entries, err := s.store.loadCatalog(ctx)
	if err != nil {
		return CatalogView{}, err
	}
	return projectCatalog(entries, cxt), nil
}

// AdFree reports whether ads are suppressed for the account in the context: some origin
// applicable there has an active no-ads term or the forever flag. Fail-closed on an untrusted
// platform (no origin applies).
func (s *Service) AdFree(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
	st, err := s.store.state(ctx, accountID)
	if err != nil {
		return false, err
	}
	now := s.clock()
	for _, o := range applicableOrigins(cxt, present) {
		b := st.benefitOf(o)
		if b.adsForever || (b.adsPaidUntil != nil && b.adsPaidUntil.After(now)) {
			return true, nil
		}
	}
	return false, nil
}

// HintsAvailable returns how many hints the account can use in the context — the sum over the
// applicable origins. Zero on an untrusted platform.
func (s *Service) HintsAvailable(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (int, error) {
	st, err := s.store.state(ctx, accountID)
	if err != nil {
		return 0, err
	}
	total := 0
	for _, o := range applicableOrigins(cxt, present) {
		total += st.benefitOf(o).hints
	}
	return total, nil
}

// SpendHint consumes one hint from the first applicable origin that has one (priority
// direct→vk→tg), returning whether a hint was spent. It spends nothing when no origin is
// applicable (untrusted platform or no attached segment).
func (s *Service) SpendHint(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source) (bool, error) {
	origins := applicableOrigins(cxt, present)
	if len(origins) == 0 {
		return false, nil
	}
	return s.store.consumeHint(ctx, accountID, origins, s.clock())
}

// Spend buys a chip-priced value: it gate-checks the context, draws the price across the
// spendable segments by priority direct→vk→tg, and applies the benefit — atomically. The
// benefit's origin is the purchase context. It fails closed on an untrusted or frozen platform
// (ErrUntrusted) and on insufficient chips (ErrInsufficientChips).
func (s *Service) Spend(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID) error {
	spendable := spendableSources(cxt, present)
	if len(spendable) == 0 {
		return ErrUntrusted // untrusted, frozen, or no attached segment — no spend
	}
	prod, err := s.store.loadProduct(ctx, productID)
	if err != nil {
		return err
	}
	st, err := s.store.state(ctx, accountID)
	if err != nil {
		return err
	}
	draws, ok := planDraws(st, spendable, prod.priceChips)
	if !ok {
		return ErrInsufficientChips
	}
	snapshot, err := marshalSnapshot(prod)
	if err != nil {
		return err
	}
	return s.store.spend(ctx, accountID, draws, cxt.Kind, productID, prod.delta, snapshot, s.clock())
}

// Grant applies a benefit to an origin as a zero-price sale — an admin_grant ledger row and the
// benefit (hints, a no-ads term in whole days, or the forever flag). It never grants chips (no
// balance is touched — D16), so its signature has no chip amount. The origin is the admin's
// compliance choice.
func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source, hints, noAdsDays int, forever bool) error {
	if !origin.Valid() {
		return fmt.Errorf("payments: invalid grant origin %q", origin)
	}
	d := benefitDelta{hintsAdd: hints, noAdsDays: noAdsDays, forever: forever}
	if d.zero() {
		return fmt.Errorf("payments: empty grant")
	}
	snapshot, err := marshalGrant(d)
	if err != nil {
		return err
	}
	return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
}

// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
// yet), and one whose atoms yield no grantable benefit.
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
	if !origin.Valid() {
		return fmt.Errorf("payments: invalid grant origin %q", origin)
	}
	in, err := s.store.productInput(ctx, productID)
	if err != nil {
		return err
	}
	var d benefitDelta
	for _, a := range in.Atoms {
		switch a.Atom {
		case "hints":
			d.hintsAdd += a.Quantity
		case "noads_days":
			d.noAdsDays += a.Quantity
		case atomChips:
			return ErrCannotGrantChips
		case "tournament":
			return ErrCannotGrantTournament
		}
	}
	if d.zero() {
		return ErrNothingToGrant
	}
	snapshot, err := marshalGrantProduct(productID, in.Title, d)
	if err != nil {
		return err
	}
	return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
}

// MergeTx merges the secondary account's segments and benefits into the primary inside the
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
// after committing (Invalidate).
func (s *Service) MergeTx(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
	return s.store.MergeTx(ctx, tx, primary, secondary, s.clock())
}

// Invalidate drops the cached state of the listed accounts (called after a merge commit).
func (s *Service) Invalidate(ids ...uuid.UUID) { s.store.Invalidate(ids...) }

// benefitView aggregates the benefits applicable in the context into the wallet view: ads-off
// forever if any applicable origin is perpetual, else the latest active term end, plus the total
// available hints.
func benefitView(st walletState, cxt Context, present []Source, now time.Time) BenefitView {
	var v BenefitView
	for _, o := range applicableOrigins(cxt, present) {
		b := st.benefitOf(o)
		if b.adsForever {
			v.AdsForever = true
		}
		if b.adsPaidUntil != nil && b.adsPaidUntil.After(now) {
			if v.AdsPaidUntil == nil || b.adsPaidUntil.After(*v.AdsPaidUntil) {
				v.AdsPaidUntil = b.adsPaidUntil
			}
		}
		v.Hints += b.hints
	}
	return v
}

// planDraws greedily allocates price across the spendable segments in priority order, draining
// each before moving on. It returns the per-segment draws and whether the segments together held
// enough.
func planDraws(st walletState, spendable []Source, price int) ([]sourceAmount, bool) {
	remaining := price
	var draws []sourceAmount
	for _, src := range spendable {
		if remaining <= 0 {
			break
		}
		avail := st.chipsOf(src)
		if avail <= 0 {
			continue
		}
		take := min(avail, remaining)
		draws = append(draws, sourceAmount{source: src, amount: take})
		remaining -= take
	}
	if remaining > 0 {
		return nil, false
	}
	return draws, true
}

// purchaseSnapshot is the catalog snapshot stored on a spend/grant ledger row, so history and
// receipts stay independent of later catalog edits (§7/D34).
type purchaseSnapshot struct {
	ProductID  string         `json:"product_id,omitempty"`
	Title      string         `json:"title,omitempty"`
	Atoms      map[string]int `json:"atoms,omitempty"`
	PriceChips int            `json:"price_chips"`
	Forever    bool           `json:"forever,omitempty"`
}

// marshalSnapshot builds the snapshot for a chip spend.
func marshalSnapshot(p catalogProduct) ([]byte, error) {
	b, err := json.Marshal(purchaseSnapshot{ProductID: p.id.String(), Title: p.title, Atoms: p.atoms, PriceChips: p.priceChips})
	if err != nil {
		return nil, fmt.Errorf("payments: marshal snapshot: %w", err)
	}
	return b, nil
}

// marshalGrant builds the snapshot for an admin grant (price 0).
func marshalGrant(d benefitDelta) ([]byte, error) {
	atoms := map[string]int{}
	if d.hintsAdd > 0 {
		atoms["hints"] = d.hintsAdd
	}
	if d.noAdsDays > 0 {
		atoms["noads_days"] = d.noAdsDays
	}
	b, err := json.Marshal(purchaseSnapshot{Atoms: atoms, PriceChips: 0, Forever: d.forever})
	if err != nil {
		return nil, fmt.Errorf("payments: marshal grant snapshot: %w", err)
	}
	return b, nil
}

// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
// benefit atoms it granted (price 0).
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
	atoms := map[string]int{}
	if d.hintsAdd > 0 {
		atoms["hints"] = d.hintsAdd
	}
	if d.noAdsDays > 0 {
		atoms["noads_days"] = d.noAdsDays
	}
	b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
	if err != nil {
		return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
	}
	return b, nil
}
