package payments

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/google/uuid"
)

// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
// provider launch payload needs — the amount to charge and a human title for the payment.
type OrderResult struct {
	OrderID uuid.UUID
	Amount  Money
	Title   string
}

// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
// method's currency, then writes the order. The caller enforces any account-level precondition
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
// knowledge.
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
	if !cxt.Trusted() || cxt.vkFrozen() {
		return OrderResult{}, ErrUntrusted
	}
	method := cxt.Kind
	if !has(present, method) {
		return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
	}
	pack, err := s.store.loadPackForOrder(ctx, productID, method)
	if err != nil {
		return OrderResult{}, err
	}
	orderID, err := uuid.NewV7()
	if err != nil {
		return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
	}
	o := newOrder{
		orderID:   orderID,
		accountID: accountID,
		platform:  string(method),
		productID: productID,
		amount:    pack.price,
		origin:    method,
		provider:  provider,
		shop:      directShop(cxt),
	}
	if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
		return OrderResult{}, err
	}
	return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}

// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
func directShop(cxt Context) string {
	if cxt.Kind == SourceDirect {
		return cxt.Subtype
	}
	return ""
}

// AttachProviderPayment records the provider's own payment identifier on a pending order, right
// after the provider mints it and before the customer has paid. Two later paths depend on it: the
// reconcile sweep, which asks the provider what became of an order no callback ever confirmed, and a
// refund, which must address the original payment. It does not credit anything and does not change
// the order status.
func (s *Service) AttachProviderPayment(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string) error {
	return s.store.attachProviderPayment(ctx, orderID, provider, providerPaymentID, s.clock())
}

// OrderRef identifies a stored order to a provider: which rail settles it, that rail's own payment
// id, the merchant shop channel it was issued through, and the amount and account it belongs to. It
// is what the reconcile sweep and the refund path need without giving them the whole order.
type OrderRef struct {
	OrderID   uuid.UUID
	AccountID uuid.UUID
	Provider  string
	PaymentID string
	Shop      string
	Amount    Money
	Status    string
}

// orderRef projects a stored order onto an OrderRef.
func orderRef(o orderRow) (OrderRef, error) {
	amount, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
	if err != nil {
		return OrderRef{}, err
	}
	return OrderRef{
		OrderID:   o.orderID,
		AccountID: o.accountID,
		Provider:  o.provider,
		PaymentID: o.paymentID,
		Shop:      o.shop,
		Amount:    amount,
		Status:    o.status,
	}, nil
}

// OrderProviderRef reads how an order reaches its provider — the rail, that rail's payment id and
// the shop it was issued through. The refund path uses it to call the right merchant account.
func (s *Service) OrderProviderRef(ctx context.Context, orderID uuid.UUID) (OrderRef, error) {
	o, err := s.store.orderByID(ctx, orderID)
	if err != nil {
		return OrderRef{}, err
	}
	return orderRef(o)
}

// OrderByProviderPayment reads the order a provider's payment id belongs to. It resolves a provider
// event that names only its own payment — such as a refund notification — back to an order.
func (s *Service) OrderByProviderPayment(ctx context.Context, provider, providerPaymentID string) (OrderRef, error) {
	o, err := s.store.orderByProviderPayment(ctx, provider, providerPaymentID)
	if err != nil {
		return OrderRef{}, err
	}
	return orderRef(o)
}

// reconcileBatch bounds one reconcile sweep, so a backlog cannot turn a periodic tick into a long
// run of provider calls.
const reconcileBatch = 50

// ReconcileAfter is how old a pending order must be before the provider is asked what became of it.
// It is deliberately NOT the order lifetime: that governs when an unpaid order is written off, which
// answers "how long may a customer take to pay", a different question from "how soon should we
// notice a lost callback". Tying the two together would make a customer wait a full lifetime for
// chips whenever the notification path fails. A minute is long enough that an order the customer is
// still paying for is not polled, and short enough that a failure costs minutes, not half an hour.
const ReconcileAfter = time.Minute

// PendingForReconcile returns the pending orders old enough to re-check that carry a provider payment
// id — the ones where the money may well have moved but no callback ever told us. The caller asks the
// provider for each one's real outcome. Orders that never reached a payment are not returned: there
// is nothing to ask about, and neither are orders younger than ReconcileAfter, so an order the
// customer is still paying for is left alone.
//
// The sweep repeats until the order settles or is written off, which bounds the provider calls one
// order can cause to its lifetime divided by the sweep interval — a handful, not an open-ended poll.
func (s *Service) PendingForReconcile(ctx context.Context) ([]OrderRef, error) {
	rows, err := s.store.pendingForReconcile(ctx, int(ReconcileAfter.Seconds()), s.clock(), reconcileBatch)
	if err != nil {
		return nil, err
	}
	out := make([]OrderRef, 0, len(rows))
	for _, r := range rows {
		ref, err := orderRef(r)
		if err != nil {
			return nil, err
		}
		out = append(out, ref)
	}
	return out, nil
}

// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
	ord, err := s.store.orderByID(ctx, orderID)
	if err != nil {
		return "", Money{}, err
	}
	_, title, err = s.store.packForCredit(ctx, ord.productID)
	if err != nil {
		return "", Money{}, err
	}
	amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
	if err != nil {
		return "", Money{}, err
	}
	return title, amount, nil
}

// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
// callback is honoured even on an expired order (§9/D23).
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
	return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}

// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
	return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}

// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
const (
	// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
	PreCheckoutGone = "order_gone"
	// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
	PreCheckoutAlreadyPaid = "already_paid"
	// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
	PreCheckoutPriceChanged = "price_changed"
)

// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
type PreCheckoutOutcome struct {
	OK        bool
	Reason    string
	AccountID uuid.UUID
}

// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
// approved, before any star is charged. It approves an order that exists, is not already paid (a
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
	ord, err := s.store.orderByID(ctx, orderID)
	if errors.Is(err, ErrOrderNotFound) {
		return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
	}
	if err != nil {
		return PreCheckoutOutcome{}, err
	}
	if ord.status == "paid" {
		return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
	}
	if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
		return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
	}
	return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}

// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
// them.
const providerVKAds = "vk_ads"

// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
	return s.store.interstitialCooldowns(ctx)
}

// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
// VK-only, D28). The client uses it to gate the "watch for chips" button.
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
	if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
		return 0, nil
	}
	payout, _, _, err := s.store.rewardConfig(ctx)
	return payout, err
}

// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
// the per-day and per-hour caps.
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
	return s.store.rewardConfig(ctx)
}

// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
// offer stale.
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
	if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
		return errors.New("payments: reward payout and caps must be non-negative")
	}
	return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
}

// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
// attached.
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
	if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
		return RewardOutcome{}, ErrUntrusted
	}
	return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
}

// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
	ttl, err := s.store.orderTTL(ctx)
	if err != nil {
		return 0, err
	}
	return s.store.expirePending(ctx, ttl, s.clock())
}

// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver to the user (live stream, botlink or email).
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
	return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
}

// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
// them and marks each delivered via MarkEventDispatched.
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
	return s.store.undispatchedEvents(ctx, limit)
}

// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
	return s.store.markEventDispatched(ctx, eventID, s.clock())
}
