package plugin

// Phase R5 — Thom Krooke economy: !sell, !craft, !lore.
// Spec: gogobee_resource_combat_integration.md §8.
//
// !sell      Post-expedition only. Liquidates harvested materials/fish/items
//            from inventory at the §8.1 sell-value baseline already stored
//            in AdvItem.Value. CHA Persuasion DC 17 — pass = +15% on the
//            entire transaction. Excludes equipment (Masterwork/Arena/etc.).
// !craft     Manual crafting at Thom Krooke from the §8.2 exemplar recipes.
//            Requires the recipe to be discovered (!lore) and all named
//            ingredients in inventory. Consumes ingredients, deposits the
//            output as a new AdvItem.
// !lore      INT/Arcana DC 15 — on success, reveals one undiscovered recipe.
//            Up to one attempt per long rest (uses dnd_character.last_long_rest_at
//            via the existing rest plumbing as a soft cooldown anchor).

import (
	"database/sql"
	"fmt"
	"math/rand/v2"
	"strings"

	"gogobee/internal/db"
	"maunium.net/go/mautrix/id"
)

// ── Recipe Registry (§8.2) ───────────────────────────────────────────────────

// ThomCraftRecipe is one §8.2 recipe. Ingredient names match the registry
// Name field exactly so inventory lookups succeed without fuzzy matching.
type ThomCraftRecipe struct {
	ID          string
	Name        string
	Ingredients map[string]int // Name → count (exact match)
	// BladeWeaponCount is the count of generic "any blade weapon"
	// inventory items consumed (§8.2 Drow Poison Blade). 0 = none.
	BladeWeaponCount int
	OutputName       string
	OutputType       string // material | item | weapon | armor | accessory | consumable | magic_item
	OutputTier       int
	OutputValue      int64
	// OutputSkillSource, when set, is stamped on the crafted AdvItem's
	// SkillSource — used for OutputType "magic_item" so the result equips and
	// resolves against the registry exactly like a looted magic item
	// ("magic_item:<id>").
	OutputSkillSource string
	Description       string
	DiscoveryHint     string // shown when newly discovered via !lore
	// AlwaysKnown recipes are never gated behind !lore discovery — they're the
	// T6 signature-item pity path, craftable the moment a player holds enough
	// of the zone's crafting anchor. They stay hidden from the recipe book
	// until the player actually has an anchor in hand (see renderRecipeBook).
	AlwaysKnown bool
}

