package plugin

import (
	"fmt"
	"log/slog"
	"math"
	"strconv"
	"strings"
	"time"

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

// !expedition — Phase 12 E1c. The command surface for the multi-day
// expedition system landed in E1a/E1b. This file is presentation +
// dispatch only; data plumbing lives in dnd_expedition.go and supply
// math in dnd_expedition_supplies.go.
//
// Subcommands:
//
//	!expedition                          → help (or status if active)
//	!expedition list                     → zones available at player level
//	!expedition start <zone> [Ns] [Md]   → outfit & start; default 1 standard pack
//	!expedition status                   → daily briefing-style block (§12.1)
//	!expedition log                      → last 5 expedition log entries
//	!expedition abandon                  → end the expedition (no rewards)
//	!expedition help                     → help text
//
// Day cycle (06:00/21:00 cron) lands in E1d. Camp commands (!camp) land
// in E1e. !advance / !search / !rest / !extract are out-of-scope for E1.

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

	c, err := LoadDnDCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
	}
	if c == nil || c.PendingSetup {
		return p.SendDM(ctx.Sender,
			"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
	}

	args = strings.TrimSpace(args)
	sub, rest := splitFirstWord(args)
	switch strings.ToLower(sub) {
	case "":
		// If active, show status; otherwise help. A party member is on an
		// expedition they do not own, so ask the leader-or-member resolver.
		if exp, _, _ := activeExpeditionFor(ctx.Sender); exp != nil {
			return p.expeditionCmdStatus(ctx, "")
		}
		return p.SendDM(ctx.Sender, expeditionHelpText())
	case "help", "?":
		return p.SendDM(ctx.Sender, expeditionHelpText())
	case "list", "ls":
		return p.expeditionCmdList(ctx, c)
	case "start", "begin":
		return p.expeditionCmdStart(ctx, c, rest)
	case "go", "choose":
		// Context-sensitive: with an active expedition + numeric rest,
		// treat as "take fork path N" (forwards to zoneCmdGo so the
		// player doesn't have to remember the !zone seam). Otherwise
		// fall back to the historical alias for `!expedition start`.
		if rest != "" && isAllDigits(strings.TrimSpace(rest)) {
			exp, isLeader, _ := activeExpeditionFor(ctx.Sender)
			switch {
			case exp != nil && isLeader:
				return p.zoneCmdGo(ctx, rest)
			case exp != nil:
				// A member reaching a fork must not silently fall through to
				// `!expedition start` and be told their path number is an
				// unknown zone. The leader picks the way (C1); P6c enforces
				// that at the !zone seam too.
				return p.SendDM(ctx.Sender, "The party leader picks the path.")
			}
		}
		return p.expeditionCmdStart(ctx, c, rest)
	case "status", "info":
		return p.expeditionCmdStatus(ctx, rest)
	case "log", "history":
		return p.expeditionCmdLog(ctx)
	case "abandon", "quit":
		return p.expeditionCmdAbandon(ctx)
	case "invite":
		return p.expeditionCmdInvite(ctx, rest)
	case "accept", "join":
		return p.expeditionCmdAccept(ctx, c, rest)
	case "decline":
		return p.expeditionCmdDecline(ctx)
	case "party", "roster":
		return p.expeditionCmdParty(ctx)
	case "leave":
		return p.expeditionCmdLeave(ctx)
	case "hire":
		return p.expeditionCmdHire(ctx, rest)
	case "dismiss":
		return p.expeditionCmdDismiss(ctx)
	case "extract":
		return p.handleExtractCmd(ctx, "")
	case "resume":
		return p.handleResumeCmd(ctx, rest)
	case "map", "m":
		return p.handleExpeditionMapCmd(ctx, "")
	case "run", "explore", "advance":
		return p.expeditionCmdRun(ctx)
	default:
		return p.SendDM(ctx.Sender, expeditionHelpText())
	}
}

func expeditionHelpText() string {
	var b strings.Builder
	b.WriteString("**!expedition** — multi-day dungeon expeditions.\n\n")
	b.WriteString("**The shape:** pick a zone, pick a supply pack, watch it play out. ")
	b.WriteString("Autopilot walks rooms, pitches camp at night, and DMs you when something needs a decision (a fork, a boss, low HP).\n\n")
	b.WriteString("**Run an expedition:**\n")
	b.WriteString("`!expedition list` — zones available at your level\n")
	b.WriteString("`!expedition start <zone>` — prompts a loadout: `lean` / `balanced` / `heavy`\n")
	b.WriteString("`!expedition run` — start (or resume) the autopilot walk (alias `!explore`)\n")
	b.WriteString("`!expedition status` — day, rooms, supplies, recent events\n\n")
	b.WriteString("**Bring someone** _(Day 1, up to three of you)_:\n")
	b.WriteString("`!expedition invite @user` — they buy their own supplies into the party pool\n")
	b.WriteString("`!expedition accept` / `!expedition decline` — answer an invite\n")
	b.WriteString("`!expedition party` — who's with you\n")
	b.WriteString("`!expedition leave` — turn back for town (members only)\n")
	b.WriteString("`!expedition hire [class]` — pay Pete to fill the role you're missing\n")
	b.WriteString("`!expedition dismiss` — send Pete home\n\n")
	b.WriteString("**Mid-expedition:**\n")
	b.WriteString("`!extract` — bail safely (resumable for 7 days)\n")
	b.WriteString("`!resume [loadout]` — resume an extracted expedition\n")
	b.WriteString("`!expedition log` — last 5 log entries\n")
	b.WriteString("`!expedition abandon` — end without rewards\n")
	b.WriteString("`!map` — region/room ASCII map\n\n")
	b.WriteString("**Overrides** _(autopilot covers these — only reach for them if you want manual control)_:\n")
	b.WriteString("`!fight` — engage an Elite/Boss the autopilot paused at\n")
	b.WriteString("`!camp` — force a rest right now (see `!camp` for types)\n")
	b.WriteString("`!expedition start <zone> Ns Md` — raw pack counts instead of a preset")
	return b.String()
}

// ── list ────────────────────────────────────────────────────────────────────

