package plugin

import (
	"hash/fnv"
	"math/rand/v2"
	"strings"
	"testing"

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

func newDeterministicRNG(t *testing.T, seed string) *rand.Rand {
	t.Helper()
	h := fnv.New64a()
	h.Write([]byte(seed))
	return rand.New(rand.NewPCG(h.Sum64(), 0xC0FFEE))
}

// Phase 11 D1e — combat / trap / boss resolution tests.
//
// These exercise the pure helpers in dnd_zone_combat.go (deterministic
// enemy pick, coin-pattern parsing, loot rolling) and the integrated
// !zone advance path (trap nick + boss combat with a beefed-up char).

func TestPickZoneEnemy_NonElite_RespectsRoster(t *testing.T) {
	zone, _ := getZone(ZoneGoblinWarrens)
	seen := map[string]bool{}
	for room := 0; room < 32; room++ {
		m, ok := pickZoneEnemy(zone, "deterministic-run", room, false)
		if !ok {
			t.Fatalf("pickZoneEnemy returned !ok at room %d", room)
		}
		// Non-elite picks must never include elite-flagged entries.
		for _, e := range zone.Enemies {
			if e.IsElite && e.BestiaryID == m.ID {
				t.Errorf("non-elite pick returned elite roster entry: %s", m.ID)
			}
		}
		seen[m.ID] = true
	}
	if len(seen) < 2 {
		t.Errorf("expected variety across 32 rooms, only saw %d unique enemies: %v", len(seen), seen)
	}
}

func TestPickZoneEnemy_Elite_FiltersToEliteRoster(t *testing.T) {
	zone, _ := getZone(ZoneGoblinWarrens)
	for room := 0; room < 16; room++ {
		m, ok := pickZoneEnemy(zone, "elite-run", room, true)
		if !ok {
			t.Fatalf("elite pickZoneEnemy returned !ok at room %d", room)
		}
		// Confirm the picked monster's ID is one of the roster's elite-flagged entries.
		isElite := false
		for _, e := range zone.Enemies {
			if e.BestiaryID == m.ID && e.IsElite {
				isElite = true
				break
			}
		}
		if !isElite {
			t.Errorf("elite pick picked non-elite roster entry: %s", m.ID)
		}
	}
}

func TestPickZoneEnemy_Deterministic(t *testing.T) {
	zone, _ := getZone(ZoneCryptValdris)
	a, _ := pickZoneEnemy(zone, "stable", 3, false)
	b, _ := pickZoneEnemy(zone, "stable", 3, false)
	if a.ID != b.ID {
		t.Errorf("deterministic pick diverged: %s vs %s", a.ID, b.ID)
	}
}

func TestRollCoinPattern_Parses(t *testing.T) {
	rng := newDeterministicRNG(t, "coins")
	v := rollCoinPattern("coins_2d10x5", rng)
	// 2d10×5: min 10, max 100.
	if v < 10 || v > 100 {
		t.Errorf("2d10x5 = %d, out of [10,100]", v)
	}
}

func TestRollCoinPattern_BadInputFallsBack(t *testing.T) {
	rng := newDeterministicRNG(t, "bad-coins")
	if got := rollCoinPattern("coins_garbage", rng); got != 25 {
		t.Errorf("bad coin pattern fallback = %d, want 25", got)
	}
}

func TestZoneCombatXP_LossFraction(t *testing.T) {
	loss := zoneCombatXP(CombatResult{PlayerWon: false}, 1.0, 1)
	win := zoneCombatXP(CombatResult{PlayerWon: true}, 1.0, 1)
	if loss >= win {
		t.Errorf("loss (%d) should be < win (%d)", loss, win)
	}
}

func TestZoneCombatXP_NearDeathBonus(t *testing.T) {
	plain := zoneCombatXP(CombatResult{PlayerWon: true}, 1.0, 1)
	nd := zoneCombatXP(CombatResult{PlayerWon: true, NearDeath: true}, 1.0, 1)
	if nd <= plain {
		t.Errorf("near-death (%d) should beat plain win (%d)", nd, plain)
	}
}

func TestZoneCombatXP_TierFloor(t *testing.T) {
	// CR 0.25 at T5 should hit the tier floor, not 25 raw.
	got := zoneCombatXP(CombatResult{PlayerWon: true}, 0.25, 5)
	if got <= int(0.25*100) {
		t.Errorf("expected tier floor to lift CR 0.25 at T5 above 25, got %d", got)
	}
}

func TestTitleCaseUnderscored(t *testing.T) {
	cases := map[string]string{
		"wpn_handaxe_+1":     "Wpn Handaxe +1",
		"valdris_phylactery": "Valdris Phylactery",
		"":                   "",
		"single":             "Single",
	}
	for in, want := range cases {
		if got := titleCaseUnderscored(in); got != want {
			t.Errorf("titleCaseUnderscored(%q) = %q, want %q", in, got, want)
		}
	}
}

// failedDetect — fabricated SkillCheckResult that always reads as a
// missed detection. Used by trap-room tests to avoid d20 flake.
func failedDetect(skill DnDSkill, dc int) SkillCheckResult {
	return SkillCheckResult{Skill: skill, DC: dc, Roll: 5, Mod: 0, Total: 5, Success: false}
}

// passedDetect — fabricated successful detection.
func passedDetect(skill DnDSkill, dc int) SkillCheckResult {
	return SkillCheckResult{Skill: skill, DC: dc, Roll: 18, Mod: 0, Total: 18, Success: true}
}

func TestZoneTrapRoom_PitDamagesAndPersists(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-trap-pit:example")
	zoneCmdTestCharacter(t, uid, 1)
	defer cleanupZoneRuns(uid)

	run, err := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
	if err != nil {
		t.Fatal(err)
	}
	zone, _ := getZone(ZoneGoblinWarrens)
	dndChar, _ := LoadDnDCharacter(uid)

	pit := tier1TrapCatalog[0]
	if pit.Kind != TrapPit {
		t.Fatalf("expected pit at index 0, got %s", pit.Kind)
	}

	p := &AdventurePlugin{}
	dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
		failedDetect(pit.DetectSkill, pit.DetectDC))
	if dmg <= 0 {
		t.Errorf("pit trap dealt no damage on failed detect: %d", dmg)
	}
	if dmg > 12 {
		t.Errorf("pit trap exceeded 2d6 max: %d", dmg)
	}
	if !strings.Contains(narration, "HP") {
		t.Errorf("trap narration missing HP line: %q", narration)
	}
	c, _ := LoadDnDCharacter(uid)
	if c.HPCurrent >= c.HPMax {
		t.Errorf("trap did not persist HP loss: cur=%d max=%d", c.HPCurrent, c.HPMax)
	}
}

