package plugin

import (
	"math/rand/v2"
	"testing"
	"time"

	"gogobee/internal/db"

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

func newMischiefTestDB(t *testing.T) {
	t.Helper()
	db.Close()
	if err := db.Init(t.TempDir()); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(db.Close)
}

func seedContract(t *testing.T, buyer, target id.UserID, tier string, signed bool) *mischiefContract {
	t.Helper()
	td, ok := mischiefTierByKey(tier)
	if !ok {
		t.Fatalf("unknown tier %q", tier)
	}
	paid := td.Fee
	if signed {
		paid = mischiefSignedFee(td.Fee)
	}
	c := &mischiefContract{
		ID:           uuid.NewString(),
		BuyerID:      buyer,
		TargetID:     target,
		Tier:         td.Key,
		Fee:          td.Fee,
		Paid:         paid,
		Status:       mischiefStatusOpen,
		Signed:       signed,
		Source:       "matrix",
		CreatedAt:    time.Now().UTC(),
		WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
	}
	if err := insertMischiefContract(c); err != nil {
		t.Fatalf("seed contract (%s → %s): %v", buyer, target, err)
	}
	return c
}

// The anti-collusion invariant, and the only reason no danger multiplier is
// needed: whatever the tier and whether or not the buyer signed, the target's
// survival purse is strictly less than what the buyer parted with. Two players
// who try to move money this way lose to !baltransfer, which is free.
func TestMischiefPurseNeverExceedsOutlay(t *testing.T) {
	for _, tier := range mischiefTiers {
		purse := mischiefPurse(tier.Fee, tier.PayoutPct)
		for _, paid := range []int{tier.Fee, mischiefSignedFee(tier.Fee)} {
			if purse >= paid {
				t.Errorf("%s: purse %d >= buyer outlay %d — collusion beats !baltransfer",
					tier.Key, purse, paid)
			}
		}
		if tier.PayoutPct > 0.75 {
			t.Errorf("%s: payout %.2f exceeds the 75%% cap", tier.Key, tier.PayoutPct)
		}
	}
}

// The cap is enforced in code, not just in the table — an escalation (M2) raises
// the payout basis, and nothing it can do may push the purse past 75%.
func TestMischiefPurseHardCap(t *testing.T) {
	if got := mischiefPurse(1000, 0.95); got != 750 {
		t.Fatalf("mischiefPurse(1000, 0.95) = %d, want 750 (capped)", got)
	}
}

func TestMischiefSignedFee(t *testing.T) {
	// The tier table's signed prices, as priced in M0.
	want := map[string]int{"grunt": 50, "mob": 125, "elite": 438, "boss": 1500}
	for _, tier := range mischiefTiers {
		if got := mischiefSignedFee(tier.Fee); got != want[tier.Key] {
			t.Errorf("%s signed fee = %d, want %d", tier.Key, got, want[tier.Key])
		}
	}
}

// Exactly one of delivery and fizzle may claim a contract, however many ticks
// race for it. Without this a restart mid-delivery could both maim the target
// and refund the buyer.
func TestMischiefClaimIsExactlyOnce(t *testing.T) {
	newMischiefTestDB(t)
	c := seedContract(t, "@buyer:x", "@target:x", "elite", false)

	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("first claim should win")
	}
	if claimMischiefForDelivery(c.ID) {
		t.Error("second claim won — a contract could be delivered twice")
	}
	if fizzleMischiefContract(c.ID) {
		t.Error("fizzle won after the delivery claim — buyer would be refunded a hit that landed")
	}

	c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
	if !fizzleMischiefContract(c2.ID) {
		t.Fatal("first fizzle should win")
	}
	if claimMischiefForDelivery(c2.ID) {
		t.Error("delivery claimed a fizzled contract — buyer would pay twice")
	}
}

// Only a closed window is due, and a resolved contract never comes back.
func TestMischiefDueWindow(t *testing.T) {
	newMischiefTestDB(t)
	now := time.Now().UTC()
	c := seedContract(t, "@buyer:x", "@target:x", "mob", false)

	if due := loadMischiefDue(now); len(due) != 0 {
		t.Fatalf("contract is due before its window closed: %d", len(due))
	}
	due := loadMischiefDue(now.Add(mischiefWindow + time.Minute))
	if len(due) != 1 || due[0].ID != c.ID {
		t.Fatalf("want the contract due after its window; got %d", len(due))
	}

	resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
	if due := loadMischiefDue(now.Add(2 * mischiefWindow)); len(due) != 0 {
		t.Error("a resolved contract came back around")
	}
}

// One live contract per target: two monsters converging on the same person is a
// mugging, not mischief.
func TestMischiefOneLiveContractPerTarget(t *testing.T) {
	newMischiefTestDB(t)
	target := id.UserID("@target:x")
	if liveMischiefForTarget(target) != nil {
		t.Fatal("no contract seeded, but one is live")
	}
	c := seedContract(t, "@buyer:x", target, "grunt", false)
	if live := liveMischiefForTarget(target); live == nil || live.ID != c.ID {
		t.Fatal("open contract should be live")
	}
	// A claimed-but-unresolved contract still blocks — the monster is en route.
	claimMischiefForDelivery(c.ID)
	if liveMischiefForTarget(target) == nil {
		t.Error("a delivering contract should still block a second one")
	}
	resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeDowned)
	if liveMischiefForTarget(target) != nil {
		t.Error("a resolved contract still reads as live")
	}
}

