package plugin

import (
	"fmt"
	"log/slog"
	"math/rand/v2"
	"strings"

	"gogobee/internal/db"

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

// The Hollow King campaign (N5/D1). A light serialized story threaded through
// the zones by way of collectible journal pages. "The Hollow King" is already
// the Forest Shadows (T2) boss; this frames him as a realm-spanning antagonist
// whose fragments turn up wherever players fight elites and open secret rooms.
//
// The fragments are in-world found artifacts — journal entries, torn letters,
// stone inscriptions — not TwinBee's voice. TwinBee's own reactions (D1b) obey
// the first-person voice rules; this text does not.

type journalPage struct {
	Title string
	Text  string
}

// journalPages is the ordered campaign. Reading the discovered pages top to
// bottom tells the fall of a kingdom to the thing its king became. Order is the
// story order; players find them out of sequence, which is why the viewer marks
// gaps.
var journalPages = []journalPage{
	{"The Long Winter", "A crown is only a circle of gold until a man decides never to take it off. Ours decided in a winter that would not end, and the frost took its cue from him."},
	{"The Last Physician", "The court healers were sent home one by one, each promising the king had years left. The last was not sent home. We heard him thank the king for the honour."},
	{"On Shadows", "He stopped casting a shadow before he stopped casting a reflection. The steward struck both from the list of things a servant may notice aloud."},
	{"The Quiet Ledger", "Grain still left the granaries; no mouths were fed. I stopped auditing the difference the night the number began to feel like a name."},
	{"A Bargain Overheard", "Through the chapel door: the king's voice, and a second that used his own words a breath before he did. Only one of them was asking."},
	{"The Hollowing", "They call it a coronation in the records. Those of us who carried the braziers call it what it was. A king was emptied so a crown could keep wearing him."},
	{"The First Knights", "His honour guard did not die. That was the mercy offered and the price paid. They stand at the old gate yet, and they still salute a throne no living thing sits on."},
	{"Letters Home, Unsent", "\"Tell mother the pay is good and the work is guarding.\" The satchel held forty such letters, every hand different, every promise the same, none of them sent."},
	{"The Map Redrawn", "The kingdom did not fall so much as come apart at the seams — a warren here, a drowned temple there, each piece keeping a splinter of him like a tooth in a wound."},
	{"Root and Rot", "The forest north of the manor grew wrong and grew fast. The woodsmen say the trees lean toward the ruin at dusk. The woodsmen no longer go at dusk."},
	{"The Warren Below", "Even the goblins gave the deep tunnels to him and asked nothing back. When a scavenger yields ground for free, ask what it saw down there."},
	{"Water That Remembers", "The temple sank in a single night with the bells still ringing. Divers say the bells ring still, slow, as if something below is counting."},
	{"The Manor's Long Guest", "Blackspire changed hands nine times in a decade. Every deed names a different owner. Every household names the same tenant, and none will write it down."},
	{"The Forge Unbanked", "The underforge keeps a heat with no fuel and no smith. What it makes, no one has seen leave. What it makes, we are told, was promised elsewhere long ago."},
	{"Descent", "The deep roads were a trade route once. Now they are a throat. Everything the surface loses is swallowed the same direction, and the direction has a door at the end."},
	{"The Bright Country", "Past the crossing the colours are too kind and the days too long. It is the most beautiful place I have run from. He is patient there; he can afford to be."},
	{"What the Dragon Keeps", "The wyrm hoards more than gold. Deep in the lair, behind the coin, a single crown sits on no head and is guarded better than the hoard."},
	{"The Portal's Arithmetic", "The abyss gate opens outward. Everyone assumes a door lets things in. This one was built by someone who only ever intended to leave through it."},
	{"The Regent's Confession", "I ruled in his name for thirty years and never once saw him rule. I signed what the crown wanted signed. I am writing this so that one honest page exists."},
	{"The Names of the Guard", "I have set down every knight's name here so that when this is read, they are grieved as men and not feared as things. It is the only rescue left to attempt."},
	{"The Flaw in the Bargain", "The second voice took the king's life and his death both — and a thing that cannot die also cannot be finished. He is not immortal. He is unpaid, and waiting to collect."},
	{"How to Call Him", "He answers only where his fragments gather and only to one who has gathered them. Do not do this to avenge us. Do it to end the account. Come with the whole ledger or do not come."},
	{"The Empty Throne", "I have seen the seat at the heart of it all. It is not empty. It is occupied by the shape of a man who left, kept warm against his return."},
	{"Last Page", "If you are reading in order, you have walked the ruin of everything he emptied to stay. One page is missing from every telling — the one you write by going in. Bring a light. He hates the light. It is the one thing he could never hollow out."},
}

// journalTotalPages is the campaign length; the drop/viewer/finale all read it
// so the story can grow by appending to journalPages alone.
var journalTotalPages = len(journalPages)

// journalPageDropChance is the per-elite-kill probability that a page turns up.
// Deliberately modest: 24 pages is a long-horizon collection, and secret rooms
// (D4) grant pages on top of this. Tunable.
const journalPageDropChance = 0.22

// setJournalPageBit is the in-memory twin of grantJournalPageDB's bitwise OR —
// the single definition of "page N lives in bit N-1". Out-of-range pages are a
// no-op.
func setJournalPageBit(mask int64, page int) int64 {
	if page < 1 || page > 63 {
		return mask
	}
	return mask | (int64(1) << (page - 1))
}

func journalPageFound(mask int64, page int) bool {
	if page < 1 || page > 63 {
		return false
	}
	return mask&(int64(1)<<(page-1)) != 0
}

func journalPageCount(mask int64) int {
	n := 0
	for i := 1; i <= journalTotalPages; i++ {
		if journalPageFound(mask, i) {
			n++
		}
	}
	return n
}

func journalComplete(mask int64) bool {
	return journalPageCount(mask) >= journalTotalPages
}

// pickUnfoundJournalPage returns a random not-yet-found page number, or 0 when
// the campaign is already complete.
func pickUnfoundJournalPage(mask int64, rng *rand.Rand) int {
	var missing []int
	for i := 1; i <= journalTotalPages; i++ {
		if !journalPageFound(mask, i) {
			missing = append(missing, i)
		}
	}
	if len(missing) == 0 {
		return 0
	}
	return missing[rngIntN(rng, len(missing))]
}

// maybeDropJournalPage rolls a page reward for an elite kill or secret room and,
// on a hit, grants a random unfound page and returns its narration line. Empty
// string means no drop (missed the roll, DB error, or campaign already
// complete). The roll draws from the same RNG the surrounding loot rolls use
// and never touches SimulateCombat's stream, so the combat golden is unmoved.
func (p *AdventurePlugin) maybeDropJournalPage(userID id.UserID, rng *rand.Rand) string {
	if rngFloat(rng) >= journalPageDropChance {
		return ""
	}
	return p.grantJournalPage(userID, rng)
}

// grantJournalPage grants a random unfound page unconditionally (used by secret
// rooms, which award a page for certain). Returns "" when already complete or on
// error.
func (p *AdventurePlugin) grantJournalPage(userID id.UserID, rng *rand.Rand) string {
	mask, err := loadJournalPages(userID)
	if err != nil {
		return ""
	}
	page := pickUnfoundJournalPage(mask, rng)
	if page == 0 {
		return ""
	}
	if err := grantJournalPageDB(userID, page); err != nil {
		return ""
	}
	// If the page turned up mid-expedition, drop a log beat so the end-of-day
	// digest can have TwinBee react to it. No expedition (legacy !zone, or a
	// secret room opened outside a run) simply means no digest to react in.
	if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
		_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "journal",
			journalPages[page-1].Title, fmt.Sprintf("page %d", page))
	}
	return fmt.Sprintf("📖 A torn journal page — _%s_ (page %d of %d). See `!adventure journal`.",
		journalPages[page-1].Title, page, journalTotalPages)
}