var thomCraftRecipes = []ThomCraftRecipe{
	{
		ID:   "potion_fire_resist",
		Name: "Potion of Fire Resistance",
		Ingredients: map[string]int{
			"Salamander Oil": 1,
			"Fire Essence":   2,
		},
		OutputName:    "Potion of Fire Resistance",
		OutputType:    "consumable",
		OutputTier:    3,
		OutputValue:   220,
		Description:   "Resist fire damage for one combat. (Effect wired in R6 polish.)",
		DiscoveryHint: "Thom rumbles: \"Salamander oil thinned with two pinches of fire essence. Don't ask me how I know. The dosage matters.\"",
	},
	{
		ID:   "mithral_dagger",
		Name: "Mithral Dagger",
		Ingredients: map[string]int{
			"Mithral Trace": 1,
			"Scrap Iron":    3,
		},
		OutputName:    "Mithral Dagger",
		OutputType:    "weapon",
		OutputTier:    4,
		OutputValue:   650,
		Description:   "Light blade. Bypasses light-armor proficiency restriction. (Effect wired in R6 polish.)",
		DiscoveryHint: "Thom: \"Mithral trace alloyed with three measures of scrap iron. The cheap iron is what holds the edge. Most apprentices get that backwards.\"",
	},
	{
		ID:   "undead_ward_charm",
		Name: "Undead Ward Charm",
		Ingredients: map[string]int{
			"Silver Grave Dust": 1,
			"Wraith Core":       1,
		},
		OutputName:    "Undead Ward Charm",
		OutputType:    "accessory",
		OutputTier:    4,
		OutputValue:   720,
		Description:   "+2 to saves vs. undead. (Effect wired in R6 polish.)",
		DiscoveryHint: "Thom turns the dust through his fingers. \"Silver dust, wraith's core. Bind them, the wraith forgets it ever wanted you. Strange but reliable.\"",
	},
	{
		ID:   "shadow_step_cloak",
		Name: "Shadow Step Cloak",
		Ingredients: map[string]int{
			"Ghost Silk":     3,
			"Shadow Essence": 1,
		},
		OutputName:    "Shadow Step Cloak",
		OutputType:    "armor",
		OutputTier:    4,
		OutputValue:   780,
		Description:   "Light armor. Stealth advantage 1/day. (Effect wired in R6 polish.)",
		DiscoveryHint: "Thom: \"Three lengths of ghost silk woven against one drop of shadow essence. The silk fights you while you stitch. That's how you know it's working.\"",
	},
	{
		ID:   "drow_poison_blade",
		Name: "Drow Poison Blade",
		Ingredients: map[string]int{
			"Drow Poison (diluted)": 1,
		},
		BladeWeaponCount: 1,
		OutputName:       "Drow Poison Blade",
		OutputType:       "weapon",
		OutputTier:       4,
		OutputValue:      540,
		Description:      "Coated blade — sleep-on-crit (DC 14 CON save) for 3 combats. (Effect wired in R6 polish.)",
		DiscoveryHint:    "Thom turns a glass vial in the lamplight. \"Drow brew, drawn down to a thin coat. Bring me any blade you trust — sword, dagger, scimitar, makes no odds. The poison picks the edge.\"",
	},

	// ── T6 signature-item pity path ────────────────────────────────────────
	// AlwaysKnown: no !lore gate. Each consumes 5 of the zone's crafting anchor
	// (one guaranteed per clear) so a bad-luck run of the 4–6% signature drops
	// still converts ~5 clears into a guaranteed best-in-slot. Output is a real
	// magic_item — same SkillSource shape the loot path stamps — so it equips
	// and carries its postgameSignatureEffects delta.
	{
		ID:   "anchor_crown_of_the_patient_king",
		Name: "Crown of the Patient King",
		Ingredients: map[string]int{
			"Verse Bound Folio": 5,
		},
		OutputName:        "Crown of the Patient King",
		OutputType:        "magic_item",
		OutputTier:        5,
		OutputValue:       8000,
		OutputSkillSource: "magic_item:crown_of_the_patient_king",
		AlwaysKnown:       true,
		Description:       "Legendary crown of Valdris — the patient plan, worn on the head. (Pity craft: 5 Ossuary clears.)",
	},
	{
		ID:   "anchor_aegis_of_the_first_scale",
		Name: "Aegis of the First Scale",
		Ingredients: map[string]int{
			"Cooling Ember Of Aurvandryx": 5,
		},
		OutputName:        "Aegis of the First Scale",
		OutputType:        "magic_item",
		OutputTier:        5,
		OutputValue:       9000,
		OutputSkillSource: "magic_item:aegis_of_the_first_scale",
		AlwaysKnown:       true,
		Description:       "Legendary breastplate of the Ember Before Fire — best armor in the game. (Pity craft: 5 First Hoard clears.)",
	},
	{
		ID:   "anchor_needle_of_the_seam",
		Name: "Needle of the Seam",
		Ingredients: map[string]int{
			"Spool Of Undone Geometry": 5,
		},
		OutputName:        "Needle of the Seam",
		OutputType:        "magic_item",
		OutputTier:        5,
		OutputValue:       8000,
		OutputSkillSource: "magic_item:needle_of_the_seam",
		AlwaysKnown:       true,
		Description:       "Legendary needle-dagger of the Seamstress — best caster weapon. (Pity craft: 5 Unplace clears.)",
	},
	{
		ID:   "anchor_halo_of_the_sunken_dawn",
		Name: "Halo of the Sunken Dawn",
		Ingredients: map[string]int{
			"Ember Of The Drowned Star": 5,
		},
		OutputName:        "Halo of the Sunken Dawn",
		OutputType:        "magic_item",
		OutputTier:        5,
		OutputValue:       8000,
		OutputSkillSource: "magic_item:halo_of_the_sunken_dawn",
		AlwaysKnown:       true,
		Description:       "Legendary halo of Seraphel — the first true healer's crown. (Pity craft: 5 Drowned Star clears.)",
	},
	{
		ID:   "anchor_the_second_hand",
		Name: "The Second Hand",
		Ingredients: map[string]int{
			"Unspent Hour": 5,
		},
		OutputName:        "The Second Hand",
		OutputType:        "magic_item",
		OutputTier:        5,
		OutputValue:       8000,
		OutputSkillSource: "magic_item:the_second_hand",
		AlwaysKnown:       true,
		Description:       "Legendary sidearm of the Last Meridian — best martial sidearm. (Pity craft: 5 Last Meridian clears.)",
	},
}

