package plugin

import (
	"fmt"
	"log/slog"
	"math"
	"math/rand/v2"
	"sort"
	"strings"
	"time"

	"maunium.net/go/mautrix/id"
)

// ── Shop Listings ────────────────────────────────────────────────────────────

// slotEmoji returns a display emoji for a slot category.
func slotEmoji(slot EquipmentSlot) string {
	switch slot {
	case SlotWeapon:
		return "⚔️"
	case SlotArmor:
		return "🛡️"
	case SlotHelmet:
		return "🪖"
	case SlotBoots:
		return "👢"
	case SlotTool:
		return "⛏️"
	default:
		return "📦"
	}
}

// slotTitle returns a capitalized display name for a slot.
func slotTitle(slot EquipmentSlot) string {
	s := string(slot)
	if len(s) == 0 {
		return s
	}
	return strings.ToUpper(s[:1]) + s[1:]
}

// ── Pending Interaction Types ───────────────────────────────────────────────

type advPendingShopCategory struct {
	ShowAll bool
}

type advPendingShopItem struct {
	Slot    EquipmentSlot
	ShowAll bool
}

type advPendingShopConfirm struct {
	Slot EquipmentSlot
	Tier int
}

// ── Session Tracking ────────────────────────────────────────────────────────

type advShopSession struct {
	StartedAt   time.Time
	ItemsBought int
	// Persuasion discount: 0.10 if the player passed Persuasion DC 15 on
	// session start, else 0. Applied to all purchases this session.
	// Expires `dndPersuasionDiscountTTL` after StartedAt — players can't
	// hold a discount session open indefinitely.
	PersuasionDiscount float64
}

// dndPersuasionDiscountTTL — how long after shop entry the Persuasion
// discount remains valid. After this, prices return to full.
const dndPersuasionDiscountTTL = 30 * time.Minute

func (p *AdventurePlugin) shopSessionGet(userID id.UserID) *advShopSession {
	if val, ok := p.shopSessions.Load(string(userID)); ok {
		return val.(*advShopSession)
	}
	return nil
}

func (p *AdventurePlugin) shopSessionStart(userID id.UserID) {
	if p.shopSessionGet(userID) == nil {
		discount := 0.0
		if dndNPCPersuasionDiscount(userID) {
			discount = 0.10
		}
		p.shopSessions.Store(string(userID), &advShopSession{
			StartedAt:          time.Now(),
			PersuasionDiscount: discount,
		})
	}
}

// shopSessionPriceFactor returns the multiplier to apply to shop prices for
// the given user's current session. 1.0 normally, 0.9 if Persuasion passed.
// The discount expires after dndPersuasionDiscountTTL to prevent indefinite
// session-holding (audit fix G).
func (p *AdventurePlugin) shopSessionPriceFactor(userID id.UserID) float64 {
	sess := p.shopSessionGet(userID)
	if sess == nil {
		return 1.0
	}
	if sess.PersuasionDiscount > 0 && time.Since(sess.StartedAt) > dndPersuasionDiscountTTL {
		return 1.0
	}
	return 1.0 - sess.PersuasionDiscount
}

// shopSessionAnnounceDiscount returns flavor text to surface a passed
// Persuasion check, or empty string if none/expired.
func (p *AdventurePlugin) shopSessionAnnounceDiscount(userID id.UserID) string {
	sess := p.shopSessionGet(userID)
	if sess == nil || sess.PersuasionDiscount <= 0 {
		return ""
	}
	if time.Since(sess.StartedAt) > dndPersuasionDiscountTTL {
		return ""
	}
	return "_(Persuasion DC 15 passed — 10% discount applied; expires 30 minutes after shop entry.)_"
}

func (p *AdventurePlugin) shopSessionBump(userID id.UserID) {
	sess := p.shopSessionGet(userID)
	if sess != nil {
		sess.ItemsBought++
	}
}

func (p *AdventurePlugin) shopSessionEnd(userID id.UserID) {
	p.shopSessions.Delete(string(userID))
}

// ── Browse Timeout ──────────────────────────────────────────────────────────

// shopNudgeGen tracks a generation counter per user so only the latest nudge fires.
// Key: userID string, Value: *int64 (atomic counter)
func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) {
	// Bump generation so any prior goroutine becomes stale.
	var gen int64
	if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); ok {
		gen = val.(int64) + 1
	}
	p.shopSessions.Store(string(userID)+":nudge_gen", gen)

	capturedGen := gen
	safeGo("shop-nudge", func() {
		time.Sleep(2 * time.Minute)
		// Check if this goroutine is still the latest.
		if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); !ok || val.(int64) != capturedGen {
			return
		}
		val, ok := p.pending.Load(string(userID))
		if !ok {
			return
		}
		pi, ok := val.(*advPendingInteraction)
		if !ok || !strings.HasPrefix(pi.Type, "shop_") {
			return
		}
		flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse")
		p.SendDM(userID, fmt.Sprintf("*%s*", flavor))
	})
}

// ── Display: Luigi Greeting + Category Menu ─────────────────────────────────