// --- N5/D4 secret content pass ---------------------------------------------
//
// Every NodeKindSecret room resolves as a no-combat treasure cache (see
// resolveSecretRoom in dnd_zone_combat.go). Finding it — via the perception /
// stat check on the fork, or a cross-zone key — is the reward: a guaranteed
// journal page, a LootBias-weighted treasure roll, and a guaranteed consumable
// cache. The flavor and the key catalog live here with the rest of the
// campaign; the resolution mechanics live next to the other room resolvers.

// secretRoomCacheCount is how many zone-tier consumables a secret room always
// hands over, so the room pays out something visible even for a player who has
// every page and misses the treasure roll.
const secretRoomCacheCount = 2

// crossZoneKey pairs a source secret room with the key item it grants. The key
// is a plain inventory item; a LockKey edge in the destination zone matches its
// lower-cased Name against lock_data.key_id (see evaluateEdgeLock). Keys persist
// in inventory, so the unlock is permanent once earned.
type crossZoneKey struct {
	item       AdvItem
	unlocksIn  ZoneID // destination zone, for the grant narration
	unlockHint string // what the key opens, surfaced when it's granted
}

// secretRoomKeys — the two cross-zone keys (N5/D4). A Sunken Temple sigil opens
// a sealed reliquary in Manor Blackspire; an Underforge seal opens a vault deep
// in the Underdark throne approach. Keyed by the source secret's node ID.
var secretRoomKeys = map[string]crossZoneKey{
	"sunken_temple.coral_reliquary": {
		item:       AdvItem{Name: "Sunken Sigil", Type: "key", Tier: 2},
		unlocksIn:  ZoneManorBlackspire,
		unlockHint: "a drowned god's mark — Blackspire has a door that remembers it",
	},
	"underforge.forge_vault": {
		item:       AdvItem{Name: "Underforge Seal", Type: "key", Tier: 3},
		unlocksIn:  ZoneUnderdark,
		unlockHint: "the forge's own brand — something in the deep roads was promised its work",
	},
}

