package plugin

import (
	"testing"

	"gogobee/internal/db"
	"maunium.net/go/mautrix/id"
)

// ── pure-function tests (no DB) ────────────────────────────────────────────

// TestLegacyRoomIdxFromNodeID exercises the G6 migration parser:
// `<zone>.r<N+1>` round-trips, hand-authored ids return ok=false.
func TestLegacyRoomIdxFromNodeID(t *testing.T) {
	cases := []struct {
		nodeID string
		zone   ZoneID
		want   int
		ok     bool
	}{
		{"goblin_warrens.r1", ZoneGoblinWarrens, 0, true},
		{"crypt_valdris.r5", ZoneCryptValdris, 4, true},
		{"crypt_valdris.fork_a", ZoneCryptValdris, 0, false},
		{"goblin_warrens.r1", ZoneCryptValdris, 0, false}, // wrong zone prefix
		{"goblin_warrens.r0", ZoneGoblinWarrens, 0, false},
		{"", ZoneGoblinWarrens, 0, false},
	}
	for _, c := range cases {
		got, ok := legacyRoomIdxFromNodeID(c.nodeID, c.zone)
		if ok != c.ok || (ok && got != c.want) {
			t.Errorf("legacyRoomIdxFromNodeID(%q, %s) = (%d, %t), want (%d, %t)",
				c.nodeID, c.zone, got, ok, c.want, c.ok)
		}
	}
}

// TestLoadHarvestNodes_LegacyRoomKeyFallback confirms a row with the
// pre-G6 `strconv.Itoa(roomIdx)` storage key is read via the fallback
// when the new node-id key is queried.
func TestLoadHarvestNodes_LegacyRoomKeyFallback(t *testing.T) {
	exp := &Expedition{
		ZoneID:        ZoneGoblinWarrens,
		CurrentRegion: "",
		RegionState: map[string]any{
			regionStateHarvestKey: map[string]map[string][]HarvestNode{
				"": {
					"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
				},
			},
		},
	}
	nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
	got := loadHarvestNodes(exp, nodeID)
	if len(got) != 1 || got[0].ResourceID != "iron_ore" || got[0].CurrentCharges != 0 {
		t.Fatalf("legacy fallback didn't return depleted node, got %+v", got)
	}
	// Authored (non-legacy) node id with no entry should lazy-seed fresh,
	// not pick up the legacy "3" entry.
	fresh := loadHarvestNodes(exp, "goblin_warrens.fork_a")
	for _, n := range fresh {
		if n.ResourceID == "iron_ore" && n.CurrentCharges == 0 {
			t.Errorf("fork_a leaked depleted state from legacy room 3 entry")
		}
	}
}

// TestSaveHarvestNodes_DropsLegacyRoomKey verifies the migration-on-save
// drops the legacy `strconv.Itoa(roomIdx)` entry after persistence runs.
func TestSaveHarvestNodes_DropsLegacyRoomKey(t *testing.T) {
	setupZoneRunTestDB(t)
	uid := id.UserID("@harvest-migrate:example.org")
	defer cleanupHarvestState(uid)

	supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
	exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
	if err != nil {
		t.Fatal(err)
	}
	// Seed the legacy "3" key directly so the load fallback fires.
	exp.RegionState[regionStateHarvestKey] = map[string]map[string][]HarvestNode{
		"": {
			"3": {{ResourceID: "iron_ore", CurrentCharges: 0, MaxCharges: 2}},
		},
	}
	nodeID := deriveLegacyNodeID(ZoneGoblinWarrens, 3)
	got := loadHarvestNodes(exp, nodeID)
	if err := saveHarvestNodes(exp, nodeID, got); err != nil {
		t.Fatalf("saveHarvestNodes: %v", err)
	}
	table := loadHarvestTable(exp)
	if _, exists := table[""]["3"]; exists {
		t.Errorf("legacy room-idx key '3' should be dropped after migration save")
	}
	if _, exists := table[""][nodeID]; !exists {
		t.Errorf("expected node-id key %q after migration save", nodeID)
	}
}

