// Package pricing builds the default LLM pricing table the synthesizer
// ships to the proxy's cost_meter middleware. The catalog is the single
// source of default rates: every catalog provider's models are folded
// into the pricing surfaces the provider declares (PricingSurfaces),
// then a small supplemental list adds priced-but-not-operator-selectable
// entries. Management is the sole pricing authority — the proxy carries
// no embedded price list and bills exclusively from the table it is
// sent.
//go:generate go run gen.go

package pricing

import (
	"sync"
	"sync/atomic"

	"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)

// Entry is a single model's pricing in USD per 1k tokens. This struct IS
// the wire shape: the synthesizer marshals it verbatim into cost_meter's
// ConfigJSON, and the proxy unmarshals the same field names.
//
// A zero rate means "no rate configured" — the proxy bills that cache
// bucket at InputPer1k (identical semantics to the retired proxy-embedded
// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are
// a subset of input); CacheReadPer1k / CacheCreationPer1k are the
// Anthropic shape (additive buckets).
type Entry struct {
	InputPer1k         float64 `json:"input_per_1k"`
	OutputPer1k        float64 `json:"output_per_1k"`
	CachedInputPer1k   float64 `json:"cached_input_per_1k,omitempty"`
	CacheReadPer1k     float64 `json:"cache_read_per_1k,omitempty"`
	CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"`
}

// supplementalDefaults are (surface, model) entries that are priced but
// deliberately not operator-selectable in the catalog. Each carries a
// reason; when one of these models joins a catalog lineup, delete the
// row here — the collision test fails loudly if the rates ever disagree.
var supplementalDefaults = map[string]map[string]Entry{
	"openai": {
		// GPT-5 (2025) family — kept for gateway requests using the
		// unsuffixed ids; the dashboard offers only the 5.x lineup.
		"gpt-5":      {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125},
		"gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025},
		"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
	},
	"anthropic": {
		// claude-opus-5 is not yet in the catalog lineup but gateway /
		// grandfathered traffic uses it; priced so it isn't skipped.
		"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
		// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
		// configure against Moonshot's Anthropic-compatible endpoint;
		// priced identically to kimi-k3 so those requests aren't skipped.
		"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
	},
	"bedrock": {
		"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
	},
}

var (
	compiledOnce  sync.Once
	compiledTable map[string]map[string]Entry
	// mergedTable holds the current live table when a pricing defaults
	// file is loaded: the file merged entry-whole over the compiled-in
	// base. Nil while no file is loaded (or after the file is removed),
	// in which case the compiled-in table serves. Swapped atomically by
	// the file loader/reloader; readers never block.
	mergedTable atomic.Pointer[map[string]map[string]Entry]
)

// DefaultTable returns the current default pricing table keyed
// surface -> model -> Entry: the management-side defaults file (see
// LoadFile / StartReloader) when one is loaded, merged over the
// compiled-in catalog table, which alone serves as the fallback when no
// file exists. The snapshot may change between calls as the file is
// re-read — consumers (the synthesizer on every reconcile, the catalog
// endpoint on every request) pick up fresh rates automatically. Callers
// must not mutate the returned maps.
func DefaultTable() map[string]map[string]Entry {
	if t := mergedTable.Load(); t != nil {
		return *t
	}
	return compiledBase()
}

// compiledBase returns the compiled-in table (catalog + supplementals),
// built once.
func compiledBase() map[string]map[string]Entry {
	compiledOnce.Do(func() {
		compiledTable = buildDefaultTable()
	})
	return compiledTable
}

func buildDefaultTable() map[string]map[string]Entry {
	out := make(map[string]map[string]Entry)
	for _, p := range catalog.All() {
		for _, surface := range p.PricingSurfaces {
			inner, ok := out[surface]
			if !ok {
				inner = make(map[string]Entry)
				out[surface] = inner
			}
			for _, m := range p.Models {
				// First writer wins; providers contributing the same
				// (surface, model) must agree on rates — enforced by
				// TestDefaultTable_NoConflictingContributions.
				if _, dup := inner[m.ID]; dup {
					continue
				}
				inner[m.ID] = entryFromCatalogModel(m)
			}
		}
	}
	for surface, models := range supplementalDefaults {
		inner, ok := out[surface]
		if !ok {
			inner = make(map[string]Entry)
			out[surface] = inner
		}
		for id, e := range models {
			if _, dup := inner[id]; dup {
				continue
			}
			inner[id] = e
		}
	}
	return out
}

func entryFromCatalogModel(m catalog.Model) Entry {
	return Entry{
		InputPer1k:         m.InputPer1k,
		OutputPer1k:        m.OutputPer1k,
		CachedInputPer1k:   m.CachedInputPer1k,
		CacheReadPer1k:     m.CacheReadPer1k,
		CacheCreationPer1k: m.CacheCreationPer1k,
	}
}

// LookupDefault returns the default entry for model on the first of the
// given surfaces that prices it. Used by the synthesizer to seed a
// per-provider entry with default cache rates before overlaying the
// operator's stored prices.
func LookupDefault(surfaces []string, model string) (Entry, bool) {
	table := DefaultTable()
	for _, s := range surfaces {
		if e, ok := table[s][model]; ok {
			return e, true
		}
	}
	return Entry{}, false
}