// grantSecretRoomKey hands over the cross-zone key a secret room carries, if it
// carries one and the player isn't already holding it. Idempotent: re-finding
// the room on a later run won't stack duplicate keys. Returns the narration line
// (empty when the room grants no key, the player already has it, or on error).
func (p *AdventurePlugin) grantSecretRoomKey(userID id.UserID, node ZoneNode) string {
	key, ok := secretRoomKeys[node.NodeID]
	if !ok {
		return ""
	}
	items, err := loadAdvInventory(userID)
	if err != nil {
		return ""
	}
	want := strings.ToLower(key.item.Name)
	for _, it := range items {
		if strings.ToLower(it.Name) == want {
			return "" // already earned — don't stack it
		}
	}
	if err := addAdvInventoryItem(userID, key.item); err != nil {
		slog.Error("secret room: grant key", "user", userID, "key", key.item.Name, "err", err)
		return ""
	}
	return fmt.Sprintf("🗝 **%s** — %s.", key.item.Name, key.unlockHint)
}

// secretRoomDiscovery is the bespoke in-world discovery line for a secret room,
// keyed by node ID. Not TwinBee's voice — the same in-world register the journal
// pages use. Nodes without an entry fall back to a generic line built from the
// node label.
var secretRoomDiscovery = map[string]string{
	"crypt_valdris.secret_chamber":      "🔍 **A sealed chamber.** The other grave-robbers pried at the wrong wall. This one gives — Valdris kept something back even from his own tomb.",
	"forest_shadows.sapling_shrine":     "🔍 **The Sapling Shrine.** A ring of young antlers grown from the dirt, and something votive left at the centre — an offering the King's shell never came back to claim.",
	"sunken_temple.coral_reliquary":     "🔍 **The Coral Reliquary.** Behind a lattice of living coral, a niche the flood sealed rather than drowned. What the temple hid, it hid well.",
	"manor_blackspire.hidden_oratory":   "🔍 **The Hidden Oratory.** A chapel with no door in the floor plan, kept for a tenant the deeds refuse to name.",
	"manor_blackspire.sealed_reliquary": "🔍 **The Sealed Reliquary.** The drowned god's sigil bites into the lock and turns. Blackspire kept a piece of the temple's secret behind a door only the temple could open.",
	"underforge.forge_vault":            "🔍 **The Forge Vault.** A strongroom off the unbanked heat, its ledger of commissions all addressed elsewhere and long overdue.",
	"underdark.lost_reliquary":          "🔍 **A lost reliquary.** A shrine the deep roads swallowed whole, still lit, still waiting on a procession that stopped coming a long age ago.",
	"underdark.sealed_forge_vault":      "🔍 **The Sealed Vault.** The Underforge's brand fits the seam, and the deep-road door gives up what the forge sent ahead — payment on the old account.",
	"feywild_crossing.illusion_garden":  "🔍 **The Illusion Garden.** A too-kind grove that isn't there when you look straight at it. He can afford beauty here; it costs him nothing to keep.",
	"dragons_lair.hoard_pillar":         "🔍 **A pillar of hoard.** Coin drifted to the ceiling around a single column the wyrm guards more than gold — as if something at its heart were worth more.",
	"abyss_portal.reality_seam":         "🔍 **A seam in the real.** The gate's arithmetic frays here, and through the frayed place a hand once reached to leave a thing behind on the way out.",

	// Tier 6 post-game — the Phylactery Verses of the Ossuary Ascendant. Each
	// is a stanza of the plan Valdris has been reciting to himself for years;
	// finding one un-writes a line of his invulnerability before the fight.
	"ossuary_ascendant.f1b2":  "🔍 **The Verse of the Waiting Grave.** Behind a course of bone laid one brick too shallow, the first stanza — inked while he was still only pretending to be dead. Read aloud, it costs him something he was counting on keeping.",
	"ossuary_ascendant.f2b2":  "🔍 **The Verse of the Bait Long Set.** A reliquary that rings hollow gives up the middle stanza: the years of losing on purpose, written as scripture. He signed each defeat like an appointment kept.",
	"ossuary_ascendant.cap32": "🔍 **The Verse of the Homecoming.** The highest vertebra was set wrong on purpose, and the last stanza waits inside it — the line about you, by name, arriving exactly on schedule. He did not expect you to read it first.",

	// The Unplace — impossible-geometry pockets. Not hidden so much as
	// briefly, wrongly, adjacent.
	"unplace.f1b2":  "🔍 **The Pocket That Isn't.** A corner you already turned is somehow still ahead, and the room it opens onto was never built — but something has been storing things in it anyway.",
	"unplace.f2b2":  "🔍 **The Unstitched Pocket.** Between two seams the celestial hasn't reached, a gap the geometry forgot to close. What fell through the tear years ago settled here, waiting to be un-fallen.",
	"unplace.cap32": "🔍 **The Undone Pocket.** A place that is not here, and closer than the door — the shortcut that shouldn't be legal. Whoever last took it left their toll behind, undone but not gone.",

	// The Drowned Star — radiant pockets of the wreck, lit from within by the
	// dying star Seraphel still shields.
	"drowned_star.f1b2":  "🔍 **The Radiant Wreck-Pocket.** Off the pilgrim road, past a pressure that folds lesser divers, a pocket of hull still glowing with the star's oldest light — and the offerings of pilgrims who made it this far and no further.",
	"drowned_star.f2b2":  "🔍 **The Star-Lit Vault.** One lantern here is not a lure. It leads to a vault the trench sealed with radiance instead of stone, and the light has been keeping its contents ten thousand years fresh.",
	"drowned_star.cap32": "🔍 **The Heart's Own Light.** A crack in the radiance, where the star leaks the light it was born with. Reaching in is reaching into the thing Seraphel has died a little every day to protect.",

	// The Last Meridian — hours the Custodian hasn't decommissioned yet.
	"last_meridian.f1b2":  "🔍 **The Unspent Hour-Pocket.** The Hour Thief overlooked one recess, and the hour it holds is still whole — sixty unspent minutes the observatory never got to bill.",
	"last_meridian.f2b2":  "🔍 **The Unwound Vault.** Behind the escapement, on the rhythm's dead beat, a strongroom the pendulum's swing kept sealed. Time out of phase with the rest of the clock, and everything left in it, out of phase too.",
	"last_meridian.cap32": "🔍 **The Hour Never Struck.** One hour on the great face was never dismantled — never even struck. It is still open, and inside it the world's timekeeping kept a little of everything it measured.",
}