func (p *AdventurePlugin) expeditionCmdList(ctx MessageContext, c *DnDCharacter) error {
	zones := zonesForLevel(c.Level)
	if len(zones) == 0 {
		return p.SendDM(ctx.Sender, "No zones available at your level. (This shouldn't happen — file a bug.)")
	}
	var b strings.Builder
	b.WriteString(fmt.Sprintf("**Expeditions available at L%d** (you can enter zones up to 2 tiers above your current tier):\n\n", c.Level))
	for i, z := range zones {
		suffix := ""
		if raidContentWarning(z.ID) != "" {
			suffix = "  _⚠ raid-shaped — solo runs not yet survivable_"
		}
		b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d–%d_  `!expedition start %s`%s\n",
			i+1, z.Display, int(z.Tier), z.LevelMin, z.LevelMax, z.ID, suffix))
		b.WriteString(fmt.Sprintf("    %s\n", z.Atmosphere))
	}
	// Post-game zones under the "Beyond the Map" divider (see zoneCmdList).
	if pg := postgameZones(); len(pg) > 0 {
		unlocked, reason := postgameUnlocked(ctx.Sender, c.Level)
		b.WriteString("\n— Beyond the Map —\n")
		if !unlocked {
			b.WriteString(fmt.Sprintf("_%s_\n", reason))
		}
		for j, z := range pg {
			if unlocked {
				b.WriteString(fmt.Sprintf("**%d.** %s — _T%d, L%d+_  `!expedition start %s`\n",
					len(zones)+j+1, z.Display, int(z.Tier), z.LevelMin, z.ID))
			} else {
				b.WriteString(fmt.Sprintf("🔒 %s — _T%d, L%d+_\n", z.Display, int(z.Tier), z.LevelMin))
			}
			b.WriteString(fmt.Sprintf("    %s\n", z.Atmosphere))
		}
	}
	if exp, isLeader, _ := activeExpeditionFor(ctx.Sender); exp != nil {
		zone := zoneOrFallback(exp.ZoneID)
		tail := "Use `!expedition status` or `!expedition abandon`."
		if !isLeader {
			tail = "Use `!expedition status` or `!expedition leave`."
		}
		b.WriteString(fmt.Sprintf("\n_⚠ Active expedition: **%s**, day %d. %s_",
			zone.Display, exp.CurrentDay, tail))
	}
	return p.SendDM(ctx.Sender, b.String())
}

// ── start ───────────────────────────────────────────────────────────────────

// parseSupplyArgs reads a token stream like "2s 1d" into a SupplyPurchase.
// Tokens are case-insensitive; suffix s|standard or d|deluxe. Bare integer
// is interpreted as standard packs. Default (empty) is one standard pack.
func parseSupplyArgs(rest string) (SupplyPurchase, error) {
	p := SupplyPurchase{}
	if strings.TrimSpace(rest) == "" {
		p.StandardPacks = 1
		return p, nil
	}
	for _, tok := range strings.Fields(rest) {
		t := strings.ToLower(strings.TrimSpace(tok))
		if t == "" {
			continue
		}
		var (
			numPart string
			suf     string
		)
		// Find first non-digit position.
		i := 0
		for i < len(t) && t[i] >= '0' && t[i] <= '9' {
			i++
		}
		numPart = t[:i]
		suf = t[i:]
		if numPart == "" {
			return p, fmt.Errorf("can't parse pack token %q", tok)
		}
		n, err := strconv.Atoi(numPart)
		if err != nil {
			return p, fmt.Errorf("can't parse pack count in %q", tok)
		}
		switch suf {
		case "", "s", "std", "standard":
			p.StandardPacks += n
		case "d", "dlx", "deluxe":
			p.DeluxePacks += n
		default:
			return p, fmt.Errorf("unknown pack suffix in %q (use s or d)", tok)
		}
	}
	return p, nil
}

// resolveLoadoutOrParse first tries a single-token preset (lean/balanced/
// heavy and short forms); on miss it falls back to raw `Ns Md` parsing.
// Tier is needed because preset purchase counts scale by zone tier.
func resolveLoadoutOrParse(tok string, tier ZoneTier) (SupplyPurchase, error) {
	trimmed := strings.TrimSpace(tok)
	if !strings.ContainsAny(trimmed, " \t") {
		if l, ok := parseLoadoutToken(trimmed); ok {
			return loadoutPurchase(tier, l), nil
		}
	}
	return parseSupplyArgs(tok)
}