// The post-resolution breather. Also the regression guard for the modernc
// datetime hazard: resolved_at round-trips through the driver's own encoding
// (bound Go time in, time.Time out), so the row has to be a real one — a
// hand-written stamp in some other format would compare lexicographically and
// silently never expire.
func TestMischiefTargetCooldown(t *testing.T) {
	newMischiefTestDB(t)
	target := id.UserID("@target:x")
	now := time.Now().UTC()

	if cooling, _ := mischiefTargetCoolingDown(target, now); cooling {
		t.Fatal("a target with no history is cooling down")
	}
	c := seedContract(t, "@buyer:x", target, "grunt", false)
	resolveMischiefContract(c.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)

	cooling, wait := mischiefTargetCoolingDown(target, now)
	if !cooling {
		t.Fatal("a just-resolved target should be cooling down")
	}
	// resolved_at is stamped a hair after `now`, so allow a second of slack.
	if wait <= 0 || wait > mischiefTargetCooldown+time.Second {
		t.Errorf("wait = %s, want within (0, %s]", wait, mischiefTargetCooldown)
	}
	if cooling, _ := mischiefTargetCoolingDown(target, now.Add(mischiefTargetCooldown+time.Minute)); cooling {
		t.Error("still cooling down after the cooldown elapsed")
	}
}

// Rate limits: contracts per buyer per day, and boss-tier per target per week.
// Fizzled contracts count against the buyer — the limit is on how often you may
// point a monster at someone, not on how often it connects.
func TestMischiefRateLimits(t *testing.T) {
	newMischiefTestDB(t)
	buyer := id.UserID("@buyer:x")
	now := time.Now().UTC()
	dayAgo := now.Add(-24 * time.Hour)

	if n := mischiefBuyerCountSince(buyer, dayAgo); n != 0 {
		t.Fatalf("fresh buyer count = %d, want 0", n)
	}
	c1 := seedContract(t, buyer, "@a:x", "grunt", false)
	seedContract(t, buyer, "@b:x", "mob", false)
	fizzleMischiefContract(c1.ID)
	if n := mischiefBuyerCountSince(buyer, dayAgo); n != mischiefBuyerDailyCap {
		t.Errorf("buyer count = %d, want %d (a fizzle still counts)", n, mischiefBuyerDailyCap)
	}

	target := id.UserID("@whale:x")
	weekAgo := now.Add(-mischiefBossPerTargetWindow)
	if n := mischiefBossCountSince(target, weekAgo); n != 0 {
		t.Fatalf("fresh boss count = %d, want 0", n)
	}
	elite := seedContract(t, "@other:x", target, "elite", false)
	if n := mischiefBossCountSince(target, weekAgo); n != 0 {
		t.Error("an elite contract counted against the boss-per-week limit")
	}
	// Resolve it first: only one contract may be live against a target at a time,
	// and the database enforces that (idx_mischief_one_live_per_target).
	resolveMischiefContract(elite.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)
	seedContract(t, "@other:x", target, "boss", false)
	if n := mischiefBossCountSince(target, weekAgo); n != 1 {
		t.Errorf("boss count = %d, want 1", n)
	}
}

// The database, not a read-then-write check, is what stops two buyers racing a
// monster at the same target. The placement path holds only the buyer's lock, so
// without this index both inserts would land and the target would be hit twice.
func TestMischiefSecondLiveContractIsRejected(t *testing.T) {
	newMischiefTestDB(t)
	target := id.UserID("@target:x")
	seedContract(t, "@buyer1:x", target, "grunt", false)

	second := &mischiefContract{
		ID: uuid.NewString(), BuyerID: "@buyer2:x", TargetID: target,
		Tier: "elite", Fee: 350, Paid: 350, Status: mischiefStatusOpen,
		Source: "matrix", CreatedAt: time.Now().UTC(),
		WindowEndsAt: time.Now().UTC().Add(mischiefWindow),
	}
	if err := insertMischiefContract(second); err == nil {
		t.Fatal("a second live contract was accepted — the target can be hit twice at once")
	}
}