func TestResolveHarvestOutcome_Brackets(t *testing.T) {
	cases := []struct {
		roll, dc int
		want     HarvestOutcome
	}{
		{20, 10, OutcomeRich},     // +10 = rich
		{15, 10, OutcomeRich},     // exactly +5
		{14, 10, OutcomeStandard}, // +4
		{10, 10, OutcomeStandard}, // exact
		{9, 10, OutcomeCommon},    // -1
		{6, 10, OutcomeCommon},    // -4
		{5, 10, OutcomeNothing},   // -5 = nothing
		{1, 20, OutcomeNothing},
	}
	for _, c := range cases {
		got := resolveHarvestOutcome(c.roll, c.dc)
		if got != c.want {
			t.Errorf("resolveHarvestOutcome(%d, %d) = %s, want %s",
				c.roll, c.dc, got, c.want)
		}
	}
}

func TestClassHarvestBonus_Affinity(t *testing.T) {
	cases := []struct {
		class  DnDClass
		action HarvestAction
		want   int
	}{
		{ClassRanger, HarvestForage, 2},
		{ClassRanger, HarvestFish, 2},
		{ClassRanger, HarvestMine, 0},
		{ClassFighter, HarvestMine, 2},
		{ClassRogue, HarvestScavenge, 2},
		{ClassMage, HarvestEssence, 2},
		{ClassCleric, HarvestCommune, 2},
		{ClassCleric, HarvestEssence, 0},
	}
	for _, c := range cases {
		got := classHarvestBonus(c.class, c.action)
		if got != c.want {
			t.Errorf("classHarvestBonus(%s, %s) = %d, want %d",
				c.class, c.action, got, c.want)
		}
	}
}

func TestZoneResourcesByAction_AllZonesPopulated(t *testing.T) {
	zones := []ZoneID{
		ZoneGoblinWarrens, ZoneCryptValdris, ZoneForestShadows,
		ZoneSunkenTemple, ZoneManorBlackspire, ZoneUnderforge,
		ZoneUnderdark, ZoneFeywildCrossing, ZoneDragonsLair, ZoneAbyssPortal,
	}
	for _, z := range zones {
		all := ZoneResourcesAll(z)
		if len(all) == 0 {
			t.Errorf("zone %s has empty registry", z)
		}
		// Every entry has zone id backfilled by init().
		for _, r := range all {
			if r.ZoneID != z {
				t.Errorf("zone %s entry %s has wrong zone id %s", z, r.ID, r.ZoneID)
			}
			if r.Action == "" || r.Name == "" || r.ID == "" {
				t.Errorf("zone %s entry malformed: %+v", z, r)
			}
		}
	}
}

func TestSeedRoomNodes_MatchesRegistry(t *testing.T) {
	nodes := seedRoomNodes(ZoneGoblinWarrens)
	all := ZoneResourcesAll(ZoneGoblinWarrens)
	if len(nodes) != len(all) {
		t.Fatalf("seeded %d, registry %d", len(nodes), len(all))
	}
	for i, n := range nodes {
		if n.CurrentCharges <= 0 {
			t.Errorf("node %d charged 0 at seed", i)
		}
		if n.CurrentCharges != n.MaxCharges {
			t.Errorf("node %d charges %d != max %d", i, n.CurrentCharges, n.MaxCharges)
		}
	}
}

func TestPickAvailableNode_PrefersLowestDC(t *testing.T) {
	exp := &Expedition{ZoneID: ZoneGoblinWarrens}
	char := &DnDCharacter{Class: ClassFighter, STR: 14}
	nodes := []HarvestNode{
		{ResourceID: "scrap_iron", CurrentCharges: 2, MaxCharges: 2}, // DC 10 mine
	}
	idx := pickAvailableNode(nodes, exp, char, HarvestMine)
	if idx != 0 {
		t.Errorf("idx = %d, want 0 (scrap iron)", idx)
	}
	// Wrong action returns -1.
	if idx := pickAvailableNode(nodes, exp, char, HarvestForage); idx != -1 {
		t.Errorf("forage on mine-only registry = %d, want -1", idx)
	}
	// Class-restricted: shaman reagents are mage-only.
	mageOnly := []HarvestNode{
		{ResourceID: "shaman_reagents", CurrentCharges: 1, MaxCharges: 1},
	}
	if idx := pickAvailableNode(mageOnly, exp, char, HarvestEssence); idx != -1 {
		t.Errorf("fighter on shaman reagents = %d, want -1 (mage-only)", idx)
	}
	mageChar := &DnDCharacter{Class: ClassMage, INT: 16}
	if idx := pickAvailableNode(mageOnly, exp, mageChar, HarvestEssence); idx != 0 {
		t.Errorf("mage on shaman reagents = %d, want 0", idx)
	}
}