// renderLoadoutPrompt formats the "pick your loadout" DM. The resume
// command and start command share it; cmdHint tells the player which
// command to type back with the chosen preset.
func renderLoadoutPrompt(zone ZoneDefinition, cmdHint string) string {
	var b strings.Builder
	b.WriteString(fmt.Sprintf("🎒 **Pick a loadout — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
	for _, l := range []SupplyLoadout{LoadoutLean, LoadoutBalanced, LoadoutHeavy} {
		pp := loadoutPurchase(zone.Tier, l)
		b.WriteString(fmt.Sprintf("  `%s` — %s — %.0f SU, %d coins — %s\n",
			loadoutName(l), packBreakdown(pp), pp.Total(), pp.Cost(), loadoutBlurb(l)))
	}
	b.WriteString(fmt.Sprintf("\nType `!%s %s` (or `lean` / `heavy`).\n", cmdHint, loadoutName(LoadoutBalanced)))
	b.WriteString("Advanced: raw pack counts like `2s 1d`.")
	return b.String()
}

func loadoutName(l SupplyLoadout) string {
	switch l {
	case LoadoutLean:
		return "lean"
	case LoadoutHeavy:
		return "heavy"
	}
	return "balanced"
}

func loadoutBlurb(l SupplyLoadout) string {
	switch l {
	case LoadoutLean:
		return "covers the intended run at calm burn; thin if things go sideways"
	case LoadoutHeavy:
		return "max cap; rides out harsh stretches and overruns"
	}
	return "recommended; absorbs a harsh patch or two"
}

func packBreakdown(p SupplyPurchase) string {
	switch {
	case p.StandardPacks > 0 && p.DeluxePacks > 0:
		return fmt.Sprintf("%d standard + %d deluxe", p.StandardPacks, p.DeluxePacks)
	case p.DeluxePacks > 0:
		return fmt.Sprintf("%d deluxe", p.DeluxePacks)
	default:
		return fmt.Sprintf("%d standard", p.StandardPacks)
	}
}

func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter, rest string) error {
	if rest == "" {
		return p.SendDM(ctx.Sender,
			"`!expedition start <zone> [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).")
	}
	if remaining := restingLockoutRemaining(c); remaining > 0 {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"🛌 You're still resting — %s remaining. Pack up after.",
			formatRespecDuration(remaining)))
	}
	zoneTok, packTok := splitFirstWord(rest)
	// N5/D1c: the campaign finale is reached here but is not a normal zone — it
	// runs a single auto-resolved boss fight with its own unlock gate, no
	// supplies, no walk. Intercept before the zone/loadout machinery.
	if strings.EqualFold(zoneTok, "epilogue") {
		return p.handleEpilogueEncounter(ctx)
	}
	available := availableZonesFor(ctx.Sender, c.Level)
	zoneID, ok := resolveZoneInput(zoneTok, available)
	if !ok {
		if reason := postgameLockReason(zoneTok, ctx.Sender, c.Level); reason != "" {
			return p.SendDM(ctx.Sender, reason)
		}
		return p.SendDM(ctx.Sender,
			"Unknown zone for your level. Try `!expedition list`.")
	}
	zoneForCaps, _ := getZone(zoneID)
	// D5-b: prompt for a preset loadout on empty args. Raw `Ns Md` syntax
	// still works as the advanced override.
	if strings.TrimSpace(packTok) == "" {
		return p.SendDM(ctx.Sender, renderLoadoutPrompt(zoneForCaps, "expedition start "+string(zoneID)))
	}
	purchase, err := resolveLoadoutOrParse(packTok, zoneForCaps.Tier)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't parse supply packs: "+err.Error())
	}
	if err := purchase.Validate(zoneForCaps.Tier); err != nil {
		return p.SendDM(ctx.Sender, "Invalid pack selection: "+err.Error())
	}
	// Reject if any expedition or zone run already active. This runs before the
	// price quote: a player who cannot leave doesn't need to hear what leaving
	// would have cost.
	//
	// The seat check spans `extracting` as well as `active` — a member of an
	// extracting party is still seated for the seven-day resume window, and
	// letting them outfit a rival expedition double-books them the moment their
	// leader types `!resume`.
	if seated, _ := seatedExpeditionFor(ctx.Sender); seated != nil {
		zone, _ := getZone(seated.ZoneID)
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You're riding a party expedition in **%s** (Day %d). `!expedition leave` before starting your own.",
			zone.Display, seated.CurrentDay))
	}
	if existing, _ := getActiveExpedition(ctx.Sender); existing != nil {
		zone, _ := getZone(existing.ZoneID)
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You're already on expedition in **%s** (Day %d). Finish it or `!expedition abandon` first.",
			zone.Display, existing.CurrentDay))
	}
	// A leader who extracted still holds their roster for the resume window, and
	// `!resume` only ever reaches the *newest* extracted row. Starting fresh on
	// top of one would orphan it: unreachable, un-reapable until the sweeper
	// catches it, with every member still seated and refused a run of their own.
	//
	// Only a row with a roster blocks. A solo extraction strands nobody, so
	// walking away from it stays a normal thing to do.
	if pending, _ := getResumableExpedition(ctx.Sender); pending != nil {
		switch {
		case extractionLapsed(pending, time.Now().UTC()):
			// Past the window — reap it here rather than make them wait an hour
			// for the sweeper, and let the new expedition proceed. Route through
			// the shared reap so the freed members hear about it, same as the
			// sweeper and `!expedition abandon` do.
			if err := p.reapLapsedExtraction(pending); err != nil {
				slog.Warn("expedition: reap lapsed on start", "expedition", pending.ID, "err", err)
			}
		default:
			// A roster still holds; block. On a roster-read error, assume it is
			// occupied and refuse — proceeding would orphan a party we could not
			// confirm was empty, the one outcome this guard exists to prevent. A
			// solo extraction (n == 1) strands nobody, so walking away is fine.
			n, err := partySize(pending.ID)
			if err != nil || n > 1 {
				zone, _ := getZone(pending.ZoneID)
				return p.SendDM(ctx.Sender, fmt.Sprintf(
					"You extracted from **%s** on Day %d and your party is still waiting on you. `!resume` to lead them back in, or `!expedition abandon` to let it go — until you do one or the other, none of them can start a run of their own.",
					zone.Display, pending.CurrentDay))
			}
		}
	}

	cost := float64(purchase.Cost())
	if p.euro == nil {
		return p.SendDM(ctx.Sender, "Coin system unavailable — try again later.")
	}
	if balance := p.euro.GetBalance(ctx.Sender); balance < cost {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"Not enough coins. Outfitting costs **%d** but you have **%.0f**.",
			int(cost), balance))
	}
	if existing, _ := getActiveZoneRun(ctx.Sender); existing != nil {
		zone, _ := getZone(existing.ZoneID)
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You have an active single-session zone run in **%s**. Finish or `!zone abandon` before starting an expedition.",
			zone.Display))
	}

	zone := zoneForCaps
	_, supplies, startLine, err := p.beginExpedition(ctx.Sender, c.Level, zone, purchase, "expedition outfitting")
	if err != nil {
		return p.SendDM(ctx.Sender, err.Error())
	}
	markActedToday(ctx.Sender)

	var b strings.Builder
	b.WriteString(fmt.Sprintf("🗺 **Expedition begins — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
	b.WriteString("_" + zone.Hook + "_\n\n")
	b.WriteString(fmt.Sprintf("**Outfitted:** %.0f SU (%d standard, %d deluxe) — %d coins\n",
		supplies.Max, purchase.StandardPacks, purchase.DeluxePacks, purchase.Cost()))
	b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — that's roughly **%d days** of provisions.\n\n",
		supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
	if startLine != "" {
		b.WriteString(startLine)
		b.WriteString("\n\n")
	}
	if w := raidContentWarning(zoneID); w != "" {
		b.WriteString(w)
		b.WriteString("\n\n")
	}
	b.WriteString("Use `!expedition status` for the daily briefing format. Day 1 begins now.")
	return p.SendDM(ctx.Sender, b.String())
}

// beginExpedition performs the non-interactive half of starting an expedition:
// supply freebies, the coin debit, persistence, the starting region's run, and
// the opening log entry. It refunds and tears down on every failure path, so a
// caller holding an error owes the player nothing.
//
// Callers own the balance check, the eligibility guards, and the player-facing
// messaging. This is the shared transaction under `!expedition start` and the
// boredom ticker (gogobee_boredom_plan.md §4); the returned errors are written
// as complete player-facing sentences so both callers can surface them as-is.
//
// It deliberately does NOT call markActedToday — an expedition the player did
// not ask for must not spend their daily action or count as them showing up.
func (p *AdventurePlugin) beginExpedition(uid id.UserID, charLevel int, zone ZoneDefinition, purchase SupplyPurchase, reason string) (*Expedition, ExpeditionSupplies, string, error) {
	if p.euro == nil {
		return nil, ExpeditionSupplies{}, "", fmt.Errorf("Coin system unavailable — try again later.")
	}
	cost := float64(purchase.Cost())

	// Holiday perk: a complimentary standard pack is added to the supplies
	// snapshot without inflating the coin cost. Bypasses the per-tier cap
	// on purpose — it's a freebie on top of whatever the player bought.
	suppliesPurchase := purchase
	if isHol, _ := isHolidayToday(); isHol {
		suppliesPurchase.StandardPacks++
	}
	if pk := activeOmen().SupplyFreebiePacks; pk > 0 { // N7/B3 the Omen
		suppliesPurchase.StandardPacks += pk
	}
	supplies := makeSupplies(zone.Tier, suppliesPurchase)

	// Debit coins; bail on debit failure (race / cap).
	if !p.euro.Debit(uid, cost, reason+": "+string(zone.ID)) {
		return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't debit outfitting cost (try again).")
	}

	exp, err := startExpedition(uid, zone.ID, "", supplies)
	if err != nil {
		// Refund on persistence failure.
		p.euro.Credit(uid, cost, "expedition outfitting refund")
		return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't start expedition: %s", err)
	}

	// R2 — auto-spawn the DungeonRun for the starting region. Per-room
	// harvest depends on this; the existing zone-run !advance/!retreat
	// commands now operate on this run while the expedition is active.
	if _, err := ensureRegionRun(exp, charLevel); err != nil {
		// Refund and tear the expedition row back down — without a
		// linked run, harvest and rooms can't function.
		_ = abandonExpedition(uid)
		p.euro.Credit(uid, cost, "expedition outfitting refund (run-spawn failed)")
		return nil, ExpeditionSupplies{}, "", fmt.Errorf("Couldn't outfit the first region: %s", err)
	}

	// Log the start with prewritten flavor.
	startLine := flavor.Pick(flavor.ExpeditionStart)
	_ = appendExpeditionLog(exp.ID, 1, "narrative", "expedition started", startLine)
	return exp, supplies, startLine, nil
}

// raidContentWarning returns a TwinBee-voiced heads-up for zones whose
// boss is tuned for a party rather than a solo adventurer. T5 zones
// (Dragon's Lair / Abyss Portal) have boss HP / damage curves that the
// solo combat path can't realistically clear — the J3 trace sweep at
// L12 showed 0% solo clears across all 10 classes. Until multiplayer
// expeditions ship, this is the surface that tells a player what
// they're walking into without nerfing the encounter for parties later.
func raidContentWarning(zoneID ZoneID) string {
	switch zoneID {
	case ZoneDragonsLair:
		return "⚠ A note before we commit. Infernax doesn't go down to one sword. I've watched better-prepared adventurers walk in here and not walk back out, and I haven't yet seen the lone exception. Bring friends when you can. For tonight — I'm with you anyway."
	case ZoneAbyssPortal:
		return "⚠ A note before we commit. Belaxath is the kind of enemy you bring a band to. Not one solo hero has put him down yet, and I'd rather you weren't the first to try. We can still go. I just want the record to show I said this."
	}
	return ""
}

func estimateDays(maxSU, dailyBurn float32) int {
	if dailyBurn <= 0 {
		return 0
	}
	return int(math.Floor(float64(maxSU / dailyBurn)))
}

// ── status ──────────────────────────────────────────────────────────────────

func (p *AdventurePlugin) expeditionCmdStatus(ctx MessageContext, args string) error {
	debug := false
	for _, tok := range strings.Fields(args) {
		switch strings.ToLower(tok) {
		case "--debug", "-d", "debug", "raw":
			debug = true
		}
	}
	return p.expeditionCmdStatusImpl(ctx, debug)
}

func (p *AdventurePlugin) expeditionCmdStatusImpl(ctx MessageContext, debug bool) error {
	exp, _, err := activeExpeditionFor(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
	}
	if exp == nil {
		return p.SendDM(ctx.Sender,
			"No active expedition. Use `!expedition list` then `!expedition start <zone>`.")
	}
	zone, _ := getZone(exp.ZoneID)
	c, _ := LoadDnDCharacter(ctx.Sender)

	target := expeditionTargetDays(zone.Tier)
	var b strings.Builder
	b.WriteString(fmt.Sprintf("🎭 **TwinBee — Status, Day %d / ~%d expected**\n\n", exp.CurrentDay, target))
	b.WriteString(fmt.Sprintf("📍 **Zone:** %s _(T%d)_\n", zone.Display, int(zone.Tier)))
	if r, ok := CurrentRegion(exp); ok {
		cleared := IsRegionCleared(exp, r.ID)
		marker := ""
		if cleared {
			marker = " ✓"
		}
		b.WriteString(fmt.Sprintf("🗺 **Region:** %s (%d/%d)%s\n",
			r.Name, r.Order, len(regionsForZone(exp.ZoneID)), marker))
	}
	if run, _, rerr := activeZoneRunFor(ctx.Sender); rerr == nil && run != nil && run.TotalRooms > 0 {
		b.WriteString(fmt.Sprintf("🚪 **Rooms:** %d / %d in this region\n",
			run.CurrentRoom+1, run.TotalRooms))
	}
	if c != nil {
		b.WriteString(fmt.Sprintf("❤️  **HP:** %d / %d\n", c.HPCurrent, c.HPMax))
	}
	// Default view is verb/outcome-led: days-left summary + threat label,
	// no raw SU / threat-out-of-100 / zone-stack / roll-modifier numbers.
	// Pass `--debug` to !expedition status for the engineering view.
	days := estimateDays(exp.Supplies.Current, currentBurn(exp))
	if debug {
		b.WriteString(fmt.Sprintf("🎒 **Supplies:** %.1f / %.1f SU  _(burn %.1f/day → ~%d days left)_\n",
			exp.Supplies.Current, exp.Supplies.Max, currentBurn(exp), days))
		b.WriteString(fmt.Sprintf("⏰ **Threat:** %d / 100 — %s\n",
			exp.ThreatLevel, threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
		if exp.TemporalStack != 0 {
			b.WriteString(fmt.Sprintf("🌡 **Zone stack:** %d\n", exp.TemporalStack))
		}
	} else {
		b.WriteString(fmt.Sprintf("🎒 **Supplies:** ~%d day%s left\n",
			days, plural(days)))
		b.WriteString(fmt.Sprintf("⏰ **Threat:** %s\n",
			threatThresholdLabel(exp.ThreatLevel, exp.SiegeMode)))
	}
	if exp.Camp != nil && exp.Camp.Active {
		b.WriteString(fmt.Sprintf("⛺ **Camp:** %s (room %d)\n", exp.Camp.Type, exp.Camp.RoomIndex+1))
	}
	state := supplyDepletion(exp.Supplies)
	if state != SupplyNormal {
		if debug {
			b.WriteString(fmt.Sprintf("⚠ **%s** — roll modifier %d\n",
				depletionLabel(state), supplyRollModifier(state)))
		} else {
			b.WriteString(fmt.Sprintf("⚠ **%s** — you're slower and clumsier than usual.\n",
				depletionLabel(state)))
		}
	}
	if entries, lerr := recentExpeditionLog(exp.ID, 3); lerr == nil && len(entries) > 0 {
		b.WriteString("\n**Recent:**\n")
		for _, e := range entries {
			line := e.Summary
			if line == "" {
				line = e.Flavor
			}
			if line == "" {
				continue
			}
			b.WriteString(fmt.Sprintf("  · _Day %d_ — %s\n", e.Day, line))
		}
	}
	b.WriteString(fmt.Sprintf("\nStarted: %s   Last activity: %s",
		exp.StartDate.Format("2006-01-02 15:04"),
		exp.LastActivity.Format("2006-01-02 15:04")))
	return p.SendDM(ctx.Sender, b.String())
}

func currentBurn(exp *Expedition) float32 {
	burn := exp.Supplies.DailyBurn
	if exp.SiegeMode {
		// §8.3: siege doubles supply burn outright, overriding HarshMod
		// (which can be < 2 at low tiers).
		mult := exp.Supplies.HarshMod
		if mult < 2 {
			mult = 2
		}
		return burn * mult
	}
	if exp.ThreatLevel > 60 {
		// Harsh conditions: §4.1.
		mult := exp.Supplies.HarshMod
		if mult <= 0 {
			mult = 1
		}
		burn *= mult
	}
	return burn
}

func threatThresholdLabel(level int, siege bool) string {
	if siege {
		return "Siege"
	}
	switch {
	case level >= 81:
		return "Siege"
	case level >= 61:
		return "Hostile"
	case level >= 41:
		return "Alert"
	case level >= 21:
		return "Stirring"
	default:
		return "Quiet"
	}
}

func depletionLabel(s SupplyDepletionState) string {
	switch s {
	case SupplyRationing:
		return "Rationing"
	case SupplySevereRationing:
		return "Severe rationing"
	case SupplyStarvation:
		return "Starvation"
	}
	return "Normal"
}

// ── log ─────────────────────────────────────────────────────────────────────

func (p *AdventurePlugin) expeditionCmdLog(ctx MessageContext) error {
	exp, _, err := activeExpeditionFor(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
	}
	if exp == nil {
		return p.SendDM(ctx.Sender, "No active expedition.")
	}
	entries, err := recentExpeditionLog(exp.ID, 5)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't read log: "+err.Error())
	}
	if len(entries) == 0 {
		return p.SendDM(ctx.Sender, "No log entries yet.")
	}
	var b strings.Builder
	b.WriteString(fmt.Sprintf("📜 **Expedition log — last %d entries**\n\n", len(entries)))
	for _, e := range entries {
		b.WriteString(fmt.Sprintf("**Day %d** — _%s_ (%s)\n",
			e.Day, e.Type, formatLogTimestamp(e.Timestamp)))
		if e.Summary != "" {
			b.WriteString("  " + e.Summary + "\n")
		}
		if e.Flavor != "" {
			b.WriteString("  _" + e.Flavor + "_\n")
		}
	}
	return p.SendDM(ctx.Sender, b.String())
}

func formatLogTimestamp(t time.Time) string {
	return t.Format("2006-01-02 15:04")
}

// ── abandon ─────────────────────────────────────────────────────────────────

func (p *AdventurePlugin) expeditionCmdAbandon(ctx MessageContext) error {
	exp, isLeader, err := activeExpeditionFor(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
	}
	if exp == nil {
		// An extracted expedition is still the owner's to close — it holds the
		// roster until the resume window lapses. Without this, a leader who
		// wanted out had to pay to `!resume` first just to abandon.
		if exp, err = getResumableExpedition(ctx.Sender); err != nil {
			return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
		}
		isLeader = exp != nil
	}
	if exp == nil {
		return p.SendDM(ctx.Sender, "No active expedition to abandon.")
	}
	if !isLeader {
		// Abandoning throws away everyone's day. A member leaves alone.
		return p.SendDM(ctx.Sender,
			"Only your party leader can abandon the expedition. `!expedition leave` to walk out alone.")
	}
	zone, _ := getZone(exp.ZoneID)
	extracted := exp.Status == ExpeditionStatusExtracting
	audience := expeditionAudience(exp) // read before abandonExpedition disbands the roster
	if err := abandonExpedition(ctx.Sender); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't abandon: "+err.Error())
	}
	markActedToday(ctx.Sender)
	_ = retireAllRegionRuns(exp)
	_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "expedition abandoned", "")
	// An extracted party is standing in town, not in the dungeon: their supplies
	// are already spent and their loot is already banked. Say the true thing.
	body := fmt.Sprintf(
		"Expedition in **%s** abandoned on Day %d. Supplies are forfeit. The dungeon remembers.",
		zone.Display, exp.CurrentDay)
	if extracted {
		body = fmt.Sprintf(
			"You let the expedition in **%s** go. Day %d is where it ends — loot, XP, and coins are kept. The dungeon remembers.",
			zone.Display, exp.CurrentDay)
	}
	// The roster is being disbanded out from under the members; they hear it from
	// their leader rather than discovering it the next time a command works again.
	for _, uid := range audience {
		if uid == ctx.Sender {
			continue
		}
		if err := p.SendDM(uid, fmt.Sprintf(
			"Your leader called off the expedition in **%s** on Day %d. You're free to start a run of your own.",
			zone.Display, exp.CurrentDay)); err != nil {
			slog.Warn("expedition: abandon DM failed", "user", uid, "expedition", exp.ID, "err", err)
		}
	}
	if err := p.SendDM(ctx.Sender, body); err != nil {
		return err
	}
	// Emergence seam: see maybeRollPetArrivalOnEmerge.
	p.maybeRollPetArrivalOnEmerge(ctx.Sender)
	return nil
}