func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool, chatLevel int) string {
	var sb strings.Builder

	// Check if fully maxed out.
	allMaxed := true
	for _, slot := range allSlots {
		eq := equip[slot]
		if eq == nil || eq.Tier < 5 {
			allMaxed = false
			break
		}
	}

	if allMaxed {
		flavor, _ := advPickFlavor(luigiMaxedOut, userID, "luigi_maxed")
		sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n\n*%s*", flavor))
		return sb.String()
	}

	var greet string
	if chatLevel >= 10 {
		greet = shopGreeting(chatLevel)
	} else {
		greet, _ = advPickFlavor(luigiGreetings, userID, "luigi_greet")
	}
	sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance))
	sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))

	// Two-column grid; the Exit chip squares the layout so Curios doesn't
	// dangle on its own row and players have a one-word out from here.
	sb.WriteString("⚔️  Weapons       🛡️  Armor\n")
	sb.WriteString("🪖  Helmets       👢  Boots\n")
	sb.WriteString("⛏️  Tools         🧪  Supplies\n")
	sb.WriteString("🔮  Curios        🚪  Exit\n\n")

	if showAll {
		flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
		sb.WriteString(fmt.Sprintf("*%s*\n\n", flavor))
	}

	sb.WriteString("Reply with a category name to browse.")
	return sb.String()
}

// ── Display: Category Item List ─────────────────────────────────────────────

func luigiCategoryView(userID id.UserID, slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
	var sb strings.Builder

	current := equip[slot]
	currentTier := 0
	currentName := "None"
	isMWOrArena := false
	if current != nil {
		currentTier = current.Tier
		currentName = current.Name
		isMWOrArena = current.Masterwork || current.ArenaTier > 0
	}

	emoji := slotEmoji(slot)
	title := strings.ToUpper(string(slot))

	// Category intro
	if intros, ok := luigiCategoryIntros[slot]; ok && len(intros) > 0 {
		intro, _ := advPickFlavor(intros, userID, "luigi_cat_"+string(slot))
		sb.WriteString(fmt.Sprintf("*%s*\n\n", intro))
	}

	sb.WriteString(fmt.Sprintf("%s **%s** — Your current: %s (T%d)", emoji, title, currentName, currentTier))
	if isMWOrArena {
		sb.WriteString(" ⭐")
	}
	sb.WriteString("\n\n")

	if isMWOrArena {
		ack, _ := advPickFlavor(luigiMasterworkAck, userID, "luigi_mw_ack")
		sb.WriteString(fmt.Sprintf("*%s*\n\n", ack))
	}

	defs := equipmentTiers[slot]
	hasItems := false

	// Show current equipped item first.
	if current != nil {
		for _, def := range defs {
			if def.Tier == currentTier {
				sb.WriteString(fmt.Sprintf("🟢 %-28s T%d   €%.0f    Currently equipped\n",
					def.Name, def.Tier, def.Price))
				break
			}
		}
	}

	for _, def := range defs {
		if def.Price == 0 {
			continue // skip tier 0
		}
		if def.Tier == currentTier {
			continue // already shown as 🟢
		}
		if !showAll && def.Tier < currentTier {
			continue // skip downgrades unless show all
		}

		hasItems = true

		// Upgrade indicator
		var indicator string
		switch {
		case def.Tier > currentTier:
			indicator = "⬆️"
		case def.Tier == currentTier:
			indicator = "➡️"
		default:
			indicator = "⬇️"
		}

		// Affordability
		afford := "✅"
		if balance < def.Price {
			afford = "💸"
		}

		// One-liner
		oneLiner := ""
		if line, ok := luigiItemOneLiners[luigiItemKey{slot, def.Tier}]; ok {
			oneLiner = fmt.Sprintf("  \"%s\"", line)
		}

		sb.WriteString(fmt.Sprintf("%s %-28s T%d   €%-8.0f %s%s\n",
			indicator, def.Name, def.Tier, def.Price, afford, oneLiner))
	}

	// Occasional unprompted Luigi commentary (~30% chance).
	if hasItems && rand.IntN(100) < 30 {
		comment, _ := advPickFlavor(luigiCommentary, userID, "luigi_comment")
		sb.WriteString(fmt.Sprintf("\n*%s*\n", comment))
	}

	if !hasItems {
		sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n")
	}

	if !showAll && currentTier > 1 {
		sb.WriteString(fmt.Sprintf("\nTiers below T%d omitted. Use `!adventure shop show all` to see everything.\n", currentTier))
	}

	sb.WriteString("\nReply with an item name to buy, or \"back\" to return to categories.")
	return sb.String()
}

// ── Display: Item Confirm ───────────────────────────────────────────────────

func luigiItemConfirm(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, current *AdvEquipment, balance float64) string {
	var sb strings.Builder

	currentTier := 0
	currentName := "None"
	if current != nil {
		currentTier = current.Tier
		currentName = current.Name
	}

	// Upgrade indicator
	indicator := "⬆️"
	if def.Tier == currentTier {
		indicator = "➡️"
	} else if def.Tier < currentTier {
		indicator = "⬇️"
	}

	sb.WriteString(fmt.Sprintf("%s **%s** — T%d %s — €%.0f\n\n", indicator, def.Name, def.Tier, slotTitle(slot), def.Price))

	if def.Tier > currentTier {
		sb.WriteString(fmt.Sprintf("An upgrade from your %s (T%d).\n", currentName, currentTier))
	} else if def.Tier == currentTier {
		sb.WriteString(fmt.Sprintf("Same tier as your current %s.\n", currentName))
	} else {
		sb.WriteString(fmt.Sprintf("A downgrade from your %s (T%d).\n", currentName, currentTier))
	}

	// Full Luigi description
	if desc, ok := luigiItemDescriptions[luigiItemKey{slot, def.Tier}]; ok {
		sb.WriteString(fmt.Sprintf("\n\"%s\"\n", desc))
	}

	afterBalance := balance - def.Price
	sb.WriteString(fmt.Sprintf("\nYour balance: €%.0f  →  €%.0f after purchase\n\n", balance, afterBalance))
	sb.WriteString("Confirm? (yes / no)")

	return sb.String()
}