func TestPickAvailableNode_SkipsRequiresKill(t *testing.T) {
	exp := &Expedition{ZoneID: ZoneGoblinWarrens, RegionState: map[string]any{}}
	char := &DnDCharacter{Class: ClassRogue, INT: 12}
	nodes := []HarvestNode{
		{ResourceID: "worg_pelt", CurrentCharges: 1, MaxCharges: 1}, // requires worg kill
	}
	if idx := pickAvailableNode(nodes, exp, char, HarvestScavenge); idx != -1 {
		t.Errorf("requires-kill resource selectable without kill: idx=%d", idx)
	}
	// With kill recorded, becomes available.
	exp.RegionState["kills"] = map[string][]string{"": {"worg"}}
	if idx := pickAvailableNode(nodes, exp, char, HarvestScavenge); idx != 0 {
		t.Errorf("post-kill, idx = %d, want 0", idx)
	}
}

func TestCurrentRoomCleared_EntryAutoPasses(t *testing.T) {
	run := &DungeonRun{
		CurrentRoom:  0,
		RoomSeq:      []RoomType{RoomEntry, RoomExploration, RoomTrap, RoomElite, RoomBoss},
		RoomsCleared: []int{},
	}
	if !currentRoomCleared(run) {
		t.Error("entry room should auto-clear")
	}
	// Exploration always allowed — interrupt roll handles risk.
	run.CurrentRoom = 1
	if !currentRoomCleared(run) {
		t.Error("exploration room should always allow harvest")
	}
	// Trap always allowed.
	run.CurrentRoom = 2
	if !currentRoomCleared(run) {
		t.Error("trap room should always allow harvest")
	}
	// Elite gated until in RoomsCleared.
	run.CurrentRoom = 3
	if currentRoomCleared(run) {
		t.Error("uncleared elite room should block harvest")
	}
	run.RoomsCleared = []int{3}
	if !currentRoomCleared(run) {
		t.Error("elite room in RoomsCleared should report cleared")
	}
	// Boss gated until in RoomsCleared.
	run.CurrentRoom = 4
	run.RoomsCleared = []int{3}
	if currentRoomCleared(run) {
		t.Error("uncleared boss room should block harvest")
	}
	run.RoomsCleared = []int{3, 4}
	if !currentRoomCleared(run) {
		t.Error("boss room in RoomsCleared should report cleared")
	}
}

// ── round-trip tests (require DB) ──────────────────────────────────────────

func cleanupHarvestState(uid id.UserID) {
	cleanupExpeditions(uid)
	_, _ = db.Get().Exec(`DELETE FROM dnd_zone_run WHERE user_id = ?`, string(uid))
	_, _ = db.Get().Exec(`DELETE FROM adventure_inventory WHERE user_id = ?`, string(uid))
}

func TestEnsureRegionRun_LinksExpeditionToRun(t *testing.T) {
	setupZoneRunTestDB(t)
	uid := id.UserID("@harvest-ensure:example.org")
	defer cleanupHarvestState(uid)

	supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
	exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
	if err != nil {
		t.Fatal(err)
	}
	run, err := ensureRegionRun(exp, 1)
	if err != nil {
		t.Fatalf("ensureRegionRun: %v", err)
	}
	if run.RunID == "" {
		t.Error("run id empty")
	}
	if exp.RunID != run.RunID {
		t.Errorf("exp.RunID = %q, want %q", exp.RunID, run.RunID)
	}
	// Idempotent: calling again returns the same run.
	again, err := ensureRegionRun(exp, 1)
	if err != nil || again.RunID != run.RunID {
		t.Errorf("second ensureRegionRun returned different run: %v vs %v", again, run)
	}
	// And the mapping persists.
	runs := regionRunsMap(exp)
	if runs[""] != run.RunID {
		t.Errorf("region_runs[\"\"]=%q, want %q", runs[""], run.RunID)
	}
}