// bladeWeaponKeywords are the name-substrings that qualify an AdvItem
// (Type=="weapon") as a blade for the §8.2 "any blade weapon" matcher.
var bladeWeaponKeywords = []string{
	"sword", "dagger", "blade", "scimitar", "rapier",
	"knife", "cutlass", "saber", "katana", "falchion",
	"shortsword", "longsword", "greatsword",
}

// isBladeWeapon reports whether an inventory item qualifies as a blade
// for crafting purposes — type "weapon" plus a blade keyword in the name.
func isBladeWeapon(it AdvItem) bool {
	if it.Type != "weapon" {
		return false
	}
	low := strings.ToLower(it.Name)
	for _, kw := range bladeWeaponKeywords {
		if strings.Contains(low, kw) {
			return true
		}
	}
	return false
}

// recipeCraftable reports whether a player may craft a recipe: either they've
// discovered it via !lore, or it's an AlwaysKnown pity recipe (which skips
// discovery entirely — the crafting anchor it needs is the real gate).
func recipeCraftable(known map[string]bool, r ThomCraftRecipe) bool {
	return r.AlwaysKnown || known[r.ID]
}

// findRecipeByID returns the recipe with the given ID.
func findRecipeByID(id string) (ThomCraftRecipe, bool) {
	for _, r := range thomCraftRecipes {
		if r.ID == id {
			return r, true
		}
	}
	return ThomCraftRecipe{}, false
}

// findRecipeByName fuzzy-matches a recipe name (case-insensitive prefix or
// substring). Returns ok=false if zero or >1 matches.
func findRecipeByName(query string) (ThomCraftRecipe, bool) {
	query = strings.ToLower(strings.TrimSpace(query))
	if query == "" {
		return ThomCraftRecipe{}, false
	}
	var matches []ThomCraftRecipe
	for _, r := range thomCraftRecipes {
		if strings.Contains(strings.ToLower(r.Name), query) {
			matches = append(matches, r)
		}
	}
	if len(matches) != 1 {
		return ThomCraftRecipe{}, false
	}
	return matches[0], true
}

// ── Recipe discovery persistence ─────────────────────────────────────────────

func loadKnownRecipes(userID id.UserID) (map[string]bool, error) {
	d := db.Get()
	rows, err := d.Query(`SELECT recipe_id FROM dnd_known_recipe WHERE user_id = ?`, string(userID))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	known := map[string]bool{}
	for rows.Next() {
		var rid string
		if err := rows.Scan(&rid); err != nil {
			return nil, err
		}
		known[rid] = true
	}
	return known, rows.Err()
}

func recordKnownRecipe(userID id.UserID, recipeID string) error {
	d := db.Get()
	_, err := d.Exec(`
		INSERT OR IGNORE INTO dnd_known_recipe (user_id, recipe_id) VALUES (?, ?)`,
		string(userID), recipeID)
	return err
}

// ── !sell ────────────────────────────────────────────────────────────────────

