package plugin

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

	"gogobee/internal/db"

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

// ── Plugin ───────────────────────────────────────────────────────────────────

type AdventurePlugin struct {
	Base
	euro           *EuroPlugin
	xp             *XPPlugin
	achievements   *AchievementsPlugin
	mu             sync.Mutex
	dmToPlayer     map[id.RoomID]id.UserID
	pending        sync.Map // userID string -> *advPendingInteraction
	userLocks      sync.Map // userID string -> *sync.Mutex
	expLocks       sync.Map // expedition ID string -> *sync.Mutex
	dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
	dmMenuSentAt   sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
	arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
	arenaPending   sync.Map // userID string -> int (pending tier number awaiting confirmation)
	arenaBailCh    sync.Map // userID string -> chan struct{} (bail signal for countdown goroutine)
	shopSessions   sync.Map // userID string -> *advShopSession
	hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
	craftResults   sync.Map // userID string -> []CraftResult (pending craft results for narrative)
	treasureUndo   sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
	morningHour    int
	summaryHour    int

	// stopCh is closed by Stop() to signal all background tickers
	// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
	// expedition briefing+recap) to exit. Each ticker selects
	// on its time.Ticker channel and stopCh; the second branch returns.
	stopCh chan struct{}
}

// Stop signals all background tickers to exit and waits for them via
// the closed channel. Idempotent — closing a closed channel would
// panic, so we guard with a sync.Once-style nil check.
func (p *AdventurePlugin) Stop() {
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.stopCh == nil {
		return
	}
	select {
	case <-p.stopCh:
		// already closed
	default:
		close(p.stopCh)
	}
}

// advUserLock returns a per-user mutex to prevent concurrent action resolution.
func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
	val, _ := p.userLocks.LoadOrStore(string(userID), &sync.Mutex{})
	return val.(*sync.Mutex)
}

// advExpeditionLock returns a per-expedition mutex, for state a whole party
// shares rather than one player. advUserLock cannot stand in: it is keyed by
// sender, so two members racing the same expedition row take two different
// mutexes and exclude nobody. Handlers run one goroutine per event, so any
// read-modify-write of the shared supply pool needs this.
func (p *AdventurePlugin) advExpeditionLock(expID string) *sync.Mutex {
	val, _ := p.expLocks.LoadOrStore(expID, &sync.Mutex{})
	return val.(*sync.Mutex)
}

// advDMResponseWindow is the default window for active state-holding
// prompts (shop/blacksmith/hospital/masterwork/treasure confirms).
// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use
// advDMResponseWindowLow so a player who gets DM'd at 1am can still
// reply when they wake up.
const advDMResponseWindow = 3 * time.Hour
const advDMResponseWindowLow = 12 * time.Hour
const advTreasureUndoWindow = 10 * time.Minute

// advTreasureUndoToken tracks a recent auto-swap so the player can undo it
// within the window by replying "undo" in DM. Holds the discarded def so
// the swap can be reversed exactly. AnnounceTimer is the deferred room
// announcement — fired once the undo window closes if the swap stands,
// stopped on undo so reversed swaps don't get publicly announced.
type advTreasureUndoToken struct {
	Discarded     *AdvTreasureDef
	Kept          *AdvTreasureDef
	ExpiresAt     time.Time
	AnnounceTimer *time.Timer
}

// advMarkMenuSent records that an actionable adventure menu was DM'd to the user.
// Only bare-number DM replies within this window will be treated as adventure choices.
func (p *AdventurePlugin) advMarkMenuSent(userID id.UserID) {
	p.dmMenuSentAt.Store(string(userID), time.Now())
}

// advIsInResponseWindow returns true if the user was recently sent an actionable menu.
func (p *AdventurePlugin) advIsInResponseWindow(userID id.UserID) bool {
	val, ok := p.dmMenuSentAt.Load(string(userID))
	if !ok {
		return false
	}
	return time.Since(val.(time.Time)) < advDMResponseWindow
}

type advPendingInteraction struct {
	Type      string // "treasure_discard"
	Data      interface{}
	ExpiresAt time.Time
}

type advPendingTreasureDiscard struct {
	NewTreasure *AdvTreasureDef
	Existing    []AdvTreasureDef
}

func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin, xp *XPPlugin) *AdventurePlugin {
	return &AdventurePlugin{
		Base:        NewBase(client),
		euro:        euro,
		xp:          xp,
		dmToPlayer:  make(map[id.RoomID]id.UserID),
		morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
		summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
	}
}

// chatLevel returns the user's chat level for perk calculations.
func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
	if p.xp == nil {
		return 0
	}
	return p.xp.GetLevel(userID)
}

func (p *AdventurePlugin) Name() string    { return "adventure" }
func (p *AdventurePlugin) Version() string { return "2.5.0" }

// SetAchievements wires the achievements plugin after both are initialized.
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
	p.achievements = ach
}

