package plugin

import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"math/rand/v2"
	"sort"
	"strconv"
	"strings"
	"time"

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

// Phase 9 SP2 — !cast / !spells / !prepare commands and out-of-combat
// resolution. Combat-time resolution of pending_cast lives in dnd_combat.go
// (SP3).
//
// Casting model:
//   - Cantrips: no slot cost. Damage cantrips queue as pending_cast for
//     next combat. Utility cantrips (Mending, Message, Minor Illusion,
//     Guidance) resolve immediately with a flavorful response.
//   - Leveled spells: consume a slot at cast time (audit-style save-then-
//     debit). DAMAGE_*, CONTROL, BUFF_SELF/ALLY queue as pending_cast.
//     HEAL and UTILITY resolve immediately.
//   - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
//     a "Phase 11" note — they require the boss turn-based engine.
//
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
// out of combat: UTILITY resolves on the caster, and everything else queues as a
// PendingCast for the *caster's* next fight, where an ally target has nothing to
// mean. In-combat targeting is splitCastTarget (combat_cmd.go).

// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
// Keep this minimal — the combat layer resolves the actual numbers on fire.
type PendingCast struct {
	SpellID   string `json:"spell"`
	SlotLevel int    `json:"slot"` // 0 for cantrips
	Target    string `json:"target,omitempty"`
}

func encodePendingCast(p PendingCast) string {
	b, _ := json.Marshal(p)
	return string(b)
}

func decodePendingCast(s string) (PendingCast, bool) {
	if s == "" {
		return PendingCast{}, false
	}
	var p PendingCast
	if err := json.Unmarshal([]byte(s), &p); err != nil {
		return PendingCast{}, false
	}
	if p.SpellID == "" {
		return PendingCast{}, false
	}
	return p, true
}