// helper: ensure we don't shadow id.UserID import in test harness.
var _ id.UserID

// isAllDigits reports whether s is a non-empty string of ASCII digits.
// Used to route `!expedition go N` to the fork-choice handler when N is
// numeric; non-numeric rest (`!expedition go ironforge`) still falls
// through to expeditionCmdStart.
func isAllDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return true
}

// ── run (autopilot) ─────────────────────────────────────────────────────────

// autopilotRoomCap bounds a single `!expedition run` invocation. Real-time
// background ticking is the planned long-game (see chat 2026-05-14); this
// cap keeps the foreground walk from monopolising the bot for an
// unbounded stretch and gives the player a natural "press to continue"
// breath between long runs.
const autopilotRoomCap = 6

// autopilotLowHPPct stops the walk when current HP drops at or below this
// fraction of max. 0.30 = "you're hurt enough that the next bad room
// could end the run; pause, heal, or commit."
const autopilotLowHPPct = 0.30

// autopilotWalkResult bundles the staged narration plus structured
// metadata so foreground (streamFlow) and background (single DM, with
// progress-based DM suppression) callers can share the same walk loop.
type autopilotWalkResult struct {
	stream   []string
	finalMsg string
	rooms    int
	reason   stopReason
	// initErr — populated when the walk couldn't start (no expedition,
	// no run). Foreground surfaces it as a DM; background swallows it.
	initErr string
}

