package plugin

// Mischief Makers (M1) — paid monster hits on live expeditions.
//
// A buyer spends euros to send a monster at an adventurer who is out in a
// dungeon right now. The games room hears the contract is out; a window later
// the monster is delivered mid-run. Surviving it pays the target and unseals
// the buyer in Pete's bulletin. See gogobee_mischief_plan.md.
//
// Three properties the design leans on, all of them enforced here rather than
// trusted:
//
//   - The monster is priced against the TARGET, not the dungeon they happen to
//     be standing in. It comes from the zone pool of the target's own level
//     bracket (mischiefBracketZones), which is the corpus the difficulty pass is
//     calibrated on. The arena ladder is deliberately NOT the source: its
//     BaseLethality is a legacy flat death chance and arena T5 one-shots a level
//     20, which would collapse every tier into an execution.
//
//   - The payout basis is the BASE fee, never what the buyer actually paid. The
//     +25% sign surcharge is a pure sink. With the per-tier percentages below
//     capped at 75%, a survival purse is always strictly less than the buyer's
//     outlay — so two players colluding to move money this way lose to
//     `!baltransfer`, which is free. That cap is the whole anti-collusion story;
//     no danger multiplier is needed.
//
//   - Mischief maims, it does not murder. The delivery close-out floors every
//     seat at 1 HP and force-extracts the expedition. A purchased attack that
//     lands while the victim is asleep must not be able to delete their
//     character — see mischiefResolveDowned.

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

	"gogobee/internal/db"
	"gogobee/internal/peteclient"

	"github.com/google/uuid"
	"maunium.net/go/mautrix/id"
)

const (
	// mischiefWindow — how long the games room has to react before the monster
	// is delivered. Long enough for a bless/escalate scramble (M2), short
	// enough that a contract resolves inside one sitting.
	mischiefWindow = 60 * time.Minute

	// mischiefMinLevel — a level 1-2 adventurer is nobody's idea of a fair
	// target, and the bracket-1 pool is the only thing we could send anyway.
	mischiefMinLevel = 3

	// mischiefTargetCooldown — a target gets a breather after one resolves, so
	// a buyer with money can't chain-maim someone out of the game.
	mischiefTargetCooldown = 24 * time.Hour

	// mischiefBuyerDailyCap — contracts one buyer may place per rolling day.
	mischiefBuyerDailyCap = 2

	// mischiefBossPerTargetWindow — boss tier is "end this expedition" against a
	// caster (0-14% survival in the M0 sweep). One per target per week, on top of
	// the cooldown and the buyer cap.
	mischiefBossPerTargetWindow = 7 * 24 * time.Hour

	// mischiefSignSurcharge — the buyer pays this much on top to put their name
	// on the contract from the start. Pure sink; see the payout-basis note above.
	mischiefSignSurcharge = 0.25

	// Fizzle: the expedition ended before the monster could find them.
	mischiefFizzleRefund = 0.90

	// A ward is priced against the contract it is defending: a floor, or a slice of
	// the fee, whichever is more. Warding someone has to stay an impulse at the
	// cheap tiers (or nobody bothers and the window is just a countdown), but the
	// M1-close-out sweep measured three wards buying roughly +40pp of survival —
	// fighter L12 vs a boss at 70% HP goes 48% → 90%. At a flat fee that let the
	// room halve a €1,200 boss contract for €75, which makes the tier the whole
	// economy rests on feel like money thrown away. Saving someone from a boss
	// should cost the town real money: €120 a ward, €360 to cover them completely.
	mischiefBlessingFeeMin = 25
	mischiefBlessingFeePct = 0.10

	// mischiefBlessingCap — how many blessings one contract can carry. Three at
	// +10% MaxHP each is a third of a health bar: enough to swing an elite
	// coin-flip, not enough to make a boss survivable on its own.
	mischiefBlessingCap = 3

	// mischiefBlessingPct — temp HP per blessing, as a fraction of the target's
	// MaxHP. Same cushion mechanism as a well-rested long rest (applyDnDHPScaling),
	// and like it, evaporates when the fight's HP is persisted back.
	mischiefBlessingPct = 0.10
)

// Contract statuses. `delivering` is the claimed-but-unresolved state a CAS
// moves through, so a ticker double-fire finds nothing left to claim.
const (
	mischiefStatusOpen       = "open"
	mischiefStatusDelivering = "delivering"
	mischiefStatusDelivered  = "delivered"
	mischiefStatusFizzled    = "fizzled"
)

const (
	mischiefOutcomeSurvived = "survived"
	mischiefOutcomeDowned   = "downed"
)

// ── Tiers ────────────────────────────────────────────────────────────────────

// mischiefTier is one rung of the grunt→boss ladder. Fee and PayoutPct come
// from the M0 pricing sweep (n=2000/cell against the real combat engine) read
// against the live prod economy — see the tier table in the plan doc.
type mischiefTier struct {
	Key       string
	Display   string
	Fee       int     // base fee; also the payout basis
	PayoutPct float64 // survival purse as a fraction of Fee. Capped at 0.75.
	Blurb     string
}

var mischiefTiers = []mischiefTier{
	{Key: "grunt", Display: "Grunt", Fee: 40, PayoutPct: 0.40,
		Blurb: "One of the local nasties. Mostly theatre — it'll cost them blood, not the run."},
	{Key: "mob", Display: "Mob", Fee: 100, PayoutPct: 0.50,
		Blurb: "Three of them, back to back. Attrition, and a real dent in the supplies."},
	{Key: "elite", Display: "Elite", Fee: 350, PayoutPct: 0.65,
		Blurb: "Something that hunts on purpose, and it opens with a free swing. A coin-flip at their level."},
	{Key: "boss", Display: "Boss", Fee: 1200, PayoutPct: 0.75,
		Blurb: "The worst thing in their bracket, with the drop on them. This ends expeditions."},
}

func mischiefTierByKey(key string) (mischiefTier, bool) {
	for _, t := range mischiefTiers {
		if t.Key == strings.ToLower(strings.TrimSpace(key)) {
			return t, true
		}
	}
	return mischiefTier{}, false
}

// mischiefNextTier is the rung an escalation buys. Boss is the top: there is
// nothing worse in the bracket to send.
func mischiefNextTier(key string) (mischiefTier, bool) {
	for i, t := range mischiefTiers {
		if t.Key == key && i+1 < len(mischiefTiers) {
			return mischiefTiers[i+1], true
		}
	}
	return mischiefTier{}, false
}

// mischiefPrevTier is the rung an escalated contract came FROM. Grunt is the
// bottom: nothing escalates into it.
func mischiefPrevTier(key string) (mischiefTier, bool) {
	for i, t := range mischiefTiers {
		if t.Key == key && i > 0 {
			return mischiefTiers[i-1], true
		}
	}
	return mischiefTier{}, false
}