// ── !cast command ────────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
	// In a turn-based Elite/Boss fight, !cast resolves as the player's turn
	// for the round rather than queuing for "next combat". handleCombatCastCmd
	// takes the fight's locks itself and re-checks the session under them.
	//
	// activeCombatSessionFor, not getActiveCombatSession: a seated party member
	// owns no session row, and would otherwise fall through to the out-of-combat
	// path and queue a PendingCast in the middle of their own boss fight.
	if sess, _ := activeCombatSessionFor(ctx.Sender); sess != nil {
		return p.handleCombatCastCmd(ctx, args)
	}

	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	args = strings.TrimSpace(args)
	advChar, _ := loadAdvCharacter(ctx.Sender)
	c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
	if err != nil || c == nil {
		return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
	}
	if !isSpellcaster(c) {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"%s doesn't cast spells.", titleClass(c.Class)))
	}

	if args == "" {
		return p.SendDM(ctx.Sender, renderCastHelp(c))
	}

	// Parse: --drop is a special form ("!cast --drop" or "!cast drop").
	lower := strings.ToLower(args)
	if lower == "--drop" || lower == "drop" {
		return p.dndCastDrop(ctx, c)
	}

	// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
	// string before the spell parser sees it, exactly as in combat. It is
	// resolved (and refused) further down, once we know the spell is a heal: a
	// target on anything else is a mistake, and refusing it early would cost us
	// the "which spell?" half of the error message.
	args, targetName := splitOutOfCombatTarget(args)

	// Parse spell name + optional --upcast N.
	tokens := strings.Fields(args)
	upcast := 0
	var spellTokens []string
	for i := 0; i < len(tokens); i++ {
		switch tokens[i] {
		case "--upcast":
			if i+1 < len(tokens) {
				n, err := strconv.Atoi(tokens[i+1])
				if err == nil {
					upcast = n
				}
				i++
			}
		default:
			spellTokens = append(spellTokens, tokens[i])
		}
	}
	if len(spellTokens) == 0 {
		return p.SendDM(ctx.Sender, "Usage: `!cast <spell> [--upcast N]`. Run `!spells` to list options.")
	}
	spell, ok := parseSpell(strings.Join(spellTokens, " "))
	if !ok {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " ")))
	}

	// Class gate. Arcane Trickster Rogues use the Mage spell list (Phase 10
	// SUB2-AT), so accept Mage-tagged spells when the player has that subclass.
	effectiveClass := c.Class
	if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
		effectiveClass = ClassMage
	}
	classOK := false
	for _, cl := range spell.Classes {
		if cl == effectiveClass {
			classOK = true
			break
		}
	}
	if !classOK {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"%s is not on the %s spell list.", spell.Name, titleClass(c.Class)))
	}
	// AT slot ceiling — capped at L1 until L7 (then L2), tracks third-caster
	// progression. Refuse upcasting beyond what the subclass actually has.
	if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
		if max := highestAvailableSlotForChar(c); spell.Level > max {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, max))
		}
	}

	// Reaction spells deferred — combat is one-shot SimulateCombat, no
	// per-turn windows where a reaction could trigger. Will land alongside
	// turn-based combat when that arrives.
	if spell.Effect == EffectReaction {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"%s is a reaction spell — those aren't usable yet (combat resolves in one beat, no reaction window). Pick a different spell.", spell.Name))
	}

	// Known + prepared check.
	known, prepared, err := playerKnowsSpell(ctx.Sender, spell.ID)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't check your spell list.")
	}
	if !known {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You don't know %s yet.", spell.Name))
	}
	if !prepared {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"%s isn't prepared today. Run `!prepare %s` first.", spell.Name, spell.ID))
	}

	// Determine slot level.
	slotLevel := spell.Level
	if upcast > slotLevel && spell.Level > 0 {
		slotLevel = upcast
	}
	if slotLevel < 0 || slotLevel > 5 {
		return p.SendDM(ctx.Sender, "Slot level out of range (1–5).")
	}

	// Concentration conflict: if spell needs concentration and player has
	// another active, warn (we'll supersede on the actual cast).
	supersedes := ""
	if spell.Concentration {
		if active := concentrationActive(c); active != "" && active != spell.ID {
			supersedes = active
		}
	}

	// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
	// heal can land on somebody else: UTILITY resolves on the caster and
	// everything else queues as a PendingCast for *this* caster's next fight, so
	// a target on those would be accepted and then silently dropped.
	var targetUser id.UserID
	if targetName != "" {
		if spell.Effect != EffectSpellHeal {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
		}
		uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
		if errMsg != "" {
			return p.SendDM(ctx.Sender, errMsg)
		}
		targetUser = uid // empty when they named themselves — self-heal as usual
	}

	// Material cost (Revivify, Raise Dead).
	if spell.MaterialCost > 0 {
		if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost))
		}
	}

	// Slot consumption (cantrips skip).
	if spell.Level > 0 {
		ok, err := consumeSpellSlot(ctx.Sender, slotLevel)
		if err != nil {
			return p.SendDM(ctx.Sender, "Couldn't consume slot: "+err.Error())
		}
		if !ok {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
		}
	}

	// Resolve. Audit pattern: for queueing effects, save state BEFORE we
	// would otherwise consider the action committed. The slot is already
	// debited above; if SaveDnDCharacter fails we refund.
	switch spell.Effect {
	case EffectSpellHeal:
		if targetUser != "" {
			return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
		}
		return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
	case EffectUtility:
		return p.resolveUtility(ctx, c, spell, slotLevel)
	default:
		// DAMAGE_*, CONTROL, BUFF_SELF/ALLY → queue for next combat.
		return p.queuePendingCast(ctx, c, spell, slotLevel, supersedes)
	}
}

// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────

// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
// property of the caster (their WIS, their domain), never of the body it lands
// on, so an ally heal rolls exactly the same as a self-heal.
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
	dice, faces, _ := parseDamageDice(spell.DamageDice)
	if dice == 0 {
		dice, faces = 1, 8 // safety fallback
	}
	// Upcast scales healing. Most heal spells are "+1d8 per slot above 1st".
	extra := slotLevel - spell.Level
	if extra < 0 {
		extra = 0
	}
	totalDice := dice + extra
	heal := 0
	supreme := lifeDomainSupremeHealing(c)
	for i := 0; i < totalDice; i++ {
		if supreme {
			heal += faces
		} else {
			heal += 1 + rand.IntN(faces)
		}
	}
	heal += abilityModifier(c.WIS)
	heal += lifeDomainHealBonus(c, spell, slotLevel)
	return heal
}

func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
	heal := rollOutOfCombatHeal(c, spell, slotLevel)

	before := c.HPCurrent
	c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
	delta := c.HPCurrent - before

	if err := SaveDnDCharacter(c); err != nil {
		// Refund the slot.
		if spell.Level > 0 {
			_ = refundSpellSlot(ctx.Sender, slotLevel)
		}
		return p.SendDM(ctx.Sender, "Couldn't apply heal: "+err.Error())
	}

	return p.SendDM(ctx.Sender, fmt.Sprintf(
		"🩹 **%s** — restored %d HP (%d → %d / %d). %s",
		spell.Name, delta, before, c.HPCurrent, c.HPMax,
		renderSlotsBrief(ctx.Sender)))
}

// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
// already spent, so every failure below refunds it: a heal that lands on nobody
// must not cost the caster anything.
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
	refund := func(msg string) error {
		if spell.Level > 0 {
			_ = refundSpellSlot(ctx.Sender, slotLevel)
		}
		return p.SendDM(ctx.Sender, msg)
	}

	heal := rollOutOfCombatHeal(c, spell, slotLevel)
	before, after, maxHP, err := healPartyMember(target, heal)

	targetName := charName(target)
	if targetName == "" {
		targetName = target.Localpart()
	}
	casterName := charName(ctx.Sender)
	if casterName == "" {
		casterName = ctx.Sender.Localpart()
	}

	switch {
	case errors.Is(err, errHealTargetFull):
		return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
	case errors.Is(err, errHealTargetDown):
		// The same rule the combat path holds: a heal is not a resurrection.
		return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
	case errors.Is(err, sql.ErrNoRows):
		return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
	case err != nil:
		return refund("Couldn't apply the heal: " + err.Error())
	}

	_ = p.SendDM(target, fmt.Sprintf(
		"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
		casterName, spell.Name, after-before, before, after, maxHP))

	return p.SendDM(ctx.Sender, fmt.Sprintf(
		"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
		spell.Name, targetName, after-before, before, after, maxHP,
		renderSlotsBrief(ctx.Sender)))
}

// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────

func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
	// Most utility spells are pure narrative — they produce a flavor line
	// and a confirmation. A few (Mage Armor, Hunter's Mark, etc.) are
	// concentration buffs that fire as pending_cast in combat. Those are
	// classified as BUFF_* in the registry, not UTILITY, so we don't see
	// them here. Concentration UTILITY spells (Detect Magic, Beast Sense)
	// merely set the concentration flag; combat doesn't care.
	supersedes := ""
	if spell.Concentration {
		supersedes = setConcentration(c, spell.ID, utilityConcentrationDuration(spell))
	}

	if err := SaveDnDCharacter(c); err != nil {
		if spell.Level > 0 {
			_ = refundSpellSlot(ctx.Sender, slotLevel)
		}
		return p.SendDM(ctx.Sender, "Couldn't save state: "+err.Error())
	}

	msg := fmt.Sprintf("✨ **%s** — %s", spell.Name, spell.Description)
	if supersedes != "" {
		msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes))
	}
	if spell.Level > 0 {
		msg += "\n" + renderSlotsBrief(ctx.Sender)
	}
	return p.SendDM(ctx.Sender, msg)
}

func utilityConcentrationDuration(spell SpellDefinition) time.Duration {
	// Phase 9 keeps durations narrative — anything that lasts past a long
	// rest is reset by long rest anyway. Use a generous default.
	switch spell.ID {
	case "detect_magic", "beast_sense":
		return 10 * time.Minute
	}
	return 1 * time.Hour
}

// ── Queue for next combat ───────────────────────────────────────────────────

func (p *AdventurePlugin) queuePendingCast(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int, supersedes string) error {
	if c.PendingCast != "" {
		// Refund the slot we just consumed.
		if spell.Level > 0 {
			_ = refundSpellSlot(ctx.Sender, slotLevel)
		}
		prev, _ := decodePendingCast(c.PendingCast)
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You already have **%s** queued. Cast `!cast --drop` to clear it first.",
			displaySpellName(prev.SpellID)))
	}

	pc := PendingCast{SpellID: spell.ID, SlotLevel: slotLevel}
	c.PendingCast = encodePendingCast(pc)
	if spell.Concentration {
		setConcentration(c, spell.ID, 1*time.Hour)
	}

	// Audit pattern (Fix C): save-then-the-rest. Slot already debited; on
	// save failure, refund slot and concentration.
	if err := SaveDnDCharacter(c); err != nil {
		if spell.Level > 0 {
			_ = refundSpellSlot(ctx.Sender, slotLevel)
		}
		c.ConcentrationSpell = ""
		c.ConcentrationExpiresAt = nil
		return p.SendDM(ctx.Sender, "Couldn't queue spell: "+err.Error())
	}

	msg := fmt.Sprintf("✨ **%s** queued for next combat.", spell.Name)
	if slotLevel > spell.Level {
		msg += " _(empowered)_"
	}
	if supersedes != "" {
		msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes))
	}
	if spell.Level > 0 {
		msg += "\n" + renderSlotsBrief(ctx.Sender)
	}
	return p.SendDM(ctx.Sender, msg)
}