// secretRoomDiscoveryLine returns the discovery flavor for a secret room,
// falling back to a label-driven generic when the node has no bespoke entry.
func secretRoomDiscoveryLine(node ZoneNode) string {
	if line, ok := secretRoomDiscovery[node.NodeID]; ok {
		return line
	}
	label := node.Label
	if label == "" {
		label = "a hidden room"
	}
	return fmt.Sprintf("🔍 **%s** — a room the others walked past. Something was left here for whoever looked closer.", label)
}

// bossEpilogues ties each zone boss's death to the Hollow King arc: a 2-3
// sentence capstone appended to the boss-down moment. Forest of Shadows is the
// King himself — but what falls there is a shell he shed, which is why the arc
// (and the finale) outlives it. In-world narration, not TwinBee's voice.
var bossEpilogues = map[ZoneID]string{
	ZoneGoblinWarrens: "Grol dies clutching a coin no goblin minted — a king's face worn smooth by handling. Whatever paid the warren to give up its deep tunnels, it paid in a currency older than these hills.",
	ZoneCryptValdris: "Valdris tried to cheat the grave and managed only to furnish it. In his last rattle he says a name that isn't his — _hollow, hollow_ — as if warning you of a colleague who did it better.",
	ZoneForestShadows: "The Hollow King falls without weight, a coat slipped from its peg — and the woods do not go quiet. What you felled here was a thing he shed, not the thing he is. Somewhere, the account he owes goes on accruing.",
	ZoneSunkenTemple: "The Aboleth's dream breaks and the drowned bells still at last. In the silence you understand what they were counting toward — and that the count did not begin with this temple, and does not end with it.",
	ZoneManorBlackspire: "Aldric was hollowed the same way, by the same hand, and made a poor imitation: a lord kept past his death to hold a house for a guest who never came. He thanks you. It is the first thing he has meant in a century.",
	ZoneUnderforge: "Thyrak's fires gutter out, and the half-made things on the anvils cool into what they were always going to be — regalia, and soldiers, and a crown with no head to fit. The forge was filling an order placed a long time ago.",
	ZoneUnderdark: "Ilvaras ruled the throat that swallows everything downward, toward the door at the bottom of the world. She dies certain she served a queen. She served a direction, and the direction has a name it never told her.",
	ZoneFeywildCrossing: "The Thornmother's garden was the loveliest cage on the road, tended for a patient guest. He can afford patience; you are learning why. She wilts, and the too-kind light dims by exactly one degree.",
	ZoneDragonsLair: "Behind Infernax's hoard, past the last of the gold, a single crown rests on no head — guarded better than the treasure, because it was the one thing here he was ever paid to keep. The dragon dies never knowing what it was.",
	ZoneAbyssPortal: "Belaxath guarded a door that opens outward, built by someone who only ever meant to leave through it. As the demon falls, the gate does not close. It was never meant to keep things out — only to let one thing come home.",

	// Tier 6 post-game epilogues — beyond the edge of the map.
	ZoneOssuaryAscendant: "Valdris does not gloat when he goes; he files. His last breath is a clerk's satisfaction — the plan closed, the ledger squared, the grave at last furnished to code. He dies the only person here who was never surprised by anything, and that, more than the crown, is what you take home.",
	ZoneFirstHoard:       "Aurvandryx never wanted the gold; the gold wanted her, gathered itself into a bed around her clutch the way frost gathers on the warmest thing left in a room. She dies shielding eggs that will not hatch, and the mountain finally, truly cools. Every dragon you ever fought was a scale she shed and forgot.",
	ZoneUnplace:          "The Seamstress finishes her stitch as she falls, and the wound she was sewing shut simply — stops needing to be. The Unplace folds back into a single honest room. She volunteered for this centuries early, and no one ever came to relieve her. You are the closest thing to relief she got.",
	ZoneDrownedStar:      "Seraphel lets go. Ten thousand years she would not, and now the dying star sinks the last fathom into the dark and is, mercifully, only dark. She is not sad. She is off-duty, for the first time since the world had names, and the trench goes quiet in a way that is almost a held breath finally let out.",
	ZoneLastMeridian:     "The Custodian apologizes one final time, on schedule, and then the hour it was dismantling simply doesn't arrive. The clocks do not stop so much as agree to stop mattering. Its contract is complete. It thanks you for your patience — and you realize, too late to ask, who it was that hired it.",
}