// handleResourceSellCmd is the top-level !sell handler. The legacy
// `!adventure sell` path remains intact; this is the new R5 surface that
// pays out at §8.1 baselines and applies the CHA DC 17 +15% bump.
//
// Forms:
//
//	!sell           — list sellable inventory + total at base price
//	!sell list      — alias of bare !sell
//	!sell all       — liquidate everything sellable in one transaction
//	!sell <name>    — fuzzy-match a single item by name
func (p *AdventurePlugin) handleResourceSellCmd(ctx MessageContext, args string) error {
	args = strings.TrimSpace(args)
	lower := strings.ToLower(args)

	// Post-expedition gate. A seated member is mid-expedition too — reading
	// only their own row would let them run a shop from inside the dungeon.
	exp, _, err := activeExpeditionFor(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't check expedition status.")
	}
	if exp != nil && exp.IsActive() {
		return p.SendDM(ctx.Sender,
			"Thom Krooke doesn't deal with you mid-expedition. Extract first, then bring the goods to him.")
	}

	items, err := loadAdvInventory(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't load inventory.")
	}
	sellable := filterSellable(items)
	if len(sellable) == 0 {
		return p.SendDM(ctx.Sender,
			"You've got nothing Thom Krooke wants to buy. Bring back something from an expedition first.")
	}

	if args == "" || lower == "list" {
		return p.SendDM(ctx.Sender, renderSellList(sellable))
	}

	var toSell []AdvItem
	if lower == "all" {
		toSell = sellable
	} else {
		match := findInventoryMatch(sellable, args)
		if match == nil {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"No sellable item matching '%s'. Try `!sell list` to see what Thom will take.", args))
		}
		toSell = []AdvItem{*match}
	}

	return p.executeResourceSell(ctx.Sender, toSell)
}

// filterSellable returns inventory items eligible for the §8.1 path:
// harvest materials, fish, and zone-loot items. Equipment, consumables,
// arena gear, and cards are left to the legacy `!adventure sell`.
func filterSellable(items []AdvItem) []AdvItem {
	var out []AdvItem
	for _, it := range items {
		switch it.Type {
		case "material", "fish", "item":
			out = append(out, it)
		}
	}
	return out
}

// findInventoryMatch returns the first sellable item whose name contains
// the query (case-insensitive). Returns nil if no match.
func findInventoryMatch(items []AdvItem, query string) *AdvItem {
	q := strings.ToLower(query)
	for i := range items {
		if strings.Contains(strings.ToLower(items[i].Name), q) {
			return &items[i]
		}
	}
	return nil
}

// renderSellList shows what Thom will buy with running totals.
func renderSellList(items []AdvItem) string {
	var sb strings.Builder
	sb.WriteString("**Thom Krooke** runs his thumb down the page.\n\n")

	grouped := map[string]struct {
		count int
		value int64
	}{}
	order := []string{}
	for _, it := range items {
		g, seen := grouped[it.Name]
		if !seen {
			order = append(order, it.Name)
		}
		g.count++
		g.value += it.Value
		grouped[it.Name] = g
	}

	var total int64
	for _, name := range order {
		g := grouped[name]
		total += g.value
		sb.WriteString(fmt.Sprintf("• %s × %d — €%d\n", name, g.count, g.value))
	}
	sb.WriteString(fmt.Sprintf("\nBase total: **€%d** across %d item(s).\n", total, len(items)))
	sb.WriteString("\n`!sell all` to liquidate everything · `!sell <name>` for a single item.")
	sb.WriteString("\nThom will roll a Persuasion check (DC 17) — pass = +15% on the take.")
	return sb.String()
}