// A delivery that dies mid-flight (restart, panic) must not strand the contract:
// the row would block the target forever and the buyer's money would be gone.
func TestMischiefStaleDeliverySweep(t *testing.T) {
	newMischiefTestDB(t)
	now := time.Now().UTC()
	c := seedContract(t, "@buyer:x", "@target:x", "boss", false)
	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("claim should win")
	}

	// Inside the grace period a slow delivery is left alone.
	if stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow)); len(stale) != 0 {
		t.Errorf("a delivery still inside its grace window was called stranded: %d", len(stale))
	}

	stale := loadStaleMischiefDeliveries(now.Add(mischiefWindow + mischiefDeliveryGrace + time.Minute))
	if len(stale) != 1 || stale[0].ID != c.ID {
		t.Fatalf("stranded delivery not found: %d", len(stale))
	}
	if !abandonStaleMischief(c.ID) {
		t.Fatal("abandon should win on a delivering row")
	}
	if abandonStaleMischief(c.ID) {
		t.Error("abandon won twice — the buyer would be refunded twice")
	}
	// And the target is free again.
	if liveMischiefForTarget("@target:x") != nil {
		t.Error("a swept contract still blocks the target")
	}
}

// The monster is priced against the TARGET's bracket, not the dungeon they are
// standing in — that is what the M0 fee table measured.
func TestMischiefBracketZones(t *testing.T) {
	for _, tc := range []struct {
		level int
		tier  ZoneTier
	}{{1, 1}, {3, 1}, {4, 2}, {7, 2}, {8, 3}, {12, 3}, {13, 4}, {17, 4}, {18, 5}, {20, 5}} {
		zone := mischiefBracketZone(tc.level)
		if zone.Tier != tc.tier {
			t.Errorf("level %d draws from tier %d, want %d", tc.level, zone.Tier, tc.tier)
		}
	}
}

// The fight shapes the fee table was priced against. Changing any of these
// invalidates the pricing, so they are pinned.
func TestMischiefMonsterChains(t *testing.T) {
	zone := mischiefBracketZone(10) // tier 3
	rng := rand.New(rand.NewPCG(0x6d69, 1))

	for _, tc := range []struct {
		tier       string
		wantLen    int
		wantAmbush bool
	}{
		{"grunt", 1, false},
		{"mob", 3, false},
		{"elite", 1, true},
		{"boss", 1, true},
	} {
		chain, ambush := mischiefMonsters(tc.tier, zone, rng)
		if len(chain) != tc.wantLen {
			t.Errorf("%s: chain of %d, want %d", tc.tier, len(chain), tc.wantLen)
		}
		if ambush != tc.wantAmbush {
			t.Errorf("%s: ambush = %v, want %v", tc.tier, ambush, tc.wantAmbush)
		}
		for _, m := range chain {
			if m.Name == "" {
				t.Errorf("%s: chain holds an empty monster — the bestiary lookup missed", tc.tier)
			}
		}
	}

	// Boss tier sends the zone's actual boss; elite sends something flagged elite.
	if chain, _ := mischiefMonsters("boss", zone, rng); chain[0].ID != zone.Boss.BestiaryID {
		t.Errorf("boss tier sent %q, want the zone boss %q", chain[0].ID, zone.Boss.BestiaryID)
	}
	elite, _ := mischiefMonsters("elite", zone, rng)
	found := false
	for _, e := range zone.Enemies {
		if e.BestiaryID == elite[0].ID && e.IsElite {
			found = true
		}
	}
	if !found {
		t.Errorf("elite tier sent %q, which isn't an elite in %s", elite[0].ID, zone.ID)
	}
}

// ── End-to-end: the delivery path against a real character ───────────────────

// seedMischiefTarget builds a real, playable adventurer on a live expedition —
// the thing a contract actually lands on. Unit tests on the contract row prove
// nothing about the fight; these drive runMischiefInterrupt and the close-outs.
func seedMischiefTarget(t *testing.T, uid id.UserID, level, hp int) *Expedition {
	t.Helper()
	if err := createAdvCharacter(uid, "target"+uid.Localpart()); err != nil {
		t.Fatalf("createAdvCharacter: %v", err)
	}
	if err := SaveDnDCharacter(&DnDCharacter{
		UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level,
		STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
		HPMax: hp, HPCurrent: hp, ArmorClass: 18,
	}); err != nil {
		t.Fatalf("SaveDnDCharacter: %v", err)
	}
	run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
	if err != nil {
		t.Fatalf("startZoneRun: %v", err)
	}
	if _, err := db.Get().Exec(`
		INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, run_id, status, start_date, coins_earned)
		VALUES (?, ?, 'goblin_warrens', ?, 'active', ?, 500)`,
		"exp-"+uid.Localpart(), string(uid), run.RunID, time.Now().UTC()); err != nil {
		t.Fatalf("seed expedition: %v", err)
	}
	exp, err := getActiveExpedition(uid)
	if err != nil || exp == nil {
		t.Fatalf("getActiveExpedition: %v", err)
	}
	return exp
}

// A grunt against a healthy level-5 fighter: the fight actually runs, it costs
// HP, and they live. This is the "mostly theatre" tier doing what it was priced
// to do.
func TestMischiefDelivery_GruntIsSurvivable(t *testing.T) {
	newMischiefTestDB(t)
	p := &AdventurePlugin{}
	uid := id.UserID("@grunted:example.org")
	exp := seedMischiefTarget(t, uid, 5, 60)

	bracket := mischiefBracketZone(5)
	chain, ambush := mischiefMonsters("grunt", bracket, rand.New(rand.NewPCG(1, 2)))

	_, monster, survived := p.runMischiefInterrupt(uid, exp, bracket, chain, ambush, 0)
	if !survived {
		t.Fatalf("a level-5 fighter at full HP died to a grunt (%s) — the tier is mispriced", monster)
	}
	hp, _ := dndHPSnapshot(uid)
	if hp <= 0 {
		t.Errorf("survivor is at %d HP", hp)
	}
}