// bossEpilogueLine returns the campaign capstone for a zone boss, or "" for
// zones with none (and for the synthetic arena, which has no ZoneID entry).
func bossEpilogueLine(zoneID ZoneID) string {
	return bossEpilogues[zoneID]
}

// writeBossEpilogue appends a zone's campaign capstone (if any) to a
// victory narration. Shared by every boss-down render path — the manual
// turn-based finishes (finishCombatSession, finishPartyWin) and the compact
// autopilot boss resolve — so the D1b epilogue fires no matter how the boss
// was cleared. Caller gates on "this was a boss, not an elite".
func writeBossEpilogue(b *strings.Builder, zoneID ZoneID) {
	if ep := bossEpilogueLine(zoneID); ep != "" {
		b.WriteString("\n" + ep + "\n")
	}
}

// twinBeeJournalReactions are TwinBee's morning/digest reactions to pages found
// during the day — first-person, implicit subject, he/him, one line, curious,
// never expository (feedback_twinbee_voice, feedback_twinbee_is_male). Picked
// deterministically so a re-rendered digest reads the same.
var twinBeeJournalReactions = []string{
	"📖 Found a torn page in your kit tonight — been reading it by the fire while you sleep. This king of theirs was not a well man.",
	"📖 Another page. Keep turning them up and I keep piecing him together, and I do not much like the shape.",
	"📖 Read the new page twice. Whoever wrote it was frightened of something patient. I think we are walking toward it.",
	"📖 Slipped the day's page into the others. The story's filling in at the edges, and none of the edges are kind.",
}