// ── !cast --drop ─────────────────────────────────────────────────────────────

func (p *AdventurePlugin) dndCastDrop(ctx MessageContext, c *DnDCharacter) error {
	if c.PendingCast == "" && c.ConcentrationSpell == "" {
		return p.SendDM(ctx.Sender, "Nothing queued and no concentration active.")
	}

	// Refund the queued slot (if any) — voluntary drop is the player's
	// choice; restore the slot rather than forfeit it.
	slotRefunded := false
	if pc, ok := decodePendingCast(c.PendingCast); ok && pc.SlotLevel > 0 {
		_ = refundSpellSlot(ctx.Sender, pc.SlotLevel)
		slotRefunded = true
	}

	dropped := []string{}
	if pc, ok := decodePendingCast(c.PendingCast); ok {
		dropped = append(dropped, displaySpellName(pc.SpellID)+" (queued)")
	}
	if c.ConcentrationSpell != "" {
		dropped = append(dropped, displaySpellName(c.ConcentrationSpell)+" (concentration)")
	}

	c.PendingCast = ""
	c.ConcentrationSpell = ""
	c.ConcentrationExpiresAt = nil
	if err := SaveDnDCharacter(c); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't drop: "+err.Error())
	}
	msg := "Dropped: " + strings.Join(dropped, ", ") + "."
	if slotRefunded {
		msg += " _(slot refunded)_"
	}
	return p.SendDM(ctx.Sender, msg)
}

// ── !spells command ─────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleDnDSpellsCmd(ctx MessageContext, args string) error {
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	advChar, _ := loadAdvCharacter(ctx.Sender)
	c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
	if err != nil || c == nil {
		return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
	}
	if !isSpellcaster(c) {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"%s isn't a caster class.", titleClass(c.Class)))
	}

	args = strings.TrimSpace(args)
	if strings.HasPrefix(strings.ToLower(args), "learn ") {
		return p.handleSpellsLearn(ctx, c, strings.TrimSpace(args[6:]))
	}

	return p.SendDM(ctx.Sender, renderSpellsList(c))
}

func renderSpellsList(c *DnDCharacter) string {
	var b strings.Builder
	b.WriteString(fmt.Sprintf("**Spellbook — %s L%d**\n\n",
		titleClass(c.Class), c.Level))

	known, _ := listKnownSpells(c.UserID)
	if len(known) == 0 {
		b.WriteString("_No spells known. Run `!spells learn <name>` to add one._\n")
	} else {
		// Group by spell level.
		byLevel := map[int][]knownSpellRow{}
		for _, k := range known {
			s, ok := lookupSpell(k.SpellID)
			if !ok {
				continue
			}
			byLevel[s.Level] = append(byLevel[s.Level], k)
		}
		levels := []int{}
		for lvl := range byLevel {
			levels = append(levels, lvl)
		}
		sort.Ints(levels)
		for _, lvl := range levels {
			label := fmt.Sprintf("Level %d", lvl)
			if lvl == 0 {
				label = "Cantrips"
			}
			b.WriteString(fmt.Sprintf("__%s__\n", label))
			for _, k := range byLevel[lvl] {
				s, _ := lookupSpell(k.SpellID)
				prepStr := ""
				if c.Class == ClassCleric && s.Level > 0 {
					if k.Prepared {
						prepStr = " ✅"
					} else {
						prepStr = " ⬜"
					}
				}
				b.WriteString(fmt.Sprintf("  • **%s**%s — %s\n", s.Name, prepStr, s.Description))
			}
		}
	}

	slots, _ := getSpellSlots(c.UserID)
	b.WriteString("\n**Slots:** " + renderSlotLine(slots) + "\n")

	if c.Class == ClassMage {
		count, _ := mageLeveledKnownCount(c.UserID)
		cap := mageKnownSpellsCap(c.Level)
		b.WriteString(fmt.Sprintf("**Spellbook:** %d / %d leveled spells learned\n", count, cap))
	}

	if pc, ok := decodePendingCast(c.PendingCast); ok {
		b.WriteString(fmt.Sprintf("\n_Queued: **%s**", displaySpellName(pc.SpellID)))
		if s, ok := lookupSpell(pc.SpellID); ok && pc.SlotLevel > s.Level {
			b.WriteString(" (empowered)")
		}
		b.WriteString(" — fires next combat._\n")
	}
	if c.ConcentrationSpell != "" && concentrationActive(c) != "" {
		b.WriteString(fmt.Sprintf("_Concentrating on **%s**._\n",
			displaySpellName(c.ConcentrationSpell)))
	}
	b.WriteString("\nSlots refresh on long rest.")
	return b.String()
}