func TestZoneTrapRoom_DetectedSpotsHarmless(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-trap-spot:example")
	zoneCmdTestCharacter(t, uid, 1)
	defer cleanupZoneRuns(uid)

	run, _ := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
	zone, _ := getZone(ZoneGoblinWarrens)
	dndChar, _ := LoadDnDCharacter(uid)
	startHP := dndChar.HPCurrent

	pit := tier1TrapCatalog[0]
	p := &AdventurePlugin{}
	dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
		passedDetect(pit.DetectSkill, pit.DetectDC))
	if dmg != 0 {
		t.Errorf("detected trap should deal 0 damage, got %d", dmg)
	}
	if !strings.Contains(narration, "spotted") {
		t.Errorf("detected trap narration missing 'spotted': %q", narration)
	}
	c, _ := LoadDnDCharacter(uid)
	if c.HPCurrent != startHP {
		t.Errorf("detected trap should not persist HP change: cur=%d, want=%d", c.HPCurrent, startHP)
	}
}

func TestZoneTrapRoom_TripwireZeroDamageButAlerts(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-trap-tripwire:example")
	zoneCmdTestCharacter(t, uid, 1)
	defer cleanupZoneRuns(uid)

	run, _ := startZoneRun(uid, ZoneGoblinWarrens, 1, nil)
	zone, _ := getZone(ZoneGoblinWarrens)
	dndChar, _ := LoadDnDCharacter(uid)
	startHP := dndChar.HPCurrent

	var tripwire zoneTrapDef
	for _, tr := range tier1TrapCatalog {
		if tr.Kind == TrapTripwireAlarm {
			tripwire = tr
			break
		}
	}
	if tripwire.Kind != TrapTripwireAlarm {
		t.Fatal("tripwire not present in catalog")
	}

	p := &AdventurePlugin{}
	dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, tripwire,
		failedDetect(tripwire.DetectSkill, tripwire.DetectDC))
	if dmg != 0 {
		t.Errorf("tripwire should deal 0 damage even on fail, got %d", dmg)
	}
	if !strings.Contains(narration, "Tripwire") {
		t.Errorf("tripwire narration missing trap name: %q", narration)
	}
	c, _ := LoadDnDCharacter(uid)
	if c.HPCurrent != startHP {
		t.Errorf("tripwire should not change HP: cur=%d, want=%d", c.HPCurrent, startHP)
	}
}

