package plugin

import (
	"fmt"
	"log/slog"
	"sort"
	"strings"
	"time"

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

// Bored adventurers (gogobee_boredom_plan.md).
//
// A player who stops tending their adventurer doesn't stop having one. After
// boredomIdleThreshold with no action against Adventure, the character gets
// restless and leaves on an expedition by itself: the easiest zone its level
// band allows, the cheapest supplies it can afford, and whatever gear was
// already on the rack.
//
// Everything downstream of the start is already autonomous — the autopilot
// (expedition_autorun.go) walks rooms, drives elite and boss fights on the real
// turn engine, camps, harvests, rolls days over, and auto-picks forks after 8h.
// The only thing that ever needed a human was `!expedition start`, so that is
// the only thing this file does.
//
// It never buys or equips gear, and that is the entire mechanic: a neglected
// adventurer grinds half-starved runs on rusting equipment and comes home taxed.

const (
	// boredomIdleThreshold — silence, measured against the player's last
	// action *against Adventure*, before the adventurer leaves on its own.
	boredomIdleThreshold = 24 * time.Hour

	// boredomCooldown — minimum gap between boredom starts for one player.
	// Also the re-trigger cadence: when a bored expedition ends, another
	// boredomCooldown of silence starts the next one. An abandoned character
	// keeps going until it runs out of coins for supplies.
	boredomCooldown = 24 * time.Hour

	// boredomTickInterval — how often the ticker wakes. The cooldown gate is
	// what actually paces starts; the tick only has to be frequent enough to
	// enforce it cleanly.
	boredomTickInterval = 30 * time.Minute
)

// ── The idle clock ───────────────────────────────────────────────────────────

// markPlayerAction stamps player_meta.last_player_action_at — the clock the
// boredom ticker reads.
//
// It is a direct UPDATE and deliberately does NOT route through
// saveAdvCharacter. Every other candidate timestamp in the schema is unusable
// here for the same reason (gogobee_boredom_plan.md §1):
//
//   - last_active_at is auto-bumped inside saveAdvCharacter, so the autopilot's
//     own combat writes would refresh a bored character's idle clock and it
//     would never qualify again.
//   - user_stats.updated_at is bumped by any Matrix message in any room. That's
//     chat presence, not a game action — a player can talk all day without
//     touching their adventurer, and the adventurer should still get bored.
//   - loadAdvDailyActivity is unified across dnd_expedition_log, so the
//     autopilot's own walk entries read back as player activity.
//
// Any future non-Matrix entry point (web, API, Pete) should call this too — the
// clock tracks actions against Adventure, not Matrix traffic.
func markPlayerAction(userID id.UserID) {
	db.Exec("boredom: mark player action",
		`UPDATE player_meta SET last_player_action_at = ? WHERE user_id = ?`,
		time.Now().UTC(), string(userID))
}

// advActionCommands — every `!command` routed by AdventurePlugin.OnMessage.
//
// This is NOT Commands() (adventure.go): that registers 28 names for the help
// surface and is missing expedition, zone, fight, camp, extract, resume and
// more. Routing is a 47-branch if-chain, and the boredom clock has to see all
// of it, so the set is spelled out here. TestAdvActionCommandsCoverDispatch
// greps the IsCommand literals out of adventure.go and fails if one is missing,
// so a new command can't silently fall out of the clock.
var advActionCommands = map[string]bool{
	"abilities": true, "adv": true, "adventure": true, "arena": true,
	"arm": true, "attack": true, "bail": true, "camp": true,
	"cast": true, "check": true, "commune": true, "consume": true,
	"craft": true, "duel": true, "essence": true, "expedition": true,
	"explore": true, "extract": true, "fight": true, "fish": true,
	"flee": true, "forage": true, "give": true, "graveyard": true,
	"hospital": true, "level": true, "lore": true, "map": true,
	"mine": true, "mischief": true, "news": true, "prepare": true, "region": true,
	"resources": true, "respec": true, "rest": true, "resume": true,
	"revisit": true, "rivals": true, "roll": true, "scavenge": true,
	"sell": true, "setup": true, "sheet": true, "spells": true,
	"stats": true, "subclass": true, "thom": true, "threat": true,
	"town": true, "zone": true,
}

// isPlayerAdventureAction reports whether this message is the player doing
// something to their adventurer — an Adventure command, or a reply to a prompt
// we're holding open for them (shop, blacksmith, pet, treasure). Both count:
// answering "2" to a shop menu is as much an action as typing `!shop`.
//
// This runs on every message the bot sees, in every room, so the command test
// is a single tokenise-and-look-up rather than 50 passes of IsCommand over the
// body. It matches util.IsCommand's rule exactly: trimmed, lowercased, the
// command is the whole body or is followed by a space.
//
// The pending-prompt check honours ExpiresAt. Nothing sweeps p.pending, so an
// abandoned prompt (offered, never answered) sits there forever — without the
// expiry test, every idle remark that player ever makes in any room would read
// as tending their adventurer, and the clock would never run down.
func (p *AdventurePlugin) isPlayerAdventureAction(ctx MessageContext) bool {
	body := strings.ToLower(strings.TrimSpace(ctx.Body))
	if strings.HasPrefix(body, p.Prefix) {
		word := strings.TrimPrefix(body, p.Prefix)
		if i := strings.IndexByte(word, ' '); i >= 0 {
			word = word[:i]
		}
		if advActionCommands[word] {
			return true
		}
	}
	if v, ok := p.pending.Load(string(ctx.Sender)); ok {
		pi, isPrompt := v.(*advPendingInteraction)
		// A zero ExpiresAt is a prompt with no deadline, not an expired one.
		if isPrompt && (pi.ExpiresAt.IsZero() || time.Now().Before(pi.ExpiresAt)) {
			return true
		}
	}
	return false
}

// ── The ticker ───────────────────────────────────────────────────────────────

func (p *AdventurePlugin) expeditionBoredomTicker() {
	ticker := time.NewTicker(boredomTickInterval)
	defer ticker.Stop()
	for {
		select {
		case <-p.stopCh:
			return
		case <-ticker.C:
			p.fireExpeditionBoredom(time.Now().UTC())
		}
	}
}

// fireExpeditionBoredom sends every eligible idle adventurer into a dungeon.
func (p *AdventurePlugin) fireExpeditionBoredom(now time.Time) {
	// Same politeness gate as the ambient ticker: don't drop a departure DM on
	// top of the 06:00 briefing or 21:00 recap.
	if inAmbientQuietWindow(now) {
		return
	}
	uids, err := loadBoredomCandidates(now)
	if err != nil {
		slog.Error("boredom: load candidates", "err", err)
		return
	}
	for _, uid := range uids {
		if err := p.tryBoredomStart(uid, now); err != nil {
			slog.Warn("boredom: start failed", "user", uid, "err", err)
		}
	}
}

// loadBoredomCandidates returns players whose last action against Adventure is
// older than boredomIdleThreshold and who haven't been sent out inside the
// cooldown.
//
// COALESCE onto created_at so a character that was made and then never played
// still qualifies — but not on its very first tick, since created_at is the
// clock until they touch anything.
func loadBoredomCandidates(now time.Time) ([]id.UserID, error) {
	idleCutoff := now.Add(-boredomIdleThreshold)
	coolCutoff := now.Add(-boredomCooldown)
	rows, err := db.Get().Query(`
		SELECT user_id
		  FROM player_meta
		 WHERE alive = 1
		   AND COALESCE(last_player_action_at, created_at) IS NOT NULL
		   AND COALESCE(last_player_action_at, created_at) < ?
		   AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
		idleCutoff, coolCutoff)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []id.UserID
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return nil, err
		}
		out = append(out, id.UserID(s))
	}
	return out, rows.Err()
}

// tryBoredomStart runs the eligibility guards for one player and, if they pass,
// outfits and launches the expedition.
func (p *AdventurePlugin) tryBoredomStart(uid id.UserID, now time.Time) error {
	// The foreground commands mutate the same rows under this lock; a boredom
	// start racing `!expedition start` would double-book the character.
	userMu := p.advUserLock(uid)
	userMu.Lock()
	defer userMu.Unlock()

	c, err := LoadDnDCharacter(uid)
	if err != nil {
		return err
	}
	if c == nil || c.PendingSetup {
		return nil // never finished character creation — nothing to get bored
	}

	// Structural blockers. Every one of these is a state the player has to
	// resolve themselves; the adventurer can't just walk out of it. Mirrors the
	// guard set in expeditionCmdStart.
	if restingLockoutRemaining(c) > 0 {
		return nil
	}
	if hasActiveCombatSession(uid) {
		return nil
	}
	if seated, _ := seatedExpeditionFor(uid); seated != nil {
		return nil // riding someone else's party
	}
	if active, _ := getActiveExpedition(uid); active != nil {
		return nil
	}
	if pending, _ := getResumableExpedition(uid); pending != nil {
		return nil // extracted, holding a roster for `!resume` — theirs to settle
	}
	if run, _ := getActiveZoneRun(uid); run != nil {
		return nil
	}

	zone, ok := pickBoredomZone(uid, c.Level)
	if !ok {
		return nil // no zone this character's level band can be sent to
	}

	// Claim the cooldown BEFORE spending anything. If the start then fails, or
	// the process dies mid-flight, we wait out the cooldown instead of looping
	// on a broken character every 30 minutes.
	res, err := db.Get().Exec(`
		UPDATE player_meta
		   SET last_boredom_at = ?
		 WHERE user_id = ?
		   AND (last_boredom_at IS NULL OR last_boredom_at < ?)`,
		now, string(uid), now.Add(-boredomCooldown))
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return nil // another tick claimed it
	}

	// Cheapest pack that exists. A zero-supply start is impossible —
	// SupplyPurchase.Validate rejects it — so a bored run always costs coins,
	// and a broke adventurer simply doesn't go. That's the floor that stops an
	// abandoned character looping forever.
	purchase := loadoutPurchase(zone.Tier, LoadoutLean)
	cost := float64(purchase.Cost())
	if p.euro == nil {
		return nil
	}
	if balance := p.euro.GetBalance(uid); balance < cost {
		return p.SendDM(uid, fmt.Sprintf(
			"🪙 I got as far as the supply counter. Outfitting for **%s** runs **%d** coins and we have **%.0f**. So I've put the pack back and come home. Nothing to be done about it from where I'm standing.",
			zone.Display, int(cost), balance))
	}

	exp, supplies, _, err := p.beginExpedition(uid, c.Level, zone, purchase, "expedition outfitting (restless)")
	if err != nil {
		return err
	}

	// Mark it as self-started. The idle reaper reads this so an expedition the
	// player never asked for can't hold their streak or spare them the shame DM
	// (gogobee_boredom_plan.md §6).
	db.Exec("boredom: flag expedition",
		`UPDATE dnd_expedition SET boredom = 1 WHERE expedition_id = ?`, exp.ID)

	slog.Info("boredom: adventurer left on its own",
		"user", uid, "zone", zone.ID, "level", c.Level, "cost", int(cost))
	emitBoredomDeparture(uid, zone, c.Level)
	return p.SendDM(uid, renderBoredomDepartureDM(zone, supplies, purchase))
}

// ── Reading the idle clock ───────────────────────────────────────────────────

// playerIsIdle reports whether the player has gone boredomIdleThreshold without
// an action against Adventure. A player with no recorded action at all (never
// played, or predates the column) counts as idle.
// The COALESCE is done in Go, not SQL, on purpose. modernc.org/sqlite rebuilds
// a time.Time from the column's declared type, and COALESCE() erases it — the
// value comes back a string and the Scan fails. Selecting the two declared
// DATETIME columns and folding them here keeps the affinity. This function
// fails open (an unreadable row reads as idle), so a broken scan here would
// declare every player idle and march the whole server into a dungeon at once.
func playerIsIdle(userID id.UserID, now time.Time) bool {
	var lastAction, created *time.Time
	err := db.Get().QueryRow(
		`SELECT last_player_action_at, created_at FROM player_meta WHERE user_id = ?`,
		string(userID)).Scan(&lastAction, &created)
	if err != nil {
		return true
	}
	last := lastAction
	if last == nil {
		last = created // never acted — the clock runs from when they were made
	}
	if last == nil {
		return true
	}
	return last.Before(now.Add(-boredomIdleThreshold))
}

// isBoredomDriven reports that this character is running on its own and its
// player has not come back: the last expedition they set out on was one the
// adventurer started for itself, and they still haven't touched Adventure.
//
// This is the gate the idle reaper uses. It's deliberately narrower than "is
// the player idle": an expedition the player *did* start still holds their
// streak while the autopilot walks it, exactly as it always has. Only an
// expedition nobody asked for loses that protection.
//
// Reading the most recent expedition rather than the active one keeps it honest
// for the night after a bored run ends — the logs it left behind would
// otherwise read as player activity for another day.
func isBoredomDriven(userID id.UserID, now time.Time) bool {
	if !playerIsIdle(userID, now) {
		return false // they came back; nothing to reap
	}
	var boredom bool
	err := db.Get().QueryRow(`
		SELECT boredom
		  FROM dnd_expedition
		 WHERE user_id = ?
		 ORDER BY start_date DESC
		 LIMIT 1`, string(userID)).Scan(&boredom)
	if err != nil {
		return false // no expedition history, or unreadable — don't reap on a guess
	}
	return boredom
}

// ── Zone selection ───────────────────────────────────────────────────────────

// pickBoredomZone chooses where a restless adventurer goes.
//
// It uses the LevelMin/LevelMax bands on ZoneDefinition rather than
// zonesForLevel, which filters on tier alone and ignores those fields — which
// is why a level-1 player can currently walk into a T3 zone. A bored adventurer
// should neither farm trivial content nor wander somewhere absurd, so the band
// is the right gate.
//
// Order of preference:
//  1. Zones whose level band contains the character.
//  2. Among those, the non-raid ones (raidContentWarning) — the party-tuned T5
//     bosses have a 0% solo clear rate, so they're avoided while an alternative
//     exists.
//  3. Among those, the lowest tier: the "easiest at-level" rule.
//  4. Ties break on least-recently-visited, so a bored adventurer rotates zones
//     instead of grinding one forever.
//
// If step 2 leaves nothing, raid zones are allowed back in. That only bites at
// L13+, where dragons_lair and abyss_portal are the only zones in band and both
// are raids. Those runs cannot be won solo — they wall at the boss-safety gate,
// starve, and force-extract with the haul tax. That is the intended fate of an
// abandoned endgame character, and it drains the coins that fund the next one.
func pickBoredomZone(uid id.UserID, level int) (ZoneDefinition, bool) {
	var inBand, nonRaid []ZoneDefinition
	for _, z := range allZones() {
		// Tier 6 post-game zones are opt-in endgame — a bored character never
		// wanders into one on autopilot, even once unlocked. This picker walks
		// allZones() directly (not zonesForLevel), so exclude T6 explicitly.
		if z.Tier >= ZoneTierMythic {
			continue
		}
		if level < z.LevelMin || level > z.LevelMax {
			continue
		}
		inBand = append(inBand, z)
		if raidContentWarning(z.ID) == "" {
			nonRaid = append(nonRaid, z)
		}
	}
	pool := nonRaid
	if len(pool) == 0 {
		pool = inBand
	}
	if len(pool) == 0 {
		return ZoneDefinition{}, false
	}

	lastVisit, err := lastExpeditionByZone(uid)
	if err != nil {
		slog.Warn("boredom: zone history unavailable, falling back to tier order", "user", uid, "err", err)
		lastVisit = map[ZoneID]time.Time{}
	}
	sort.SliceStable(pool, func(i, j int) bool {
		if pool[i].Tier != pool[j].Tier {
			return pool[i].Tier < pool[j].Tier
		}
		return lastVisit[pool[i].ID].Before(lastVisit[pool[j].ID])
	})
	return pool[0], true
}

// lastExpeditionByZone returns when this player last set out for each zone.
// Zones they've never visited are absent, so they sort first (zero time).
//
// No MAX(start_date)/GROUP BY: the aggregate erases the column's declared type
// and modernc.org/sqlite hands back a string the Scan can't take. Walking the
// rows in ascending order and letting each overwrite the last gives the same
// answer with the affinity intact.
func lastExpeditionByZone(uid id.UserID) (map[ZoneID]time.Time, error) {
	rows, err := db.Get().Query(`
		SELECT zone_id, start_date
		  FROM dnd_expedition
		 WHERE user_id = ?
		 ORDER BY start_date ASC`, string(uid))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[ZoneID]time.Time{}
	for rows.Next() {
		var zone string
		var last time.Time
		if err := rows.Scan(&zone, &last); err != nil {
			return nil, err
		}
		out[ZoneID(zone)] = last // ascending, so the last write is the most recent
	}
	return out, rows.Err()
}

// ── Rendering ────────────────────────────────────────────────────────────────

// renderBoredomDepartureDM is the note left on the table. It states plainly
// that nothing was bought and nothing was upgraded — the player should be able
// to read this and know exactly why the haul is going to be bad.
func renderBoredomDepartureDM(zone ZoneDefinition, supplies ExpeditionSupplies, purchase SupplyPurchase) string {
	var b strings.Builder
	b.WriteString(fmt.Sprintf("🎒 **Gone on ahead — %s** _(T%d)_\n\n", zone.Display, int(zone.Tier)))
	b.WriteString("_" + zone.Hook + "_\n\n")
	b.WriteString(flavor.Pick(flavor.ExpeditionBoredomStart))
	b.WriteString("\n\n")
	b.WriteString(fmt.Sprintf("**Packed:** %.0f SU (%d standard) — %d coins, the cheapest that would get us through the gate\n",
		supplies.Max, purchase.StandardPacks, purchase.Cost()))
	b.WriteString(fmt.Sprintf("**Daily burn:** %.1f SU/day — roughly **%d days** before we're rationing\n",
		supplies.DailyBurn, estimateDays(supplies.Max, supplies.DailyBurn)))
	b.WriteString("**Equipment:** unchanged. I don't do the shopping.\n\n")
	if w := raidContentWarning(zone.ID); w != "" {
		b.WriteString(w)
		b.WriteString("\n\n")
	}
	b.WriteString("`!expedition status` to see where we've got to. Come and find us.")
	return b.String()
}