// expeditionCmdRun is the autopilot surface. It loops advanceOnce until a
// natural interrupt fires (fork, elite/boss doorway, death, complete) or
// an injected interrupt fires (low HP, low SU, room cap). Each iteration's
// staged narration is concatenated into a single 2–3s-paced stream so the
// player reads the full multi-room walk as one continuous beat. Trash
// combat already auto-resolves inside resolveCombatRoom; elite/boss
// doorways stop here so the player can choose !fight on their own terms.
func (p *AdventurePlugin) expeditionCmdRun(ctx MessageContext) error {
	// runAutopilotWalk resolves through getActiveExpedition, so a member would
	// be told they are not on an expedition at all. Same refusal `!zone advance`
	// gives — this is the same walk, reached by its other name.
	if isPartyMember(ctx.Sender) {
		return p.SendDM(ctx.Sender,
			"Your party leader sets the pace. `!map` to see where you're standing.")
	}
	r := p.runAutopilotWalk(ctx, autopilotRoomCap, false, false)
	if r.initErr != "" {
		return p.SendDM(ctx.Sender, r.initErr)
	}
	// Emergence seam: a natural run-complete (boss down / dead-end node)
	// surfaces the player alive just like an extract or abandon — roll pet
	// arrival here too. The roll lives in the real callers, not in
	// runAutopilotWalk, so the sim path (which calls the walk directly)
	// never fires arrival DMs. See maybeRollPetArrivalOnEmerge. Defer it
	// behind the paced stream so the "animal in your house" DM lands after
	// the "Run complete" beat, not before it.
	var after func()
	if r.reason == stopComplete {
		uid := ctx.Sender
		after = func() { p.maybeRollPetArrivalOnEmerge(uid) }
	}
	return p.streamFlowThen(ctx.Sender, r.stream, r.finalMsg, after)
}

