package payments

import (
	"context"
	"fmt"
	"math"
	"slices"
	"strings"
)

// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
const pricingMarker = "<#pricing_template#>"

// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
	s.offerMu.Lock()
	defer s.offerMu.Unlock()
	if s.offerFresh {
		return s.offerMD, nil
	}
	md, err := s.buildOfferPricing(ctx)
	if err != nil {
		return "", err
	}
	s.offerMD = md
	s.offerFresh = true
	return md, nil
}

// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
func (s *Service) markOfferStale() {
	s.offerMu.Lock()
	s.offerFresh = false
	s.offerMu.Unlock()
}

// buildOfferPricing loads the active catalog and projects it into the offer tables.
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
	entries, err := s.store.loadCatalog(ctx)
	if err != nil {
		return "", err
	}
	return projectOfferPricing(entries), nil
}

// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
func projectOfferPricing(entries []catalogEntry) string {
	// Order the whole catalog once by the shared canonical rank, then split it into the two offer
	// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
	// caller's slice is left untouched.
	sorted := slices.Clone(entries)
	sortCatalogEntries(sorted)
	var packs, values []catalogEntry
	for _, e := range sorted {
		if isPackEntry(e) {
			packs = append(packs, e)
		} else {
			values = append(values, e)
		}
	}

	var b strings.Builder
	if len(packs) > 0 {
		b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
		b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
		b.WriteString("| --- | ---: | ---: | ---: |\n")
		for _, e := range packs {
			fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
				offerCell(e.title),
				offerPrice(e, string(SourceDirect), CurrencyRUB),
				offerPrice(e, string(SourceVK), CurrencyVote),
				offerPrice(e, string(SourceTelegram), CurrencyStar),
			)
		}
	}
	if len(values) > 0 {
		if len(packs) > 0 {
			b.WriteString("\n")
		}
		b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
		b.WriteString("| Наименование | «Фишки» |\n")
		b.WriteString("| --- | ---: |\n")
		for _, e := range values {
			fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
		}
	}
	return strings.TrimRight(b.String(), "\n")
}

// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
func offerValueGroup(e catalogEntry) int {
	hasHints, hasNoAds, hasTournament := false, false, false
	for _, a := range e.atoms {
		switch a.atomType {
		case "hints":
			hasHints = true
		case "noads_days":
			hasNoAds = true
		case "tournament":
			hasTournament = true
		}
	}
	return valueGroup(hasHints, hasNoAds, hasTournament)
}

// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
// the catalog shape forbids it).
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
	switch {
	case hasTournament:
		return 3
	case hasHints && hasNoAds:
		return 2
	case hasNoAds:
		return 1
	case hasHints:
		return 0
	default:
		return 4
	}
}

// offerSortAmount returns the entry's price in the given method and currency for ordering, or
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
	if amt, ok := offerAmount(e, method, cur); ok {
		return amt
	}
	return math.MaxInt64
}

// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
// chips with money) rather than being a chip-priced value.
func isPackEntry(e catalogEntry) bool {
	for _, a := range e.atoms {
		if a.atomType == atomChips {
			return true
		}
	}
	return false
}

// offerPrice formats the entry's price for the given payment method and currency as a major-unit
// string, or an em dash when the entry carries no such price.
func offerPrice(e catalogEntry, method string, cur Currency) string {
	amt, ok := offerAmount(e, method, cur)
	if !ok {
		return "—"
	}
	m, err := MoneyFromMinor(amt, cur)
	if err != nil {
		return "—"
	}
	return m.Major()
}

// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
// currency, and whether such a price exists.
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
	for _, pr := range e.prices {
		if pr.method == method && pr.currency == cur {
			return pr.amount, true
		}
	}
	return 0, false
}

// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&amp;" does not
// double-escape the entities the other rules emit.
var offerCellReplacer = strings.NewReplacer(
	"&", "&amp;",
	"<", "&lt;",
	">", "&gt;",
	`"`, "&quot;",
	"'", "&#39;",
	"|", `\|`,
	"[", `\[`,
	"]", `\]`,
	"\n", " ",
)

// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
// the public offer (see [offerCellReplacer]).
func offerCell(s string) string {
	return offerCellReplacer.Replace(s)
}