func TestZoneTrapRoom_DoesNotKO(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-trap-low-hp:example")
	zoneCmdTestCharacter(t, uid, 1)
	defer cleanupZoneRuns(uid)

	c, _ := LoadDnDCharacter(uid)
	c.HPCurrent = 2
	if err := SaveDnDCharacter(c); err != nil {
		t.Fatal(err)
	}
	run, _ := startZoneRun(uid, ZoneCryptValdris, 1, nil)
	zone, _ := getZone(ZoneCryptValdris)
	dndChar, _ := LoadDnDCharacter(uid)

	pit := tier1TrapCatalog[0]
	p := &AdventurePlugin{}
	_, _ = p.applyTrapEffectWithDetect(uid, run, zone, dndChar, pit,
		failedDetect(pit.DetectSkill, pit.DetectDC))
	c, _ = LoadDnDCharacter(uid)
	if c.HPCurrent < 1 {
		t.Errorf("trap KO'd a player below 1 HP: %d", c.HPCurrent)
	}
}

func TestPickZoneTrap_DeterministicAndTier1Only(t *testing.T) {
	zone, _ := getZone(ZoneGoblinWarrens)
	a, ok := pickZoneTrap(zone, "fixed-run", 3)
	if !ok {
		t.Fatal("pickZoneTrap returned !ok for tier 1 zone")
	}
	b, _ := pickZoneTrap(zone, "fixed-run", 3)
	if a.Kind != b.Kind {
		t.Errorf("pickZoneTrap not deterministic: %s vs %s", a.Kind, b.Kind)
	}
	if a.Tier != ZoneTierBeginner {
		t.Errorf("tier 1 zone picked tier %d trap", a.Tier)
	}
}

func TestPickZoneTrap_Tier2UsesTier2Catalog(t *testing.T) {
	zone, ok := getZone(ZoneForestShadows)
	if !ok {
		t.Fatal("forest_shadows zone missing")
	}
	if zone.Tier != ZoneTierApprentice {
		t.Fatalf("forest_shadows expected tier 2, got %d", zone.Tier)
	}
	seen := map[ZoneTrapKind]bool{}
	for room := 0; room < 64; room++ {
		tr, ok := pickZoneTrap(zone, "t2-run", room)
		if !ok {
			t.Fatalf("pickZoneTrap !ok at room %d", room)
		}
		if tr.Tier != ZoneTierApprentice {
			t.Errorf("tier 2 zone picked tier %d trap (%s)", tr.Tier, tr.Kind)
		}
		seen[tr.Kind] = true
	}
	if len(seen) < 2 {
		t.Errorf("expected variety across 64 rooms in tier 2, only saw %d kinds: %v", len(seen), seen)
	}
}

func TestRollTrapDamage_GlyphOfWardingIn5d8Range(t *testing.T) {
	var glyph zoneTrapDef
	for _, tr := range tier2TrapCatalog {
		if tr.Kind == TrapGlyphOfWarding {
			glyph = tr
			break
		}
	}
	if glyph.Kind != TrapGlyphOfWarding {
		t.Fatal("glyph of warding not in tier 2 catalog")
	}
	for room := 0; room < 64; room++ {
		dmg := rollTrapDamage(glyph, "glyph-run", room)
		if dmg < 5 || dmg > 40 {
			t.Errorf("glyph damage out of 5..40 range at room %d: %d", room, dmg)
		}
	}
}

func TestTier2TrapCatalog_AllValid(t *testing.T) {
	if len(tier2TrapCatalog) < 3 {
		t.Fatalf("tier 2 catalog should have at least 3 traps, got %d", len(tier2TrapCatalog))
	}
	for _, tr := range tier2TrapCatalog {
		if tr.Tier != ZoneTierApprentice {
			t.Errorf("trap %s tagged tier %d, expected 2", tr.Kind, tr.Tier)
		}
		if tr.Display == "" || tr.Trigger == "" {
			t.Errorf("trap %s missing display/trigger text", tr.Kind)
		}
		if !tr.AlertsEnemies && (tr.DamageDiceN == 0 || tr.DamageDiceD == 0) {
			t.Errorf("trap %s deals no damage and is not an alarm", tr.Kind)
		}
		if tr.DetectDC <= 12 {
			t.Errorf("trap %s detect DC %d looks tier 1; tier 2 should be 13+", tr.Kind, tr.DetectDC)
		}
	}
}