// executeResourceSell consumes the items, rolls one CHA Persuasion check,
// credits the payout, and DMs the result.
func (p *AdventurePlugin) executeResourceSell(userID id.UserID, items []AdvItem) error {
	if len(items) == 0 {
		return p.SendDM(userID, "Nothing to sell.")
	}

	var base int64
	for _, it := range items {
		base += it.Value
	}

	// CHA Persuasion DC 17 — single roll for the whole transaction.
	var bumpApplied bool
	var rollLine string
	if char, err := LoadDnDCharacter(userID); err == nil && char != nil && !char.PendingSetup {
		res := performSkillCheck(char, SkillPersuasion, 17)
		bumpApplied = res.Success
		if res.Auto {
			rollLine = fmt.Sprintf("_Persuasion (auto-%s)_",
				ternary(res.Success, "success on a nat 20", "fail on a nat 1"))
		} else {
			rollLine = fmt.Sprintf("_Persuasion: rolled %d + %d = %d vs DC 17 — %s_",
				res.Roll, res.Mod, res.Total, ternary(res.Success, "+15%", "no bump"))
		}
	}

	final := base
	if bumpApplied {
		final = base + (base*15)/100
	}

	// Remove items.
	removed := 0
	for _, it := range items {
		if err := removeAdvInventoryItem(it.ID); err == nil {
			removed++
		}
	}
	if removed == 0 {
		return p.SendDM(userID, "Couldn't remove items from inventory. Nothing sold.")
	}

	if p.euro != nil {
		reason := "dnd_resource_sell"
		if len(items) == 1 {
			reason = "dnd_resource_sell_" + items[0].Name
		}
		p.euro.Credit(userID, float64(final), reason)
	}

	var sb strings.Builder
	sb.WriteString("**Thom Krooke** finishes counting and slides a stack across the counter.\n\n")
	if len(items) == 1 {
		sb.WriteString(fmt.Sprintf("Sold **%s** at base €%d.\n", items[0].Name, base))
	} else {
		sb.WriteString(fmt.Sprintf("Sold **%d items** at base total €%d.\n", removed, base))
	}
	if rollLine != "" {
		sb.WriteString(rollLine + "\n")
	}
	if bumpApplied {
		sb.WriteString(fmt.Sprintf("Final payout: **€%d** (+15%%).\n", final))
		sb.WriteString("\n_Thom: \"Reasonable.\"_")
	} else {
		sb.WriteString(fmt.Sprintf("Final payout: **€%d**.\n", final))
		sb.WriteString("\n_Thom: \"Standard rate. Don't take it personally.\"_")
	}
	return p.SendDM(userID, sb.String())
}

func ternary(b bool, t, f string) string {
	if b {
		return t
	}
	return f
}

// ── !craft ───────────────────────────────────────────────────────────────────

// handleCraftCmd dispatches `!craft`, `!craft list`, and `!craft <recipe>`.
func (p *AdventurePlugin) handleCraftCmd(ctx MessageContext, args string) error {
	args = strings.TrimSpace(args)
	lower := strings.ToLower(args)

	known, err := loadKnownRecipes(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't load your recipe book.")
	}

	if args == "" || lower == "list" {
		return p.SendDM(ctx.Sender, renderRecipeBook(ctx.Sender, known))
	}

	recipe, ok := findRecipeByName(args)
	if !ok {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"No recipe matches '%s'. Try `!craft list` to see what you've discovered.", args))
	}
	if !recipeCraftable(known, recipe) {
		return p.SendDM(ctx.Sender,
			"_Thom: \"Doesn't ring a bell. If you don't know the recipe, I don't either.\"_\n\nDiscover it via `!lore` first.")
	}

	return p.executeCraft(ctx.Sender, recipe)
}