// twinBeeJournalReaction picks one reaction line deterministically from the day
// and the number of pages found, so the digest is stable across re-renders.
func twinBeeJournalReaction(day, pagesToday int) string {
	if len(twinBeeJournalReactions) == 0 || pagesToday <= 0 {
		return ""
	}
	idx := (day + pagesToday) % len(twinBeeJournalReactions)
	if idx < 0 {
		idx = -idx
	}
	return twinBeeJournalReactions[idx]
}

// handleJournalCmd renders the player's collected campaign pages.
func (p *AdventurePlugin) handleJournalCmd(ctx MessageContext) 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.")
	}
	return p.SendDM(ctx.Sender, renderJournal(char.JournalPages))
}

// renderJournal builds the `!adventure journal` view: discovered pages in story
// order, with runs of missing pages collapsed to a single "…".
func renderJournal(mask int64) string {
	found := journalPageCount(mask)
	var b strings.Builder
	b.WriteString(fmt.Sprintf("📖 **The Hollow King** — Journal (%d / %d pages)\n",
		found, journalTotalPages))

	if found == 0 {
		b.WriteString("\nYou carry no pages yet. They surface where the realm's fragments gather — in the hands of elites, and behind doors most adventurers walk past.")
		return b.String()
	}

	b.WriteString("\n")
	gapOpen := false
	for i := 1; i <= journalTotalPages; i++ {
		if journalPageFound(mask, i) {
			gapOpen = false
			jp := journalPages[i-1]
			b.WriteString(fmt.Sprintf("\n**%s. %s**\n%s\n", romanNumeral(i), jp.Title, jp.Text))
		} else if !gapOpen {
			gapOpen = true
			b.WriteString("\n…\n")
		}
	}

	if journalComplete(mask) {
		b.WriteString("\nThe ledger is whole. Clear both Tier-5 zones and you can follow him to the end — `!expedition start epilogue`.")
	} else {
		b.WriteString(fmt.Sprintf("\n_%d pages still scattered._", journalTotalPages-found))
	}
	return b.String()
}