func TestRetireRegionRun_AbandonsAndClears(t *testing.T) {
	setupZoneRunTestDB(t)
	uid := id.UserID("@harvest-retire:example.org")
	defer cleanupHarvestState(uid)

	supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
	exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
	if err != nil {
		t.Fatal(err)
	}
	run, err := ensureRegionRun(exp, 1)
	if err != nil {
		t.Fatal(err)
	}
	if err := retireRegionRun(exp, ""); err != nil {
		t.Fatalf("retireRegionRun: %v", err)
	}
	// Underlying run should be marked abandoned.
	got, _ := getZoneRun(run.RunID)
	if got == nil || !got.Abandoned {
		t.Errorf("expected run abandoned, got %+v", got)
	}
	// Mapping cleared.
	if got := regionRunsMap(exp); got[""] != "" {
		t.Errorf("region_runs not cleared: %+v", got)
	}
	// exp.RunID cleared.
	if exp.RunID != "" {
		t.Errorf("exp.RunID = %q, want empty", exp.RunID)
	}
}

func TestReplenishHarvestNodes_RestoresAllRooms(t *testing.T) {
	setupZoneRunTestDB(t)
	uid := id.UserID("@harvest-replenish:example.org")
	defer cleanupHarvestState(uid)

	supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
	exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
	if err != nil {
		t.Fatal(err)
	}

	// Seed two rooms and drain one node in each.
	n0 := deriveLegacyNodeID(ZoneGoblinWarrens, 0)
	n1 := deriveLegacyNodeID(ZoneGoblinWarrens, 1)
	r0 := loadHarvestNodes(exp, n0)
	r0[0].CurrentCharges = 0
	if err := saveHarvestNodes(exp, n0, r0); err != nil {
		t.Fatal(err)
	}
	r1 := loadHarvestNodes(exp, n1)
	r1[0].CurrentCharges = 0
	if err := saveHarvestNodes(exp, n1, r1); err != nil {
		t.Fatal(err)
	}

	if err := ReplenishHarvestNodes(exp); err != nil {
		t.Fatal(err)
	}
	for _, idx := range []int{0, 1} {
		nodes := loadHarvestNodes(exp, deriveLegacyNodeID(ZoneGoblinWarrens, idx))
		for i, n := range nodes {
			if n.CurrentCharges != n.MaxCharges {
				t.Errorf("room %d node %d not replenished: %d/%d",
					idx, i, n.CurrentCharges, n.MaxCharges)
			}
		}
	}
}

func TestSaveHarvestNodes_PersistsAcrossLoad(t *testing.T) {
	setupZoneRunTestDB(t)
	uid := id.UserID("@harvest-persist:example.org")
	defer cleanupHarvestState(uid)

	supplies := ExpeditionSupplies{Current: 30, Max: 30, DailyBurn: 1, HarshMod: 1}
	exp, err := startExpedition(uid, ZoneGoblinWarrens, "", supplies)
	if err != nil {
		t.Fatal(err)
	}
	n2 := deriveLegacyNodeID(ZoneGoblinWarrens, 2)
	n5 := deriveLegacyNodeID(ZoneGoblinWarrens, 5)
	nodes := loadHarvestNodes(exp, n2)
	nodes[0].CurrentCharges = 0
	if err := saveHarvestNodes(exp, n2, nodes); err != nil {
		t.Fatal(err)
	}
	// Reload from DB.
	reloaded, err := getActiveExpedition(uid)
	if err != nil {
		t.Fatal(err)
	}
	got := loadHarvestNodes(reloaded, n2)
	if got[0].CurrentCharges != 0 {
		t.Errorf("after reload, room 2 node 0 charges = %d, want 0", got[0].CurrentCharges)
	}
	// Other rooms should still be untouched (lazy-seeded fresh).
	other := loadHarvestNodes(reloaded, n5)
	for _, n := range other {
		if n.CurrentCharges != n.MaxCharges {
			t.Errorf("room 5 leaked depletion: %+v", n)
		}
	}
}