// The boss tier is the dangerous one, and the one where the no-perma-death rule
// has to hold. A level-3 caster-grade sheet against a tier-1 boss goes down —
// and must come back up at 1 HP with the expedition force-extracted, never dead.
//
// This is the property that separates mischief from paid murder: it is bought,
// and it lands while the victim may well be asleep.
func TestMischiefDelivery_DownedTargetIsMaimedNotKilled(t *testing.T) {
	newMischiefTestDB(t)
	p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
	uid := id.UserID("@doomed:example.org")
	// 1 HP: whatever the dice do, this fight ends with them on the floor.
	exp := seedMischiefTarget(t, uid, 3, 1)

	c := seedContract(t, "@buyer:example.org", uid, "boss", false)
	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("claim should win")
	}
	if err := p.deliverMischief(c, exp); err != nil {
		t.Fatalf("deliverMischief: %v", err)
	}

	// Alive, floored, and carried home.
	char, err := loadAdvCharacter(uid)
	if err != nil || char == nil {
		t.Fatal("target's character is gone")
	}
	if !char.Alive {
		t.Fatal("a PURCHASED monster killed a player outright — mischief must maim, never murder")
	}
	if hp, _ := dndHPSnapshot(uid); hp != 1 {
		t.Errorf("downed target is at %d HP, want the 1 HP floor", hp)
	}
	if e, _ := getActiveExpedition(uid); e != nil {
		t.Error("the expedition survived a downing — it must be force-extracted")
	}

	// The buyer gets nothing back; the whole fee is a sink.
	got, err := mischiefContractByID(c.ID)
	if err != nil || got == nil {
		t.Fatal("contract row vanished")
	}
	if got.Status != mischiefStatusDelivered || got.Outcome != mischiefOutcomeDowned {
		t.Errorf("contract resolved as %s/%s, want delivered/downed", got.Status, got.Outcome)
	}
	if pot := communityPotBalance(); pot < c.Paid {
		t.Errorf("community pot holds %d, want at least the %d fee — a landed hit refunds nobody", pot, c.Paid)
	}
	if bal := p.euro.GetBalance(c.BuyerID); bal > 0 {
		t.Errorf("buyer was credited %.0f on a successful hit; glory only", bal)
	}
}

// Survival pays the target out of the buyer's money, and never more than the
// buyer spent. Driven through the real close-out, not the arithmetic helper.
func TestMischiefDelivery_SurvivalPaysTheTarget(t *testing.T) {
	newMischiefTestDB(t)
	p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
	uid := id.UserID("@tough:example.org")
	exp := seedMischiefTarget(t, uid, 12, 400) // comfortably survives a tier-1 grunt

	c := seedContract(t, "@buyer:example.org", uid, "grunt", false)
	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("claim should win")
	}
	before := p.euro.GetBalance(uid)
	if err := p.deliverMischief(c, exp); err != nil {
		t.Fatalf("deliverMischief: %v", err)
	}

	got, _ := mischiefContractByID(c.ID)
	if got.Outcome != mischiefOutcomeSurvived {
		t.Fatalf("a level-12 fighter with 400 HP lost to a tier-1 grunt (outcome %s)", got.Outcome)
	}
	tier, _ := mischiefTierByKey("grunt")
	purse := mischiefPurse(tier.Fee, tier.PayoutPct)
	if gained := int(p.euro.GetBalance(uid) - before); gained != purse {
		t.Errorf("survivor was paid %d, want the %d purse", gained, purse)
	}
	if purse >= c.Paid {
		t.Errorf("purse %d >= outlay %d", purse, c.Paid)
	}
	// The remainder is a sink, not a rebate.
	if pot := communityPotBalance(); pot != c.Paid-purse {
		t.Errorf("pot holds %d, want the %d remainder", pot, c.Paid-purse)
	}
	// The expedition is untouched — they fought it off and walk on.
	if e, _ := getActiveExpedition(uid); e == nil {
		t.Error("surviving a contract ended the expedition")
	}
}