// romanNumeral renders 1..24 as upper-case Roman numerals for the page headers.
// The campaign never exceeds a couple dozen pages, so a small table beats a
// general algorithm here.
func romanNumeral(n int) string {
	if n < 1 || n > len(romanNumerals) {
		return fmt.Sprintf("%d", n)
	}
	return romanNumerals[n-1]
}

// ── D1c: the finale ─────────────────────────────────────────────────────────

// finaleTitle is the unique role awarded for the first finale clear.
const finaleTitle = "Kingsbane"

const epilogueEntryFlavor = "You bring the whole ledger to the Empty Throne. The shape kept warm against his return stirs, and stands, and is finally, fully here."

// hollowKingFinaleMonster is the finale stat block: Belaxath's block — the abyss
// boss whose gate "was meant to let one thing come home" — re-dressed as the
// King returned in full, with HP bumped for a capstone. A variant stat block
// with no new mechanics, per the plan. Built on the fly (not registered in
// dndBestiary) because runZoneCombat takes the template directly, exactly as the
// arena bosses do.
func hollowKingFinaleMonster() DnDMonsterTemplate {
	m := dndBestiary["boss_belaxath"]
	m.ID = "boss_hollow_king_finale"
	m.Name = "The Hollow King, Unhoused"
	m.HP = int(float64(m.HP) * 1.25)
	m.Notes = "Finale encounter — the Hollow King returned in full through the abyss gate. Variant of the Balor stat block; no new mechanics."
	return m
}

// epilogueUnlocked gates the finale on the whole ledger plus both Tier-5 clears.
func (p *AdventurePlugin) epilogueUnlocked(char *AdventureCharacter) (bool, string) {
	if !journalComplete(char.JournalPages) {
		return false, fmt.Sprintf(
			"The trail runs cold. You've recovered **%d of %d** journal pages — find them all before you can follow him to the end. (`!adventure journal`)",
			journalPageCount(char.JournalPages), journalTotalPages)
	}
	if !clearedEveryZoneOfTier(db.Get(), char.UserID, ZoneTier(5)) {
		return false, "You hold the whole ledger, but not the standing to use it. Clear **both** Tier-5 zones — the Dragon's Lair and the Abyss Portal — then come back to the throne."
	}
	return true, ""
}