// ── !spells learn (Mage spellbook) ───────────────────────────────────────────

func (p *AdventurePlugin) handleSpellsLearn(ctx MessageContext, c *DnDCharacter, raw string) error {
	if c.Class != ClassMage {
		return p.SendDM(ctx.Sender, "`!spells learn` is for Mage. "+spellRouteHintFor(c)+
			" Run `!spells` to see what you already know.")
	}
	if raw == "" {
		return p.SendDM(ctx.Sender, "Usage: `!spells learn <spell name>`")
	}
	spell, ok := parseSpell(raw)
	if !ok {
		return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown spell %q.", raw))
	}
	classOK := false
	for _, cl := range spell.Classes {
		if cl == ClassMage {
			classOK = true
		}
	}
	if !classOK {
		return p.SendDM(ctx.Sender, fmt.Sprintf("%s isn't on the Mage spell list.", spell.Name))
	}
	if spell.Level > highestAvailableSlot(ClassMage, c.Level) && spell.Level > 0 {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"L%d spells require Mage level %d+.", spell.Level, requiredMageLevelFor(spell.Level)))
	}
	known, _, err := playerKnowsSpell(ctx.Sender, spell.ID)
	if err == nil && known {
		return p.SendDM(ctx.Sender, "You already know "+spell.Name+".")
	}
	// Cap applies to leveled spells only — cantrips are unbounded.
	if spell.Level > 0 {
		count, err := mageLeveledKnownCount(ctx.Sender)
		if err != nil {
			return p.SendDM(ctx.Sender, "Couldn't check your spellbook.")
		}
		cap := mageKnownSpellsCap(c.Level)
		if count >= cap {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"Spellbook is full (%d/%d). Level up to learn more leveled spells.",
				count, cap))
		}
	}
	if err := addKnownSpell(ctx.Sender, spell.ID, "class", true); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't learn: "+err.Error())
	}
	return p.SendDM(ctx.Sender, fmt.Sprintf("📖 Learned **%s**.", spell.Name))
}

func requiredMageLevelFor(slotLevel int) int {
	switch slotLevel {
	case 1:
		return 1
	case 2:
		return 3
	case 3:
		return 5
	case 4:
		return 7
	case 5:
		return 9
	}
	return 1
}

// ── !prepare command (Cleric stub — full SP4) ───────────────────────────────

func (p *AdventurePlugin) handleDnDPrepareCmd(ctx MessageContext, args string) error {
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	advChar, _ := loadAdvCharacter(ctx.Sender)
	c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
	if err != nil || c == nil {
		return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
	}
	if c.Class != ClassCleric {
		return p.SendDM(ctx.Sender, "`!prepare` is for Cleric. "+spellRouteHintFor(c)+
			" Run `!spells` to see what you already know.")
	}
	args = strings.TrimSpace(args)
	if args == "" {
		return p.SendDM(ctx.Sender, renderClericPrepStatus(c))
	}

	clear := false
	if strings.HasPrefix(strings.ToLower(args), "clear ") {
		clear = true
		args = strings.TrimSpace(args[6:])
	}

	spell, ok := parseSpell(args)
	if !ok {
		return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown spell %q.", args))
	}
	if spell.Level == 0 {
		return p.SendDM(ctx.Sender, "Cantrips are always prepared.")
	}
	known, _, err := playerKnowsSpell(ctx.Sender, spell.ID)
	if err != nil || !known {
		return p.SendDM(ctx.Sender, fmt.Sprintf("%s isn't on your known list.", spell.Name))
	}

	if clear {
		if err := setSpellPrepared(ctx.Sender, spell.ID, false); err != nil {
			return p.SendDM(ctx.Sender, "Couldn't unprepare: "+err.Error())
		}
		return p.SendDM(ctx.Sender, fmt.Sprintf("Unprepared **%s**.", spell.Name))
	}

	// Cap = WIS mod + Cleric level. Cantrips don't count.
	cap := abilityModifier(c.WIS) + c.Level
	if cap < 1 {
		cap = 1
	}
	rows, _ := listKnownSpells(ctx.Sender)
	prepCount := 0
	for _, r := range rows {
		s, ok := lookupSpell(r.SpellID)
		if !ok || s.Level == 0 {
			continue
		}
		if r.Prepared && r.SpellID != spell.ID {
			prepCount++
		}
	}
	if prepCount+1 > cap {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"Your prepared list is full (%d/%d). Unprepare one first: `!prepare clear <name>`.",
			prepCount, cap))
	}
	if err := setSpellPrepared(ctx.Sender, spell.ID, true); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't prepare: "+err.Error())
	}
	return p.SendDM(ctx.Sender, fmt.Sprintf("📖 Prepared **%s** (%d/%d).",
		spell.Name, prepCount+1, cap))
}