// mischiefOutlay splits what a contract collected into the buyer's stake and the
// escalator's. It exists because `paid` carries BOTH — escalateMischiefContract
// adds the delta to it so the purse cap keeps biting — and every path that gives
// money back has to give each of them back their own. Crediting the whole of
// `paid` to the buyer turns being escalated on top of into a windfall: buy an
// elite for €350, have someone bump it to a boss (+€850), let the target extract,
// and the 90% fizzle refund hands the buyer €1080 of somebody else's money.
//
// The escalator's stake is derived, not stored. An escalation is exactly one rung
// and happens at most once (escalation_count = 0 is in the UPDATE's WHERE), so it
// is always the fee gap between the contract's tier and the one below it.
func mischiefOutlay(c *mischiefContract) (buyer, escalator int) {
	if c.EscalatedBy == "" || c.EscalationCount == 0 {
		return c.Paid, 0
	}
	cur, okCur := mischiefTierByKey(c.Tier)
	prev, okPrev := mischiefPrevTier(c.Tier)
	if !okCur || !okPrev {
		return c.Paid, 0
	}
	delta := cur.Fee - prev.Fee
	if delta <= 0 || delta > c.Paid {
		return c.Paid, 0 // nonsense ladder; don't invent money out of it
	}
	return c.Paid - delta, delta
}

// trackMischiefSink books a sink against the people whose money it actually was.
// The escalator's stake sits inside c.Paid, so charging the whole rake to the
// buyer would credit them with tax they never paid — and credit the escalator,
// who funded most of a boss contract, with none.
func trackMischiefSink(c *mischiefContract, amount int) {
	buyerPaid, escPaid := mischiefOutlay(c)
	total := buyerPaid + escPaid
	if amount <= 0 || total <= 0 {
		return
	}
	escShare := amount * escPaid / total
	if escShare > 0 {
		trackTaxPaid(c.EscalatedBy, escShare)
	}
	if buyerShare := amount - escShare; buyerShare > 0 {
		trackTaxPaid(c.BuyerID, buyerShare)
	}
}

// mischiefBlessingFee is what one ward costs against a contract of this fee. It
// reads the contract's CURRENT basis, so an escalation raises the price of
// protecting its target too — the room's counterplay tracks the threat.
func mischiefBlessingFee(contractFee int) int {
	fee := int(math.Round(float64(contractFee) * mischiefBlessingFeePct))
	if fee < mischiefBlessingFeeMin {
		return mischiefBlessingFeeMin
	}
	return fee
}

// mischiefBlessingTempHP is the cushion a contract's blessings are worth to a
// target of this MaxHP. At least 1 per blessing, so a low-HP character still
// feels the ward they were bought.
func mischiefBlessingTempHP(hpMax, blessings int) int {
	if blessings <= 0 || hpMax <= 0 {
		return 0
	}
	if blessings > mischiefBlessingCap {
		blessings = mischiefBlessingCap
	}
	per := int(float64(hpMax) * mischiefBlessingPct)
	if per < 1 {
		per = 1
	}
	return per * blessings
}

// mischiefSignedFee is what a buyer pays to put their name on the contract up
// front. The extra is a sink — mischiefPurse never sees it.
func mischiefSignedFee(fee int) int {
	return int(math.Round(float64(fee) * (1 + mischiefSignSurcharge)))
}

// mischiefPurse is the target's cut for surviving: a percentage of the BASE fee
// (which escalations raise, and the sign surcharge does not). Hard-capped at 75%
// so no combination of tier + escalation can pay out more than three-quarters of
// what was spent.
func mischiefPurse(fee int, pct float64) int {
	if pct > 0.75 {
		pct = 0.75
	}
	return int(math.Round(float64(fee) * pct))
}

// ── Monster selection ────────────────────────────────────────────────────────

// mischiefBracketZones — the zone whose enemy pool a target of each level
// bracket draws its attacker from. The target's *current* dungeon is not used:
// a level 20 slumming in a tier 1 zone should still be sent something that can
// find them, and a level 4 who wandered into a tier 4 zone should not be sent
// its boss.
var mischiefBracketZones = map[int]ZoneID{
	1: ZoneGoblinWarrens,
	2: ZoneSunkenTemple,
	3: ZoneManorBlackspire,
	4: ZoneUnderdark,
	5: ZoneDragonsLair,
}

func mischiefBracketForLevel(level int) int {
	switch {
	case level <= 3:
		return 1
	case level <= 7:
		return 2
	case level <= 12:
		return 3
	case level <= 17:
		return 4
	}
	return 5
}

// mischiefBracketZone resolves the zone a target's attacker is drawn from.
func mischiefBracketZone(level int) ZoneDefinition {
	zone, _ := getZone(mischiefBracketZones[mischiefBracketForLevel(level)])
	return zone
}

func mischiefPickEnemy(zone ZoneDefinition, elite bool, rng *rand.Rand) DnDMonsterTemplate {
	var pool []ZoneEnemy
	for _, e := range zone.Enemies {
		if e.IsElite == elite {
			pool = append(pool, e)
		}
	}
	if len(pool) == 0 {
		pool = zone.Enemies
	}
	if len(pool) == 0 {
		return DnDMonsterTemplate{}
	}
	return dndBestiary[pool[rng.IntN(len(pool))].BestiaryID]
}

// mischiefMonsters returns the fight chain a tier sends, and whether the first
// monster gets the ambush (the attacker's privilege — it was lying in wait).
// This IS the shape the M0 sweep priced; changing it invalidates the fee table.
//
//	grunt = one non-elite zone enemy, no ambush
//	mob   = three non-elite zone enemies back to back (HP carries), no ambush
//	elite = one elite zone enemy, ambush
//	boss  = the zone boss, ambush
func mischiefMonsters(tierKey string, zone ZoneDefinition, rng *rand.Rand) (chain []DnDMonsterTemplate, ambush bool) {
	switch tierKey {
	case "grunt":
		return []DnDMonsterTemplate{mischiefPickEnemy(zone, false, rng)}, false
	case "mob":
		return []DnDMonsterTemplate{
			mischiefPickEnemy(zone, false, rng),
			mischiefPickEnemy(zone, false, rng),
			mischiefPickEnemy(zone, false, rng),
		}, false
	case "elite":
		return []DnDMonsterTemplate{mischiefPickEnemy(zone, true, rng)}, true
	case "boss":
		return []DnDMonsterTemplate{dndBestiary[zone.Boss.BestiaryID]}, true
	}
	return nil, false
}

// ── Model + persistence ──────────────────────────────────────────────────────

type mischiefContract struct {
	ID              string
	BuyerID         id.UserID
	TargetID        id.UserID
	Tier            string
	Fee             int // payout basis (base fee + escalations)
	Paid            int // what the buyer actually parted with
	Status          string
	Signed          bool
	EscalationCount int
	EscalatedBy     id.UserID // "" unless somebody paid to bump the tier
	BlessingCount   int
	Source          string
	Outcome         string
	OrderGUID       string // the web order this was opened for; "" for a Matrix buy
	CreatedAt       time.Time
	WindowEndsAt    time.Time
}

const mischiefCols = `contract_id, buyer_id, target_id, tier, fee, paid, status, signed,
	escalation_count, COALESCE(escalated_by, ''), blessing_count, source, COALESCE(outcome, ''),
	COALESCE(order_guid, ''), created_at, window_ends_at`

func scanMischief(row interface{ Scan(...any) error }) (*mischiefContract, error) {
	c := &mischiefContract{}
	err := row.Scan(&c.ID, &c.BuyerID, &c.TargetID, &c.Tier, &c.Fee, &c.Paid, &c.Status,
		&c.Signed, &c.EscalationCount, &c.EscalatedBy, &c.BlessingCount, &c.Source, &c.Outcome,
		&c.OrderGUID, &c.CreatedAt, &c.WindowEndsAt)
	if err != nil {
		return nil, err
	}
	return c, nil
}