// An expedition that ends before the window closes fizzles: the buyer is
// refunded 90%, the town rakes the rest, and nobody is attacked.
func TestMischiefDelivery_FizzlesWhenTargetWentHome(t *testing.T) {
	newMischiefTestDB(t)
	p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
	uid := id.UserID("@gone:example.org")
	seedMischiefTarget(t, uid, 5, 60)

	c := seedContract(t, "@buyer:example.org", uid, "elite", false)
	// They extract before the monster arrives.
	if _, _, err := forcedExtractExpedition("exp-"+uid.Localpart(), "went home"); err != nil {
		t.Fatalf("forcedExtractExpedition: %v", err)
	}

	// Tomorrow at noon UTC: past the contract's window, and deliberately nowhere
	// near the 06:00 briefing or the 21:00 recap. fireMischiefDeliveries refuses to
	// run inside those quiet windows, so a wall-clock `now` here makes this test
	// fail for two hours of every real day.
	due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
	p.fireMischiefDeliveries(due)

	got, _ := mischiefContractByID(c.ID)
	if got.Status != mischiefStatusFizzled {
		t.Fatalf("contract status %s, want fizzled — the dungeon was empty", got.Status)
	}
	refund := int(float64(c.Paid) * mischiefFizzleRefund)
	if bal := int(p.euro.GetBalance(c.BuyerID)); bal != refund {
		t.Errorf("buyer refunded %d, want %d (90%%)", bal, refund)
	}
	if pot := communityPotBalance(); pot != c.Paid-refund {
		t.Errorf("pot raked %d, want %d", pot, c.Paid-refund)
	}
	if hp, max := dndHPSnapshot(uid); hp != max {
		t.Errorf("a fizzled contract still hurt the target (%d/%d HP)", hp, max)
	}
}