// renderRecipeBook lists known recipes, ingredient lists, and a count of
// undiscovered ones.
func renderRecipeBook(userID id.UserID, known map[string]bool) string {
	var sb strings.Builder
	sb.WriteString("📜 **Thom Krooke — Recipe Book**\n\n")

	items, _ := loadAdvInventory(userID)
	have := inventoryByName(items)

	shownCount := 0
	for _, r := range thomCraftRecipes {
		// AlwaysKnown pity recipes only surface once the player actually holds
		// one of the crafting anchor — no need to spoil T6 chase items to a
		// level-3 player. Discovered recipes always show.
		switch {
		case known[r.ID]:
		case r.AlwaysKnown && recipeHasAnyIngredient(have, r):
		default:
			continue
		}
		shownCount++
		sb.WriteString(fmt.Sprintf("**%s**\n", r.Name))
		var parts []string
		ready := true
		for ing, need := range r.Ingredients {
			got := have[ing]
			parts = append(parts, fmt.Sprintf("%s ×%d (%d)", ing, need, got))
			if got < need {
				ready = false
			}
		}
		if r.BladeWeaponCount > 0 {
			haveBlades := 0
			for _, it := range items {
				if isBladeWeapon(it) {
					haveBlades++
				}
			}
			parts = append(parts, fmt.Sprintf("any blade weapon ×%d (%d)", r.BladeWeaponCount, haveBlades))
			if haveBlades < r.BladeWeaponCount {
				ready = false
			}
		}
		sb.WriteString("  Ingredients: " + strings.Join(parts, " + ") + "\n")
		sb.WriteString("  " + r.Description + "\n")
		if ready {
			sb.WriteString("  ✅ Ready to craft.\n")
		}
		sb.WriteString("\n")
	}

	if shownCount == 0 {
		sb.WriteString("_You don't know any recipes yet. Try `!lore` at Thom Krooke's._\n\n")
	}

	// Only discoverable (non-AlwaysKnown) recipes count toward the !lore hint;
	// pity recipes are never "discovered", so they'd make the count never zero.
	hidden := 0
	for _, r := range thomCraftRecipes {
		if !r.AlwaysKnown && !known[r.ID] {
			hidden++
		}
	}
	if hidden > 0 {
		sb.WriteString(fmt.Sprintf("_%d undiscovered recipe(s). Use `!lore` to dig._", hidden))
	} else {
		sb.WriteString("_All recipes discovered._")
	}
	return sb.String()
}

// recipeHasAnyIngredient reports whether the player holds at least one unit of
// any of a recipe's named ingredients — the gate for surfacing an AlwaysKnown
// pity recipe in the book.
func recipeHasAnyIngredient(have map[string]int, r ThomCraftRecipe) bool {
	for ing := range r.Ingredients {
		if have[ing] > 0 {
			return true
		}
	}
	return false
}

// inventoryByName counts items grouped by name.
func inventoryByName(items []AdvItem) map[string]int {
	out := map[string]int{}
	for _, it := range items {
		out[it.Name]++
	}
	return out
}

// executeCraft consumes ingredients and deposits the output. Returns a DM.
func (p *AdventurePlugin) executeCraft(userID id.UserID, r ThomCraftRecipe) error {
	items, err := loadAdvInventory(userID)
	if err != nil {
		return p.SendDM(userID, "Couldn't load inventory.")
	}

	// Pick item IDs to consume — first-fit per ingredient name plus any
	// generic "blade weapon" slots required by the recipe.
	consume, missing := pickIngredients(items, r.Ingredients)
	bladeIDs, bladeShort := pickBladeWeapons(items, r.BladeWeaponCount, consume)
	consume = append(consume, bladeIDs...)
	if len(missing) > 0 || bladeShort > 0 {
		var lines []string
		for ing, short := range missing {
			lines = append(lines, fmt.Sprintf("  • %s — short %d", ing, short))
		}
		if bladeShort > 0 {
			lines = append(lines, fmt.Sprintf("  • any blade weapon — short %d", bladeShort))
		}
		return p.SendDM(userID, fmt.Sprintf(
			"_Thom: \"Not enough material. Come back when you've got the rest.\"_\n\n%s",
			strings.Join(lines, "\n")))
	}

	for _, itemID := range consume {
		if err := removeAdvInventoryItem(itemID); err != nil {
			return p.SendDM(userID, "Crafting failed mid-step. Inventory may be partially consumed.")
		}
	}

	out := AdvItem{
		Name:        r.OutputName,
		Type:        r.OutputType,
		Tier:        r.OutputTier,
		Value:       r.OutputValue,
		SkillSource: r.OutputSkillSource,
	}
	if err := addAdvInventoryItem(userID, out); err != nil {
		return p.SendDM(userID, "Crafted item couldn't be deposited. Tell an admin.")
	}

	return p.SendDM(userID, fmt.Sprintf(
		"**Thom Krooke** lays the materials out, works for a moment, and slides the result back to you.\n\n"+
			"Crafted: **%s** _(tier %d, sell value €%d)_\n%s",
		r.OutputName, r.OutputTier, r.OutputValue, r.Description))
}