// handleEpilogueEncounter runs the finale: a single auto-resolved boss fight
// against the Hollow King's true form, reached via `!expedition start epilogue`.
// Reward drops only on the first clear (reward-once); later clears are a
// flavour-only rematch. A loss costs a death, as any boss fight does.
func (p *AdventurePlugin) handleEpilogueEncounter(ctx MessageContext) error {
	char, _, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` first.")
	}
	if !char.Alive {
		return p.SendDM(ctx.Sender, "You're in no shape to face him. Mend at `!hospital` first.")
	}
	if ok, why := p.epilogueUnlocked(char); !ok {
		return p.SendDM(ctx.Sender, why)
	}
	// Busy guards — the finale is a synchronous auto-resolved fight, so refuse if
	// anything else is in flight and could collide on HP or run state.
	if exp, _ := getActiveExpedition(ctx.Sender); exp != nil {
		return p.SendDM(ctx.Sender, "Finish your expedition before you walk to the Empty Throne.")
	}
	if run, _ := getActiveZoneRun(ctx.Sender); run != nil {
		return p.SendDM(ctx.Sender, "Finish your current zone run first.")
	}
	if sess, _ := getActiveCombatSession(ctx.Sender); sess != nil {
		return p.SendDM(ctx.Sender, "You're already in a fight. Finish it first.")
	}

	monster := hollowKingFinaleMonster()
	displayName, _ := loadDisplayName(ctx.Sender)
	if displayName == "" {
		displayName = "You"
	}

	preHP, _ := dndHPSnapshot(ctx.Sender)
	result, err := p.runZoneCombat(ctx.Sender, monster, 5, bossCombatPhases, 50)
	if err != nil {
		slog.Error("epilogue: combat failed", "user", ctx.Sender, "err", err)
		return p.SendDM(ctx.Sender, "Something went wrong at the throne. Try again in a moment.")
	}
	postHP, maxHP := dndHPSnapshot(ctx.Sender)
	nat20s, nat1s := countNat20sAnd1s(result)

	intro := fmt.Sprintf("👑 **The Empty Throne — %s** (HP %d, AC %d)\n_%s_",
		monster.Name, monster.HP, monster.AC, epilogueEntryFlavor)
	phases := RenderCombatLog(result, displayName, monster.Name)
	outcome := renderBossOutcome(BossOutcomeInputs{
		// Synthetic ZoneArena keeps twinBeeLine's deterministic pickers off any
		// real zone's pool, exactly as the arena does.
		ZoneID:          ZoneArena,
		RunID:           "epilogue-" + string(ctx.Sender),
		RoomIdx:         0,
		Monster:         monster,
		Result:          result,
		PreHP:           preHP,
		PostHP:          postHP,
		MaxHP:           maxHP,
		PhaseTwoAt:      0.40,
		Nat20s:          nat20s,
		Nat1s:           nat1s,
		DefeatHeadline:  "💀 The Hollow King folds you into the quiet he keeps. The account goes unpaid a while longer.",
		VictoryHeadline: fmt.Sprintf("🏆 **The Hollow King falls — and this time stays down.** You finished at **%d/%d HP**.", postHP, maxHP),
	})

	var tail string
	if !result.PlayerWon {
		markAdventureDead(ctx.Sender, "adventure", "the Empty Throne")
	} else {
		tail = p.finishEpilogueWin(ctx.Sender, char.EpilogueCleared)
	}

	_ = p.sendZoneCombatMessages(ctx.Sender, append([]string{intro}, phases...), joinLootLines(outcome, tail))
	return nil
}

// finishEpilogueWin grants the reward on a first clear and returns the reward
// narration; a repeat clear is flavour only. The character is reloaded before
// the title write so runZoneCombat's post-fight HP persist isn't clobbered.
func (p *AdventurePlugin) finishEpilogueWin(userID id.UserID, alreadyCleared bool) string {
	if alreadyCleared {
		return "_The throne stays empty. You came to be sure. You are sure._"
	}

	// Latch the once-only flag BEFORE handing out the Legendary + title, so a
	// failed write (or a crash) can never leave the reward repeatable. If the
	// latch fails, skip the grant entirely — the player re-enters uncleared and
	// can try again, rather than pocketing a second Legendary on the retry.
	if err := markEpilogueClearedDB(userID); err != nil {
		slog.Error("epilogue: mark cleared failed", "user", userID, "err", err)
		return "_The account won't quite close — the ledger jams. Try `!expedition start epilogue` again._"
	}

	var b strings.Builder
	b.WriteString("🎖️ **The account is closed.** You are named **" + finaleTitle + "** — the one who unhoused the Hollow King.\n")
	if mi, ok := pickMagicItemForRarity(RarityLegendary, nil); ok {
		if line := p.dropMagicItemLoot(userID, mi, LootTierLegendary); line != "" {
			b.WriteString(line + "\n")
		}
	} else {
		slog.Error("epilogue: no legendary in registry", "user", userID)
	}

	if fresh, err := loadAdvCharacter(userID); err == nil && fresh != nil {
		if fresh.Title != finaleTitle {
			fresh.Title = finaleTitle
			if err := saveAdvCharacter(fresh); err != nil {
				slog.Error("epilogue: title save failed", "user", userID, "err", err)
			}
		}
	}

	if gr := gamesRoom(); gr != "" {
		if dn, _ := loadDisplayName(userID); dn != "" {
			p.SendMessage(id.RoomID(gr), fmt.Sprintf(
				"👑 **%s** carried the whole ledger to the Empty Throne and closed the account. The Hollow King is ended. **%s.**",
				dn, finaleTitle))
		}
	}
	return b.String()
}

var romanNumerals = []string{
	"I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X",
	"XI", "XII", "XIII", "XIV", "XV", "XVI", "XVII", "XVIII", "XIX", "XX",
	"XXI", "XXII", "XXIII", "XXIV", "XXV", "XXVI", "XXVII", "XXVIII", "XXIX", "XXX",
}