// runAutopilotWalk runs the autopilot loop up to maxRooms times and
// returns a bundle the caller can either streamFlow (foreground) or
// post as a single DM (background ticker). Pure side effects on the
// run graph / harvest tally / supplies / threat — same as before, just
// no streamFlow here. compact==true switches the underlying combat
// narration into terse mode and auto-resolves elite (not boss) rooms.
// forkAutoPickTimeout — how long a background fork may sit unanswered
// before the autopilot picks an available route itself. Short enough that
// the expedition keeps moving rather than idling out to the 24h stale-run
// reaper; long enough that a player away for the evening still gets first
// say on a genuine fork.
const forkAutoPickTimeout = 8 * time.Hour

// autoPickStaleFork commits the first unlocked option of a stale background
// fork, advancing the run to that node exactly as `!zone go <n>` would
// (advanceZoneRunNode + region-transition hook). Returns false — no pick —
// when every option is locked, so the caller re-emits the prompt and the
// run idles on toward the reaper. The choice is logged as a narrative entry
// so the end-of-day digest can surface the decision the player missed.
func (p *AdventurePlugin) autoPickStaleFork(exp *Expedition, run *DungeonRun, pf *pendingFork) bool {
	var chosen *pendingChoice
	for i := range pf.Options {
		if pf.Options[i].Unlocked {
			chosen = &pf.Options[i]
			break
		}
	}
	if chosen == nil {
		return false // nothing unlocked — leave it for the player / reaper
	}
	if _, err := advanceZoneRunNode(run.RunID, chosen.To); err != nil {
		slog.Warn("expedition: auto-pick stale fork",
			"user", run.UserID, "run", run.RunID, "err", err)
		return false
	}
	g, _ := loadZoneGraph(run.ZoneID)
	fireGraphRegionTransition(run.UserID, g.Nodes[run.CurrentNode], g.Nodes[chosen.To])
	if exp != nil {
		_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative",
			fmt.Sprintf("autopilot took an available path after %dh idle at the fork: %s",
				int(forkAutoPickTimeout.Hours()), chosen.Label), "")
	}
	return true
}