// pickBladeWeapons returns inventory IDs covering the requested generic
// blade-weapon count, skipping any IDs already in `exclude`. Returns the
// chosen IDs plus a shortfall count when inventory can't cover.
func pickBladeWeapons(items []AdvItem, count int, exclude []int64) ([]int64, int) {
	if count <= 0 {
		return nil, 0
	}
	used := map[int64]bool{}
	for _, id := range exclude {
		used[id] = true
	}
	var picked []int64
	for _, it := range items {
		if len(picked) >= count {
			break
		}
		if used[it.ID] {
			continue
		}
		if isBladeWeapon(it) {
			picked = append(picked, it.ID)
			used[it.ID] = true
		}
	}
	return picked, count - len(picked)
}

// pickIngredients walks the inventory and selects item IDs to consume,
// covering all required (Name → count). Returns (idsToRemove, missingByName).
func pickIngredients(items []AdvItem, need map[string]int) ([]int64, map[string]int) {
	remaining := map[string]int{}
	for k, v := range need {
		remaining[k] = v
	}
	var consume []int64
	for _, it := range items {
		if remaining[it.Name] > 0 {
			consume = append(consume, it.ID)
			remaining[it.Name]--
		}
	}
	missing := map[string]int{}
	for name, left := range remaining {
		if left > 0 {
			missing[name] = left
		}
	}
	return consume, missing
}

// ── !lore ────────────────────────────────────────────────────────────────────

// handleLoreCmd rolls INT/Arcana DC 15. On success, reveals one
// undiscovered recipe at random.
func (p *AdventurePlugin) handleLoreCmd(ctx MessageContext) error {
	char, err := LoadDnDCharacter(ctx.Sender)
	if err != nil || char == nil || char.PendingSetup {
		return p.SendDM(ctx.Sender,
			"You need a D&D character to dig through Thom Krooke's lore stacks. Run `!setup`.")
	}

	known, err := loadKnownRecipes(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't load your recipe book.")
	}

	var undiscovered []ThomCraftRecipe
	for _, r := range thomCraftRecipes {
		// AlwaysKnown recipes are never in the discovery pool — they can't be
		// "found" and shouldn't dilute the odds of finding the real ones.
		if r.AlwaysKnown {
			continue
		}
		if !known[r.ID] {
			undiscovered = append(undiscovered, r)
		}
	}
	if len(undiscovered) == 0 {
		return p.SendDM(ctx.Sender,
			"_Thom: \"You've already pulled every loose page out of these shelves. Nothing new to find.\"_")
	}

	res := performSkillCheck(char, SkillArcana, 15)
	var sb strings.Builder
	sb.WriteString("**Thom Krooke** waves at the stacks. \"Help yourself. Don't drop anything.\"\n\n")
	if res.Auto {
		sb.WriteString(fmt.Sprintf("_Arcana (auto-%s)_\n",
			ternary(res.Success, "success on a nat 20", "fail on a nat 1")))
	} else {
		sb.WriteString(fmt.Sprintf("_Arcana: rolled %d + %d = %d vs DC 15_\n",
			res.Roll, res.Mod, res.Total))
	}

	if !res.Success {
		sb.WriteString("\nThe pages don't connect for you today. Come back later.")
		return p.SendDM(ctx.Sender, sb.String())
	}

	pick := undiscovered[rand.IntN(len(undiscovered))]
	if err := recordKnownRecipe(ctx.Sender, pick.ID); err != nil {
		return p.SendDM(ctx.Sender, "Lore check passed but the page wouldn't stay copied. Tell an admin.")
	}
	sb.WriteString(fmt.Sprintf("\n📖 **New recipe discovered: %s**\n%s\n\n_Use `!craft list` to review your book._",
		pick.Name, pick.DiscoveryHint))
	return p.SendDM(ctx.Sender, sb.String())
}

// ── Test helper ──────────────────────────────────────────────────────────────

// resetKnownRecipesForTest wipes recipe discovery for a user. Test-only.
func resetKnownRecipesForTest(userID id.UserID) error {
	d := db.Get()
	_, err := d.Exec(`DELETE FROM dnd_known_recipe WHERE user_id = ?`, string(userID))
	if err == sql.ErrNoRows {
		return nil
	}
	return err
}