// The limbo state. A leader can win a fight their friend went down in — and a
// mischief delivery deliberately skips the close-outs that would normally mark a
// 0-HP seat dead. So the seat must be floored on BOTH outcomes, or the member is
// left alive at 0 HP: not dead enough for the hospital, too dead to act, and
// read as broken by every gate that tests `HPCurrent <= 0`.
func TestMischiefDelivery_SurvivalFloorsADroppedPartyMember(t *testing.T) {
	newMischiefTestDB(t)
	p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
	leader := id.UserID("@leader:example.org")
	member := id.UserID("@member:example.org")

	exp := seedMischiefTarget(t, leader, 12, 400) // wins comfortably
	seatLeaderFixture(t, exp.ID)
	if err := createAdvCharacter(member, "member"); err != nil {
		t.Fatal(err)
	}
	if err := SaveDnDCharacter(&DnDCharacter{
		UserID: member, Race: RaceHuman, Class: ClassFighter, Level: 3,
		STR: 14, DEX: 12, CON: 12, INT: 10, WIS: 10, CHA: 10,
		HPMax: 30, HPCurrent: 30, ArmorClass: 12,
	}); err != nil {
		t.Fatal(err)
	}
	if err := joinParty(exp.ID, member); err != nil {
		t.Fatalf("joinParty: %v", err)
	}
	// Put the member on the floor the way the fight would.
	if _, err := db.Get().Exec(
		`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(member)); err != nil {
		t.Fatal(err)
	}

	c := seedContract(t, "@buyer:example.org", leader, "grunt", false)
	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("claim should win")
	}
	if err := p.deliverMischief(c, exp); err != nil {
		t.Fatalf("deliverMischief: %v", err)
	}

	got, _ := mischiefContractByID(c.ID)
	if got.Outcome != mischiefOutcomeSurvived {
		t.Fatalf("leader lost; this test needs a survival (outcome %s)", got.Outcome)
	}
	hp, _ := dndHPSnapshot(member)
	if hp <= 0 {
		t.Errorf("dropped party member left at %d HP on a survived contract — alive, but stuck at zero", hp)
	}
	if mc, _ := loadAdvCharacter(member); mc == nil || !mc.Alive {
		t.Error("a bought monster killed a party member — mischief maims, it never murders")
	}
}

// ── M2: the window (bless / escalate) ────────────────────────────────────────

// mischiefWindowPlugin is a plugin wired for command-level tests: a euro ledger
// and a sink, so `!mischief bless/escalate` can be driven the way a player drives
// them (the CAS races these commands lose are the whole point of M2, and they only
// exist on the command path).
func mischiefWindowPlugin(t *testing.T) (*AdventurePlugin, *captureSink) {
	t.Helper()
	sink := &captureSink{}
	p := &AdventurePlugin{euro: NewEuroPlugin(nil)}
	p.Sink = sink
	return p, sink
}

func mischiefCtx(sender id.UserID) MessageContext {
	return MessageContext{Sender: sender, RoomID: id.RoomID("!room:x"), EventID: id.EventID("$e")}
}

// A ward is priced against the thing it defends against. The M1-close-out sweep
// measured three wards buying ~+40pp of survival, which at a flat fee let the room
// halve a €1,200 boss contract for €75 — the tier the whole economy rests on,
// bought off for pocket change. Cheap at the impulse tiers, real money at the top.
func TestMischiefBlessingFee_ScalesWithTheContract(t *testing.T) {
	want := map[string]int{"grunt": 25, "mob": 25, "elite": 35, "boss": 120}
	for _, tier := range mischiefTiers {
		if got := mischiefBlessingFee(tier.Fee); got != want[tier.Key] {
			t.Errorf("%s ward = %d, want %d", tier.Key, got, want[tier.Key])
		}
		// Where a ward is a rational purchase — the tiers the sweep says can
		// actually put someone on the floor — covering a target must cost less than
		// the contract does. Otherwise the room's counterplay is priced out and the
		// window is dead.
		//
		// Grunt and mob are deliberately exempt: the €25 floor is above-market
		// there (3 wards cost more than a €40 grunt), and that is fine, because
		// nothing in the sweep needs warding against theatre — those tiers sit at
		// 100% survival unwounded. The floor is there to keep a ward an impulse
		// buy, not to make it a sensible one against a goblin.
		if tier.Key != "elite" && tier.Key != "boss" {
			continue
		}
		if full := mischiefBlessingFee(tier.Fee) * mischiefBlessingCap; full >= tier.Fee {
			t.Errorf("%s: %d wards cost %d, which is more than the %d contract — "+
				"the room can't afford to save anyone", tier.Key, mischiefBlessingCap, full, tier.Fee)
		}
	}
}

// The cap lives in the UPDATE, not in the read that precedes it. A room that
// piles four wards on in the same second must still land exactly three — and the
// player who lost that race must get their money back rather than pay for
// nothing.
func TestMischiefBlessing_CapIsEnforcedByTheUpdate(t *testing.T) {
	newMischiefTestDB(t)
	c := seedContract(t, "@buyer:x", "@target:x", "elite", false)

	for i := 1; i <= mischiefBlessingCap; i++ {
		if !blessMischiefContract(c.ID) {
			t.Fatalf("blessing %d/%d was rejected", i, mischiefBlessingCap)
		}
	}
	if blessMischiefContract(c.ID) {
		t.Errorf("a %dth blessing landed — the cap is not enforced", mischiefBlessingCap+1)
	}
	got, _ := mischiefContractByID(c.ID)
	if got.BlessingCount != mischiefBlessingCap {
		t.Errorf("blessing_count = %d, want %d", got.BlessingCount, mischiefBlessingCap)
	}
	// And nobody may ward a contract that has already been claimed for delivery:
	// the monster is in the room with them.
	c2 := seedContract(t, "@buyer:x", "@other:x", "grunt", false)
	if !claimMischiefForDelivery(c2.ID) {
		t.Fatal("claim should win")
	}
	if blessMischiefContract(c2.ID) {
		t.Error("warded a contract mid-delivery — the fight had already started")
	}
}

// A blessing buys HP, never euros: the fee is a sink (community pot), and the
// purse is untouched by it. Otherwise a target's friend could ward them as a way
// of routing money into their pocket on the way out.
func TestMischiefBlessing_FeeIsASinkNotAPurseTopUp(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	target := id.UserID("@warded:x")
	friend := id.UserID("@friend:x")
	p.euro.Credit(friend, 500, "test")

	c := seedContract(t, "@buyer:x", target, "elite", false)
	feeBefore := c.Fee
	ward := mischiefBlessingFee(c.Fee) // 10% of the €350 elite fee

	if err := p.mischiefBlessCmd(mischiefCtx(friend), []string{string(target)}); err != nil {
		t.Fatalf("bless: %v", err)
	}

	got, _ := mischiefContractByID(c.ID)
	if got.BlessingCount != 1 {
		t.Fatalf("blessing_count = %d, want 1", got.BlessingCount)
	}
	if got.Fee != feeBefore {
		t.Errorf("a blessing moved the payout basis to %d (was %d) — wards must not pay the target",
			got.Fee, feeBefore)
	}
	if bal := p.euro.GetBalance(friend); bal != float64(500-ward) {
		t.Errorf("blesser balance %.0f, want %d", bal, 500-ward)
	}
	if pot := communityPotBalance(); pot != ward {
		t.Errorf("pot holds %d, want the %d ward fee", pot, ward)
	}
	// Broke, so the second ward can't be bought — and must cost nothing.
	pauper := id.UserID("@pauper:x")
	if err := p.mischiefBlessCmd(mischiefCtx(pauper), []string{string(target)}); err != nil {
		t.Fatalf("bless: %v", err)
	}
	if bal := p.euro.GetBalance(pauper); bal != 0 {
		t.Errorf("a failed blessing moved the pauper's balance to %.0f", bal)
	}
	if got, _ := mischiefContractByID(c.ID); got.BlessingCount != 1 {
		t.Errorf("a blessing nobody could pay for still landed (count %d)", got.BlessingCount)
	}
}

// Escalation moves the tier, the payout basis and the outlay together — which is
// what keeps the anti-collusion invariant (purse < what was spent) true after the
// room has piled on. It also happens exactly once.
func TestMischiefEscalation_RaisesBasisAndOutlayTogether(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	target := id.UserID("@hunted:x")
	piler := id.UserID("@piler:x")
	rival := id.UserID("@rival:x")
	p.euro.Credit(piler, 5000, "test")
	p.euro.Credit(rival, 5000, "test")

	c := seedContract(t, "@buyer:x", target, "elite", true) // signed: paid > fee
	elite, _ := mischiefTierByKey("elite")
	boss, _ := mischiefTierByKey("boss")
	delta := boss.Fee - elite.Fee

	if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
		t.Fatalf("escalate: %v", err)
	}

	got, _ := mischiefContractByID(c.ID)
	if got.Tier != "boss" || got.Fee != boss.Fee {
		t.Fatalf("contract is %s/fee %d, want boss/%d", got.Tier, got.Fee, boss.Fee)
	}
	if got.Paid != c.Paid+delta {
		t.Errorf("paid = %d, want %d (%d + the %d delta)", got.Paid, c.Paid+delta, c.Paid, delta)
	}
	if got.EscalatedBy != piler {
		t.Errorf("escalated_by = %q, want %s — the unseal has nobody to name", got.EscalatedBy, piler)
	}
	if bal := p.euro.GetBalance(piler); bal != float64(5000-delta) {
		t.Errorf("escalator paid %.0f, want the %d delta", 5000-bal, delta)
	}
	// The invariant, after the escalation: the purse is still strictly less than
	// the money that went in.
	if purse := mischiefPurse(got.Fee, boss.PayoutPct); purse >= got.Paid {
		t.Errorf("escalated purse %d >= outlay %d — collusion now beats !baltransfer", purse, got.Paid)
	}

	// One step, once — whoever else was reaching for it is refunded.
	if err := p.mischiefEscalateCmd(mischiefCtx(rival), []string{string(target)}); err != nil {
		t.Fatalf("second escalate: %v", err)
	}
	if bal := p.euro.GetBalance(rival); bal != 5000 {
		t.Errorf("the losing escalator is out %.0f — a rejected escalation must cost nothing", 5000-bal)
	}
	after, _ := mischiefContractByID(c.ID)
	if after.EscalationCount != 1 || after.Paid != got.Paid {
		t.Errorf("contract escalated twice (count %d, paid %d)", after.EscalationCount, after.Paid)
	}
}

// A fizzle refunds `paid`, and after an escalation `paid` is TWO people's money.
// Refunding all of it to the buyer is a money pump: buy an elite for €350, have
// somebody bump it to a boss (+€850), let the target walk out of the dungeon, and
// the 90% refund hands the buyer €1080 — a €730 profit funded entirely by the
// escalator, who gets nothing and is never told.
func TestMischiefFizzle_RefundsTheEscalatorTheirOwnMoney(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	target := id.UserID("@hunted:x")
	buyer := id.UserID("@buyer:x")
	piler := id.UserID("@piler:x")
	p.euro.Credit(piler, 5000, "test")
	exp := seedMischiefTarget(t, target, 5, 60)

	c := seedContract(t, buyer, target, "elite", false)
	elite, _ := mischiefTierByKey("elite")
	boss, _ := mischiefTierByKey("boss")
	delta := boss.Fee - elite.Fee

	if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
		t.Fatalf("escalate: %v", err)
	}
	// They extract before the boss arrives. Nobody's monster ever lands.
	if _, _, err := forcedExtractExpedition(exp.ID, "went home"); err != nil {
		t.Fatalf("forcedExtractExpedition: %v", err)
	}
	due := time.Now().UTC().AddDate(0, 0, 1).Truncate(24 * time.Hour).Add(12 * time.Hour)
	p.fireMischiefDeliveries(due)

	if got, _ := mischiefContractByID(c.ID); got.Status != mischiefStatusFizzled {
		t.Fatalf("contract status %s, want fizzled", got.Status)
	}

	// Each stakeholder is refunded 90% of THEIR OWN stake — not 90% of the pooled
	// `paid`, which is what the buyer would otherwise pocket.
	wantBuyer := int(float64(c.Paid) * mischiefFizzleRefund)
	wantPiler := int(float64(delta) * mischiefFizzleRefund)
	if bal := int(p.euro.GetBalance(buyer)); bal != wantBuyer {
		t.Errorf("buyer refunded %d, want %d — they were handed the escalator's stake", bal, wantBuyer)
	}
	if spent := 5000 - int(p.euro.GetBalance(piler)); spent != delta-wantPiler {
		t.Errorf("escalator is out %d, want %d (their 10%% rake) — their refund went elsewhere",
			spent, delta-wantPiler)
	}
	if pot, want := communityPotBalance(), (c.Paid-wantBuyer)+(delta-wantPiler); pot != want {
		t.Errorf("pot raked %d, want %d", pot, want)
	}
}

// Boss tier is the "end their expedition" button, and the weekly cap on it is the
// target's protection. An escalation into boss is still a boss landing on them, so
// it answers to the same cap — otherwise the cap is bought around for the price of
// an elite plus the delta.
func TestMischiefEscalation_IntoBossRespectsTheWeeklyCap(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	target := id.UserID("@bossed:x")
	piler := id.UserID("@piler:x")
	p.euro.Credit(piler, 5000, "test")

	// They already took a boss this week (delivered, so it counts).
	spent := seedContract(t, "@buyer:x", target, "boss", false)
	resolveMischiefContract(spent.ID, mischiefStatusDelivered, mischiefOutcomeSurvived)

	c := seedContract(t, "@buyer2:x", target, "elite", false)
	if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
		t.Fatalf("escalate: %v", err)
	}
	got, _ := mischiefContractByID(c.ID)
	if got.Tier != "elite" {
		t.Errorf("escalated to %s — the weekly boss cap was bought around", got.Tier)
	}
	if bal := p.euro.GetBalance(piler); bal != 5000 {
		t.Errorf("a refused escalation charged %.0f", 5000-bal)
	}
}

// Boss is the top of the ladder, and a target may not raise the price on their
// own head.
func TestMischiefEscalation_RefusesBossAndSelfService(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	target := id.UserID("@top:x")
	piler := id.UserID("@piler:x")
	p.euro.Credit(piler, 5000, "test")
	p.euro.Credit(target, 5000, "test")

	c := seedContract(t, "@buyer:x", target, "boss", false)
	if err := p.mischiefEscalateCmd(mischiefCtx(piler), []string{string(target)}); err != nil {
		t.Fatalf("escalate: %v", err)
	}
	if got, _ := mischiefContractByID(c.ID); got.EscalationCount != 0 {
		t.Error("something got escalated past boss tier")
	}
	if bal := p.euro.GetBalance(piler); bal != 5000 {
		t.Errorf("charged %.0f for an impossible escalation", 5000-bal)
	}

	newMischiefTestDB(t)
	c2 := seedContract(t, "@buyer:x", target, "grunt", false)
	if err := p.mischiefEscalateCmd(mischiefCtx(target), []string{string(target)}); err != nil {
		t.Fatalf("escalate: %v", err)
	}
	if got, _ := mischiefContractByID(c2.ID); got.EscalationCount != 0 {
		t.Error("the target escalated their own contract")
	}
}

// The window keeps writing to an open contract right up to the claim, so the
// delivery must build the fight from the row as it stands AFTER the claim — not
// from the copy the due-sweep handed it. A tier bought at the last second (an
// escalation) is the visible half of this: read stale, the buyer pays for a boss
// and the target fights a grunt.
func TestMischiefDelivery_ReadsTheContractAfterTheClaim(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	uid := id.UserID("@lastsecond:x")
	seedMischiefTarget(t, uid, 12, 400)

	c := seedContract(t, "@buyer:x", uid, "grunt", false)
	// Somebody escalates in the gap between the due-sweep and the claim. `c` is now
	// the stale copy the ticker is still holding.
	elite, _ := mischiefTierByKey("elite")
	if !escalateMischiefContract(c.ID, "grunt", elite, elite.Fee-c.Fee, "@piler:x") {
		t.Fatal("escalation should have taken")
	}

	before := p.euro.GetBalance(uid)
	p.fireOneMischiefDelivery(c)

	got, _ := mischiefContractByID(c.ID)
	if got.Outcome != mischiefOutcomeSurvived {
		t.Fatalf("target lost; this test needs a survival to read the purse (outcome %s)", got.Outcome)
	}
	want := mischiefPurse(elite.Fee, elite.PayoutPct)
	if gained := int(p.euro.GetBalance(uid) - before); gained != want {
		t.Errorf("survivor was paid %d, want the escalated %d purse — "+
			"the delivery ran off the pre-claim copy of the contract", gained, want)
	}
}

// The ward is a cushion for THIS fight and nothing else: it goes on the sheet as
// temp HP for the delivery and comes off after — without eating the well-rested
// cushion the target may already have been carrying out of the door.
func TestMischiefBlessing_CushionsTheFightThenClearsItself(t *testing.T) {
	newMischiefTestDB(t)
	p, _ := mischiefWindowPlugin(t)
	uid := id.UserID("@blessed:x")
	exp := seedMischiefTarget(t, uid, 12, 400)

	// They long-rested at home before setting out.
	dc, _ := LoadDnDCharacter(uid)
	dc.TempHP = 32
	if err := SaveDnDCharacter(dc); err != nil {
		t.Fatal(err)
	}

	c := seedContract(t, "@buyer:x", uid, "grunt", false)
	for i := 0; i < mischiefBlessingCap; i++ {
		if !blessMischiefContract(c.ID) {
			t.Fatal("seed blessing rejected")
		}
	}
	c.BlessingCount = mischiefBlessingCap

	want := mischiefBlessingTempHP(400, mischiefBlessingCap) // 3 × 10% of 400
	if want != 120 {
		t.Fatalf("ward is %d HP, want 120 — the fight is being cushioned by something else", want)
	}
	if !claimMischiefForDelivery(c.ID) {
		t.Fatal("claim should win")
	}
	if err := p.deliverMischief(c, exp); err != nil {
		t.Fatalf("deliverMischief: %v", err)
	}

	after, _ := LoadDnDCharacter(uid)
	if after.TempHP != 32 {
		t.Errorf("temp HP is %d after the delivery, want the 32 they rested for — "+
			"the ward must not linger, and must not eat their own cushion", after.TempHP)
	}
}