func (p *AdventurePlugin) runAutopilotWalk(ctx MessageContext, maxRooms int, compact, inlineBossCombat bool) autopilotWalkResult {
	exp, err := getActiveExpedition(ctx.Sender)
	if err != nil {
		return autopilotWalkResult{initErr: "Couldn't read expedition state: " + err.Error()}
	}
	if exp == nil {
		return autopilotWalkResult{initErr: "You're not on an expedition. `!expedition list` to pick one."}
	}
	if exp.RunID == "" {
		return autopilotWalkResult{initErr: "No active region run. Try `!region` to refresh."}
	}

	// Already standing at a pending fork. The autopilot can't pick for the
	// player, so a fresh fork re-emits the prompt with rooms=0 (background
	// DM suppression keeps quiet; the rooms counter doesn't tick on a no-op
	// walk). But a background fork left unanswered past forkAutoPickTimeout
	// would otherwise idle all the way to the 24h stale-run reaper and end
	// the expedition — so once it's stale, auto-pick the first available
	// (unlocked) route and keep walking instead of stalling out.
	if run, rerr := getActiveZoneRun(ctx.Sender); rerr == nil && run != nil {
		if pf, derr := decodePendingFork(run.NodeChoices); derr == nil && pf != nil {
			picked := compact &&
				time.Since(run.LastActionAt) > forkAutoPickTimeout &&
				p.autoPickStaleFork(exp, run, pf)
			if !picked {
				zone := zoneOrFallback(run.ZoneID)
				return autopilotWalkResult{
					finalMsg: renderForkPrompt(zone, *pf),
					rooms:    0,
					reason:   stopFork,
				}
			}
			// Auto-picked: the run now points at the chosen node. Fall
			// through into the walk loop so this same tick advances it.
		}
	}

	var stream []string
	var finalMsg string
	rooms := 0
	reason := stopOK // sticky: last loop-exit reason

	// Phase 2 — walk-wide auto-harvest tally. Per-room footers go into the
	// stream as we go; the cumulative haul renders on the final block.
	walkYields := map[string]int{}
	walkNames := map[string]string{}

	for i := 0; i < maxRooms; i++ {
		// Pre-iteration interrupts: low HP / low SU. Skip on the first
		// iteration so the player always gets at least one room out of
		// `!expedition run` even if they limped in — autopilot stops on
		// thresholds *between* rooms, not before the first one.
		if i > 0 {
			if msg, stop := autopilotPreflight(ctx.Sender, exp); stop {
				finalMsg = msg
				reason = stopPreflight
				break
			}
		}

		res, aerr := p.advanceOnceWithOpts(ctx, compact, inlineBossCombat)
		if aerr != nil {
			return autopilotWalkResult{initErr: aerr.Error()}
		}

		// Roll this step's beats into the running stream.
		stream = append(stream, res.preStream...)
		if res.intro != "" {
			stream = append(stream, res.intro)
		}
		stream = append(stream, res.phases...)

		// Doorway/blocked stops fire *before* the current room actually
		// resolved — those don't count as a walked room. Everything else
		// (OK, fork after a clear, ended after combat, complete) does.
		// stopBossSafety also fires at the doorway (compact autopilot
		// bailed before engaging), so it doesn't count either.
		if res.reason != stopBlocked && res.reason != stopElite && res.reason != stopBoss && res.reason != stopBossSafety {
			rooms++
		}

		// Multi-region auto-advance: a mid-zone region clear completes the
		// region's run (stopComplete) but leaves the wrapping expedition
		// active. Rather than dead-stopping the walk at every region
		// boundary, cross into the next region — burning the transit day +
		// supplies exactly like manual `!region travel` — and keep walking
		// within the remaining room budget. A full zone clear instead flips
		// the expedition to 'complete' (getActiveExpedition → nil) and falls
		// through to the normal stop below.
		if res.reason == stopComplete {
			if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil &&
				IsMultiRegionZone(fresh.ZoneID) {
				if cur, ok := CurrentRegion(fresh); ok {
					if next, ok := nextRegion(fresh.ZoneID, cur.ID); ok {
						// A region crossing burns a transit day + supplies and
						// draws unprotected wandering damage. On the background
						// walk, don't cross while the player is weak — preflight
						// HP/SU and hand the crossing back to a foreground
						// `!region travel` / `!expedition run` if either is low.
						if compact {
							if msg, stop := autopilotPreflight(ctx.Sender, fresh); stop {
								finalMsg = res.final + "\n\n" + msg
								reason = stopPreflight
								break
							}
						}
						stream = append(stream, res.final)
						transit, terr := p.advanceToNextRegion(ctx.Sender, fresh, cur, next)
						if terr != nil {
							finalMsg = res.final + "\n\n⏸ **Autopilot paused — region transit failed.** `!region travel` to cross over manually."
							reason = stopComplete
							break
						}
						stream = append(stream, transit)
						exp = fresh
						continue
					}
				}
			}
		}

		if res.reason != stopOK {
			footer := autopilotFooter(res.reason, rooms)
			if footer != "" {
				finalMsg = res.final + "\n\n" + footer
			} else {
				finalMsg = res.final
			}
			reason = res.reason
			break
		}

		// Walked into the next room. Push this step's "✓ cleared / next
		// room" block as a phase so the next iteration's intro lands
		// after it, then loop. Refresh exp for the next preflight read
		// (HP/SU may have moved during combat).
		stream = append(stream, res.final)
		if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
			exp = fresh
		}

		// Arrived at an Elite/Boss doorway. Foreground stops here so the
		// player can decide; the "Room X/Y — Boss. !fight when ready."
		// line in res.final already tells them what to do.
		//
		// In compact mode (background autopilot, long-expedition D2/D3)
		// we let the next iteration run because the gate will auto-
		// resolve the encounter inline — elite always, boss when the
		// safety check passes (otherwise the gate returns stopBossSafety
		// and the autorun ticker pitches a rest camp).
		if !compact && (res.nextRoomType == RoomBoss || res.nextRoomType == RoomElite) {
			r := stopBoss
			if res.nextRoomType == RoomElite {
				r = stopElite
			}
			finalMsg = autopilotFooter(r, rooms)
			reason = r
			break
		}

		// Phase 2 — auto-harvest the room we just walked into.
		// advanceOnceWithOpts now runs the pass inline and surfaces the
		// result via res.harvest + res.harvestFooter. The "✓ cleared /
		// next room" line in res.final already has the per-room footer
		// appended (and the combat-interrupt narration if one fired);
		// we still need to aggregate into walkYields/walkNames and to
		// split out the harvest-combat path so the walk-end footer/tally
		// rendering matches the pre-H2 behavior.
		hr := res.harvest
		for k, v := range hr.Summary.Yields {
			walkYields[k] += v
			walkNames[k] = hr.Summary.Names[k]
		}
		if hr.CombatNarr != "" {
			// A harvest interrupt fired combat. res.final already
			// includes the ✓-cleared block, the harvest footer, and the
			// combat narration; just attach the walk footer/tally and
			// stop.
			r := stopHarvestCombat
			if hr.PlayerEnded {
				r = stopEnded
			}
			footer := autopilotFooter(r, rooms)
			finalMsg = res.final
			if footer != "" {
				finalMsg += "\n\n" + footer
			}
			if tally := renderWalkTally(walkYields, walkNames); tally != "" && !hr.PlayerEnded {
				finalMsg += "\n\n" + tally
			}
			// Drop the just-pushed res.final from the stream so the
			// combined finalMsg lands as the closer, not as a duplicate
			// phase entry.
			if len(stream) > 0 && stream[len(stream)-1] == res.final {
				stream = stream[:len(stream)-1]
			}
			reason = r
			break
		}
		// Refresh exp once more — auto-harvest may have bumped threat
		// via noise interrupts.
		if fresh, ferr := getActiveExpedition(ctx.Sender); ferr == nil && fresh != nil {
			exp = fresh
		}
	}

	if finalMsg == "" {
		// Hit the room cap without a hard interrupt — synthesize a
		// "press to continue" footer so the player knows autopilot
		// stopped on its own clock, not because something needs them.
		finalMsg = autopilotFooter(stopOK, rooms)
	}
	// Walk-wide haul tally — appended to whatever the final block is,
	// unless a combat-interrupt branch already added it (death suppresses
	// it; harvest-combat survival adds it inline).
	if tally := renderWalkTally(walkYields, walkNames); tally != "" &&
		!strings.Contains(finalMsg, "Walk haul:") {
		finalMsg += "\n\n" + tally
	}
	return autopilotWalkResult{
		stream:   stream,
		finalMsg: finalMsg,
		rooms:    rooms,
		reason:   reason,
	}
}