func TestPickZoneTrap_VariesAcrossRooms(t *testing.T) {
	zone, _ := getZone(ZoneGoblinWarrens)
	seen := map[ZoneTrapKind]bool{}
	for room := 0; room < 64; room++ {
		tr, ok := pickZoneTrap(zone, "varied-run", room)
		if !ok {
			t.Fatalf("pickZoneTrap !ok at room %d", room)
		}
		seen[tr.Kind] = true
	}
	if len(seen) < 2 {
		t.Errorf("expected variety across 64 rooms, only saw %d kinds: %v", len(seen), seen)
	}
}

func TestRollTrapDamage_PoisonDartIn2d4Range(t *testing.T) {
	var dart zoneTrapDef
	for _, tr := range tier1TrapCatalog {
		if tr.Kind == TrapPoisonDart {
			dart = tr
			break
		}
	}
	if dart.Kind != TrapPoisonDart {
		t.Fatal("poison dart not in catalog")
	}
	for room := 0; room < 64; room++ {
		dmg := rollTrapDamage(dart, "dart-run", room)
		if dmg < 2 || dmg > 8 { // 2d4 range
			t.Errorf("poison dart damage out of 2..8 range at room %d: %d", room, dmg)
		}
	}
}

func TestRollTrapDamage_TripwireZero(t *testing.T) {
	var tripwire zoneTrapDef
	for _, tr := range tier1TrapCatalog {
		if tr.Kind == TrapTripwireAlarm {
			tripwire = tr
			break
		}
	}
	if dmg := rollTrapDamage(tripwire, "any-run", 0); dmg != 0 {
		t.Errorf("tripwire damage should be 0, got %d", dmg)
	}
}

// ── Phase 11 D4b — Tier 3 trap catalog ──────────────────────────────────────

func TestPickZoneTrap_Tier3UsesTier3Catalog(t *testing.T) {
	zone, ok := getZone(ZoneManorBlackspire)
	if !ok {
		t.Fatal("manor_blackspire zone missing")
	}
	if zone.Tier != ZoneTierJourneyman {
		t.Fatalf("manor_blackspire expected tier 3, got %d", zone.Tier)
	}
	seen := map[ZoneTrapKind]bool{}
	for room := 0; room < 64; room++ {
		tr, ok := pickZoneTrap(zone, "t3-run", room)
		if !ok {
			t.Fatalf("pickZoneTrap !ok at room %d", room)
		}
		if tr.Tier != ZoneTierJourneyman {
			t.Errorf("tier 3 zone picked tier %d trap (%s)", tr.Tier, tr.Kind)
		}
		seen[tr.Kind] = true
	}
	if len(seen) < 2 {
		t.Errorf("expected variety across 64 rooms in tier 3, only saw %d kinds: %v", len(seen), seen)
	}
}

func TestTier3TrapCatalog_AllValid(t *testing.T) {
	if len(tier3TrapCatalog) < 4 {
		t.Fatalf("tier 3 catalog should have at least 4 traps, got %d", len(tier3TrapCatalog))
	}
	for _, tr := range tier3TrapCatalog {
		if tr.Tier != ZoneTierJourneyman {
			t.Errorf("trap %s tagged tier %d, expected 3", tr.Kind, tr.Tier)
		}
		if tr.Display == "" || tr.Trigger == "" {
			t.Errorf("trap %s missing display/trigger text", tr.Kind)
		}
		nonDamaging := tr.AlertsEnemies || tr.SuppressesMagic
		if !nonDamaging && (tr.DamageDiceN == 0 || tr.DamageDiceD == 0) {
			t.Errorf("trap %s deals no damage and is not alarm/suppression", tr.Kind)
		}
		if tr.DetectDC < 16 {
			t.Errorf("trap %s detect DC %d looks tier 2; tier 3 should be 16+", tr.Kind, tr.DetectDC)
		}
	}
}

func TestRollTrapDamage_NecroticMiasmaIn6d6Range(t *testing.T) {
	var miasma zoneTrapDef
	for _, tr := range tier3TrapCatalog {
		if tr.Kind == TrapNecroticMiasma {
			miasma = tr
			break
		}
	}
	if miasma.Kind != TrapNecroticMiasma {
		t.Fatal("necrotic miasma not in tier 3 catalog")
	}
	for room := 0; room < 64; room++ {
		dmg := rollTrapDamage(miasma, "miasma-run", room)
		if dmg < 6 || dmg > 36 { // 6d6 range
			t.Errorf("miasma damage out of 6..36 range at room %d: %d", room, dmg)
		}
	}
}