// ── Resolver: Category Choice ───────────────────────────────────────────────

func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interaction *advPendingInteraction) error {
	data := interaction.Data.(*advPendingShopCategory)
	reply := strings.ToLower(strings.TrimSpace(ctx.Body))

	if reply == "back" || reply == "exit" || reply == "cancel" {
		p.shopSessionEnd(ctx.Sender)
		flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
		return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
	}

	// Check for supplies category first
	if reply == "supplies" || reply == "supply" || reply == "consumables" || reply == "potions" {
		balance := p.euro.GetBalance(ctx.Sender)
		text := luigiSuppliesView(ctx.Sender, balance)
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_supply",
			Data:      data,
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		p.advMarkMenuSent(ctx.Sender)
		p.shopScheduleBrowseNudge(ctx.Sender)
		return p.SendDM(ctx.Sender, text)
	}

	// Curios — Open5e magic items, daily rotating stock.
	if reply == "curios" || reply == "curio" || reply == "magic" || reply == "trinkets" {
		balance := p.euro.GetBalance(ctx.Sender)
		text := p.luigiCuriosView(ctx.Sender, balance)
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_curio",
			Data:      data,
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		p.advMarkMenuSent(ctx.Sender)
		p.shopScheduleBrowseNudge(ctx.Sender)
		return p.SendDM(ctx.Sender, text)
	}

	slot := advParseShopCategory(reply)
	if slot == "" {
		// Re-store pending and reprompt.
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, supplies, or curios.")
	}

	_, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}
	balance := p.euro.GetBalance(ctx.Sender)

	text := luigiCategoryView(ctx.Sender, slot, equip, balance, data.ShowAll)
	p.pending.Store(string(ctx.Sender), &advPendingInteraction{
		Type:      "shop_item",
		Data:      &advPendingShopItem{Slot: slot, ShowAll: data.ShowAll},
		ExpiresAt: time.Now().Add(advDMResponseWindow),
	})
	p.advMarkMenuSent(ctx.Sender)
	p.shopScheduleBrowseNudge(ctx.Sender)

	return p.SendDM(ctx.Sender, text)
}

// ── Resolver: Item Choice ───────────────────────────────────────────────────

func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction *advPendingInteraction) error {
	data := interaction.Data.(*advPendingShopItem)
	reply := strings.TrimSpace(ctx.Body)
	lower := strings.ToLower(reply)

	if lower == "back" {
		// Return to category menu.
		_, equip, err := p.ensureCharacter(ctx.Sender)
		if err != nil {
			return p.SendDM(ctx.Sender, "Failed to load your character.")
		}
		balance := p.euro.GetBalance(ctx.Sender)

		text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_category",
			Data:      &advPendingShopCategory{ShowAll: data.ShowAll},
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		p.advMarkMenuSent(ctx.Sender)
		return p.SendDM(ctx.Sender, text)
	}

	if lower == "exit" || lower == "cancel" {
		p.shopSessionEnd(ctx.Sender)
		flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
		return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
	}

	// Try to find the item scoped to the current slot first, then globally.
	slot, def, found := advFindShopItemInSlot(reply, data.Slot)
	if !found {
		slot, def, found = advFindShopItem(reply)
	}
	if !found {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf("I don't have anything matching \"%s\". Reply with an item name from the list, or \"back\" to return.", reply))
	}

	// Load fresh data.
	_, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}
	balance := p.euro.GetBalance(ctx.Sender)

	current := equip[slot]

	// Check Arena/Masterwork block — only block if shop item isn't clearly better.
	// Arena gear always blocks (1.5x multiplier, earned through combat).
	// Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier.
	if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear, T%d) in that slot. That T%d shop item isn't an upgrade.\n\nPick something else, or \"back\" to return.",
			current.Name, current.ArenaTier, def.Tier))
	}
	if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Masterwork ⭐) in that slot. That T%d shop item isn't an upgrade over your T%d Masterwork.\n\nPick something else, or \"back\" to return.",
			current.Name, def.Tier, current.Tier))
	}

	text := luigiItemConfirm(ctx.Sender, slot, def, current, balance)
	p.pending.Store(string(ctx.Sender), &advPendingInteraction{
		Type:      "shop_confirm",
		Data:      &advPendingShopConfirm{Slot: slot, Tier: def.Tier},
		ExpiresAt: time.Now().Add(advDMResponseWindow),
	})
	p.advMarkMenuSent(ctx.Sender)
	p.shopScheduleBrowseNudge(ctx.Sender)

	return p.SendDM(ctx.Sender, text)
}

// ── Resolver: Purchase Confirm ──────────────────────────────────────────────