// autopilotPreflight checks the threshold-based interrupts that fire
// between rooms. Returns (footer, true) when autopilot should stop.
func autopilotPreflight(userID id.UserID, exp *Expedition) (string, bool) {
	cur, max := dndHPSnapshot(userID)
	if max > 0 && float64(cur) <= float64(max)*autopilotLowHPPct {
		return fmt.Sprintf(
			"⏸ **Autopilot paused — HP low** (%d/%d). `!camp` to rest, `!cast` healing, or `!expedition run` to push on.",
			cur, max), true
	}
	if exp.Supplies.DailyBurn > 0 && exp.Supplies.Current < exp.Supplies.DailyBurn {
		return fmt.Sprintf(
			"⏸ **Autopilot paused — supplies low** (%.1f / %.1f SU, under one day). `!extract` to bail or `!expedition run` to push on.",
			exp.Supplies.Current, exp.Supplies.DailyBurn), true
	}
	return "", false
}

// autopilotFooter renders the closing line for an autopilot walk. The
// rooms-walked tally is informational; reason controls the verb and the
// suggested next step.
func autopilotFooter(reason stopReason, rooms int) string {
	roomsStr := "1 room"
	if rooms != 1 {
		roomsStr = fmt.Sprintf("%d rooms", rooms)
	}
	switch reason {
	case stopFork:
		return fmt.Sprintf("⏸ **Autopilot paused at a fork** (after %s). `!expedition go <n>` to choose; auto-walk resumes automatically.", roomsStr)
	case stopElite:
		return fmt.Sprintf("⏸ **Autopilot paused — elite ahead** (after %s). `!fight` when ready, then `!expedition run` to continue.", roomsStr)
	case stopBoss:
		return fmt.Sprintf("⏸ **Autopilot paused — boss ahead** (after %s). `!fight` when ready.", roomsStr)
	case stopBossSafety:
		return "" // res.final already carries the held-back-from-boss line
	case stopEnded:
		return "" // death narration is the final; no footer
	case stopComplete:
		return "" // run-complete block is the final; no footer
	case stopBlocked:
		return "" // "finish your fight first" is the final; no footer
	case stopHarvestCombat:
		return fmt.Sprintf("⏸ **Autopilot paused — interrupted while gathering** (after %s). Reassess and `!expedition run` to continue.", roomsStr)
	default: // stopOK — hit the room cap
		return fmt.Sprintf("⏸ **Autopilot stretch complete** (%s). `!expedition run` to keep walking, or `!resources` / `!camp` first.", roomsStr)
	}
}