func TestRollTrapDamage_AntimagicFieldZero(t *testing.T) {
	var anti zoneTrapDef
	for _, tr := range tier3TrapCatalog {
		if tr.Kind == TrapAntimagicField {
			anti = tr
			break
		}
	}
	if anti.Kind != TrapAntimagicField {
		t.Fatal("antimagic field not in tier 3 catalog")
	}
	if dmg := rollTrapDamage(anti, "any-run", 0); dmg != 0 {
		t.Errorf("antimagic field should deal 0 damage, got %d", dmg)
	}
}

func TestZoneTrapRoom_AntimagicFieldNoHPLoss(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-trap-antimagic:example")
	zoneCmdTestCharacter(t, uid, 5)
	defer cleanupZoneRuns(uid)

	run, err := startZoneRun(uid, ZoneManorBlackspire, 5, nil)
	if err != nil {
		t.Fatal(err)
	}
	zone, _ := getZone(ZoneManorBlackspire)
	dndChar, _ := LoadDnDCharacter(uid)
	startHP := dndChar.HPCurrent

	var anti zoneTrapDef
	for _, tr := range tier3TrapCatalog {
		if tr.Kind == TrapAntimagicField {
			anti = tr
			break
		}
	}

	p := &AdventurePlugin{}
	dmg, narration := p.applyTrapEffectWithDetect(uid, run, zone, dndChar, anti,
		failedDetect(anti.DetectSkill, anti.DetectDC))
	if dmg != 0 {
		t.Errorf("antimagic field should deal 0 damage even on fail, got %d", dmg)
	}
	if !strings.Contains(narration, "Antimagic") {
		t.Errorf("antimagic narration missing trap name: %q", narration)
	}
	c, _ := LoadDnDCharacter(uid)
	if c.HPCurrent != startHP {
		t.Errorf("antimagic field should not change HP: cur=%d, want=%d", c.HPCurrent, startHP)
	}
}

// TestZoneLootToInventory_RegistryItemBecomesMagicItem — a signature drop whose
// ItemID is in the magic-item registry materializes as an equippable magic_item
// carrying the "magic_item:<id>" SkillSource, not generic treasure.
func TestZoneLootToInventory_RegistryItemBecomesMagicItem(t *testing.T) {
	zone, ok := getZone(ZoneFirstHoard)
	if !ok {
		t.Fatal("first_hoard zone not registered")
	}
	rng := newDeterministicRNG(t, "sig-loot")
	entry := ZoneLootEntry{ItemID: "aegis_of_the_first_scale", DropChance: 1.0, BossOnly: true}
	item, ok := zoneLootToInventory(entry, zone, rng)
	if !ok {
		t.Fatal("zoneLootToInventory returned ok=false for a registry item")
	}
	if item.Type != "magic_item" {
		t.Errorf("Type = %q, want magic_item", item.Type)
	}
	if item.SkillSource != "magic_item:aegis_of_the_first_scale" {
		t.Errorf("SkillSource = %q, want magic_item:aegis_of_the_first_scale", item.SkillSource)
	}
	// A plain non-registry entry still falls through to treasure.
	plain, _ := zoneLootToInventory(ZoneLootEntry{ItemID: "cooling_ember_of_aurvandryx", UniqueAlways: true}, zone, rng)
	if plain.Type != "treasure" {
		t.Errorf("crafting anchor Type = %q, want treasure", plain.Type)
	}
}

func TestRollZoneLoot_BossLootDrops(t *testing.T) {
	setupAuditTestDB(t)
	uid := id.UserID("@zone-loot:example")
	zoneCmdTestCharacter(t, uid, 1)
	defer cleanupZoneRuns(uid)

	run, _ := startZoneRun(uid, ZoneCryptValdris, 1, nil)
	zone, _ := getZone(ZoneCryptValdris)

	p := &AdventurePlugin{}
	granted := p.rollZoneLoot(uid, run, zone, true)
	// Crypt of Valdris has UniqueAlways "valdris_phylactery_shard" + always-drop coins.
	foundShard := false
	foundCoins := false
	for _, id := range granted {
		if id == "valdris_phylactery_shard" {
			foundShard = true
		}
		if strings.HasPrefix(id, "coins_") {
			foundCoins = true
		}
	}
	if !foundShard {
		t.Errorf("UniqueAlways quest item missing from drops: %v", granted)
	}
	if !foundCoins {
		t.Errorf("100%%-drop coins missing from drops: %v", granted)
	}
}