func (p *AdventurePlugin) Commands() []CommandDef {
	return []CommandDef{
		{Name: "adventure", Description: "Town services menu (expeditions: see !expedition)", Usage: "!adventure", Category: "Games"},
		{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
		{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
		{Name: "setup", Description: "Create or continue your character (race, class, stats)", Usage: "!setup", Category: "Games"},
		{Name: "sheet", Description: "View your character sheet", Usage: "!sheet", Category: "Games"},
		{Name: "abilities", Description: "List your class and race abilities", Usage: "!abilities", Category: "Games"},
		{Name: "respec", Description: "Wipe and rebuild your character (5000 euros, 7-day cooldown)", Usage: "!respec", Category: "Games"},
		{Name: "subclass", Description: "View or choose your subclass (unlocks at level 5; change costs 500 euros, 30-day cooldown)", Usage: "!subclass [name|number]", Category: "Games"},
		{Name: "check", Description: "Try a skill — see if you pull it off", Usage: "!check <skill> [difficulty]", Category: "Games"},
		{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
		{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
		{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
		{Name: "duel", Description: "Challenge another adventurer to a staked, no-death bout", Usage: "!duel @user [stake]", Category: "Games"},
		{Name: "mischief", Description: "Pay to send a monster after an adventurer who's out on an expedition", Usage: "!mischief send <tier> @user", Category: "Games"},
		{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
		{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
		{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
		{Name: "spells", Description: "List your known spells, prepared spells, and remaining casts", Usage: "!spells [learn <spell>]", Category: "Games"},
		{Name: "prepare", Description: "Cleric: prepare a spell for the day", Usage: "!prepare <spell> | !prepare clear <spell>", Category: "Games"},
		{Name: "resources", Description: "List what's harvestable in your current expedition region", Usage: "!resources", Category: "Games"},
		{Name: "explore", Description: "Autopilot: walk through expedition rooms until something needs your attention (fork, elite/boss, low HP/supplies)", Usage: "!explore", Category: "Games"},
		{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
		{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
		{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
		{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
		{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
		{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
		{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
		{Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"},
	}
}

func (p *AdventurePlugin) Init() error {
	// Bootstrap: migrate any legacy adventure_characters rows that don't yet
	// have a player_meta row. Idempotent. Required for DBs that didn't go
	// through the L4-L5h dual-write soak (fresh deploys, restored backups).
	bootstrapPlayerMetaFromLegacy()
	bootstrapRestoreExpeditionStreakDecay()
	bootstrapRoomsTraversed()

	// Rehydrate DM room mappings for existing characters
	chars, err := loadAllAdvCharacters()
	if err != nil {
		slog.Warn("adventure: no characters to rehydrate", "err", err)
	} else {
		for _, c := range chars {
			p.registerDMRoom(c.UserID)
		}
		slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
	}

	// Always reset daily actions at startup — idempotent (WHERE clause
	// only touches characters whose last_action_date < today). This handles
	// the case where the old buggy code marked the midnight job as completed
	// even though the actual reset failed due to SQLite contention.
	if err := resetAllPlayerMetaDailyActions(); err != nil {
		slog.Error("adventure: startup daily reset failed", "err", err)
	}
	// Phase L3 — cancel any open/active legacy coop dungeon runs and
	// refund member contributions + unsettled bets. Idempotent.
	closeAndRefundLegacyCoopRuns(p.euro)
	// Phase L5 close-out — drop the now-cold adventure_characters table when
	// the operator sets GOGOBEE_LEGACY_PURGE=1. Default off; idempotent.
	purgeLegacyAdvCharacterTable()
	// Phase G9 close-out — drop the now-cold current_room / room_seq_json
	// columns from dnd_zone_run when the operator sets
	// GOGOBEE_BRANCHING_PURGE=1. Default off; idempotent.
	purgeLegacyZoneRunColumns()
	// 2026-05-10 fun-pump: floor existing characters' DEX at 14 and
	// recompute armor_class so armor upgrades actually translate into
	// AC. One-shot, idempotent (WHERE dex_score < 14).
	bumpDexFloorForExistingCharacters()
	// 2026-05-10 immersion: seed short rest charges = dnd_level for any
	// character not yet on the new charge system. One-shot, idempotent.
	seedShortRestChargesForExistingCharacters()
	// 2026-05-15 Phase 5-B: refresh hp_max for existing characters
	// after the Phase 5-B HP-floor multiplier (phase5BHPMult in dnd.go)
	// shipped. Without this, existing characters keep their pre-Phase-5-B
	// hp_max until the next computeMaxHP recall (level-up / reset). The
	// migration walks dnd_character once at startup; idempotent via
	// JobCompleted gate.
	bootstrapPhase5BHPRefresh()
	// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
	// existing caster rows once at startup so the lift reaches live
	// players without waiting for level-up.
	bootstrapCasterHPRefresh()
	// 2026-06-18 caster-aid: backfill default spells that postdate a
	// character's roll (ensureSpellsForCharacter only seeds an empty book),
	// and a one-off pet gift for an endgame player who never got the morning
	// arrival roll. Both idempotent via JobCompleted gates.
	bootstrapCasterSpellBackfill()
	bootstrapGrantStarterPet()
	// Pete adventure-news cold-start: replay the back-catalogue (realm-firsts,
	// deaths, single-holder achievements) the first boot the seam is live, so
	// launch doesn't open onto an empty section. One-shot, kept (see gap #7).
	p.bootstrapPeteNewsBackfill()
	// Phase R1 orphan-archive used to run here on every Init, but it
	// over-archived: it treats any active dnd_zone_run row not linked to
	// an active expedition as a legacy `!adventure dungeon` orphan, which
	// also kills legitimate `!zone enter` single-session runs every time
	// the bot restarts. That migration's job was done on its first
	// execution; leaving it on Init was eating live runs. The function is
	// preserved for one-off invocation if a future schema change ever
	// needs it again.
	// Revive any characters whose DeadUntil has expired
	p.catchUpRespawns(chars)

	// Seed the boredom idle clock before the ticker that reads it can wake:
	// an unseeded column reads as "idle since character creation" for every
	// pre-existing player.
	bootstrapBoredomClock()

	// Start schedulers — single shared stopCh allows graceful shutdown.
	if p.stopCh == nil {
		p.stopCh = make(chan struct{})
	}
	go p.morningTicker()
	go p.summaryTicker()
	go p.midnightTicker()
	go p.eventTicker()
	// Arena auto-cashout ticker removed — replaced by per-session countdown goroutines
	go p.rivalChallengeTicker()
	go p.robbieTicker()
	go p.hospitalNudgeTicker()
	go p.mortgageTicker()
	go p.expeditionBriefingTicker()
	go p.expeditionRecapTicker()
	go p.expeditionAmbientTicker()
	go p.expeditionAutoRunTicker()
	go p.peteRosterTicker()
	go p.expeditionExtractionSweepTicker()
	go p.expeditionBoredomTicker()
	go p.mischiefTicker()
	go p.peteMischiefTicker()
	go p.peteEquipTicker()

	// Auto-cashout any arena runs left in 'awaiting' from a prior restart
	p.arenaCleanupStaleRuns()
	// Extractions that lapsed while the bot was down: the roster they hold is
	// blocking those members right now, not an hour from now.
	p.sweepLapsedExtractions(time.Now().UTC())

	return nil
}

func (p *AdventurePlugin) catchUpRespawns(chars []AdventureCharacter) {
	now := time.Now().UTC()
	for _, char := range chars {
		if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
			char.Alive = true
			char.DeadUntil = nil
			if err := saveAdvCharacter(&char); err != nil {
				slog.Error("adventure: catch-up revive failed", "user", char.UserID, "err", err)
				continue
			}
			slog.Info("adventure: catch-up revived player", "user", char.UserID)
			text := renderAdvRespawnDM(char.UserID)
			if err := p.SendDM(char.UserID, text); err != nil {
				slog.Error("adventure: catch-up respawn DM failed", "user", char.UserID, "err", err)
			}
		}
	}
}

func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }

// ── Message Dispatch ─────────────────────────────────────────────────────────

func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
	// D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle
	// player's event-anchored expedition, the briefing fires here on their
	// next inbound message. Fast-paths to a no-op for users with no active
	// expedition.
	p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())

	// Bored adventurers (gogobee_boredom_plan.md §1) — the idle clock that
	// decides when a character gives up waiting and leaves on its own. Stamped
	// only for a real action against Adventure: idle chatter in a room is not
	// tending your adventurer, and no ticker or autopilot path may touch this,
	// or a bored character would refresh its own clock and never qualify again.
	if p.isPlayerAdventureAction(ctx) {
		markPlayerAction(ctx.Sender)
	}

	// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
	if p.IsCommand(ctx.Body, "setup") {
		return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
	}
	if p.IsCommand(ctx.Body, "sheet") {
		return p.handleDnDSheetCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "abilities") {
		return p.handleDnDAbilitiesCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "respec") {
		return p.handleDnDRespecCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "subclass") {
		return p.handleDnDSubclassCmd(ctx, p.GetArgs(ctx.Body, "subclass"))
	}
	if p.IsCommand(ctx.Body, "check") {
		return p.handleDnDCheckCmd(ctx, p.GetArgs(ctx.Body, "check"))
	}
	if p.IsCommand(ctx.Body, "rest") {
		return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
	}
	if p.IsCommand(ctx.Body, "duel") {
		return p.handleDuelCmd(ctx, p.GetArgs(ctx.Body, "duel"))
	}
	if p.IsCommand(ctx.Body, "mischief") {
		return p.handleMischiefCmd(ctx, p.GetArgs(ctx.Body, "mischief"))
	}
	if p.IsCommand(ctx.Body, "arm") {
		return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
	}
	if p.IsCommand(ctx.Body, "cast") {
		return p.handleDnDCastCmd(ctx, p.GetArgs(ctx.Body, "cast"))
	}
	if p.IsCommand(ctx.Body, "spells") {
		return p.handleDnDSpellsCmd(ctx, p.GetArgs(ctx.Body, "spells"))
	}
	if p.IsCommand(ctx.Body, "prepare") {
		return p.handleDnDPrepareCmd(ctx, p.GetArgs(ctx.Body, "prepare"))
	}
	if p.IsCommand(ctx.Body, "roll") {
		return p.handleDnDRollCmd(ctx, p.GetArgs(ctx.Body, "roll"))
	}
	if p.IsCommand(ctx.Body, "stats") {
		return p.handleDnDStatsCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "level") {
		return p.handleDnDLevelCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "zone") {
		return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone"))
	}
	if p.IsCommand(ctx.Body, "revisit") {
		return p.handleRevisitCmd(ctx, p.GetArgs(ctx.Body, "revisit"))
	}
	if p.IsCommand(ctx.Body, "fight") {
		return p.handleFightCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "attack") {
		return p.handleAttackCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "flee") {
		return p.handleFleeCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "consume") {
		return p.handleConsumeCmd(ctx, p.GetArgs(ctx.Body, "consume"))
	}
	if p.IsCommand(ctx.Body, "expedition") {
		return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
	}
	if p.IsCommand(ctx.Body, "camp") {
		return p.handleCampCmd(ctx, p.GetArgs(ctx.Body, "camp"))
	}
	if p.IsCommand(ctx.Body, "threat") {
		return p.handleThreatCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "region") {
		return p.handleRegionCmd(ctx, p.GetArgs(ctx.Body, "region"))
	}
	if p.IsCommand(ctx.Body, "extract") {
		return p.handleExtractCmd(ctx, p.GetArgs(ctx.Body, "extract"))
	}
	if p.IsCommand(ctx.Body, "resume") {
		return p.handleResumeCmd(ctx, p.GetArgs(ctx.Body, "resume"))
	}
	if p.IsCommand(ctx.Body, "map") {
		return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map"))
	}
	if p.IsCommand(ctx.Body, "explore") {
		return p.expeditionCmdRun(ctx)
	}
	if p.IsCommand(ctx.Body, "forage") ||
		p.IsCommand(ctx.Body, "mine") ||
		p.IsCommand(ctx.Body, "scavenge") ||
		p.IsCommand(ctx.Body, "essence") ||
		p.IsCommand(ctx.Body, "commune") ||
		p.IsCommand(ctx.Body, "fish") {
		return p.SendDM(ctx.Sender, "Harvest is automatic now — just walk. (`!expedition run` or `!zone advance`)")
	}
	if p.IsCommand(ctx.Body, "resources") {
		return p.handleResourcesCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "sell") {
		return p.handleResourceSellCmd(ctx, p.GetArgs(ctx.Body, "sell"))
	}
	if p.IsCommand(ctx.Body, "craft") {
		return p.handleCraftCmd(ctx, p.GetArgs(ctx.Body, "craft"))
	}
	if p.IsCommand(ctx.Body, "lore") {
		return p.handleLoreCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "give") {
		return p.handleGiveCmd(ctx)
	}

	// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
	if p.IsCommand(ctx.Body, "town") {
		return p.handleTownCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "graveyard") {
		return p.handleGraveyardCmd(ctx)
	}
	if p.IsCommand(ctx.Body, "rivals") {
		return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
	}
	if p.IsCommand(ctx.Body, "news") {
		return p.handleNewsCmd(ctx)
	}

	// 1. Arena commands (work in rooms and DMs)
	if p.IsCommand(ctx.Body, "bail") {
		return p.handleArenaBail(ctx)
	}
	if p.IsCommand(ctx.Body, "arena") {
		return p.dispatchArenaCommand(ctx)
	}

	// 1b. Hospital commands (work in rooms and DMs)
	if p.IsCommand(ctx.Body, "hospital") {
		return p.handleHospitalCmd(ctx)
	}

	// 2. Check if this is a DM reply from a registered player
	p.mu.Lock()
	playerID, isDM := p.dmToPlayer[ctx.RoomID]
	p.mu.Unlock()

	if isDM && playerID == ctx.Sender {
		return p.handleDMReply(ctx)
	}

	// 3. NPC encounter tracking — count room messages from adventure players
	if !isDM && !strings.HasPrefix(ctx.Body, "!") {
		safeGo("npc-track", func() {
			p.npcTrackMessage(ctx.Sender)
		})
	}

	// 4. Thom Krooke commands (work in rooms and DMs)
	if p.IsCommand(ctx.Body, "thom") {
		return p.handleThomCmd(ctx)
	}

	// 5. Command dispatch
	if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
		return nil
	}

	return p.dispatchCommand(ctx)
}

func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
	args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
	if args == "" && p.IsCommand(ctx.Body, "adv") {
		args = strings.TrimSpace(p.GetArgs(ctx.Body, "adv"))
	}
	lower := strings.ToLower(args)

	switch {
	case args == "" || lower == "menu":
		return p.handleMenu(ctx)
	case lower == "status":
		return p.handleStatus(ctx)
	case strings.HasPrefix(lower, "sell "):
		return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
	case lower == "shop" || strings.HasPrefix(lower, "shop "):
		return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop")))
	case strings.HasPrefix(lower, "buy "):
		return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
	case lower == "equip":
		return p.handleEquipCmd(ctx)
	case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
		return p.handleEquipMagicCmd(ctx)
	case lower == "unequip-magic" || lower == "unequipmagic" || lower == "unequip magic":
		return p.handleUnequipMagicCmd(ctx)
	case lower == "inventory" || lower == "inv":
		return p.handleInventoryCmd(ctx)
	case lower == "leaderboard" || lower == "lb":
		return p.handleLeaderboard(ctx)
	case strings.HasPrefix(lower, "revive "):
		return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
	case lower == "summary":
		return p.handleAdminSummary(ctx)
	case lower == "respond":
		return p.handleEventRespond(ctx)
	case lower == "help":
		return p.SendDM(ctx.Sender, advHelpText)
	case lower == "rivals":
		return p.handleRivalsCmd(ctx)
	case lower == "duel" || strings.HasPrefix(lower, "duel "):
		return p.handleDuelCmd(ctx, strings.TrimSpace(args[len("duel"):]))
	case lower == "mischief" || strings.HasPrefix(lower, "mischief "):
		return p.handleMischiefCmd(ctx, strings.TrimSpace(args[len("mischief"):]))
	case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
		return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
	case lower == "blacksmith" || lower == "repair":
		return p.handleBlacksmithCmd(ctx)
	case lower == "repair all":
		return p.handleRepairAllCmd(ctx)
	case strings.HasPrefix(lower, "repair "):
		return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
	case lower == "temper":
		return p.handleTemperCmd(ctx)
	case lower == "boost":
		return p.handleBoostCmd(ctx)
	case lower == "recipes":
		return p.handleRecipesCmd(ctx)
	case lower == "mastery":
		return p.handleMasteryCmd(ctx)
	case lower == "treasures" || strings.HasPrefix(lower, "treasures "):
		return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
	case lower == "vault" || strings.HasPrefix(lower, "vault "):
		return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
	case lower == "journal":
		return p.handleJournalCmd(ctx)
	case lower == "shadow":
		return p.handleShadowCmd(ctx)
	case lower == "worldboss" || strings.HasPrefix(lower, "worldboss "):
		return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("worldboss"):]))
	case lower == "siege" || strings.HasPrefix(lower, "siege "):
		return p.handleWorldBossCmd(ctx, strings.TrimSpace(args[len("siege"):]))
	}

	return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
}

const advHelpText = `**Adventure Commands**

` + "`!adventure`" + ` — Show town services menu (expeditions are run via ` + "`!expedition`" + `)
` + "`!adventure status`" + ` — View your character sheet
` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go
` + "`!adventure shop`" + ` — Browse equipment categories
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool, supplies, curios)
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
` + "`!adventure unequip-magic`" + ` — Take a worn magic item off and return it to inventory
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!duel @user [stake]`" + ` — Challenge another adventurer to a staked, no-death bout (` + "`!duel accept`" + `/` + "`decline`" + `/` + "`status`" + `)
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
` + "`!adventure babysit focus <skill>`" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging)
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!adventure temper`" + ` — Push a magic item one rarity step higher (blacksmith)
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
` + "`!adventure journal`" + ` — Read the campaign pages you've recovered
` + "`!adventure shadow`" + ` — See how your rival's run compares to yours
` + "`!adventure worldboss`" + ` — The monthly Siege: one communal bout a day (` + "`fight`" + ` to join)
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message

**Arena:**
` + "`!arena`" + ` — Show arena tier menu
` + "`!arena tier <1-5>`" + ` — Enter a tier
` + "`!arena fight`" + ` — Fight current round
` + "`!arena descend`" + ` — Descend to next tier (keep earnings at risk)
` + "`!arena cashout`" + ` — Take earnings and leave
` + "`!arena status`" + ` — Current run state
` + "`!arena leaderboard`" + ` — Top arena players

**Systems:**
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.

**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`

// ── Command Handlers ─────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
	char, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
	}

	if !char.Alive {
		// On-demand revive if death timer has expired
		if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
			char.Alive = true
			char.DeadUntil = nil
			if err := saveAdvCharacter(char); err != nil {
				slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
			} else {
				text := renderAdvRespawnDM(char.UserID)
				p.SendDM(ctx.Sender, text)
				// Fall through to show menu
			}
		}
		if !char.Alive {
			text := renderAdvDeathStatusDM(char.UserID)
			return p.SendDM(ctx.Sender, text)
		}
	}

	isHol, holName := isHolidayToday()
	if char.AllActionsUsed(isHol) {
		now := time.Now().UTC()
		midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
		remaining := midnight.Sub(now)
		hours := int(remaining.Hours())
		minutes := int(remaining.Minutes()) % 60
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You've used all your actions today. Tomorrow awaits. Try to survive it.\n\n"+
				"Next action: 00:00 UTC (%dh %dm from now)\n"+
				"Morning DM: %02d:00 UTC\n\n"+
				"The Arena is always open: `!arena`",
			hours, minutes, p.morningHour))
	}

	treasures, _ := loadAdvTreasureBonuses(char.UserID)
	buffs, _ := loadAdvActiveBuffs(char.UserID)
	bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
	applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
	balance := p.euro.GetBalance(char.UserID)

	text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
	p.advMarkMenuSent(ctx.Sender)
	return p.SendDM(ctx.Sender, text)
}

func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
	char, err := loadAdvCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
	}

	// On-demand revive if death timer has expired
	if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
		char.Alive = true
		char.DeadUntil = nil
		if err := saveAdvCharacter(char); err != nil {
			slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
		} else {
			p.SendDM(ctx.Sender, renderAdvRespawnDM(char.UserID))
		}
	}

	equip, err := loadAdvEquipment(ctx.Sender)
	if err != nil {
		slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
	}
	items, err := loadAdvInventory(ctx.Sender)
	if err != nil {
		slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
	}
	treasures, err := loadAdvTreasureBonuses(ctx.Sender)
	if err != nil {
		slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
	}
	balance := p.euro.GetBalance(ctx.Sender)

	text := renderAdvCharacterSheet(char.UserID, equip, items, treasures, balance)
	return p.SendDM(ctx.Sender, text)
}

func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error {
	_, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
	}

	balance := p.euro.GetBalance(ctx.Sender)
	showAll := strings.Contains(strings.ToLower(args), "show all")
	category := strings.TrimSpace(strings.Replace(strings.ToLower(args), "show all", "", 1))

	p.shopSessionStart(ctx.Sender)

	if category == "" {
		text := luigiShopGreeting(ctx.Sender, equip, balance, showAll, p.chatLevel(ctx.Sender))
		if disc := p.shopSessionAnnounceDiscount(ctx.Sender); disc != "" {
			text += "\n\n" + disc
		}
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "shop_category",
			Data:      &advPendingShopCategory{ShowAll: showAll},
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		p.advMarkMenuSent(ctx.Sender)
		p.shopScheduleBrowseNudge(ctx.Sender)
		return p.SendDM(ctx.Sender, text)
	}

	slot := advParseShopCategory(category)
	if slot == "" {
		return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
	}

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

func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
	char, equip, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
	}
	if !char.Alive {
		return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.")
	}

	if itemName == "" {
		return p.SendDM(ctx.Sender, "Usage: `!buy <item name>`. Type `!adventure shop` to browse.")
	}

	slot, def, found := advFindShopItem(itemName)
	if !found {
		return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
	}

	result := p.advBuyEquipment(ctx.Sender, slot, def, equip)
	return p.SendDM(ctx.Sender, result)
}

func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
	char, _, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
	}
	if !char.Alive {
		return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
	}

	var result string
	if strings.ToLower(args) == "all" {
		result = p.advSellAll(ctx.Sender)
	} else {
		result = p.advSellItem(ctx.Sender, args)
	}
	err = p.SendDM(ctx.Sender, result)
	// N1/A6 — a sale at Thom's is a moment the player is present and reading.
	p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell)
	return err
}

func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
	if _, _, err := p.ensureCharacter(ctx.Sender); err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
	}
	text := advInventoryDisplay(ctx.Sender)
	return p.SendDM(ctx.Sender, text)
}

func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
	chars, err := loadAllAdvCharacters()
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
	}
	entries := make([]AdvLeaderboardEntry, 0, len(chars))
	for _, c := range chars {
		entries = append(entries, AdvLeaderboardEntry{
			UserID:        c.UserID,
			Level:         dndLevelForUser(c.UserID),
			MiningSkill:   c.MiningSkill,
			ForagingSkill: c.ForagingSkill,
			FishingSkill:  c.FishingSkill,
			CurrentStreak: c.CurrentStreak,
			Renown:        renownLevelForUser(c.UserID),
		})
	}
	text := renderAdvLeaderboard(entries)
	return p.SendReply(ctx.RoomID, ctx.EventID, text)
}

func (p *AdventurePlugin) handleAdminRevive(ctx MessageContext, target string) error {
	if !p.IsAdmin(ctx.Sender) {
		return nil
	}

	// Resolve user
	targetID, found := p.ResolveUser(target, ctx.RoomID)
	if !found {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find that user.")
	}

	char, err := loadAdvCharacter(targetID)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "That user has no adventurer.")
	}

	displayName, _ := loadDisplayName(targetID)
	if char.Alive {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is already alive.", displayName))
	}

	char.Alive = true
	char.DeadUntil = nil
	if err := saveAdvCharacter(char); err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to revive.")
	}

	p.SendDM(targetID, renderAdvRespawnDM(targetID))
	if p.achievements != nil {
		p.achievements.GrantAchievement(targetID, "adv_revived")
	}
	return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", displayName))
}

func (p *AdventurePlugin) handleAdminSummary(ctx MessageContext) error {
	if !p.IsAdmin(ctx.Sender) {
		return nil
	}
	go p.postDailySummary()
	return p.SendReply(ctx.RoomID, ctx.EventID, "Daily summary will be posted shortly.")
}

// ── DM Reply Handling ────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
	body := strings.TrimSpace(ctx.Body)

	// Skip if it looks like a command for another plugin
	lower := strings.ToLower(body)
	if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") {
		return nil
	}

	// Handle !thom in DMs
	if strings.HasPrefix(lower, "!thom") {
		return p.handleThomCmd(ctx)
	}

	// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
	if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
		return p.dispatchCommand(ctx)
	}

	// "undo" reverts a recent treasure auto-swap. Checked ahead of the
	// pending-interaction block so it doesn't get consumed by an unrelated
	// pending prompt.
	if lower == "undo" {
		if p.tryTreasureUndo(ctx.Sender) {
			return nil
		}
	}

	// Check for pending interaction first (always honored regardless of window)
	if val, ok := p.pending.Load(string(ctx.Sender)); ok {
		interaction := val.(*advPendingInteraction)
		if time.Now().Before(interaction.ExpiresAt) {
			return p.resolvePendingInteraction(ctx, interaction)
		}
		p.pending.Delete(string(ctx.Sender))
		p.shopSessionEnd(ctx.Sender)
		p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
	}

	// Only interpret bare messages as adventure choices if the user was recently
	// shown an actionable menu. This prevents "1" typed during UNO (or any other
	// DM-based game) from triggering adventure responses.
	if !p.advIsInResponseWindow(ctx.Sender) {
		return nil
	}

	// Parse as activity choice
	return p.parseAndResolveChoice(ctx, body)
}

func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interaction *advPendingInteraction) error {
	p.pending.Delete(string(ctx.Sender))

	switch interaction.Type {
	case "treasure_discard":
		return p.handleTreasureDiscard(ctx, interaction)
	case "masterwork_equip":
		return p.handleMasterworkEquipReply(ctx, interaction)
	case "magic_equip":
		return p.resolveMagicEquipReply(ctx, interaction)
	case "magic_unequip":
		return p.resolveMagicUnequipReply(ctx, interaction)
	case "masterwork_equip_confirm":
		return p.handleMasterworkEquipConfirm(ctx, interaction)
	case "rival_rps":
		return p.resolveRivalRPSRound(ctx, interaction)
	case "blacksmith_slot":
		return p.resolveBlacksmithSlotChoice(ctx, interaction)
	case "blacksmith_confirm":
		return p.resolveBlacksmithConfirm(ctx, interaction)
	case "temper_pick":
		return p.resolveTemperPick(ctx, interaction)
	case "temper_confirm":
		return p.resolveTemperConfirm(ctx, interaction)
	case "shop_category":
		return p.resolveShopCategoryChoice(ctx, interaction)
	case "shop_item":
		return p.resolveShopItemChoice(ctx, interaction)
	case "shop_supply":
		return p.resolveShopSupplyChoice(ctx, interaction)
	case "shop_curio":
		return p.resolveShopCurioChoice(ctx, interaction)
	case "shop_confirm":
		return p.resolveShopConfirm(ctx, interaction)
	case "hospital_pay":
		return p.resolveHospitalPay(ctx, interaction)
	case "npc_encounter":
		return p.resolveNPCEncounter(ctx, interaction)
	case "pet_arrival":
		return p.resolvePetArrival(ctx, interaction)
	case "pet_type":
		return p.resolvePetType(ctx, interaction)
	case "pet_name":
		return p.resolvePetName(ctx, interaction)
	}
	return nil
}

func (p *AdventurePlugin) handleTreasureDiscard(ctx MessageContext, interaction *advPendingInteraction) error {
	data := interaction.Data.(*advPendingTreasureDiscard)
	body := strings.TrimSpace(strings.ToLower(ctx.Body))

	if body == "keep" {
		return p.SendDM(ctx.Sender, fmt.Sprintf("You left the %s behind. It will stay where you found it, judging you, forever.", data.NewTreasure.Name))
	}

	choice, err := strconv.Atoi(body)
	if err != nil || choice < 1 || choice > len(data.Existing) {
		return p.SendDM(ctx.Sender, "Reply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.")
	}

	// Discard the chosen treasure
	discarded := data.Existing[choice-1]
	if err := advDiscardTreasure(ctx.Sender, discarded.Key); err != nil {
		return p.SendDM(ctx.Sender, "Failed to discard treasure. Try again.")
	}

	// Save the new treasure
	if err := advSaveTreasure(ctx.Sender, data.NewTreasure); err != nil {
		return p.SendDM(ctx.Sender, "Failed to save new treasure. Something went wrong.")
	}

	return p.SendDM(ctx.Sender, fmt.Sprintf("You discarded **%s** and kept **%s**.\n\n_%s_",
		discarded.Name, data.NewTreasure.Name, data.NewTreasure.InventoryDesc))
}

// ── Activity Choice Parsing ──────────────────────────────────────────────────

func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) error {
	// Acquire per-user lock to prevent double actions from concurrent DM replies
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	char, err := loadAdvCharacter(ctx.Sender)
	if err != nil {
		return nil // not a registered player
	}

	if !char.Alive {
		// On-demand revive if death timer has expired
		if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
			char.Alive = true
			char.DeadUntil = nil
			if err := saveAdvCharacter(char); err != nil {
				slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
			} else {
				p.SendDM(ctx.Sender, renderAdvRespawnDM(char.UserID))
			}
		}
		if !char.Alive {
			return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID))
		}
	}

	isHol, _ := isHolidayToday()
	if char.AllActionsUsed(isHol) {
		// Only send the reminder once per day — subsequent DM messages
		// are silently ignored so they can be handled by other plugins (e.g. UNO).
		today := time.Now().UTC().Format("2006-01-02")
		if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
			return nil
		}
		p.dmRemindedDate.Store(string(ctx.Sender), today)

		now := time.Now().UTC()
		midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
		remaining := midnight.Sub(now)
		hours := int(remaining.Hours())
		minutes := int(remaining.Minutes()) % 60
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You've used all your actions today. Rest now. Try again tomorrow.\n\n"+
				"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
				"The Arena is always open: `!arena`",
			hours, minutes))
	}

	lower := strings.ToLower(body)

	// Parse "7" or "rest"
	if lower == "7" || lower == "rest" {
		return p.resolveRest(ctx, char)
	}

	// Parse "6" or "blacksmith"
	if lower == "6" || lower == "blacksmith" {
		return p.handleBlacksmithCmd(ctx)
	}

	// Parse "5" or "shop"
	if lower == "5" || lower == "shop" {
		return p.handleShopCmd(ctx, "")
	}

	// Phase R1 — legacy activity loop (1/dungeon, 2/mine, 3/forage, 4/fish + tier-or-name) is
	// deprecated. The new path is the expedition system. Anything that parses as a legacy
	// activity is intercepted here and routed to a deprecation notice; non-matches still
	// fall through to the unknown-command response so other plugins can pick it up.
	if isLegacyActivityInput(lower, char) {
		return p.SendDM(ctx.Sender, renderLegacyActivityDeprecation(char))
	}

	return p.SendDM(ctx.Sender, "I didn't understand that. Type `!adventure help` for commands, or `!expedition` to head into a zone.")
}

// isLegacyActivityInput reports whether `input` looks like a legacy activity-loop
// dispatch (1/dungeon, 2/mine, 3/forage, 4/fish, with or without tier/location).
// Used by the Phase R1 deprecation gate to redirect the player to !expedition
// without swallowing arbitrary unrelated DMs.
func isLegacyActivityInput(input string, char *AdventureCharacter) bool {
	if input == "" {
		return false
	}
	parts := strings.SplitN(input, " ", 2)
	first := parts[0]
	switch first {
	case "1", "2", "3", "4",
		"d", "m", "f",
		"dungeon", "mine", "forage", "forest", "fish", "fishing":
		return true
	}
	for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} {
		if findAdvLocation(act, input) != nil {
			return true
		}
	}
	return false
}

// renderLegacyActivityDeprecation is the Phase R1 deprecation DM. The standalone
// daily activity loop is gone; players now run expeditions and harvest inside
// rooms instead.
func renderLegacyActivityDeprecation(char *AdventureCharacter) string {
	var sb strings.Builder
	sb.WriteString("⚠️ **The daily adventure loop has retired.**\n\n")
	sb.WriteString("Solo dungeon runs, mining, foraging, and fishing are now part of the **expedition system** — pick a zone, advance through rooms, harvest as you go.\n\n")
	sb.WriteString("Get started:\n")
	sb.WriteString("• `!expedition` — overview & open expeditions\n")
	sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
	sb.WriteString("• Harvest is automatic — yields land as you walk through cleared rooms.\n")
	sb.WriteString("• `!resources` — list nodes in your current room\n\n")
	sb.WriteString("Shop, blacksmith, rest, and Thom Krooke are still here on `!adventure`.")
	return sb.String()
}

func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
	isHol, _ := isHolidayToday()

	// Rest consumes all remaining actions
	combatMax := maxCombatActions
	harvestMax := maxHarvestActions
	if isHol {
		combatMax++
		harvestMax++
	}
	char.CombatActionsUsed = combatMax
	char.HarvestActionsUsed = harvestMax
	char.ActionTakenToday = true
	char.LastActionDate = time.Now().UTC().Format("2006-01-02")

	if err := saveAdvCharacter(char); err != nil {
		return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
	}
	_ = upsertPlayerMetaLifecycleState(char.UserID, lifecycleStateFromAdvChar(char))

	logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")

	dispName, _ := loadDisplayName(char.UserID)

	// Compute reset countdown
	now := time.Now().UTC()
	midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
	remaining := midnight.Sub(now)
	hours := int(remaining.Hours())
	minutes := int(remaining.Minutes()) % 60

	restMsg := fmt.Sprintf(
		"%s, you chose rest. No loot. No XP. No death.\n\n"+
			"You sat in your hovel and stared at the wall and achieved absolutely nothing. "+
			"Tomorrow awaits. It will probably be the same.\n\n"+
			"─────────────────────────────\n"+
			"Next action: 00:00 UTC (%dh %dm from now)\n"+
			"Morning DM: %02d:00 UTC\n"+
			"Evening summary: %02d:00 UTC\n\n"+
			"The Arena is always open: `!arena`",
		dispName, hours, minutes, p.morningHour, p.summaryHour)

	if err := p.SendDM(ctx.Sender, restMsg); err != nil {
		slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
	}

	return nil
}

// ── Treasure Drop Check ─────────────────────────────────────────────────────

// checkTreasureDrop rolls the treasure table for one earned moment. weight
// scales the drop rate by how big that moment was (see advTreasureWeight*).
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation, weight float64) {
	drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID), weight)
	if drop == nil {
		// Near-miss feedback: when the roll was within 2× of the drop rate,
		// tell the player they almost got it. Treasure rates are 0.15–1.5%
		// so without this signal the system feels invisible.
		//
		// Only for weighted moments (boss/elite/zone clear). A standard kill
		// fires dozens of times a day on autopilot, and a near-miss DM on 3%
		// of them turns the signal into noise.
		if weight > 1 && rate > 0 && roll < rate*2 {
			p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
				roll*100, rate*100))
		}
		return
	}

	// Check treasure count
	count, err := advCountTreasures(userID)
	if err != nil {
		return
	}

	if count < maxTreasuresForTier(char.HouseTier) {
		// Directly save
		if err := advSaveTreasure(userID, drop.Def); err != nil {
			slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
			return
		}

		// Send discovery flavor
		p.sendTreasureDiscoveryDM(userID, char, drop.Def, loc)
		return
	}

	// At cap — try the auto-swap path before falling back to the manual
	// discard prompt. The prompt was the wound; most players don't want
	// agency at the rare moment of finding a treasure, they want the swap.
	existing, err := advUserTreasures(userID)
	if err != nil || len(existing) == 0 {
		return
	}

	// Lock honors the player's "I'm done curating" state — refuse the drop
	// instead of churning their set.
	if char.TreasuresLocked {
		p.SendDM(userID, fmt.Sprintf("💎 *Treasure refused* — found **%s** (Tier %d) but your treasures are locked. Unlock with `!adventure treasures unlock` to allow swaps.",
			drop.Def.Name, drop.Def.Tier))
		return
	}

	// Pick the worst replaceable existing treasure as the swap candidate.
	// Irreplaceable treasures (special_* bonuses) are protected — never
	// auto-discarded, even by a higher-tier drop.
	worstIdx := -1
	var worstRank advTreasureRankKey
	for i := range existing {
		ex := existing[i]
		if advTreasureIrreplaceable(&ex) {
			continue
		}
		r := advTreasureRank(&ex)
		if worstIdx < 0 || advTreasureRankBetter(worstRank, r) {
			worstIdx, worstRank = i, r
		}
	}

	newRank := advTreasureRank(drop.Def)

	// All existing are irreplaceable — fall back to the manual prompt so
	// the player consciously chooses what to give up.
	if worstIdx < 0 {
		p.pending.Store(string(userID), &advPendingInteraction{
			Type: "treasure_discard",
			Data: &advPendingTreasureDiscard{
				NewTreasure: drop.Def,
				Existing:    existing,
			},
			ExpiresAt: time.Now().Add(advDMResponseWindow),
		})
		p.SendDM(userID, renderAdvTreasureDiscardPrompt(drop.Def, existing))
		return
	}

	// New treasure is irreplaceable but would force out a replaceable —
	// auto-swap is fine here, the protection rule is about not removing
	// existing irreplaceables.
	if !advTreasureRankBetter(newRank, worstRank) {
		// New is no better than the worst we'd give up. Don't churn —
		// just tell the player about the find passively.
		p.SendDM(userID, fmt.Sprintf("💎 *Found %s (Tier %d)* — left behind: your current set ranks higher.",
			drop.Def.Name, drop.Def.Tier))
		return
	}

	// Swap: discard worst, save new, store undo token.
	discarded := existing[worstIdx]
	if err := advDiscardTreasure(userID, discarded.Key); err != nil {
		slog.Error("adventure: failed to discard for auto-swap", "user", userID, "err", err)
		return
	}
	if err := advSaveTreasure(userID, drop.Def); err != nil {
		slog.Error("adventure: failed to save auto-swapped treasure", "user", userID, "err", err)
		// Best-effort recovery — restore the discarded one.
		_ = advSaveTreasure(userID, &discarded)
		return
	}

	// High-tier story items earn a public moment even via auto-swap, but
	// defer the announce until after the undo window so reversed swaps
	// don't get publicly announced. Capture the data we need on close
	// over so the timer doesn't reach back into mutable state.
	announceChar := *char
	announceDef := *drop.Def
	announceLoc := *loc
	announceTimer := time.AfterFunc(advTreasureUndoWindow, func() {
		// Atomically claim the token so a concurrent `undo` reply
		// can't also fire the announcement (or vice-versa).
		if _, ok := p.treasureUndo.LoadAndDelete(string(userID)); !ok {
			return
		}
		p.announceTreasureToRoom(&announceChar, &announceDef, &announceLoc)
	})

	p.treasureUndo.Store(string(userID), &advTreasureUndoToken{
		Discarded:     &discarded,
		Kept:          drop.Def,
		ExpiresAt:     time.Now().Add(advTreasureUndoWindow),
		AnnounceTimer: announceTimer,
	})

	p.SendDM(userID, fmt.Sprintf(
		"💎 **Found %s** (Tier %d)\nAuto-swapped for **%s** (Tier %d, lowest in your set).\n_Reply `undo` within %d min to revert, or `!adventure treasures` to review._",
		drop.Def.Name, drop.Def.Tier,
		discarded.Name, discarded.Tier,
		int(advTreasureUndoWindow.Minutes())))
}

// tryTreasureUndo reverses a recent auto-swap. Returns true when an undo
// was applied. Stale or missing tokens return false so the caller falls
// through to the regular DM dispatch.
func (p *AdventurePlugin) tryTreasureUndo(userID id.UserID) bool {
	// LoadAndDelete races against the announce timer's claim — only
	// one side wins, the other no-ops.
	val, ok := p.treasureUndo.LoadAndDelete(string(userID))
	if !ok {
		return false
	}
	tok := val.(*advTreasureUndoToken)
	// Cancel the deferred room announcement — a reversed swap shouldn't
	// produce a public "X recovered Y" line.
	if tok.AnnounceTimer != nil {
		tok.AnnounceTimer.Stop()
	}
	if time.Now().After(tok.ExpiresAt) {
		p.SendDM(userID, "⌛ The undo window expired. Your treasure swap stands.")
		return true
	}

	// Reverse: discard the kept one, restore the discarded one.
	if err := advDiscardTreasure(userID, tok.Kept.Key); err != nil {
		slog.Error("adventure: failed to discard kept on undo", "user", userID, "err", err)
		return true
	}
	if err := advSaveTreasure(userID, tok.Discarded); err != nil {
		slog.Error("adventure: failed to restore on undo", "user", userID, "err", err)
		return true
	}
	p.SendDM(userID, fmt.Sprintf("↩️ Reverted. Your **%s** is back, **%s** released.",
		tok.Discarded.Name, tok.Kept.Name))
	return true
}

func (p *AdventurePlugin) sendTreasureDiscoveryDM(userID id.UserID, char *AdventureCharacter, def *AdvTreasureDef, loc *AdvLocation) {
	// Pick from discovery pool
	pool, ok := TreasureDiscovery[def.Tier]
	if !ok || len(pool) == 0 {
		return
	}

	text := pool[rand.IntN(len(pool))]
	text = advSubstituteFlavor(text, map[string]string{
		"{treasure_name}": def.Name,
		"{bonus_desc}":    def.InventoryDesc,
		"{location}":      loc.Name,
	})

	p.SendDM(userID, text)

	p.announceTreasureToRoom(char, def, loc)
}

// announceTreasureToRoom posts the public "X recovered Y from Z" notice for
// treasures that carry a RoomAnnounce string (typically tier 5 / story
// items). Called from both the normal discovery path and the auto-swap
// path so high-tier finds keep their public moment regardless of whether
// they arrived under or at the treasure cap.
func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *AdvTreasureDef, loc *AdvLocation) {
	if def == nil || def.RoomAnnounce == "" {
		return
	}
	// The same story-grade gate feeds Pete's trophy case. Emit before the
	// games-room check so a find is still recorded as news even when no room is
	// configured to announce it in.
	emitTreasureFound(char.UserID, def, loc)
	gr := gamesRoom()
	if gr == "" {
		return
	}
	displayName, _ := loadDisplayName(char.UserID)
	announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
		"{name}":     displayName,
		"{location}": loc.Name,
	})
	p.SendMessage(id.RoomID(gr), announce)
}

// ── Flavor Text Selection ────────────────────────────────────────────────────

func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *AdvActionResult) (string, string) {
	loc := result.Location
	displayName, _ := loadDisplayName(char.UserID)
	vars := map[string]string{
		"{name}":     displayName,
		"{location}": loc.Name,
		"{value}":    fmt.Sprintf("%d", result.TotalLootValue),
		"{xp}":       fmt.Sprintf("%d", result.XPGained),
	}

	// Add item names
	if len(result.LootItems) > 0 {
		names := make([]string, len(result.LootItems))
		for i, item := range result.LootItems {
			names[i] = item.Name
		}
		vars["{item}"] = joinAdvItems(names)
		vars["{ore}"] = names[0]
		vars["{item_2}"] = ""
		if len(names) > 1 {
			vars["{item_2}"] = names[1]
		}
	} else {
		vars["{item}"] = ""
		vars["{ore}"] = ""
	}

	// Equipment names for flavor
	equip, _ := loadAdvEquipment(char.UserID)
	if eq, ok := equip[SlotTool]; ok {
		vars["{tool}"] = eq.Name
	}
	if eq, ok := equip[SlotArmor]; ok {
		vars["{armor}"] = eq.Name
	}

	var pool []string
	category := fmt.Sprintf("%s_%s", loc.Activity, result.Outcome)

	switch loc.Activity {
	case AdvActivityDungeon:
		switch result.Outcome {
		case AdvOutcomeDeath:
			if tierPool, ok := DungeonDeath[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeEmpty:
			if tierPool, ok := DungeonEmpty[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeSuccess:
			if tierPool, ok := DungeonSuccess[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeExceptional:
			if tierPool, ok := DungeonExceptional[loc.Tier]; ok {
				pool = tierPool
			}
		}

	case AdvActivityMining:
		switch result.Outcome {
		case AdvOutcomeDeath:
			if tierPool, ok := MiningDeath[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeCaveIn:
			pool = MiningCaveIn
		case AdvOutcomeEmpty:
			if tierPool, ok := MiningEmpty[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeSuccess, AdvOutcomeExceptional:
			if tierPool, ok := MiningSuccess[loc.Tier]; ok {
				pool = tierPool
			}
		}

	case AdvActivityForaging:
		switch result.Outcome {
		case AdvOutcomeDeath:
			pool = ForagingDeath
		case AdvOutcomeHornets:
			pool = ForagingHornets
		case AdvOutcomeBear:
			pool = ForagingBear
		case AdvOutcomeRiver:
			pool = ForagingRiver
		case AdvOutcomeSuccess, AdvOutcomeExceptional:
			if tierPool, ok := ForagingGoodHaul[loc.Tier]; ok {
				pool = tierPool
			}
		}

	case AdvActivityFishing:
		switch result.Outcome {
		case AdvOutcomeDeath:
			if tierPool, ok := FishingDeath[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeEmpty:
			if tierPool, ok := FishingEmpty[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeSuccess:
			if tierPool, ok := FishingSuccess[loc.Tier]; ok {
				pool = tierPool
			}
		case AdvOutcomeExceptional:
			if tierPool, ok := FishingExceptional[loc.Tier]; ok {
				pool = tierPool
			}
		}
	}

	if len(pool) == 0 {
		return fmt.Sprintf("You went to %s. Things happened.", loc.Name), ""
	}

	text, idx := advPickFlavor(pool, char.UserID, category)
	key := fmt.Sprintf("%s_%d", category, idx)
	return advSubstituteFlavor(text, vars), key
}

// ── Character Ensurance ──────────────────────────────────────────────────────

func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
	char, err := loadAdvCharacter(userID)
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			// Query error (e.g. missing column) — do NOT auto-create over existing data
			slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
			return nil, nil, fmt.Errorf("failed to load character: %w", err)
		}
		// Genuinely new player — auto-create
		displayName := p.DisplayName(userID)
		if err := createAdvCharacter(userID, displayName); err != nil {
			return nil, nil, err
		}
		char, err = loadAdvCharacter(userID)
		if err != nil {
			return nil, nil, err
		}

		// Register DM room
		p.registerDMRoom(userID)

		// Send onboarding
		text := renderAdvOnboardingDM(userID)
		p.SendDM(userID, text)
	}

	equip, err := loadAdvEquipment(userID)
	if err != nil {
		return char, nil, err
	}

	return char, equip, nil
}

// ── Double XP/Money Boost ───────────────────────────────────────────────────

const advBoostCacheKey = "adv_boost_active"

func advBoostActive() bool {
	return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
}

func advSetBoost(active bool) {
	v := "0"
	if active {
		v = "1"
	}
	db.CacheSet(advBoostCacheKey, v)
}

func advApplyBoost(result *AdvActionResult) {
	if !advBoostActive() {
		return
	}
	result.XPGained *= 2
	result.TotalLootValue = 0
	for i := range result.LootItems {
		result.LootItems[i].Value *= 2
		result.TotalLootValue += result.LootItems[i].Value
	}
}

func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
	char, _, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}
	return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
}

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

	char, err := loadAdvCharacter(ctx.Sender)
	if err != nil || char == nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}

	switch arg {
	case "lock":
		char.TreasuresLocked = true
		if err := saveAdvCharacter(char); err != nil {
			slog.Error("adventure: failed to save treasures_locked", "user", ctx.Sender, "err", err)
			return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
		}
		return p.SendDM(ctx.Sender, "🔒 **Treasures locked.** Drops at cap will be refused — no auto-swap. Unlock with `!adventure treasures unlock`.")
	case "unlock":
		char.TreasuresLocked = false
		if err := saveAdvCharacter(char); err != nil {
			slog.Error("adventure: failed to save treasures_locked", "user", ctx.Sender, "err", err)
			return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
		}
		return p.SendDM(ctx.Sender, "🔓 **Treasures unlocked.** Higher-tier drops will auto-swap your lowest replaceable treasure (with 10-min undo).")
	case "":
		treasures, err := advUserTreasures(ctx.Sender)
		if err != nil {
			return p.SendDM(ctx.Sender, "Failed to load your treasures.")
		}
		return p.SendDM(ctx.Sender, renderAdvTreasureList(treasures, char.TreasuresLocked))
	default:
		return p.SendDM(ctx.Sender, "Usage: `!adventure treasures` (list) · `!adventure treasures lock` · `!adventure treasures unlock`")
	}
}

func (p *AdventurePlugin) handleMasteryCmd(ctx MessageContext) error {
	if _, _, err := p.ensureCharacter(ctx.Sender); err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your character.")
	}
	equip, err := loadAdvEquipment(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Failed to load your equipment.")
	}
	return p.SendDM(ctx.Sender, renderAdvMasteryView(equip))
}

func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
	if !p.IsAdmin(ctx.Sender) {
		return nil
	}

	if advBoostActive() {
		advSetBoost(false)
		return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
	}
	advSetBoost(true)
	return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}