// insertMischiefContract writes the contract, and FAILS if one is already live
// against this target (idx_mischief_one_live_per_target). That error is the
// serialization point for two buyers racing at the same victim — the caller must
// refund on it, not ignore it.
//
// Every timestamp is bound as a Go time.Time rather than left to
// CURRENT_TIMESTAMP: created_at and window_ends_at are both compared against
// bound Go times later (the daily cap, the due sweep), and mixing SQLite's own
// text stamp with the driver's encoding is how you get a comparison that is
// lexicographic-by-accident.
func insertMischiefContract(c *mischiefContract) error {
	_, err := db.Get().Exec(
		`INSERT INTO mischief_contracts
		 (contract_id, buyer_id, target_id, tier, fee, paid, status, signed, source, order_guid,
		  created_at, window_ends_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ID, string(c.BuyerID), string(c.TargetID), c.Tier, c.Fee, c.Paid,
		c.Status, c.Signed, c.Source, c.OrderGUID, c.CreatedAt, c.WindowEndsAt)
	return err
}

// mischiefContractByOrderGUID finds the contract opened for a web order, if one
// exists. It is the idempotency backstop for the storefront's retrying claim
// loop: a placement that already created a contract must not create a second, so
// the loop checks here before it debits or inserts. Returns nil when no contract
// carries this order (the normal first-attempt case).
func mischiefContractByOrderGUID(orderGUID string) *mischiefContract {
	if orderGUID == "" {
		return nil
	}
	row := db.Get().QueryRow(
		`SELECT `+mischiefCols+` FROM mischief_contracts WHERE order_guid = ?`, orderGUID)
	c, err := scanMischief(row)
	if err != nil {
		return nil // sql.ErrNoRows (the common case) or a scan miss — treat as absent
	}
	return c
}

// blessMischiefContract adds one ward to an open contract and reports whether it
// took. The cap and the still-open check are in the WHERE clause, not in the
// caller: the window is a scramble, and a room that piles four blessings in the
// same second must still land exactly three. A caller that loses this race has
// already been debited and must refund.
func blessMischiefContract(contractID string) bool {
	res := db.ExecResult("mischief: bless contract",
		`UPDATE mischief_contracts SET blessing_count = blessing_count + 1
		  WHERE contract_id = ? AND status = ? AND blessing_count < ?`,
		contractID, mischiefStatusOpen, mischiefBlessingCap)
	if res == nil {
		return false
	}
	n, _ := res.RowsAffected()
	return n == 1
}

// escalateMischiefContract bumps an open contract one rung: the tier, the payout
// basis (fee) and the buyer-side outlay (paid) all move together, so the purse
// stays a percentage of what was actually spent and the 75% cap survives an
// escalation untouched.
//
// The escalation_count = 0 and tier = <from> guards are what make it one step,
// once, even if two people hit `!mischief escalate` on the same contract at the
// same moment. The loser is refunded.
func escalateMischiefContract(contractID, fromTier string, next mischiefTier, delta int, by id.UserID) bool {
	res := db.ExecResult("mischief: escalate contract",
		`UPDATE mischief_contracts
		    SET tier = ?, fee = ?, paid = paid + ?, escalation_count = 1, escalated_by = ?
		  WHERE contract_id = ? AND status = ? AND tier = ? AND escalation_count = 0`,
		next.Key, next.Fee, delta, string(by), contractID, mischiefStatusOpen, fromTier)
	if res == nil {
		return false
	}
	n, _ := res.RowsAffected()
	return n == 1
}

// claimMischiefForDelivery moves open → delivering and reports whether THIS
// caller won the race. Exactly one delivery per contract, whatever the ticker
// does across a restart.
func claimMischiefForDelivery(contractID string) bool {
	res := db.ExecResult("mischief: claim delivery",
		`UPDATE mischief_contracts SET status = ?
		 WHERE contract_id = ? AND status = ?`,
		mischiefStatusDelivering, contractID, mischiefStatusOpen)
	if res == nil {
		return false
	}
	n, _ := res.RowsAffected()
	return n == 1
}

// resolveMischiefContract stamps the terminal state. Called only by the
// delivery path, which already holds the claim.
func resolveMischiefContract(contractID, status, outcome string) {
	db.Exec("mischief: resolve contract",
		`UPDATE mischief_contracts
		    SET status = ?, outcome = ?, resolved_at = ?
		  WHERE contract_id = ?`,
		status, outcome, time.Now().UTC(), contractID)
}

// fizzleMischiefContract claims an open contract straight to fizzled — the
// target's expedition ended before the monster found them. Same CAS discipline
// as delivery, so a fizzle and a delivery can never both fire.
func fizzleMischiefContract(contractID string) bool {
	return casMischiefStatus("mischief: fizzle contract", contractID,
		mischiefStatusOpen, mischiefStatusFizzled, mischiefStatusFizzled)
}

// abandonStaleMischief releases a stuck delivery. CAS off `delivering`, so a
// delivery that is merely slow (a long fight) cannot be swept out from under
// itself and double-refunded.
func abandonStaleMischief(contractID string) bool {
	return casMischiefStatus("mischief: abandon stale delivery", contractID,
		mischiefStatusDelivering, mischiefStatusFizzled, mischiefStatusFizzled)
}

// casMischiefStatus is the one status transition every terminal path takes: move
// from → to only if the row is still in `from`, and report whether THIS caller
// won. Every transition is conditional, which is what makes a ticker double-fire
// or a restart mid-delivery unable to pay (or refund) twice.
func casMischiefStatus(label, contractID, from, to, outcome string) bool {
	res := db.ExecResult(label,
		`UPDATE mischief_contracts
		    SET status = ?, outcome = ?, resolved_at = ?
		  WHERE contract_id = ? AND status = ?`,
		to, outcome, time.Now().UTC(), contractID, from)
	if res == nil {
		return false
	}
	n, _ := res.RowsAffected()
	return n == 1
}

// loadStaleMischiefDeliveries returns contracts stuck in `delivering` — claimed
// by a delivery that then crashed, or was killed mid-fight by a restart. Without
// this sweep the row lives forever: the target can never be targeted again (the
// live-contract check counts `delivering`) and the buyer's money is simply gone.
func loadStaleMischiefDeliveries(before time.Time) []mischiefContract {
	return loadMischiefByStatus(mischiefStatusDelivering, before)
}

// loadMischiefDue returns open contracts whose window has closed.
func loadMischiefDue(now time.Time) []mischiefContract {
	return loadMischiefByStatus(mischiefStatusOpen, now)
}

func loadMischiefByStatus(status string, dueBy time.Time) []mischiefContract {
	rows, err := db.Get().Query(
		`SELECT `+mischiefCols+` FROM mischief_contracts
		  WHERE status = ? AND window_ends_at <= ?`,
		status, dueBy)
	if err != nil {
		return nil
	}
	defer rows.Close()
	var out []mischiefContract
	for rows.Next() {
		if c, err := scanMischief(rows); err == nil {
			out = append(out, *c)
		}
	}
	return out
}

// liveMischiefForTarget returns the contract already in flight against a target,
// or nil. One at a time: two monsters converging on the same person turns a
// mischief into a mugging.
func liveMischiefForTarget(targetID id.UserID) *mischiefContract {
	row := db.Get().QueryRow(
		`SELECT `+mischiefCols+` FROM mischief_contracts
		  WHERE target_id = ? AND status IN (?, ?)
		  ORDER BY created_at DESC LIMIT 1`,
		string(targetID), mischiefStatusOpen, mischiefStatusDelivering)
	c, err := scanMischief(row)
	if err != nil {
		return nil
	}
	return c
}

// mischiefTargetCoolingDown reports whether the target survived (or was floored
// by) a delivered contract too recently to be sent another.
//
// Only a DELIVERED contract cools a target down. A fizzle never reached them,
// and a crash-swept stranding never happened at all — counting either would sell
// cheap immunity: a target whose friend places a grunt and who then extracts pays
// 10% of a grunt fee for a full day of being untargetable.
func mischiefTargetCoolingDown(targetID id.UserID, now time.Time) (bool, time.Duration) {
	var resolved time.Time
	err := db.Get().QueryRow(
		`SELECT resolved_at FROM mischief_contracts
		  WHERE target_id = ? AND status = ? AND resolved_at IS NOT NULL
		  ORDER BY resolved_at DESC LIMIT 1`,
		string(targetID), mischiefStatusDelivered).Scan(&resolved)
	if err != nil {
		return false, 0 // no row (or unreadable stamp) == not cooling down
	}
	if wait := mischiefTargetCooldown - now.Sub(resolved.UTC()); wait > 0 {
		return true, wait
	}
	return false, 0
}

// mischiefBuyerCountSince is how many contracts this buyer has placed in the
// window — the anti-spam cap. Fizzled ones count: the limit is on how often you
// may point a monster at someone, not on how often it connects.
func mischiefBuyerCountSince(buyerID id.UserID, since time.Time) int {
	var n int
	_ = db.Get().QueryRow(
		`SELECT COUNT(*) FROM mischief_contracts WHERE buyer_id = ? AND created_at > ?`,
		string(buyerID), since).Scan(&n)
	return n
}

// mischiefBossCountSince counts boss-tier contracts aimed at a target inside the
// window, from anyone. Boss is the "end their expedition" button; one a week.
//
// Fizzled ones do NOT count, and that asymmetry with the buyer cap is deliberate:
// the buyer cap limits how often you may POINT a monster at someone, but this cap
// protects the target from being boss'd, and a boss that never found them is not
// something they need protecting from. Counting it would let a friendly buyer burn
// the target's weekly boss slot for the price of a fizzle rake.
func mischiefBossCountSince(targetID id.UserID, since time.Time) int {
	var n int
	_ = db.Get().QueryRow(
		`SELECT COUNT(*) FROM mischief_contracts
		  WHERE target_id = ? AND tier = 'boss' AND created_at > ? AND status != ?`,
		string(targetID), since, mischiefStatusFizzled).Scan(&n)
	return n
}

// ── Eligibility ──────────────────────────────────────────────────────────────

// mischiefTargetable reports why a target can't be sent a monster, or "" if they
// can. The active expedition is the whole premise: the monster has to have
// somewhere to walk in from.
func (p *AdventurePlugin) mischiefTargetable(targetID id.UserID, tier mischiefTier, now time.Time) string {
	dndChar, err := LoadDnDCharacter(targetID)
	if err != nil || dndChar == nil {
		return fmt.Sprintf("%s has no adventurer.", p.DisplayName(targetID))
	}
	// A dead character can still own an 'active' expedition row — that drift is
	// exactly what the ambient ticker keeps a run-liveness net for. Don't sell a
	// hit on a corpse.
	if advChar, err := loadAdvCharacter(targetID); err != nil || advChar == nil || !advChar.Alive {
		return fmt.Sprintf("%s is in no state to be attacked. Leave them be.", p.DisplayName(targetID))
	}
	if dndChar.Level < mischiefMinLevel {
		return fmt.Sprintf("%s is only level %d — nobody's putting a price on a rookie's head (level %d+).",
			p.DisplayName(targetID), dndChar.Level, mischiefMinLevel)
	}
	exp, err := getActiveExpedition(targetID)
	if err != nil || exp == nil {
		return fmt.Sprintf("%s isn't out on an expedition. Monsters don't do house calls.",
			p.DisplayName(targetID))
	}
	if c := liveMischiefForTarget(targetID); c != nil {
		return fmt.Sprintf("Somebody already has coin on %s. Wait for it to land.", p.DisplayName(targetID))
	}
	if cooling, wait := mischiefTargetCoolingDown(targetID, now); cooling {
		return fmt.Sprintf("%s has only just shaken one off. Try again %s.",
			p.DisplayName(targetID), formatDuration(wait))
	}
	if tier.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 {
		return fmt.Sprintf("%s has already had a boss sent after them this week. Once is plenty.",
			p.DisplayName(targetID))
	}
	return ""
}

// ── Command surface ──────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleMischiefCmd(ctx MessageContext, args string) error {
	fields := strings.Fields(strings.TrimSpace(args))
	if len(fields) == 0 {
		return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
	}
	switch strings.ToLower(fields[0]) {
	case "board", "tiers", "help":
		return p.SendReply(ctx.RoomID, ctx.EventID, p.mischiefBoard())
	case "status":
		return p.mischiefStatusCmd(ctx)
	case "send":
		return p.mischiefSendCmd(ctx, fields[1:])
	case "bless":
		return p.mischiefBlessCmd(ctx, fields[1:])
	case "escalate":
		return p.mischiefEscalateCmd(ctx, fields[1:])
	default:
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"Unknown option. `!mischief` for the board · `!mischief send <tier> @user` · "+
				"`!mischief bless @user` · `!mischief escalate @user` · `!mischief status`")
	}
}

func (p *AdventurePlugin) mischiefBoard() string {
	var b strings.Builder
	b.WriteString("😈 **Mischief Makers** — put coin on an adventurer's head while they're out in a dungeon.\n")
	b.WriteString("The monster comes from their own bracket, so it's always a fight worth watching. ")
	b.WriteString("If they survive it, they keep a cut of your money — and the town finds out it was you.\n\n")
	for _, t := range mischiefTiers {
		b.WriteString(fmt.Sprintf("· **%s** — %s (signed: %s) — survivor keeps %s · ward %s\n   _%s_\n",
			t.Display, fmtEuro(t.Fee), fmtEuro(mischiefSignedFee(t.Fee)),
			fmtEuro(mischiefPurse(t.Fee, t.PayoutPct)), fmtEuro(mischiefBlessingFee(t.Fee)), t.Blurb))
	}
	b.WriteString("\n`!mischief send <tier> @user` — anonymous · add `signed` to put your name on it up front.\n")
	b.WriteString(fmt.Sprintf(
		"While a contract is open, the rest of us get a say:\n"+
			"· `!mischief bless @user` — a ward for the fight, up to %d of them. Help them. "+
			"The worse the thing coming, the more a ward costs.\n"+
			"· `!mischief escalate @user` — pay the difference, send something worse. \"Help\" them. "+
			"It raises their purse too, if they live.\n",
		mischiefBlessingCap))
	b.WriteString(fmt.Sprintf("_They have to be out on an expedition, level %d+. It lands about %s later._",
		mischiefMinLevel, formatDuration(mischiefWindow)))
	return b.String()
}

func (p *AdventurePlugin) mischiefStatusCmd(ctx MessageContext) error {
	if c := liveMischiefForTarget(ctx.Sender); c != nil {
		t, _ := mischiefTierByKey(c.Tier)
		who := "Someone"
		if c.Signed {
			who = "**" + p.DisplayName(c.BuyerID) + "**"
		}
		wards := ""
		if c.BlessingCount > 0 {
			wards = fmt.Sprintf("\n🕯️ %d of the town's wards are on you.", c.BlessingCount)
		}
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"😈 %s has paid for a **%s** to find you. It arrives %s. Survive it and you keep %s.%s",
			who, strings.ToLower(t.Display), formatDuration(time.Until(c.WindowEndsAt)),
			fmtEuro(mischiefPurse(c.Fee, t.PayoutPct)), wards))
	}
	rows, err := db.Get().Query(
		`SELECT `+mischiefCols+` FROM mischief_contracts
		  WHERE buyer_id = ? AND status IN (?, ?) ORDER BY created_at DESC`,
		string(ctx.Sender), mischiefStatusOpen, mischiefStatusDelivering)
	if err == nil {
		defer rows.Close()
		var lines []string
		for rows.Next() {
			if c, err := scanMischief(rows); err == nil {
				lines = append(lines, fmt.Sprintf("· a **%s** for **%s**, landing %s",
					c.Tier, p.DisplayName(c.TargetID), formatDuration(time.Until(c.WindowEndsAt))))
			}
		}
		if len(lines) > 0 {
			return p.SendReply(ctx.RoomID, ctx.EventID,
				"😈 **Your contracts in flight**\n"+strings.Join(lines, "\n"))
		}
	}
	return p.SendReply(ctx.RoomID, ctx.EventID,
		"Nothing out on you, and nothing out from you. `!mischief` for the board.")
}

func (p *AdventurePlugin) mischiefSendCmd(ctx MessageContext, fields []string) error {
	if len(fields) < 2 {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"Usage: `!mischief send <grunt|mob|elite|boss> @user [signed]`")
	}
	tier, ok := mischiefTierByKey(fields[0])
	if !ok {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"That's not a tier. Try `grunt`, `mob`, `elite` or `boss` — `!mischief` for the board.")
	}
	targetID, ok := p.ResolveUser(fields[1], ctx.RoomID)
	if !ok {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"Could not resolve that user. Usage: `!mischief send <tier> @user`")
	}
	if targetID == ctx.Sender {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"You can't put a price on your own head. Get help elsewhere.")
	}
	signed := len(fields) > 2 && strings.EqualFold(fields[2], "signed")

	// Serialise this buyer's placement so a double-submit can't pass the daily
	// cap twice and double-debit — same discipline as the duel escrow.
	lock := p.advUserLock(ctx.Sender)
	lock.Lock()
	defer lock.Unlock()

	now := time.Now().UTC()
	if n := mischiefBuyerCountSince(ctx.Sender, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"You've already sent %d monsters today. Even mischief keeps office hours.", n))
	}
	if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
		return p.SendReply(ctx.RoomID, ctx.EventID, reason)
	}

	price := tier.Fee
	if signed {
		price = mischiefSignedFee(tier.Fee)
	}
	if bal := p.euro.GetBalance(ctx.Sender); int(bal) < price {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"A %s runs %s — you have %s.", strings.ToLower(tier.Display), fmtEuro(price), fmtEuro(int(bal))))
	}
	if !p.euro.Debit(ctx.Sender, float64(price), "mischief_contract") {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
	}

	c := &mischiefContract{
		ID:           uuid.NewString(),
		BuyerID:      ctx.Sender,
		TargetID:     targetID,
		Tier:         tier.Key,
		Fee:          tier.Fee,
		Paid:         price,
		Status:       mischiefStatusOpen,
		Signed:       signed,
		Source:       "matrix",
		CreatedAt:    now,
		WindowEndsAt: now.Add(mischiefWindow),
	}
	// The one-live-contract-per-target index is the real guard: the check above
	// ran under this buyer's lock, and another buyer could have slipped a contract
	// onto the same target since. Losing that race costs the buyer nothing.
	if err := insertMischiefContract(c); err != nil {
		p.euro.Credit(ctx.Sender, float64(price), "mischief_refund_contested")
		slog.Warn("mischief: contract insert rejected",
			"buyer", ctx.Sender, "target", targetID, "err", err)
		// Refund either way, but don't tell a buyer a rival outbid them when the
		// write simply failed — they'd stop retrying and believe in a contract that
		// does not exist. The index is the only thing that legitimately rejects here.
		if liveMischiefForTarget(targetID) != nil {
			return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
				"Somebody beat you to **%s** — there's already a monster on the way. You've been refunded.",
				p.DisplayName(targetID)))
		}
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"The contract didn't take — something went wrong on our end. You've been refunded; try again.")
	}

	p.announceMischiefContract(c, tier)
	p.dmMischiefVictim(c, tier)
	emitMischiefContractNews(c, tier)

	return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
		"😈 Done. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
		strings.ToLower(tier.Display), p.DisplayName(targetID), formatDuration(mischiefWindow),
		mischiefBuyerNote(signed)))
}

// Web-order verdicts. These strings are the storefront order statuses Pete files,
// so they are part of the wire contract — see internal/storage/mischief.go.
const (
	mischiefWebPlaced            = "placed"
	mischiefWebBouncedFunds      = "bounced_funds"
	mischiefWebBouncedIneligible = "bounced_ineligible"
)

// mischiefWebResult is the outcome of a web placement. Retry means "leave the
// order pending" — a transient failure (DB hiccup, half-done state) the poll loop
// should try again — and is never itself a verdict Pete files.
type mischiefWebResult struct {
	Status string
	Detail string
	Retry  bool
}

// placeWebMischief opens a contract for a web storefront order. It is the poll
// loop's fulfilment path, and differs from the Matrix mischiefSendCmd in exactly
// the ways a retrying wire demands:
//
//   - The order guid is the idempotency key. The euro debit goes through
//     DebitIdem keyed on it, and the contract is stamped with it, so a claim
//     whose ack was lost can re-run the whole thing without charging twice or
//     opening a second contract.
//   - It debits *before* checking eligibility, then refunds if the target turns
//     out ineligible. Doing it in that order keeps the money state a pure
//     function of the guid: on any retry the debit is a settled fact, not a
//     coin-flip on where we crashed. The Matrix path can afford the friendlier
//     check-first flow because it never retries.
//   - It returns a verdict for Pete to render instead of replying in a room.
//
// The buyer is DM'd the same way a Matrix buyer is told, and the victim and news
// feed see exactly what a Matrix contract produces — a web hit is indistinguishable
// downstream from one placed in chat.
func (p *AdventurePlugin) placeWebMischief(order peteclient.MischiefOrder) mischiefWebResult {
	// Already fulfilled on a prior attempt — the surest idempotency check there
	// is. Report placed without touching money again.
	if existing := mischiefContractByOrderGUID(order.GUID); existing != nil {
		return mischiefWebResult{Status: mischiefWebPlaced, Detail: "already placed"}
	}

	tier, ok := mischiefTierByKey(order.Tier)
	if !ok {
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown tier"}
	}

	buyerID, ok := p.mischiefBuyerMXID(order.BuyerUsername)
	if !ok {
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "unknown buyer"}
	}
	targetID, ok := resolveRosterToken(order.TargetToken)
	if !ok {
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "that adventurer is no longer on the board"}
	}
	if targetID == buyerID {
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "you can't put a price on your own head"}
	}

	// Serialise this buyer's placements, same discipline as the Matrix path: two
	// orders in one poll batch can't both slip past the daily cap.
	lock := p.advUserLock(buyerID)
	lock.Lock()
	defer lock.Unlock()

	now := time.Now().UTC()
	if n := mischiefBuyerCountSince(buyerID, now.Add(-24*time.Hour)); n >= mischiefBuyerDailyCap {
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: fmt.Sprintf("daily limit reached (%d today)", n)}
	}

	price := tier.Fee
	if order.Signed {
		price = mischiefSignedFee(tier.Fee)
	}

	// Affordability gate, matching the Matrix path: a mischief buy takes no credit,
	// so a buyer short of the fee is bounced before any money moves. The gate is
	// skipped on a retry of an order already debited — re-reading the now-lower
	// balance would wrongly bounce a hit that was already paid for.
	if !p.euro.HasExternalTx(order.GUID) && int(p.euro.GetBalance(buyerID)) < price {
		return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
	}

	// Debit, keyed on the order guid so a replay is a no-op. ok=false means the
	// buyer is already past the debt floor, which the gate above all but rules out.
	debited, _, err := p.euro.DebitIdem(buyerID, float64(price), "mischief_contract_web", order.GUID)
	if err != nil {
		slog.Warn("mischief: web debit errored, will retry", "order", order.GUID, "err", err)
		return mischiefWebResult{Retry: true}
	}
	if !debited {
		return mischiefWebResult{Status: mischiefWebBouncedFunds, Detail: fmt.Sprintf("a %s runs %s", strings.ToLower(tier.Display), fmtEuro(price))}
	}

	// From here the buyer is debited; every failure path must refund. The refund
	// keys on a distinct external id so it can't be swallowed as a replay of the
	// debit itself.
	refund := func() {
		if _, _, err := p.euro.CreditIdem(buyerID, float64(price), "mischief_refund_web", order.GUID+":refund"); err != nil {
			slog.Error("mischief: web refund failed — buyer is short", "order", order.GUID, "buyer", buyerID, "err", err)
		}
	}

	if reason := p.mischiefTargetable(targetID, tier, now); reason != "" {
		refund()
		return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: reason}
	}

	c := &mischiefContract{
		ID:           uuid.NewString(),
		BuyerID:      buyerID,
		TargetID:     targetID,
		Tier:         tier.Key,
		Fee:          tier.Fee,
		Paid:         price,
		Status:       mischiefStatusOpen,
		Signed:       order.Signed,
		Source:       "web",
		OrderGUID:    order.GUID,
		CreatedAt:    now,
		WindowEndsAt: now.Add(mischiefWindow),
	}
	if err := insertMischiefContract(c); err != nil {
		// Distinguish the one legitimate rejection from a transient write failure,
		// the same way the Matrix path does — the two want opposite handling.
		if liveMischiefForTarget(targetID) != nil {
			// A rival contract is already on this target (ours isn't: we checked
			// order_guid up top). Refund and terminally bounce; the buyer lost a
			// race, not money.
			refund()
			slog.Warn("mischief: web contract lost the race", "order", order.GUID, "target", targetID)
			return mischiefWebResult{Status: mischiefWebBouncedIneligible, Detail: "someone already sent a monster their way"}
		}
		// A real write failure with no rival to blame. Leave the debit in place
		// and the order pending — the guid makes the next poll's re-run a no-op on
		// the money and a clean retry on the insert, which is the whole point of
		// keying on it. Refunding here would both drop a placeable hit and, worse,
		// hand out a free contract if the retry then succeeded against the credit.
		slog.Warn("mischief: web contract insert failed, will retry", "order", order.GUID, "target", targetID, "err", err)
		return mischiefWebResult{Retry: true}
	}

	p.announceMischiefContract(c, tier)
	p.dmMischiefVictim(c, tier)
	emitMischiefContractNews(c, tier)
	p.dmMischiefWebBuyer(buyerID, c, tier)

	return mischiefWebResult{Status: mischiefWebPlaced, Detail: fmt.Sprintf("a %s is on the way", strings.ToLower(tier.Display))}
}

// mischiefBuyerMXID reconstructs the buyer's Matrix id from the username Pete
// sent. Authentik's preferred_username is the Matrix localpart by construction
// (verified on the MAS box), so the buyer is @<username>:<our homeserver>. We
// lowercase because Matrix localparts are lowercase and an Authentik display of
// the name may not be. Fails closed if the client isn't up (tests) or the name
// is empty.
func (p *AdventurePlugin) mischiefBuyerMXID(username string) (id.UserID, bool) {
	lp := strings.ToLower(strings.TrimSpace(username))
	if lp == "" || p.Client == nil {
		return "", false
	}
	server := p.Client.UserID.Homeserver()
	if server == "" {
		return "", false
	}
	return id.NewUserID(lp, server), true
}

// dmMischiefWebBuyer tells a web buyer their hit is out, the same courtesy a
// Matrix buyer gets in-thread. Best-effort: a buyer with no DM room open just
// watches the storefront status instead.
func (p *AdventurePlugin) dmMischiefWebBuyer(buyerID id.UserID, c *mischiefContract, tier mischiefTier) {
	msg := fmt.Sprintf("😈 The word's out. A **%s** is on its way to **%s** — it finds them in about %s.\n%s",
		strings.ToLower(tier.Display), p.DisplayName(c.TargetID), formatDuration(mischiefWindow),
		mischiefBuyerNote(c.Signed))
	if err := p.SendDM(buyerID, msg); err != nil {
		slog.Debug("mischief: web buyer DM failed", "buyer", buyerID, "err", err)
	}
}

// mischiefOpenContractFor resolves "@user" to the contract currently open against
// them, or explains why the room can't act on it. A contract already claimed for
// delivery is deliberately out of reach: the monster is in the room with them and
// a ward bought now would have to reach back into a fight that has started.
func (p *AdventurePlugin) mischiefOpenContractFor(ctx MessageContext, fields []string, verb string) (*mischiefContract, id.UserID, string) {
	if len(fields) < 1 {
		return nil, "", fmt.Sprintf("Usage: `!mischief %s @user`", verb)
	}
	targetID, ok := p.ResolveUser(fields[0], ctx.RoomID)
	if !ok {
		return nil, "", fmt.Sprintf("Could not resolve that user. Usage: `!mischief %s @user`", verb)
	}
	c := liveMischiefForTarget(targetID)
	if c == nil {
		return nil, targetID, fmt.Sprintf("Nobody has coin on **%s** right now.", p.DisplayName(targetID))
	}
	if c.Status != mischiefStatusOpen {
		return nil, targetID, fmt.Sprintf(
			"Too late — the thing has already found **%s**.", p.DisplayName(targetID))
	}
	return c, targetID, ""
}

// mischiefBlessCmd — the helping half of the window. The fee is a pure sink (it
// goes to the community pot, never to the purse), so a blessing can't be used to
// route money to the target: it buys them HP, not euros.
func (p *AdventurePlugin) mischiefBlessCmd(ctx MessageContext, fields []string) error {
	lock := p.advUserLock(ctx.Sender)
	lock.Lock()
	defer lock.Unlock()

	c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "bless")
	if c == nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, reason)
	}
	if c.BlessingCount >= mischiefBlessingCap {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"**%s** is already carrying every ward the town has to give (%d). The rest is up to them.",
			p.DisplayName(targetID), mischiefBlessingCap))
	}
	// Priced off the contract's current basis: warding someone against a boss costs
	// what a boss is worth, and an escalation raises the price of saving them.
	fee := mischiefBlessingFee(c.Fee)
	if bal := p.euro.GetBalance(ctx.Sender); int(bal) < fee {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"A ward against a %s costs %s — you have %s.",
			strings.ToLower(c.Tier), fmtEuro(fee), fmtEuro(int(bal))))
	}
	if !p.euro.Debit(ctx.Sender, float64(fee), "mischief_blessing") {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
	}
	// The cap is enforced by the UPDATE, not by the read above — two people can
	// buy the third ward in the same second, and only one of them may have it.
	if !blessMischiefContract(c.ID) {
		p.euro.Credit(ctx.Sender, float64(fee), "mischief_blessing_refund")
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"Somebody got there first — the ward wasn't needed. You've been refunded.")
	}
	communityPotAdd(fee)
	trackTaxPaid(ctx.Sender, fee)

	// Read the tally back rather than deriving it from the pre-UPDATE snapshot: two
	// blessers landing together would both compute BlessingCount+1 off a count of 0
	// and both announce "1 of 3" for a contract that is now carrying 2.
	count := c.BlessingCount + 1
	if fresh, err := mischiefContractByID(c.ID); err == nil && fresh != nil {
		count = fresh.BlessingCount
	}

	p.announceMischiefBlessing(c, ctx.Sender, count)
	p.SendDM(targetID, fmt.Sprintf(
		"🕯️ **%s** has paid for a ward on you. Whatever's coming, I've made you harder to put down.",
		p.DisplayName(ctx.Sender)))
	return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
		"🕯️ Done — **%s** goes into that fight tougher than they would have. (%d/%d wards)",
		p.DisplayName(targetID), count, mischiefBlessingCap))
}

// mischiefEscalateCmd — the "helping" half. Anyone but the target can pay the
// difference to send something worse. The money joins the payout basis, so piling
// on raises the jackpot the target walks away with if they live: the room's cruelty
// is also its generosity, which is the joke.
func (p *AdventurePlugin) mischiefEscalateCmd(ctx MessageContext, fields []string) error {
	lock := p.advUserLock(ctx.Sender)
	lock.Lock()
	defer lock.Unlock()

	c, targetID, reason := p.mischiefOpenContractFor(ctx, fields, "escalate")
	if c == nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, reason)
	}
	if targetID == ctx.Sender {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"You don't get to raise the price on your own head. Have some dignity.")
	}
	cur, _ := mischiefTierByKey(c.Tier)
	next, ok := mischiefNextTier(c.Tier)
	if !ok {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"It's already the worst thing in their bracket. There is nothing bigger to send.")
	}
	if c.EscalationCount > 0 {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"Somebody has already made it worse — it's a **%s** now. Once is the limit.",
			strings.ToLower(cur.Display)))
	}
	// An escalation into boss tier is still a boss landing on this person, so it
	// answers to the same weekly limit a bought boss does. (This contract is not a
	// boss yet, so it cannot count itself.)
	now := time.Now().UTC()
	if next.Key == "boss" && mischiefBossCountSince(targetID, now.Add(-mischiefBossPerTargetWindow)) > 0 {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"**%s** has already had a boss sent after them this week. Let them keep this one.",
			p.DisplayName(targetID)))
	}
	delta := next.Fee - cur.Fee
	if bal := p.euro.GetBalance(ctx.Sender); int(bal) < delta {
		return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
			"Bumping a %s up to a %s costs the difference: %s. You have %s.",
			strings.ToLower(cur.Display), strings.ToLower(next.Display), fmtEuro(delta), fmtEuro(int(bal))))
	}
	if !p.euro.Debit(ctx.Sender, float64(delta), "mischief_escalation") {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't take your money. Try again.")
	}
	if !escalateMischiefContract(c.ID, cur.Key, next, delta, ctx.Sender) {
		p.euro.Credit(ctx.Sender, float64(delta), "mischief_escalation_refund")
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"Somebody beat you to it, or the thing already left. You've been refunded.")
	}

	purse := mischiefPurse(next.Fee, next.PayoutPct)
	p.announceMischiefEscalation(c, next, purse)
	p.SendDM(targetID, fmt.Sprintf(
		"😈 It got worse. What's coming for you is a **%s** now — somebody in town paid to upgrade it.\n"+
			"Their money's in the pot with the rest: walk away from it and you keep %s.",
		strings.ToLower(next.Display), fmtEuro(purse)))
	if c.BuyerID != ctx.Sender {
		p.SendDM(c.BuyerID, fmt.Sprintf(
			"😈 Somebody liked your idea and paid to make it worse: **%s** is getting a **%s** now.",
			p.DisplayName(targetID), strings.ToLower(next.Display)))
	}
	return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf(
		"😈 Paid. It's a **%s** now. If **%s** walks away from it they keep %s — you helped with that too.\n"+
			"_Nobody knows it was you — unless they survive._",
		strings.ToLower(next.Display), p.DisplayName(targetID), fmtEuro(purse)))
}

func mischiefBuyerNote(signed bool) string {
	if signed {
		return "_Your name is on it. Everyone already knows._"
	}
	return "_Nobody knows it was you — unless they walk away from it._"
}

// ── Announcements ────────────────────────────────────────────────────────────

// announceMischiefContract tells the games room a hit is out. The buyer is named
// only if they paid to sign it; otherwise this is the whole point of the window —
// the room knows someone did it and doesn't know who.
func (p *AdventurePlugin) announceMischiefContract(c *mischiefContract, tier mischiefTier) {
	gr := gamesRoom()
	if gr == "" {
		return
	}
	who := "**Someone in town**"
	if c.Signed {
		who = fmt.Sprintf("**%s**", p.DisplayName(c.BuyerID))
	}
	p.SendMessage(gr, fmt.Sprintf(
		"😈 %s has put %s on **%s**'s head — a **%s** is already looking for them out there.\n"+
			"It finds them in about %s. If they walk away from it, they keep %s of that money — and we all find out who paid.",
		who, fmtEuro(c.Paid), p.DisplayName(c.TargetID), strings.ToLower(tier.Display),
		formatDuration(time.Until(c.WindowEndsAt)), fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
}

// dmMischiefVictim is TwinBee telling the target that somebody wants them dead.
// Pure flavor — the expedition is autonomous and they cannot act on it directly —
// but it is what lets them ask the room for wards, which is the whole window.
func (p *AdventurePlugin) dmMischiefVictim(c *mischiefContract, tier mischiefTier) {
	who := "Somebody in town"
	if c.Signed {
		who = "**" + p.DisplayName(c.BuyerID) + "**"
	}
	p.SendDM(c.TargetID, fmt.Sprintf(
		"😈 **Word's reached me, and you're not going to like it.**\n"+
			"%s has paid to have a **%s** find you out there. It's already looking. I make it about %s.\n\n"+
			"I can't call it off, and I can't come out there. What the town *can* do is ward you — "+
			"anyone who likes you can `!mischief bless %s` (%s each, up to %d).\n"+
			"Walk away from it and their money is yours: **%s**. And we all find out who paid.",
		who, strings.ToLower(tier.Display), formatDuration(time.Until(c.WindowEndsAt)),
		// The full MXID, not the display name: handleMischiefCmd splits on whitespace,
		// so a copy-pasted `bless @Misty Blue` would resolve on "@Misty" — or on
		// somebody else entirely. ResolveUser takes an "@user:server" verbatim.
		c.TargetID, fmtEuro(mischiefBlessingFee(c.Fee)), mischiefBlessingCap,
		fmtEuro(mischiefPurse(c.Fee, tier.PayoutPct))))
}

// announceMischiefBlessing — helping is public and immediate. The blesser is
// named on the spot: there is no reason to hide having done someone a kindness,
// and the room seeing wards go up is what pulls the next one out of somebody.
func (p *AdventurePlugin) announceMischiefBlessing(c *mischiefContract, blesser id.UserID, count int) {
	gr := gamesRoom()
	if gr == "" {
		return
	}
	p.SendMessage(gr, fmt.Sprintf(
		"🕯️ **%s** puts a ward on **%s** — %d of %d. Whatever's coming for them will have to work harder.",
		p.DisplayName(blesser), p.DisplayName(c.TargetID), count, mischiefBlessingCap))
}

// announceMischiefEscalation — "helping" is anonymous, exactly like the purchase
// it piles onto, and unsealed by the same survival.
func (p *AdventurePlugin) announceMischiefEscalation(c *mischiefContract, next mischiefTier, purse int) {
	gr := gamesRoom()
	if gr == "" {
		return
	}
	p.SendMessage(gr, fmt.Sprintf(
		"😈 **Somebody has made it worse.** What's hunting **%s** out there is a **%s** now.\n"+
			"They put their money in the pot to do it — so if **%s** walks away, the purse is up to %s.",
		p.DisplayName(c.TargetID), strings.ToLower(next.Display),
		p.DisplayName(c.TargetID), fmtEuro(purse)))
}

func (p *AdventurePlugin) announceMischiefSurvived(c *mischiefContract, monsterName string, purse int) {
	gr := gamesRoom()
	if gr == "" {
		return
	}
	// The unseal. Anonymity is the buyer's to lose, and losing it is what makes
	// a survival worth announcing. Whoever paid to make it worse loses theirs on
	// the same terms — piling on is a purchase, and it carries the same exposure.
	p.SendMessage(gr, fmt.Sprintf(
		"🛡️ **%s walked away from it.** The **%s** that came for them is dead, and %s is %s richer for the trouble.\n"+
			"The coin came from **%s**.%s",
		p.DisplayName(c.TargetID), monsterName, p.DisplayName(c.TargetID), fmtEuro(purse),
		p.DisplayName(c.BuyerID), p.mischiefEscalatorNote(c)))
}

// mischiefEscalatorNote names whoever paid to upgrade the contract, for the
// unseal. Empty when nobody did.
func (p *AdventurePlugin) mischiefEscalatorNote(c *mischiefContract) string {
	if c.EscalatedBy == "" {
		return ""
	}
	return fmt.Sprintf(" **%s** paid to make it worse.", p.DisplayName(c.EscalatedBy))
}

func (p *AdventurePlugin) announceMischiefDowned(c *mischiefContract, monsterName string) {
	gr := gamesRoom()
	if gr == "" {
		return
	}
	who := "Whoever paid for it"
	if c.Signed {
		who = fmt.Sprintf("**%s**, who paid for it,", p.DisplayName(c.BuyerID))
	}
	p.SendMessage(gr, fmt.Sprintf(
		"💀 **%s didn't walk away.** A **%s** found them mid-run and put them on the floor. "+
			"They're being carried home — expedition over, pockets lighter.\n%s keeps nothing: the money's gone to the community pot.",
		p.DisplayName(c.TargetID), monsterName, who))
}

// ── Pete news ────────────────────────────────────────────────────────────────
//
// Deploy Pete BEFORE gogobee whenever these types change: Pete 400s an unknown
// event_type, gogobee retries, and the bulletin parks forever.
//
// BULLETIN, never priority. Priority tier is the one thing that makes Pete post a
// live Matrix beat, and TwinBee already announces every one of these in the games
// room as it happens (announceMischiefContract / Survived / Downed) — a priority
// fact would just read Pete's version back to the people who watched it. Bulletin
// still carries the story to the site and the daily digest, where a roundup is
// what it is. Same call 1cbd68a made for death and zone_first.

func emitMischiefContractNews(c *mischiefContract, tier mischiefTier) {
	target := charName(c.TargetID)
	if target == "" {
		return
	}
	buyer := ""
	buyerUser := id.UserID("")
	if c.Signed {
		buyer = charName(c.BuyerID)
		buyerUser = c.BuyerID
	}
	ts := nowUnix()
	emitFact(peteclient.Fact{
		GUID:       fmt.Sprintf("mischief_contract:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
		EventType:  "mischief_contract",
		Tier:       "bulletin",
		Subject:    target,
		Opponent:   buyer,
		Boss:       tier.Display,
		Stakes:     fmtEuro(c.Paid),
		Level:      charLevel(c.TargetID),
		OccurredAt: ts,
	}, c.TargetID, buyerUser)
}

// emitMischiefResolvedNews files the survival or the maiming. On a survival the
// buyer is named whether or not they signed — that is the unseal, and it is the
// only brake on casual griefing. (`!news optout` still anonymizes them to "an
// adventurer", as it does everywhere else.)
func emitMischiefResolvedNews(c *mischiefContract, monsterName, outcome string, purse int) {
	target := charName(c.TargetID)
	if target == "" {
		return
	}
	eventType := "mischief_downed"
	buyer, buyerUser := "", id.UserID("")
	if outcome == mischiefOutcomeSurvived {
		eventType = "mischief_survived"
		buyer, buyerUser = charName(c.BuyerID), c.BuyerID
	} else if c.Signed {
		buyer, buyerUser = charName(c.BuyerID), c.BuyerID
	}
	ts := nowUnix()
	emitFact(peteclient.Fact{
		GUID:       fmt.Sprintf("%s:%s:%d", eventType, eventToken(c.TargetID, "mischief:"+c.ID), ts),
		EventType:  eventType,
		Tier:       "bulletin",
		Subject:    target,
		Opponent:   buyer,
		Boss:       monsterName,
		Stakes:     fmtEuro(purse),
		Level:      charLevel(c.TargetID),
		Outcome:    outcome,
		OccurredAt: ts,
	}, c.TargetID, buyerUser)
}

func emitMischiefFizzledNews(c *mischiefContract, refund int) {
	target := charName(c.TargetID)
	if target == "" {
		return
	}
	ts := nowUnix()
	emitFact(peteclient.Fact{
		GUID:       fmt.Sprintf("mischief_fizzled:%s:%d", eventToken(c.TargetID, "mischief:"+c.ID), ts),
		EventType:  "mischief_fizzled",
		Tier:       "bulletin",
		Subject:    target,
		Stakes:     fmtEuro(refund),
		Outcome:    "fizzled",
		OccurredAt: ts,
	}, c.TargetID, "")
}

// mischiefContractByID is the test/ops read path.
func mischiefContractByID(contractID string) (*mischiefContract, error) {
	row := db.Get().QueryRow(
		`SELECT `+mischiefCols+` FROM mischief_contracts WHERE contract_id = ?`, contractID)
	c, err := scanMischief(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	return c, err
}
