package catalogue

import (
	"context"
	"fmt"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
)

// Item is one Paddle line item: a price and how many of it.
type Item struct {
	PriceID  string `json:"price_id"`
	Quantity int    `json:"quantity"`
}

// LineItems builds the subscription items for a configuration.
//
// The base row is always quantity 1. A metered row's quantity is the configured
// TOTAL minus the plan's base allowance, so a Professional customer at exactly
// three servers has a single-item subscription rather than one with a zero
// quantity Paddle would reject. A feature with no price in this environment
// produces no item and is granted free.
func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) {
	if !sells(plan.Deployment, term) {
		return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term)
	}
	rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier)
	if err != nil {
		return nil, err
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("%w: %s/%s is priced by nothing",
			ErrUnpriced, plan.Deployment, plan.Tier)
	}

	wanted := map[string]bool{}
	for _, f := range cfg.Features {
		wanted[f] = true
	}

	items := []Item{}
	for _, r := range rows {
		switch r.Kind {
		case models.KindBase:
			id := r.PriceID(env, term)
			if id == "" {
				return nil, fmt.Errorf("%w: base price for %s/%s in %s",
					ErrUnpriced, plan.Deployment, plan.Tier, env)
			}
			items = append(items, Item{PriceID: id, Quantity: 1})

		case models.KindLimit:
			qty := billable(cfg, r.LimitKey, plan.BaseLimits)
			if qty <= 0 {
				continue
			}
			id := r.PriceID(env, term)
			if id == "" {
				return nil, fmt.Errorf("%w: %s price for %s/%s in %s",
					ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env)
			}
			items = append(items, Item{PriceID: id, Quantity: qty})

		case models.KindFeature:
			if !wanted[r.FeatureKey] {
				continue
			}
			id := r.PriceID(env, term)
			if id == "" {
				// Free to toggle. Resolve() still grants it.
				continue
			}
			items = append(items, Item{PriceID: id, Quantity: 1})
		}
	}
	return items, nil
}

// billable is how many UNITS to charge for a metered dimension.
//
// The configured value is the total the customer sees, which includes the base
// allowance they were given. Charging for that base is the single most likely
// bug in this file, so the subtraction lives here and nowhere else.
func billable(cfg models.Config, limitKey string, base license.Limits) int {
	switch limitKey {
	case models.LimitKeyServers:
		if base.MaxServers == license.Unlimited {
			return 0
		}
		return cfg.Servers - base.MaxServers
	default:
		return 0
	}
}

// Match is what an item list says about itself.
type Match struct {
	Deployment string
	Tier       string
	Term       string
	Servers    int
	Features   []string
}

// ResolveItems maps a full item list back to a plan and a configuration.
//
// This replaces a price-ID-to-tier lookup, which cannot work once a subscription
// has several prices. The base item identifies the plan and the term; everything
// else is read relative to it. An item matching nothing fails the whole list.
//
// Only the running environment's IDs are consulted, so a production process
// cannot be talked into resolving a sandbox price by a forged or misrouted
// event.
//
// It is a function of the COMPLETE list, which is what keeps out-of-order
// delivery correct by construction: Paddle sends every item on every
// subscription event, so reading all of them is reading current state rather
// than a transition.
func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) {
	all, err := models.AllCatalogue(ctx)
	if err != nil {
		return Match{}, err
	}

	// Pass 1: find the base item. Until we know the plan, no other item means
	// anything — a quantity of 7 is 7 of what?
	var m Match
	found := false
	for _, it := range items {
		for _, r := range all {
			if r.Kind != models.KindBase {
				continue
			}
			for _, term := range []string{"monthly", "annual"} {
				if r.PriceID(env, term) != it.PriceID || it.PriceID == "" {
					continue
				}
				if found {
					return Match{}, fmt.Errorf(
						"item list names two plans: %s/%s and %s/%s",
						m.Deployment, m.Tier, r.Deployment, r.Tier)
				}
				m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term
				found = true
			}
		}
	}
	if !found {
		return Match{}, ErrNoBaseItem
	}
	if !sells(m.Deployment, m.Term) {
		return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue",
			ErrTermNotSold, m.Deployment, m.Term)
	}

	plan, err := models.GetPlan(ctx, m.Deployment, m.Tier)
	if err != nil {
		return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w",
			m.Deployment, m.Tier, err)
	}
	m.Servers = plan.BaseLimits.MaxServers
	m.Features = []string{}

	// Pass 2: everything else, relative to that plan. An item matching no row of
	// this plan is a configuration error even if it matches some other plan's
	// row — mixing two plans in one subscription is not a thing we sell.
	rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier)
	if err != nil {
		return Match{}, err
	}
	for _, it := range items {
		matched := false
		for _, r := range rows {
			if r.PriceID(env, m.Term) != it.PriceID {
				continue
			}
			matched = true
			switch r.Kind {
			case models.KindBase:
				// Already handled.
			case models.KindLimit:
				if r.LimitKey == models.LimitKeyServers {
					m.Servers = plan.BaseLimits.MaxServers + it.Quantity
				}
			case models.KindFeature:
				m.Features = append(m.Features, r.FeatureKey)
			}
		}
		if !matched {
			return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)",
				ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier)
		}
	}
	return m, nil
}

// sells reports whether a deployment offers a term.
func sells(deployment, term string) bool {
	for _, t := range license.TermsFor(deployment) {
		if t == term {
			return true
		}
	}
	return false
}
