package web

import (
	"bytes"
	"encoding/json"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"pete/internal/storage"
)

func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
	t.Helper()
	body, _ := json.Marshal(push)
	req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
	req.Header.Set("Authorization", "Bearer "+token)
	w := httptest.NewRecorder()
	s.handleRosterIngest(w, req)
	return w
}

func entry(token, name, status, zone string) storage.RosterEntry {
	return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
}

// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
// sends the *complete* set of adventurers it is willing to show; anyone missing
// from a later snapshot has left the board — they deleted their character, or
// (the case that actually matters) they just ran `!news optout` and must
// disappear from a public page. An upsert would leave them standing there
// forever, which is precisely the exposure the opt-out exists to prevent.
func TestRosterReplacesNeverMerges(t *testing.T) {
	s, _ := newAdvServer(t, "tok")
	now := time.Now().Unix()

	if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
		entry("t1", "Josie", "expedition", "holymachina"),
		entry("t2", "Quack", "idle", ""),
	}}); w.Code != 200 {
		t.Fatalf("first push = %d, want 200", w.Code)
	}

	// Quack opts out; gogobee stops including her.
	if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
		entry("t1", "Josie", "expedition", "holymachina"),
	}}); w.Code != 200 {
		t.Fatalf("second push = %d, want 200", w.Code)
	}

	views, stale, _ := s.roster()
	if stale {
		t.Error("fresh snapshot read as stale")
	}
	if len(views) != 1 {
		t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
	}
	if views[0].Name != "Josie" {
		t.Errorf("board kept %q, want Josie", views[0].Name)
	}
}

// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
// "Josie is in holymachina" and would say so forever. The board has to admit it
// lost the wire rather than keep asserting a stale fact as live.
func TestRosterGoesStale(t *testing.T) {
	s, _ := newAdvServer(t, "tok")
	old := time.Now().Add(-2 * rosterStaleAfter).Unix()

	postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
		entry("t1", "Josie", "expedition", "holymachina"),
	}})

	views, stale, _ := s.roster()
	if !stale {
		t.Error("snapshot older than rosterStaleAfter still reported live")
	}
	// Stale, but still shown: "who was out when we lost contact" beats a blank page.
	if len(views) != 1 {
		t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
	}
}

// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
// legitimate state and must not be confused with gogobee having gone away. This
// is why the snapshot time lives in its own row instead of MAX() over entries.
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
	s, _ := newAdvServer(t, "tok")

	if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
		t.Fatalf("empty push = %d, want 200", w.Code)
	}
	views, stale, _ := s.roster()
	if len(views) != 0 {
		t.Errorf("empty snapshot produced %d rows", len(views))
	}
	if stale {
		t.Error("a quiet realm read as a dead wire — the two must not collapse")
	}
}

func TestRosterIngestRejects(t *testing.T) {
	s, _ := newAdvServer(t, "tok")
	now := time.Now().Unix()

	if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
		t.Errorf("bad bearer = %d, want 401", w.Code)
	}
	// An unknown status would render as neither "out there" nor "in town".
	bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
		{Token: "t1", Name: "Josie", Status: "vibing"},
	}}
	if w := postRoster(t, s, "tok", bad); w.Code != 400 {
		t.Errorf("unknown status = %d, want 400", w.Code)
	}
	// A nameless row would render an empty slot on a public page.
	nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
		{Token: "t1", Status: "idle"},
	}}
	if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
		t.Errorf("nameless adventurer = %d, want 400", w.Code)
	}
}

// TestDepartureRenders guards the cross-repo contract: gogobee emits this
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
// 400 that retries and parks the bulletin forever.
func TestDepartureRenders(t *testing.T) {
	headline, lede, ok := renderAdventure(AdvFact{
		EventType: "departure",
		Subject:   "Camcast",
		Zone:      "crypt_valdris",
		Level:     1,
		Actors:    []string{"Camcast"},
	})
	if !ok {
		t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
	}
	if headline == "" || lede == "" {
		t.Error("departure rendered empty")
	}
}

func TestRosterViewWhere(t *testing.T) {
	out := toRosterView(storage.RosterEntry{
		Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
	})
	if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
		t.Errorf("expedition row = %+v", out)
	}
	in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
	if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
		t.Errorf("idle row = %+v", in)
	}
}

// TestAdventurePageRendersBoard drives the real page through the real template.
// The handler tests above prove the data is right; this proves it reaches a
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
// 500s the whole adventure section at request time, board and clippings alike.
func TestAdventurePageRendersBoard(t *testing.T) {
	s, _ := newAdvServer(t, "tok")

	postRoster(t, s, "tok", rosterPush{
		SnapshotAt: time.Now().Unix(),
		Adventurers: []storage.RosterEntry{
			{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
				Status: "expedition", Zone: "holymachina", Day: 3},
			{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
				Status: "idle", IdleHours: 50},
		},
	})

	req := httptest.NewRequest("GET", "/adventure", nil)
	w := httptest.NewRecorder()
	s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})

	if w.Code != 200 {
		t.Fatalf("GET /adventure = %d, want 200", w.Code)
	}
	body := w.Body.String()
	for _, want := range []string{
		"Out there right now",           // the board's headline
		"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
		"Quack", "in town", "quiet for 2 days",
		"/api/roster", // the client re-poll, so an open tab stays true
	} {
		if !strings.Contains(body, want) {
			t.Errorf("adventure page missing %q", want)
		}
	}
}