func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	data := interaction.Data.(*advPendingShopConfirm)
	reply := strings.ToLower(strings.TrimSpace(ctx.Body))

	if reply != "yes" && reply != "y" && reply != "confirm" {
		p.shopSessionEnd(ctx.Sender)
		flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
		return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
	}

	// Reload fresh equipment and balance (TOCTOU protection).
	_, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}
	balance := p.euro.GetBalance(ctx.Sender)

	// Find the def fresh.
	defs := equipmentTiers[data.Slot]
	var def *EquipmentDef
	for i := range defs {
		if defs[i].Tier == data.Tier {
			def = &defs[i]
			break
		}
	}
	if def == nil {
		return p.SendDM(ctx.Sender, "Something went wrong. Item not found.")
	}

	current := equip[data.Slot]

	// Re-check Arena/Masterwork block.
	if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
		return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Arena gear T%d) is not worse than that T%d shop item.", current.Name, current.ArenaTier, def.Tier))
	}
	if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
		return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier))
	}

	// Persuasion-discounted price.
	price := def.Price * p.shopSessionPriceFactor(ctx.Sender)

	// Affordability.
	if balance < price {
		flavor, _ := advPickFlavor(luigiInsufficientFunds, ctx.Sender, "luigi_broke")
		return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, price))
	}

	// Debit.
	if !p.euro.Debit(ctx.Sender, price, "adventure_shop_"+string(data.Slot)) {
		return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
	}

	// Community contribution: 5% of purchase price.
	if potCut := int(price * 0.05); potCut > 0 {
		communityPotAdd(potCut)
		trackTaxPaid(ctx.Sender, potCut)
	}

	// Move old gear to inventory before replacing.
	if current != nil && current.Tier > 0 {
		itemType := "ShopGear"
		var resaleValue int64
		if current.Masterwork {
			itemType = "MasterworkGear"
			// Masterwork has no resale value (it's special, not shop-priced)
		} else if current.ArenaTier > 0 {
			itemType = "ArenaGear"
		} else if current.Tier < len(equipmentTiers[data.Slot]) {
			resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3)
		}
		err := addAdvInventoryItem(ctx.Sender, AdvItem{
			Name:        current.Name,
			Type:        itemType,
			Tier:        current.Tier,
			Value:       resaleValue,
			Slot:        data.Slot,
			SkillSource: current.SkillSource,
		})
		if err != nil {
			slog.Error("shop: failed to move old gear to inventory", "user", ctx.Sender, "slot", data.Slot, "err", err)
		}
	}

	// Save new equipment.
	eq := &AdvEquipment{
		Slot:        data.Slot,
		Tier:        def.Tier,
		Condition:   100,
		Name:        def.Name,
		ActionsUsed: 0,
	}
	if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
		// Refund on DB error.
		p.euro.Credit(ctx.Sender, def.Price, "adventure_shop_refund")
		return p.SendDM(ctx.Sender, "Something went wrong saving your equipment. You've been refunded.")
	}

	// Build confirmation message.
	p.shopSessionBump(ctx.Sender)

	var confirmText string
	sess := p.shopSessionGet(ctx.Sender)
	isCombo := sess != nil && sess.ItemsBought >= 3

	if isCombo {
		flavor, _ := advPickFlavor(luigiComboConfirm, ctx.Sender, "luigi_combo")
		confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
	} else if def.Tier == 5 {
		flavor, _ := advPickFlavor(luigiTier5Confirm, ctx.Sender, "luigi_t5")
		confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
	} else {
		flavor, _ := advPickFlavor(luigiPurchaseConfirm, ctx.Sender, "luigi_buy")
		confirmText = fmt.Sprintf(flavor, def.Name, int(def.Price))
	}

	var sb strings.Builder
	sb.WriteString(confirmText)
	sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
	if current != nil && current.Tier > 0 {
		sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
	}
	sb.WriteString(fmt.Sprintf("\nBalance: €%.0f", balance-def.Price))

	return p.SendDM(ctx.Sender, sb.String())
}

// ── Find Shop Item ───────────────────────────────────────────────────────────

// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents.
var quoteReplacer = strings.NewReplacer(
	"\u2018", "'", "\u2019", "'", // left/right single curly quotes
	"\u201C", "\"", "\u201D", "\"", // left/right double curly quotes
	"\u2032", "'", // prime
)

func normalizeQuotes(s string) string {
	return quoteReplacer.Replace(s)
}

func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
	name = normalizeQuotes(strings.TrimSpace(name))

	// Support tier+category shorthand: "3 sword", "tier 3 weapon", "t3 boots", etc.
	if slot, def, ok := advFindByTierShorthand(name); ok {
		return slot, def, true
	}

	for _, slot := range allSlots {
		for i := range equipmentTiers[slot] {
			def := &equipmentTiers[slot][i]
			if def.Price == 0 {
				continue // can't buy tier 0
			}
			if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
				return slot, def, true
			}
		}
	}
	return "", nil, false
}

// advFindShopItemInSlot finds an item scoped to a specific slot.
func advFindShopItemInSlot(name string, slot EquipmentSlot) (EquipmentSlot, *EquipmentDef, bool) {
	name = normalizeQuotes(strings.TrimSpace(name))

	for i := range equipmentTiers[slot] {
		def := &equipmentTiers[slot][i]
		if def.Price == 0 {
			continue
		}
		if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
			return slot, def, true
		}
	}
	return "", nil, false
}

// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots".
func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
	lower := strings.ToLower(input)

	// Strip optional "tier " or "t" prefix.
	lower = strings.TrimPrefix(lower, "tier ")
	lower = strings.TrimPrefix(lower, "t")

	// Expect "<number> <category>" or "<number><category>".
	parts := strings.SplitN(strings.TrimSpace(lower), " ", 2)
	if len(parts) < 2 {
		return "", nil, false
	}

	tierStr := strings.TrimSpace(parts[0])
	category := strings.TrimSpace(parts[1])

	tier := 0
	for _, c := range tierStr {
		if c < '0' || c > '9' {
			return "", nil, false
		}
		tier = tier*10 + int(c-'0')
	}

	slot := advParseShopCategory(category)
	if slot == "" {
		return "", nil, false
	}

	defs := equipmentTiers[slot]
	for i := range defs {
		if defs[i].Tier == tier && defs[i].Price > 0 {
			return slot, &defs[i], true
		}
	}
	return "", nil, false
}

var shopCategoryAliases = map[string]EquipmentSlot{
	"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
	"armor": SlotArmor, "armour": SlotArmor,
	"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
	"boots": SlotBoots, "boot": SlotBoots,
	"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
}

// advParseShopCategory maps user input to an EquipmentSlot.
func advParseShopCategory(input string) EquipmentSlot {
	return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
}

// ── Buy Equipment (legacy !adventure buy path) ──────────────────────────────

func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
	current := equip[slot]
	if current != nil && current.Tier >= def.Tier {
		return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
			current.Name, current.Tier, def.Name, def.Tier)
	}
	// Block shop purchases that would overwrite arena gear (always) or
	// masterwork gear (only if shop item isn't clearly better).
	if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
		return fmt.Sprintf("You already have **%s** (Arena gear T%d). That T%d shop item isn't an upgrade.",
			current.Name, current.ArenaTier, def.Tier)
	}
	if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
		return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.",
			current.Name, current.Tier, def.Tier)
	}

	price := def.Price * p.shopSessionPriceFactor(userID)
	balance := p.euro.GetBalance(userID)
	if balance < price {
		flavor, _ := advPickFlavor(luigiInsufficientFunds, userID, "luigi_broke")
		return fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, price)
	}

	if !p.euro.Debit(userID, price, "adventure_shop_"+string(slot)) {
		return "Transaction failed. The economy is having a moment."
	}

	// Community contribution: 5% of purchase price.
	if potCut := int(price * 0.05); potCut > 0 {
		communityPotAdd(potCut)
		trackTaxPaid(userID, potCut)
	}

	// Move old gear to inventory.
	if current != nil && current.Tier > 0 {
		itemType := "ShopGear"
		var resaleValue int64
		if current.Masterwork {
			itemType = "MasterworkGear"
		} else if current.ArenaTier > 0 {
			itemType = "ArenaGear"
		} else if current.Tier < len(equipmentTiers[slot]) {
			resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3)
		}
		err := addAdvInventoryItem(userID, AdvItem{
			Name:        current.Name,
			Type:        itemType,
			Tier:        current.Tier,
			Value:       resaleValue,
			Slot:        slot,
			SkillSource: current.SkillSource,
		})
		if err != nil {
			slog.Error("shop: failed to move old gear to inventory", "user", userID, "slot", slot, "err", err)
		}
	}

	// Update equipment
	eq := &AdvEquipment{
		Slot:        slot,
		Tier:        def.Tier,
		Condition:   100,
		Name:        def.Name,
		ActionsUsed: 0,
	}
	if err := saveAdvEquipment(userID, eq); err != nil {
		// Refund on DB error
		p.euro.Credit(userID, def.Price, "adventure_shop_refund")
		return "Something went wrong saving your equipment. You've been refunded."
	}
	equip[slot] = eq

	var sb strings.Builder
	if def.Tier == 5 {
		flavor, _ := advPickFlavor(luigiTier5Confirm, userID, "luigi_t5")
		sb.WriteString(fmt.Sprintf(flavor, int(def.Price), def.Name))
	} else {
		flavor, _ := advPickFlavor(luigiPurchaseConfirm, userID, "luigi_buy")
		sb.WriteString(fmt.Sprintf(flavor, def.Name, int(def.Price)))
	}
	sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
	if current != nil && current.Tier > 0 {
		sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
	}

	return sb.String()
}

// ── Sell Items ────────────────────────────────────────────────────────────────