func renderClericPrepStatus(c *DnDCharacter) string {
	cap := abilityModifier(c.WIS) + c.Level
	if cap < 1 {
		cap = 1
	}
	rows, _ := listKnownSpells(c.UserID)
	prepCount := 0
	for _, r := range rows {
		s, ok := lookupSpell(r.SpellID)
		if !ok || s.Level == 0 {
			continue
		}
		if r.Prepared {
			prepCount++
		}
	}
	return fmt.Sprintf("**Prepared spells:** %d/%d. Run `!prepare <spell>` to add, `!prepare clear <spell>` to remove. Long rest re-opens choices.",
		prepCount, cap)
}

// spellRouteHintFor returns a one-sentence pointer telling the player which
// command actually advances their spellbook, scoped to their class. Used by
// the !spells learn / !prepare wrong-class error messages so the player isn't
// left guessing after we reject them.
func spellRouteHintFor(c *DnDCharacter) string {
	if c == nil {
		return ""
	}
	switch c.Class {
	case ClassMage:
		return "Mage learns spells with `!spells learn <name>`."
	case ClassCleric:
		return "Cleric picks today's spells with `!prepare <name>`."
	case ClassRogue:
		if c.Subclass == SubclassArcaneTrickster {
			return "Arcane Trickster learns spells with `!spells learn <name>` once it unlocks."
		}
		return ""
	case ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin, ClassRanger:
		return "Your class learns spells automatically as you level — nothing to prepare."
	}
	return ""
}

// ── helpers ──────────────────────────────────────────────────────────────────

func renderCastHelp(c *DnDCharacter) string {
	var b strings.Builder
	b.WriteString("**Cast a spell**\n\n")
	b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
	b.WriteString("       `!cast <heal> @friend` to heal someone on your expedition.\n")
	b.WriteString("       `!cast --drop` to clear queued/concentration.\n\n")
	b.WriteString("Run `!spells` for your full list.\n\n")
	if pc, ok := decodePendingCast(c.PendingCast); ok {
		b.WriteString(fmt.Sprintf("_Currently queued: **%s**._\n", displaySpellName(pc.SpellID)))
	}
	if c.ConcentrationSpell != "" && concentrationActive(c) != "" {
		b.WriteString(fmt.Sprintf("_Concentrating on **%s**._\n",
			displaySpellName(c.ConcentrationSpell)))
	}
	b.WriteString("\n" + renderSlotsBrief(c.UserID))
	return b.String()
}

func renderSlotsBrief(userID id.UserID) string {
	slots, err := getSpellSlots(userID)
	if err != nil {
		slog.Warn("dnd: getSpellSlots", "user", userID, "err", err)
	}
	return "**Slots:** " + renderSlotLine(slots)
}

// parseDamageDice extracts (count, faces, flatBonus) from "3d6", "1d8",
// "3d4+3" style strings. Returns (0,0,0) on failure.
func parseDamageDice(s string) (int, int, int) {
	s = strings.TrimSpace(strings.ToLower(s))
	if s == "" {
		return 0, 0, 0
	}
	// Optional flat bonus: split on +.
	flat := 0
	if i := strings.Index(s, "+"); i > 0 {
		f, err := strconv.Atoi(strings.TrimSpace(s[i+1:]))
		if err == nil {
			flat = f
		}
		s = strings.TrimSpace(s[:i])
	}
	i := strings.Index(s, "d")
	if i <= 0 {
		return 0, 0, flat
	}
	count, err := strconv.Atoi(s[:i])
	if err != nil {
		return 0, 0, flat
	}
	faces, err := strconv.Atoi(s[i+1:])
	if err != nil {
		return 0, 0, flat
	}
	return count, faces, flat
}
