package plugin

import (
	"testing"
	"time"

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

// These exercise the idle clock against real rows.
//
// The hazard they exist for: modernc.org/sqlite rebuilds a time.Time from the
// column's *declared* type, and both COALESCE() and MAX() erase it. A Scan that
// silently fails here is not a small bug — playerIsIdle fails open (a player it
// can't read counts as idle), so a broken scan would declare the entire server
// idle and march everyone into a dungeon at once.

func seedBoredomPlayer(t *testing.T, uid id.UserID, created, lastAction, lastBoredom *time.Time) {
	t.Helper()
	_, err := db.Get().Exec(
		`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_player_action_at, last_boredom_at)
		 VALUES (?, ?, 1, ?, ?, ?)`,
		string(uid), "Test", created, lastAction, lastBoredom)
	if err != nil {
		t.Fatalf("seed player_meta: %v", err)
	}
}

func TestPlayerIsIdleReadsTheClock(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	old := now.Add(-72 * time.Hour)
	recent := now.Add(-1 * time.Hour)

	seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
	seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
	// Never acted: the COALESCE falls through to created_at.
	seedBoredomPlayer(t, "@never:test", &old, nil, nil)
	seedBoredomPlayer(t, "@fresh:test", &recent, nil, nil)

	cases := []struct {
		uid  id.UserID
		want bool
		why  string
	}{
		{"@idle:test", true, "last acted 72h ago"},
		{"@active:test", false, "acted an hour ago"},
		{"@never:test", true, "never acted, created 72h ago — COALESCE to created_at"},
		{"@fresh:test", false, "never acted, but only just created — must not be yanked out on day one"},
	}
	for _, tc := range cases {
		if got := playerIsIdle(tc.uid, now); got != tc.want {
			t.Errorf("playerIsIdle(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
		}
	}
}

func TestLoadBoredomCandidatesRespectsClockAndCooldown(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	old := now.Add(-72 * time.Hour)
	recent := now.Add(-1 * time.Hour)

	seedBoredomPlayer(t, "@idle:test", &old, &old, nil)
	seedBoredomPlayer(t, "@active:test", &old, &recent, nil)
	// Idle, but already sent out an hour ago — the cooldown holds them back.
	seedBoredomPlayer(t, "@cooling:test", &old, &old, &recent)
	// Idle, and their last outing was days ago — eligible again.
	seedBoredomPlayer(t, "@ready:test", &old, &old, &old)

	got, err := loadBoredomCandidates(now)
	if err != nil {
		t.Fatalf("loadBoredomCandidates: %v", err)
	}
	set := map[id.UserID]bool{}
	for _, u := range got {
		set[u] = true
	}
	for uid, want := range map[id.UserID]bool{
		"@idle:test":    true,
		"@ready:test":   true,
		"@active:test":  false,
		"@cooling:test": false,
	} {
		if set[uid] != want {
			t.Errorf("candidate %s: got %v, want %v", uid, set[uid], want)
		}
	}
}

func TestMarkPlayerActionResetsTheClock(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	old := now.Add(-72 * time.Hour)
	seedBoredomPlayer(t, "@back:test", &old, &old, nil)

	if !playerIsIdle("@back:test", now) {
		t.Fatal("precondition: player should start idle")
	}
	markPlayerAction("@back:test")
	if playerIsIdle("@back:test", now) {
		t.Error("player acted against Adventure but is still counted idle — the clock isn't being stamped")
	}
}

// TestIsBoredomDrivenNeedsBothHalves pins the rule the idle reaper depends on:
// only an expedition the player never asked for, from a player who still hasn't
// come back, loses its streak protection. A self-started expedition holds it.
func TestIsBoredomDrivenNeedsBothHalves(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	old := now.Add(-72 * time.Hour)
	recent := now.Add(-1 * time.Hour)

	seedExp := func(t *testing.T, uid id.UserID, boredom bool) {
		t.Helper()
		_, err := db.Get().Exec(
			`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date, boredom)
			 VALUES (?, ?, 'goblin_warrens', 'active', ?, ?)`,
			string(uid)+"-exp", string(uid), old, boredom)
		if err != nil {
			t.Fatalf("seed expedition: %v", err)
		}
	}

	seedBoredomPlayer(t, "@bored:test", &old, &old, nil)
	seedExp(t, "@bored:test", true)

	seedBoredomPlayer(t, "@selfstart:test", &old, &old, nil)
	seedExp(t, "@selfstart:test", false)

	seedBoredomPlayer(t, "@returned:test", &old, &recent, nil)
	seedExp(t, "@returned:test", true)

	seedBoredomPlayer(t, "@noexp:test", &old, &old, nil)

	cases := []struct {
		uid  id.UserID
		want bool
		why  string
	}{
		{"@bored:test", true, "absent player, expedition it started itself — reap"},
		{"@selfstart:test", false, "absent, but they started this expedition themselves — the autopilot still holds their streak"},
		{"@returned:test", false, "bored expedition, but the player came back — don't reap someone who's playing"},
		{"@noexp:test", false, "no expedition history at all — don't reap on a guess"},
	}
	for _, tc := range cases {
		if got := isBoredomDriven(tc.uid, now); got != tc.want {
			t.Errorf("isBoredomDriven(%s) = %v, want %v (%s)", tc.uid, got, tc.want, tc.why)
		}
	}
}

// TestLastExpeditionByZoneScans covers the MAX(start_date) scan — the same
// type-affinity hazard as the COALESCE above, and the tie-breaker that stops a
// bored adventurer grinding one zone forever.
func TestLastExpeditionByZoneScans(t *testing.T) {
	newBoredomTestDB(t)
	uid := id.UserID("@hist:test")
	older := time.Now().UTC().Add(-96 * time.Hour)
	newer := time.Now().UTC().Add(-24 * time.Hour)

	for i, e := range []struct {
		zone string
		when time.Time
	}{
		{"goblin_warrens", older},
		{"goblin_warrens", newer},
		{"crypt_valdris", older},
	} {
		if _, err := db.Get().Exec(
			`INSERT INTO dnd_expedition (expedition_id, user_id, zone_id, status, start_date)
			 VALUES (?, ?, ?, 'complete', ?)`,
			string(uid)+string(rune('a'+i)), string(uid), e.zone, e.when); err != nil {
			t.Fatalf("seed: %v", err)
		}
	}

	got, err := lastExpeditionByZone(uid)
	if err != nil {
		t.Fatalf("lastExpeditionByZone: %v", err)
	}
	if len(got) != 2 {
		t.Fatalf("got %d zones, want 2 — the MAX(start_date) scan is dropping rows: %v", len(got), got)
	}
	if !got[ZoneGoblinWarrens].After(got[ZoneCryptValdris]) {
		t.Errorf("goblin_warrens (%v) should be more recent than crypt_valdris (%v); "+
			"the picker relies on this to rotate zones", got[ZoneGoblinWarrens], got[ZoneCryptValdris])
	}
}

// The deploy this column ships in: last_player_action_at is NULL for everyone
// alive, and created_at is when the character was *made* — months ago for the
// regulars. Without a seed, the very first tick after restart reads the whole
// server as idle-since-creation and marches all of them into a dungeon, coins
// debited, including the player who typed a command a minute before the deploy.
func TestBootstrapSeedsClockSoADeployDoesNotEmptyTheTown(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	longAgo := now.Add(-90 * 24 * time.Hour)
	recent := now.Add(-30 * time.Minute)

	// A regular: made months ago, played half an hour before the deploy.
	seedBoredomPlayerActive(t, "@regular:test", longAgo, &recent, nil)
	// Genuinely abandoned: made months ago, last touched the game months ago.
	seedBoredomPlayerActive(t, "@lapsed:test", longAgo, &longAgo, nil)

	bootstrapBoredomClock()

	uids, err := loadBoredomCandidates(now)
	if err != nil {
		t.Fatalf("loadBoredomCandidates: %v", err)
	}
	got := map[id.UserID]bool{}
	for _, u := range uids {
		got[u] = true
	}
	if got["@regular:test"] {
		t.Error("a player who acted 30 minutes ago was sent into a dungeon by the deploy")
	}
	if !got["@lapsed:test"] {
		t.Error("the abandoned character is exactly who this feature is for, and it skipped them")
	}
	if playerIsIdle("@regular:test", now) {
		t.Error("playerIsIdle still reads an active player as idle — Robbie would go silent for them")
	}
}

// Same seed, but the character is already out on a bored expedition
// (last_boredom_at set). Its own autopilot bumps last_active_at every day, so
// re-seeding from it on each restart would hand the character a fresh idle
// clock and boredom would never fire again.
func TestBootstrapLeavesAlreadyBoredCharactersAlone(t *testing.T) {
	newBoredomTestDB(t)
	now := time.Now().UTC()
	longAgo := now.Add(-90 * 24 * time.Hour)
	autopilotWrite := now.Add(-10 * time.Minute)
	wentOut := now.Add(-48 * time.Hour)

	seedBoredomPlayerActive(t, "@bored:test", longAgo, &autopilotWrite, &wentOut)
	bootstrapBoredomClock()

	if !playerIsIdle("@bored:test", now) {
		t.Fatal("restart re-seeded a bored character's clock off its own autopilot writes; it would never get bored again")
	}
}

// seedBoredomPlayerActive seeds a row that predates the boredom column:
// last_player_action_at NULL, with a last_active_at the autopilot/saves have
// been keeping current.
func seedBoredomPlayerActive(t *testing.T, uid id.UserID, created time.Time, lastActive, lastBoredom *time.Time) {
	t.Helper()
	_, err := db.Get().Exec(
		`INSERT INTO player_meta (user_id, display_name, alive, created_at, last_active_at, last_player_action_at, last_boredom_at)
		 VALUES (?, ?, 1, ?, ?, NULL, ?)`,
		string(uid), "Test", created, lastActive, lastBoredom)
	if err != nil {
		t.Fatalf("seed player_meta: %v", err)
	}
}
