package plugin

import (
	"testing"
	"time"

	"gogobee/internal/db"

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

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

func TestWorldBossName_DeterministicAndInPool(t *testing.T) {
	first := worldBossNameFor("2026-07")
	if again := worldBossNameFor("2026-07"); again != first {
		t.Errorf("name not deterministic: %q vs %q", first, again)
	}
	inPool := false
	for _, n := range worldBossNames {
		if n == first {
			inPool = true
			break
		}
	}
	if !inPool {
		t.Errorf("generated name %q not in worldBossNames pool", first)
	}
}

func TestMedianInt(t *testing.T) {
	cases := []struct {
		in   []int
		want int
	}{
		{nil, 0},
		{[]int{5}, 5},
		{[]int{3, 1, 2}, 2},         // odd, unsorted
		{[]int{4, 2, 8, 6}, 5},      // even → mean of the two middles (4,6)
		{[]int{10, 10, 10, 10}, 10}, // even, all equal
	}
	for _, c := range cases {
		if got := medianInt(c.in); got != c.want {
			t.Errorf("medianInt(%v) = %d, want %d", c.in, got, c.want)
		}
	}
}

func TestTierForCombinedLevel(t *testing.T) {
	cases := []struct{ level, want int }{
		{0, worldBossMinTier},
		{11, worldBossMinTier},
		{12, 4},
		{20, 4},
		{21, 5},
		{99, 5},
	}
	for _, c := range cases {
		if got := tierForCombinedLevel(c.level); got != c.want {
			t.Errorf("tierForCombinedLevel(%d) = %d, want %d", c.level, got, c.want)
		}
	}
}

func TestWorldBoss_InsertLoadRoundTrip(t *testing.T) {
	newWorldBossTestDB(t)
	now := time.Now().UTC().Truncate(time.Second)
	id0, err := insertWorldBoss("Gorloth", 5, 2400, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}
	active, err := loadActiveWorldBoss()
	if err != nil || active == nil {
		t.Fatalf("loadActiveWorldBoss: %v (nil=%v)", err, active == nil)
	}
	if active.ID != id0 || active.Name != "Gorloth" || active.Tier != 5 ||
		active.HPMax != 2400 || active.HPCurrent != 2400 || active.Status != "active" {
		t.Errorf("round-trip mismatch: %+v", active)
	}
}

func TestApplyWorldBossDamage_ClampsAndFells(t *testing.T) {
	newWorldBossTestDB(t)
	now := time.Now().UTC()
	bossID, err := insertWorldBoss("Kravok", 3, 100, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}

	rem, killed, err := applyWorldBossDamage(bossID, 40)
	if err != nil || rem != 60 || killed {
		t.Fatalf("first hit: rem=%d killed=%v err=%v (want 60,false)", rem, killed, err)
	}

	// Overkill clamps at 0 and reports the fell.
	rem, killed, err = applyWorldBossDamage(bossID, 999)
	if err != nil || rem != 0 || !killed {
		t.Fatalf("overkill: rem=%d killed=%v err=%v (want 0,true)", rem, killed, err)
	}

	// Negative damage is treated as zero.
	if rem, _, err := applyWorldBossDamage(bossID, -5); err != nil || rem != 0 {
		t.Fatalf("negative: rem=%d err=%v", rem, err)
	}
}

func TestSetWorldBossStatus_ResolvesOnce(t *testing.T) {
	newWorldBossTestDB(t)
	now := time.Now().UTC()
	bossID, err := insertWorldBoss("Ymirok", 4, 500, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}
	if ok, err := setWorldBossStatus(bossID, "defeated"); err != nil || !ok {
		t.Fatalf("first close-out: ok=%v err=%v (want true)", ok, err)
	}
	// A second close-out is a no-op — the boss is no longer active.
	if ok, err := setWorldBossStatus(bossID, "survived"); err != nil || ok {
		t.Fatalf("double close-out: ok=%v err=%v (want false)", ok, err)
	}
	// And damage no longer lands on a resolved boss.
	if rem, killed, _ := applyWorldBossDamage(bossID, 500); rem != 500 || killed {
		t.Errorf("damage on resolved boss changed pool: rem=%d killed=%v", rem, killed)
	}
}

func TestWorldBossContrib_UpsertAccumulates(t *testing.T) {
	newWorldBossTestDB(t)
	user := id.UserID("@a:test.invalid")
	if err := upsertWorldBossContrib(1, user, 30, "2026-07-01"); err != nil {
		t.Fatal(err)
	}
	if err := upsertWorldBossContrib(1, user, 45, "2026-07-02"); err != nil {
		t.Fatal(err)
	}
	c, err := loadWorldBossContrib(1, user)
	if err != nil || c == nil {
		t.Fatalf("load: %v (nil=%v)", err, c == nil)
	}
	if c.Fights != 2 || c.Damage != 75 || c.LastFightDate != "2026-07-02" {
		t.Errorf("accumulate mismatch: %+v", c)
	}
}

func TestLoadWorldBossContribs_OrderedByFights(t *testing.T) {
	newWorldBossTestDB(t)
	a := id.UserID("@a:test.invalid")
	b := id.UserID("@b:test.invalid")
	// a: 1 fight; b: 2 fights → b sorts first.
	upsertWorldBossContrib(7, a, 100, "2026-07-01")
	upsertWorldBossContrib(7, b, 10, "2026-07-01")
	upsertWorldBossContrib(7, b, 10, "2026-07-02")
	got, err := loadWorldBossContribs(7)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 2 || got[0].UserID != b || got[1].UserID != a {
		t.Errorf("order = %+v, want b then a", got)
	}
}

func TestComputeWorldBossPayouts_ScalesByFights(t *testing.T) {
	contribs := []worldBossContrib{
		{UserID: "@a:t", Fights: 3, Damage: 999},
		{UserID: "@b:t", Fights: 1, Damage: 10},
		{UserID: "@c:t", Fights: 0, Damage: 0}, // no fights → no payout
	}
	pays := computeWorldBossPayouts(contribs, 1000)
	if len(pays) != 2 {
		t.Fatalf("got %d payouts, want 2 (zero-fight excluded)", len(pays))
	}
	if pays[0].Euro != 3000 || pays[1].Euro != 1000 {
		t.Errorf("payouts = %d,%d want 3000,1000", pays[0].Euro, pays[1].Euro)
	}
}

// TestWorldBossSpawnPlan_SizesToActiveTown seeds three active max-level players
// and checks the boss is T5 with a pool scaled to the turnout.
func TestWorldBossSpawnPlan_SizesToActiveTown(t *testing.T) {
	newWorldBossTestDB(t)
	today := time.Now().UTC().Format("2006-01-02")
	for _, u := range []string{"@a:test.invalid", "@b:test.invalid", "@c:test.invalid"} {
		c := &AdventureCharacter{UserID: id.UserID(u), DisplayName: "P", Alive: true,
			ForagingSkill: 30, CreatedAt: time.Now().UTC()}
		if err := saveAdvCharacter(c); err != nil {
			t.Fatal(err)
		}
		if _, err := db.Get().Exec(
			`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)`,
			u, today); err != nil {
			t.Fatal(err)
		}
	}
	tier, hpMax, activeN := worldBossSpawnPlan()
	if activeN != 3 {
		t.Errorf("activeN = %d, want 3", activeN)
	}
	if tier != 5 {
		t.Errorf("tier = %d, want 5 (combined >= 21)", tier)
	}
	// bouts = 3 × 2.0 = 6; perBout at T5 = 400 → pool 2400.
	perBout, _, _, _, _ := arenaTierBaseStats(5)
	if want := perBout * 6; hpMax != want {
		t.Errorf("hpMax = %d, want %d", hpMax, want)
	}
}

// TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss: no active players still yields a
// beatable floor boss so a manual spawn works.
func TestWorldBossSpawnPlan_EmptyTownGetsFloorBoss(t *testing.T) {
	newWorldBossTestDB(t)
	tier, hpMax, activeN := worldBossSpawnPlan()
	if activeN != 0 {
		t.Errorf("activeN = %d, want 0", activeN)
	}
	if tier != worldBossMinTier {
		t.Errorf("tier = %d, want floor %d", tier, worldBossMinTier)
	}
	perBout, _, _, _, _ := arenaTierBaseStats(worldBossMinTier)
	if want := perBout * worldBossMinBouts; hpMax != want {
		t.Errorf("hpMax = %d, want %d (min bouts floor)", hpMax, want)
	}
}

func TestSpawnWorldBoss_RefusesWhenOneIsActive(t *testing.T) {
	newWorldBossTestDB(t)
	p := &AdventurePlugin{}
	first, err := p.spawnWorldBoss("2026-07")
	if err != nil || first == nil {
		t.Fatalf("first spawn: %v (nil=%v)", err, first == nil)
	}
	second, err := p.spawnWorldBoss("2026-07")
	if err == nil {
		t.Error("second spawn should refuse while one is active")
	}
	if second == nil || second.ID != first.ID {
		t.Error("refusal should return the existing active boss")
	}
}

// TestResolveWorldBossSurvived_DebitsPot exercises the survive path end to end:
// the boss closes out and the pot loses its tribute.
func TestResolveWorldBossSurvived_DebitsPot(t *testing.T) {
	newWorldBossTestDB(t)
	communityPotAdd(1000)
	now := time.Now().UTC()
	bossID, err := insertWorldBoss("Vornath", 5, 2400, now.Add(-worldBossWindow), now.Add(-time.Hour))
	if err != nil {
		t.Fatal(err)
	}
	boss, _ := loadWorldBoss(bossID)
	p := &AdventurePlugin{}
	p.resolveWorldBossSurvived(boss)

	after, _ := loadWorldBoss(bossID)
	if after.Status != "survived" {
		t.Errorf("status = %q, want survived", after.Status)
	}
	// tribute = 20% of 1000 = 200 → pot left with 800.
	if bal := communityPotBalance(); bal != 800 {
		t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
	}
}

// ── W2: the bout ─────────────────────────────────────────────────────────────

func fightableChar(t *testing.T, uid id.UserID) {
	t.Helper()
	if err := createAdvCharacter(uid, "boxer"); err != nil {
		t.Fatalf("createAdvCharacter: %v", err)
	}
	c := &DnDCharacter{
		UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 12,
		STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
		HPMax: 120, HPCurrent: 120, ArmorClass: 18,
	}
	if err := SaveDnDCharacter(c); err != nil {
		t.Fatalf("SaveDnDCharacter: %v", err)
	}
}

func TestWorldBossFloorHP(t *testing.T) {
	newWorldBossTestDB(t)
	uid := id.UserID("@floor:test.invalid")
	fightableChar(t, uid)
	if floorHPAtOne(uid) {
		t.Error("floor should be a no-op above 0 HP")
	}
	if _, err := db.Get().Exec(`UPDATE dnd_character SET hp_current = 0 WHERE user_id = ?`, string(uid)); err != nil {
		t.Fatal(err)
	}
	if !floorHPAtOne(uid) {
		t.Error("floor should raise a 0-HP fighter")
	}
	if cur, _ := dndHPSnapshot(uid); cur != 1 {
		t.Errorf("hp after floor = %d, want 1", cur)
	}
}

func TestResolveWorldBossBout_SubtractsAndRecords(t *testing.T) {
	newWorldBossTestDB(t)
	uid := id.UserID("@bout:test.invalid")
	fightableChar(t, uid)
	now := time.Now().UTC()
	// Big pool so one bout can only chip it — assert the partial subtract + contrib.
	bossID, err := insertWorldBoss("Kravok", 3, 100000, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}
	boss, _ := loadWorldBoss(bossID)
	p := &AdventurePlugin{}
	bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
	if err != nil {
		t.Fatalf("bout: %v", err)
	}
	if bout.Damage < 0 {
		t.Errorf("damage negative: %d", bout.Damage)
	}
	if bout.Killed {
		t.Error("a 100k pool should survive one bout")
	}
	if bout.Remaining != 100000-bout.Damage {
		t.Errorf("remaining %d != max-dmg %d", bout.Remaining, 100000-bout.Damage)
	}
	c, _ := loadWorldBossContrib(bossID, uid)
	if c == nil || c.Fights != 1 || c.Damage != bout.Damage || c.LastFightDate != "2026-07-05" {
		t.Errorf("contrib mismatch: %+v (bout dmg %d)", c, bout.Damage)
	}
	if !worldBossBoutUsedToday(bossID, uid, "2026-07-05") {
		t.Error("bout should read as used for that day")
	}
	if worldBossBoutUsedToday(bossID, uid, "2026-07-06") {
		t.Error("a different day should still be available")
	}
}

func TestResolveWorldBossBout_FellsTinyPool(t *testing.T) {
	newWorldBossTestDB(t)
	uid := id.UserID("@kill:test.invalid")
	fightableChar(t, uid)
	now := time.Now().UTC()
	bossID, err := insertWorldBoss("Wisp", 3, 1, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}
	boss, _ := loadWorldBoss(bossID)
	p := &AdventurePlugin{}
	bout, err := p.resolveWorldBossBout(uid, boss, "2026-07-05")
	if err != nil {
		t.Fatalf("bout: %v", err)
	}
	// A L12 fighter lands at least one blow on a T3 dummy over a full boss fight,
	// felling a 1-HP pool.
	if !bout.Killed || bout.Remaining != 0 {
		t.Errorf("tiny pool not felled: killed=%v remaining=%d dmg=%d", bout.Killed, bout.Remaining, bout.Damage)
	}
	// Resolution is the caller's job — the bout leaves the boss active.
	after, _ := loadWorldBoss(bossID)
	if after.Status != "active" {
		t.Errorf("bout should not self-resolve; status=%q", after.Status)
	}
}

// TestWorldBossTick_ResolvesZeroHPBossAsDefeated: a killing blow that committed
// the pool to 0 but (crash/redeploy) never resolved the status must be resolved
// as DEFEATED by the ticker net — never fall through to the survive/pot-debit
// path. No contributors here, so resolution never touches euro.
func TestWorldBossTick_ResolvesZeroHPBossAsDefeated(t *testing.T) {
	newWorldBossTestDB(t)
	now := time.Now().UTC()
	communityPotAdd(1000)
	bossID, err := insertWorldBoss("Ghost", 3, 100, now, now.Add(worldBossWindow))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := db.Get().Exec(`UPDATE world_boss SET hp_current = 0 WHERE id = ?`, bossID); err != nil {
		t.Fatal(err)
	}
	p := &AdventurePlugin{}
	p.worldBossTick()
	after, _ := loadWorldBoss(bossID)
	if after.Status != "defeated" {
		t.Errorf("status = %q, want defeated (ticker safety net)", after.Status)
	}
	if bal := communityPotBalance(); bal != 1000 {
		t.Errorf("pot = %d, want 1000 — a defeat mints, it must not debit the pot", bal)
	}
}

// TestWorldBossTick_ResolvesLapsedBossAsSurvived: a boss whose window lapsed with
// the pool still up survives and loots the pot.
func TestWorldBossTick_ResolvesLapsedBossAsSurvived(t *testing.T) {
	newWorldBossTestDB(t)
	now := time.Now().UTC()
	communityPotAdd(1000)
	bossID, err := insertWorldBoss("Titan", 4, 500, now.Add(-worldBossWindow), now.Add(-time.Minute))
	if err != nil {
		t.Fatal(err)
	}
	p := &AdventurePlugin{}
	p.worldBossTick()
	after, _ := loadWorldBoss(bossID)
	if after.Status != "survived" {
		t.Errorf("status = %q, want survived", after.Status)
	}
	if bal := communityPotBalance(); bal != 800 {
		t.Errorf("pot = %d, want 800 after 20%% tribute", bal)
	}
}