func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
	items, err := loadAdvInventory(userID)
	if err != nil {
		return "Failed to access your inventory. Try again."
	}
	if len(items) == 0 {
		return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
	}

	var total float64
	var sold int
	var keptSpecial int
	var keptConsumable int
	var keptMagic int
	for _, item := range items {
		if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
			keptSpecial++
			continue
		}
		if item.Type == "magic_item" {
			// B1: magic items are bonded curios, not bulk loot — sell-all must
			// never silently delete them. Route the player to !adventure
			// equip-magic instead.
			keptMagic++
			continue
		}
		if item.Type == "consumable" {
			keptConsumable++
			continue
		}
		if err := removeAdvInventoryItem(item.ID); err != nil {
			continue
		}
		total += float64(item.Value)
		sold++
	}

	if sold == 0 {
		switch {
		case keptMagic > 0 && (keptSpecial > 0 || keptConsumable > 0):
			return "Your inventory only contains special gear, curios, and/or consumables. The merchant won't touch any of it. Use `!adventure equip-magic` for curios, `!adventure equip` for gear, or `!adventure sell <name>` for individual consumables."
		case keptMagic > 0:
			return "Your inventory only contains curios (magic items). The merchant doesn't deal in those. Use `!adventure equip-magic` instead."
		case keptSpecial > 0 && keptConsumable > 0:
			return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell <name>` for individual consumables."
		case keptSpecial > 0:
			return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead."
		case keptConsumable > 0:
			return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell <name>` to sell a specific consumable."
		}
		return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
	}

	potCut := math.Round(total * 0.05)
	payout := total - potCut
	p.euro.Credit(userID, payout, "adventure_sell_all")
	if int(potCut) > 0 {
		communityPotAdd(int(potCut))
		trackTaxPaid(userID, int(potCut))
	}

	msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+
		"This is the most respect anyone has shown your loot collection. Take the money.",
		sold, fmtEuro(total), fmtEuro(payout))
	if keptSpecial > 0 {
		msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
	}
	if keptConsumable > 0 {
		msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell <name>`.)", keptConsumable)
	}
	if keptMagic > 0 {
		msg += fmt.Sprintf("\n(%d curio(s) kept — `!adventure equip-magic` to bond them. The merchant wouldn't know what to do with them.)", keptMagic)
	}
	return msg
}

func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string {
	items, err := loadAdvInventory(userID)
	if err != nil {
		return "Failed to access your inventory."
	}

	// Fuzzy match
	for _, item := range items {
		if containsFold(item.Name, itemName) {
			if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
				return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
			}
			if item.Type == "magic_item" {
				return fmt.Sprintf("**%s** is a curio — the merchant doesn't deal in those. Use `!adventure equip-magic` to bond it instead.", item.Name)
			}
			if err := removeAdvInventoryItem(item.ID); err != nil {
				return "Failed to sell that item."
			}
			potCut := int(math.Round(float64(item.Value) * 0.05))
			payout := int(item.Value) - potCut
			p.euro.Credit(userID, float64(payout), "adventure_sell_"+item.Name)
			if potCut > 0 {
				communityPotAdd(potCut)
				trackTaxPaid(userID, potCut)
			}
			return fmt.Sprintf("Sold **%s** for **%s** (%s after 5%% broker fee → community pot). The merchant nodded. That's it. That's the transaction.", item.Name, fmtEuro(item.Value), fmtEuro(payout))
		}
	}

	return fmt.Sprintf("No item matching '%s' found in your inventory.", itemName)
}

// ── Inventory Display ────────────────────────────────────────────────────────

func advInventoryDisplay(userID id.UserID) string {
	items, err := loadAdvInventory(userID)
	if err != nil {
		return "Failed to load inventory."
	}
	if len(items) == 0 {
		return "🎒 Your inventory is empty. Go adventure."
	}

	var sb strings.Builder
	sb.WriteString("🎒 **Inventory**\n\n")

	var total int64
	hasMagic := false
	for _, item := range items {
		switch item.Type {
		case "MasterworkGear":
			sb.WriteString(fmt.Sprintf("  ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
		case "ArenaGear":
			sb.WriteString(fmt.Sprintf("  ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
		case "card":
			sb.WriteString(fmt.Sprintf("  🃏 %s\n", item.Name))
		case "magic_item":
			// Tag magic items so they read as a separate class — they don't
			// behave like sellable loot, and the equip-magic footer below
			// only fires when the player actually has one.
			hasMagic = true
			sb.WriteString(fmt.Sprintf("  🔮 %s (%s)\n", item.Name, magicItemRarityLabel(item.Tier)))
		default:
			sb.WriteString(fmt.Sprintf("  %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
			total += item.Value
		}
	}
	sb.WriteString(fmt.Sprintf("\n%d items — sellable value ~€%d", len(items), total))
	sb.WriteString("\n\nTo sell: `!adventure sell all` or `!adventure sell <item>`")
	sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
	if hasMagic {
		sb.WriteString("\nTo wear a 🔮 curio: `!adventure equip-magic`")
	}
	return sb.String()
}

// magicItemRarityLabel pretty-prints the tier number magic items use as a
// rarity stand-in. Inventory is the only surface that sees the bare tier;
// the shop builds its rarity string from the registry directly.
func magicItemRarityLabel(tier int) string {
	switch tier {
	case 1:
		return "Common"
	case 2:
		return "Uncommon"
	case 3:
		return "Rare"
	case 4:
		return "Very Rare"
	case 5:
		return "Legendary"
	}
	return fmt.Sprintf("T%d", tier)
}

// ── Supplies (Consumables) ──────────────────────────────────────────────────

func luigiSuppliesView(_ id.UserID, balance float64) string {
	var sb strings.Builder
	sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n")
	sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
	sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n")

	defs := BuyableConsumables()
	if len(defs) == 0 {
		sb.WriteString("_Nothing in stock right now._\n\n")
	} else {
		for _, def := range defs {
			effectDesc := consumableEffectDescription(def.Effect, def.Value)
			sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n  %s\n\n", def.Name, def.Tier, def.Price, effectDesc))
		}
	}

	sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
	sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
	return sb.String()
}

func consumableEffectDescription(effect ConsumableEffect, value float64) string {
	switch effect {
	case EffectHeal:
		return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value))
	case EffectDefBoost:
		return fmt.Sprintf("+%.0f%% Defense for the fight", value*100)
	case EffectAtkBoost:
		return fmt.Sprintf("+%.0f%% Attack for the fight", value*100)
	case EffectWard:
		return "Blocks one hit completely"
	case EffectSpeedBoost:
		return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100)
	case EffectCritBoost:
		return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100)
	case EffectFlatDmg:
		return fmt.Sprintf("Deals %d damage at fight start", int(value))
	case EffectSpore:
		return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value))
	case EffectReflect:
		return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100)
	case EffectAutoCrit:
		return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100)
	}
	return ""
}

func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error {
	reply := strings.ToLower(strings.TrimSpace(ctx.Body))

	if reply == "back" {
		data := interaction.Data.(*advPendingShopCategory)
		_, equip, err := p.ensureCharacter(ctx.Sender)
		if err != nil {
			return p.SendDM(ctx.Sender, "Failed to load your character.")
		}
		balance := p.euro.GetBalance(ctx.Sender)
		text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_category",
			Data:      &advPendingShopCategory{ShowAll: data.ShowAll},
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		return p.SendDM(ctx.Sender, text)
	}
	if reply == "exit" || reply == "cancel" {
		p.shopSessionEnd(ctx.Sender)
		return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
	}

	// Find matching consumable
	var match *ConsumableDef
	for i := range consumableDefs {
		if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) {
			match = &consumableDefs[i]
			break
		}
	}

	if match == nil {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.")
	}

	consumablePrice := float64(match.Price) * p.shopSessionPriceFactor(ctx.Sender)
	balance := p.euro.GetBalance(ctx.Sender)
	if balance < consumablePrice {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", consumablePrice, match.Name, balance))
	}

	// Purchase the consumable
	p.euro.Debit(ctx.Sender, consumablePrice, "shop_consumable")
	if potCut := int(math.Round(consumablePrice * 0.05)); potCut > 0 {
		communityPotAdd(potCut)
		trackTaxPaid(ctx.Sender, potCut)
	}
	item := AdvItem{
		Name:  match.Name,
		Type:  "consumable",
		Tier:  match.Tier,
		Value: match.Price / 2,
	}
	_ = addAdvInventoryItem(ctx.Sender, item)

	// Stay in supplies view for more purchases
	p.pending.Store(string(ctx.Sender), interaction)
	newBalance := p.euro.GetBalance(ctx.Sender)
	return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
		match.Name, consumablePrice, newBalance))
}

// ── Curios (Magic Items) ────────────────────────────────────────────────────

// curiosStockSize — how many registry magic items Luigi stocks per day.
const curiosStockSize = 8

// dailyCuriosStock returns the deterministic per-day rotation of magic items
// on Luigi's Curios shelf. Seeded by UTC date so the slate is stable for the
// day and identical for every player. 237 registry items is far too many to
// list, so a rotating shelf keeps the menu legible and gives players a reason
// to check back.
//
// The day flips at 06:00 UTC instead of midnight so EU-evening players see
// "yesterday's stock" through the night and a fresh shelf with their morning
// coffee, not at 1 a.m. mid-session.
func dailyCuriosStock() []MagicItem {
	ids := make([]string, 0, len(magicItemRegistry))
	for id := range magicItemRegistry {
		ids = append(ids, id)
	}
	sort.Strings(ids)

	// FNV-1a over the offset-UTC date string → PCG seed.
	day := time.Now().UTC().Add(-6 * time.Hour).Format("2006-01-02")
	var seed uint64 = 1469598103934665603
	for _, b := range []byte(day) {
		seed ^= uint64(b)
		seed *= 1099511628211
	}
	rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15))
	rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] })

	target := curiosStockSize
	if target > len(ids) {
		target = len(ids)
	}
	stock := make([]MagicItem, 0, target)
	seen := make(map[string]bool)

	// N7/E4 — a live season features its curated items at the front of the shelf,
	// displacing the day's rotation. They are existing registry items, so no
	// net-new power enters the economy for the week; only which items rotate in.
	if s, ok := activeSeason(); ok {
		for _, id := range s.CurioIDs {
			if mi, ok := magicItemRegistry[id]; ok && !seen[id] {
				stock = append(stock, mi)
				seen[id] = true
			}
		}
		if len(stock) > target {
			target = len(stock)
		}
	}

	// Fill the remaining slots from the day's deterministic rotation.
	for _, id := range ids {
		if len(stock) >= target {
			break
		}
		if seen[id] {
			continue
		}
		stock = append(stock, magicItemRegistry[id])
		seen[id] = true
	}
	// Stable display order: rarity ascending, then name.
	sort.Slice(stock, func(i, j int) bool {
		ri, rj := rarityPowerScalar(stock[i].Rarity), rarityPowerScalar(stock[j].Rarity)
		if ri != rj {
			return ri < rj
		}
		return stock[i].Name < stock[j].Name
	})
	return stock
}

func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string {
	var sb strings.Builder
	if s, ok := activeSeason(); ok {
		sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name))
	} else {
		sb.WriteString("🔮 **Curios** — fresh stock daily at dawn\n")
	}
	sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))

	factor := p.shopSessionPriceFactor(userID)
	for _, mi := range dailyCuriosStock() {
		price := float64(mi.Value) * factor
		// Pretty rarity ("Very Rare" not "very_rare"), drop the bare "wondrous"
		// (the effect line already tells the player what it does), and call
		// attunement what it actually means.
		tag := magicItemRarityLabel(rarityLootTierNum(mi.Rarity))
		if mi.Kind != MagicItemWondrous {
			tag = string(prettyMagicItemKind(mi.Kind)) + " · " + tag
		}
		if mi.Attunement {
			tag += " · needs bonding"
		}
		if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
			tag += " · auto-uses"
		}
		afford := "✅"
		if balance < price {
			afford = "💸"
		}
		sb.WriteString(fmt.Sprintf("**%s** _(%s)_ — €%.0f %s\n", mi.Name, tag, price, afford))
		// The codified effect is what actually fires in combat; the SRD desc
		// is flavour. Lead with the effect, then the flavour line.
		if eff := magicItemEffectSummary(mi); eff != "" && eff != "no combat effect" {
			sb.WriteString(fmt.Sprintf("  → %s\n", eff))
		}
		if mi.Desc != "" {
			sb.WriteString(fmt.Sprintf("  _%s_\n", mi.Desc))
		}
	}

	sb.WriteString("\nReply with an item name to buy, or `back` to return.\n")
	sb.WriteString("_Bonding_ = at most 3 magic items can be \"on\" at once. Worn-but-unbonded curios are inert until you free a slot. Potions & scrolls fire automatically in combat.")
	return sb.String()
}

// prettyMagicItemKind renders the registry's machine kind in a way a player
// would actually use ("staff" not "MagicItemStaff", "scroll" not the raw
// const).
func prettyMagicItemKind(k MagicItemKind) string {
	switch k {
	case MagicItemWeapon:
		return "weapon"
	case MagicItemArmor:
		return "armor"
	case MagicItemRing:
		return "ring"
	case MagicItemWand:
		return "wand"
	case MagicItemRod:
		return "rod"
	case MagicItemStaff:
		return "staff"
	case MagicItemPotion:
		return "potion"
	case MagicItemScroll:
		return "scroll"
	}
	return string(k)
}

func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction *advPendingInteraction) error {
	reply := strings.TrimSpace(ctx.Body)
	lower := strings.ToLower(reply)

	if lower == "back" {
		data := interaction.Data.(*advPendingShopCategory)
		_, equip, err := p.ensureCharacter(ctx.Sender)
		if err != nil {
			return p.SendDM(ctx.Sender, "Failed to load your character.")
		}
		balance := p.euro.GetBalance(ctx.Sender)
		text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_category",
			Data:      &advPendingShopCategory{ShowAll: data.ShowAll},
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		return p.SendDM(ctx.Sender, text)
	}
	if lower == "exit" || lower == "cancel" {
		p.shopSessionEnd(ctx.Sender)
		return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
	}

	// Match against today's stock. Exact (case-fold) name wins outright;
	// otherwise we accumulate substring hits and require a unique candidate
	// so "ring" doesn't silently sell whichever ring came first in the slate.
	var (
		match      *MagicItem
		candidates []MagicItem
	)
	stock := dailyCuriosStock()
	for _, mi := range stock {
		if strings.EqualFold(mi.Name, reply) {
			m := mi
			match = &m
			candidates = nil
			break
		}
		if containsFold(mi.Name, reply) {
			candidates = append(candidates, mi)
		}
	}
	if match == nil {
		switch len(candidates) {
		case 0:
			p.pending.Store(string(ctx.Sender), interaction)
			return p.SendDM(ctx.Sender, "That's not on the shelf today. Reply with an item name from the list, or `back` to return.")
		case 1:
			m := candidates[0]
			match = &m
		default:
			p.pending.Store(string(ctx.Sender), interaction)
			var sb strings.Builder
			sb.WriteString(fmt.Sprintf("Several curios match \"%s\":\n", reply))
			for _, c := range candidates {
				sb.WriteString(fmt.Sprintf("  • %s\n", c.Name))
			}
			sb.WriteString("Reply with the full name (or enough to be unique), or `back` to return.")
			return p.SendDM(ctx.Sender, sb.String())
		}
	}

	price := float64(match.Value) * p.shopSessionPriceFactor(ctx.Sender)
	balance := p.euro.GetBalance(ctx.Sender)
	if balance < price {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", price, match.Name, balance))
	}

	// Guard the Debit return: GetBalance above is a snapshot read, but
	// the debt-limit check inside Debit (BLACKJACK_DEBT_LIMIT) can refuse
	// the write if a concurrent debit (blackjack, lottery, etc.) has
	// slipped the balance across the limit between read and write.
	// Without this guard the curio would be granted on a refused debit.
	if !p.euro.Debit(ctx.Sender, price, "shop_curio") {
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"Tried to charge €%.0f for %s, but the purse refused — your balance moved out from under us. Try again.",
			price, match.Name))
	}
	if potCut := int(math.Round(price * 0.05)); potCut > 0 {
		communityPotAdd(potCut)
		trackTaxPaid(ctx.Sender, potCut)
	}
	item := magicItemSell(*match)
	item.Value = int64(match.Value) / 2 // resale at half, like gear/consumables
	item.SkillSource = "magic_item:" + match.ID
	_ = addAdvInventoryItem(ctx.Sender, item)

	// Stay in the Curios view for more purchases.
	p.pending.Store(string(ctx.Sender), interaction)
	newBalance := p.euro.GetBalance(ctx.Sender)
	return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
		match.Name, price, newBalance))
}
