package web

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

	"pete/internal/storage"
)

// The adventurer detail page. Two contracts under test: the public sheet reaches
// any visitor (stats + gear, never a handle), and the private self-view unlocks
// only for the signed-in owner of that exact page — anon and other users see the
// public page and nothing more.

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

// publicDetail is the sheet gogobee hangs on a roster entry, marshalled the way
// the roster push carries it.
func publicDetail(t *testing.T) json.RawMessage {
	t.Helper()
	raw, err := json.Marshal(map[string]any{
		"hp_current":  30,
		"hp_max":      42,
		"temp_hp":     5,
		"armor_class": 17,
		"abilities":   [6]int{16, 14, 15, 10, 12, 8},
		"modifiers":   [6]int{3, 2, 2, 0, 1, -1},
		"gear": []map[string]any{
			{"slot": "weapon", "name": "Rusty Sword", "tier": 0, "condition": 100},
			{"slot": "armor", "name": "Padded Vest", "tier": 0, "condition": 100},
		},
		"supplies":     8,
		"threat_level": 2,
		"room":         "3 / 7",
	})
	if err != nil {
		t.Fatal(err)
	}
	return raw
}

// seedWho puts one adventurer on the board with a public detail sheet, and
// pushes a private self-detail set owned by `owner` for that same token.
func seedWho(t *testing.T, owner string) *Server {
	s, _ := newAdvServer(t, "tok")
	s.auth = &Authenticator{secret: []byte("test-secret-key-at-least-16")}
	now := time.Now().Unix()

	e := entry("tok-josie", "Josie", "expedition", "holymachina")
	e.Level = 14
	e.ClassRace = "human cleric"
	e.Detail = publicDetail(t)
	if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{e}}); w.Code != 200 {
		t.Fatalf("seed roster = %d", w.Code)
	}
	if w := postDetail(t, s, "tok", detailPush{SnapshotAt: now, Players: []storage.PlayerDetail{{
		Localpart: owner,
		Token:     "tok-josie",
		Inventory: []storage.ItemView{
			{Name: "Iron Ore", Type: "ore", Tier: 1, Value: 10},
			{Name: "Miner's Pick", Type: "MasterworkGear", Tier: 3, Value: 300,
				Slot: "weapon", SkillSource: "mining", Desc: "A pick balanced for a long seam."},
			// Wants a bond, but it's in the backpack: not worn, so not inert.
			{Name: "Ring of Protection", Type: "ring", Tier: 4, Value: 900,
				Slot: "ring", Attunement: true, Effect: "-8% damage taken"},
			// A wearable magic item (carries an equip id) with gogobee's compare
			// card attached: a sidegrade against the worn Flame Tongue.
			{Name: "Blazing Brand", Type: "weapon", Tier: 5, Value: 4000, ID: 77,
				Slot: "main_hand", Desc: "A blade that hums.", Effect: "+20% damage",
				Compare: &storage.ItemCompare{
					Verdict: "sidegrade", VsName: "Flame Tongue", VsSlot: "main_hand",
					Deltas: []storage.ItemDelta{
						{Label: "damage", Better: true, Text: "+5% damage"},
						{Label: "hp", Better: false, Text: "-4 HP"},
					},
				}},
		},
		Equipped: []storage.ItemView{
			{Name: "Flame Tongue", Type: "weapon", Value: 5000, Temper: 1, Slot: "weapon",
				Desc: "A sword that ignites on command.", Effect: "+15% damage",
				Attunement: true, Attuned: true},
			// Worn while the bond cap is full: on her, doing nothing.
			{Name: "Cloak of Elvenkind", Type: "wondrous", Value: 2000, Slot: "cloak",
				Effect: "faster to act, +4 HP", Attunement: true, Attuned: false},
		},
		Vault: []storage.ItemView{{Name: "Jeweled Crown", Type: "treasure", Tier: 4, Value: 5000}},
		House: storage.HouseView{Tier: 2, LoanBalance: 1500},
		Pets:  []storage.PetView{{Type: "cat", Name: "Mittens", Level: 3}},
	}}}); w.Code != 200 {
		t.Fatalf("seed detail = %d", w.Code)
	}
	return s
}

// seedHistory pushes facts about Josie through the real ingest path, so the
// history panels are driven by rows that arrived the way gogobee sends them
// rather than by a hand-built struct that could drift from the contract.
func seedHistory(t *testing.T, s *Server) {
	t.Helper()
	facts := []AdvFact{
		{GUID: "boss_first:a:1", EventType: "boss_first", Tier: "priority", Actors: []string{"Josie"},
			Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", Region: "the Reach", OccurredAt: 100},
		{GUID: "boss_kill:b:2", EventType: "boss_kill", Tier: "bulletin", Actors: []string{"Josie"},
			Subject: "Josie", Boss: "The Rotmother", Zone: "holymachina", OccurredAt: 200},
		{GUID: "death:c:3", EventType: "death", Tier: "priority", Actors: []string{"Josie"},
			Subject: "Josie", Zone: "holymachina", Level: 14, OccurredAt: 300},
		{GUID: "milestone:d:4", EventType: "milestone", Tier: "bulletin", Actors: []string{"Josie"},
			Subject: "Josie", Milestone: "level 14", OccurredAt: 400},
		{GUID: "treasure_found:e:5", EventType: "treasure_found", Tier: "priority", Actors: []string{"Josie"},
			Subject: "Josie", Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", OccurredAt: 500},
	}
	for _, f := range facts {
		body, _ := json.Marshal(f)
		req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
		req.Header.Set("Authorization", "Bearer tok")
		w := httptest.NewRecorder()
		s.handleAdventureIngest(w, req)
		if w.Code != 200 {
			t.Fatalf("seed fact %s = %d: %s", f.GUID, w.Code, w.Body.String())
		}
	}
}

func getWho(t *testing.T, s *Server, token, asUser string) *httptest.ResponseRecorder {
	t.Helper()
	var req = httptest.NewRequest("GET", "/adventure/who/"+token, nil)
	req.SetPathValue("token", token)
	if asUser != "" {
		payload, _ := json.Marshal(SessionUser{Sub: "sub-1", Username: asUser, Exp: time.Now().Add(time.Hour).Unix()})
		req.AddCookie(&http.Cookie{Name: sessionCookie, Value: s.auth.sign(payload)})
	}
	w := httptest.NewRecorder()
	s.handleAdventureWho(w, req)
	return w
}

// TestWhoPublicSheet: any visitor sees the mark's stats and gear — driven
// through the real template, so a field slip 500s here rather than in prod.
func TestWhoPublicSheet(t *testing.T) {
	s := seedWho(t, "josie")

	w := getWho(t, s, "tok-josie", "") // anonymous
	if w.Code != 200 {
		t.Fatalf("GET who = %d, want 200", w.Code)
	}
	body := w.Body.String()
	for _, want := range []string{"Josie", "human cleric", "Rusty Sword", "Padded Vest"} {
		if !strings.Contains(body, want) {
			t.Errorf("public page missing %q", want)
		}
	}
	// The private goods must NOT be on an anonymous render.
	for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
		if strings.Contains(body, leak) {
			t.Errorf("anonymous page leaked private detail %q", leak)
		}
	}
}

// TestWhoSelfUnlock: the owner, signed in on their own page, sees the private
// inventory/vault/house/pets inline.
func TestWhoSelfUnlock(t *testing.T) {
	s := seedWho(t, "josie")

	w := getWho(t, s, "tok-josie", "josie")
	if w.Code != 200 {
		t.Fatalf("owner GET who = %d, want 200", w.Code)
	}
	body := w.Body.String()
	for _, want := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
		if !strings.Contains(body, want) {
			t.Errorf("owner page missing private detail %q", want)
		}
	}
}

// TestWhoItemDetail: the item panels carry the facts a player decides on —
// descriptions, the engine's effect summary, slot and skill tags — and the worn
// set renders with its bond count.
func TestWhoItemDetail(t *testing.T) {
	s := seedWho(t, "josie")

	body := getWho(t, s, "tok-josie", "josie").Body.String()
	for _, want := range []string{
		// "15% damage", not "+15%": html/template escapes a leading + to &#43;.
		"Worn", "Flame Tongue", "A sword that ignites on command.", "15% damage",
		"1 of 3 bonds in use", // Flame Tongue is bonded; the Cloak is not
		"A pick balanced for a long seam.", "mining",
	} {
		if !strings.Contains(body, want) {
			t.Errorf("owner page missing item detail %q", want)
		}
	}
}

// TestWhoInertOnlyWhenWorn pins the one distinction the panels can get wrong.
// "Inert" is a real problem state — the item is on you and doing nothing because
// every bond slot is full. An unworn attunement item is not inert, it's just not
// worn; gogobee tracks bonds only on equipped rows, so its Attuned is undefined
// rather than false. Rendering both the same would invent a problem the player
// does not have, on the exact panel they'd use to fix it.
func TestWhoInertOnlyWhenWorn(t *testing.T) {
	s := seedWho(t, "josie")

	body := getWho(t, s, "tok-josie", "josie").Body.String()
	// The Cloak is worn with no bond free.
	if !strings.Contains(body, "inert") {
		t.Error("a worn item with no bond should be called out as inert")
	}
	if strings.Count(body, "inert") != 1 {
		t.Errorf("only the worn unbonded item is inert, got %d mentions", strings.Count(body, "inert"))
	}
	// The backpack Ring of Protection wants a bond but isn't worn.
	if !strings.Contains(body, "needs a bond") {
		t.Error("an unworn attunement item should say it needs a bond, not that it is inert")
	}
}

// TestWhoItemCompare: the compare card that gogobee attaches to a backpack magic
// item renders on the owner's page — verdict chip, per-stat deltas, and the name
// of the worn item it's measured against — and stays off the public page, since
// it rides the owner-private inventory.
func TestWhoItemCompare(t *testing.T) {
	s := seedWho(t, "josie")

	owner := getWho(t, s, "tok-josie", "josie").Body.String()
	for _, want := range []string{
		"sidegrade",            // the verdict chip
		"vs worn Flame Tongue", // what it's measured against
		"5% damage",            // the better delta ("+5%" — leading + is escaped to &#43;)
		"-4 HP",                // the worse delta (minus is not escaped)
		"cmp-delta-up",         // the gain is coloured as a gain
		"cmp-delta-down",       // the loss as a loss
	} {
		if !strings.Contains(owner, want) {
			t.Errorf("owner compare card missing %q", want)
		}
	}

	// The compare rides the private inventory, so an anonymous visitor never sees it.
	anon := getWho(t, s, "tok-josie", "").Body.String()
	if strings.Contains(anon, "sidegrade") || strings.Contains(anon, "Blazing Brand") {
		t.Error("compare card leaked onto the public page")
	}
}

// TestWhoSelfUnlockCaseInsensitive: Authentik may hand back a mixed-case
// username; the localpart it maps to is lowercase. The owner must still unlock.
func TestWhoSelfUnlockCaseInsensitive(t *testing.T) {
	s := seedWho(t, "josie")

	w := getWho(t, s, "tok-josie", "Josie") // capital-J session
	if !strings.Contains(w.Body.String(), "Iron Ore") {
		t.Error("a mixed-case owner username failed to unlock their own self-view")
	}
}

// TestWhoOtherUserNoUnlock: a different signed-in player sees only the public
// sheet on someone else's page — the ownership join must not leak across users.
func TestWhoOtherUserNoUnlock(t *testing.T) {
	s := seedWho(t, "josie")

	w := getWho(t, s, "tok-josie", "quack") // signed in, but not the owner
	if w.Code != 200 {
		t.Fatalf("other-user GET who = %d, want 200", w.Code)
	}
	body := w.Body.String()
	if !strings.Contains(body, "Josie") {
		t.Error("public sheet missing for a signed-in non-owner")
	}
	for _, leak := range []string{"Iron Ore", "Jeweled Crown", "Mittens"} {
		if strings.Contains(body, leak) {
			t.Errorf("a non-owner unlocked private detail %q", leak)
		}
	}
}

// TestWhoOffBoardIs404: a token that isn't on the current board resolves to
// nothing — the same liveness gate the storefront uses, so a stale or guessed
// token never renders a page.
func TestWhoOffBoardIs404(t *testing.T) {
	s := seedWho(t, "josie")
	if w := getWho(t, s, "tok-ghost", ""); w.Code != 404 {
		t.Errorf("off-board token = %d, want 404", w.Code)
	}
}

// TestDetailIngestReplacesAndAuth: the detail push is bearer-gated and swaps the
// set whole, so a player dropped from a later push loses their self-view.
func TestDetailIngestReplacesAndAuth(t *testing.T) {
	s := seedWho(t, "josie")

	if w := postDetail(t, s, "wrong", detailPush{SnapshotAt: time.Now().Unix()}); w.Code != 401 {
		t.Errorf("bad bearer = %d, want 401", w.Code)
	}

	// A later push without Josie: her self-view must be gone, but the public
	// board (a separate keyspace) is untouched, so her page still renders public.
	if w := postDetail(t, s, "tok", detailPush{SnapshotAt: time.Now().Unix() + 60}); w.Code != 200 {
		t.Fatalf("empty detail push = %d, want 200", w.Code)
	}
	body := getWho(t, s, "tok-josie", "josie").Body.String()
	if strings.Contains(body, "Iron Ore") {
		t.Error("owner still unlocked after her detail was dropped from the push")
	}
	if !strings.Contains(body, "Josie") {
		t.Error("public page vanished when only the private detail was dropped")
	}
}

// TestWhoHistoryPanels: the record and the trail render for an adventurer with
// facts on file. Driven through the real template and the real ingest path, so a
// field slip in either 500s here rather than in prod.
func TestWhoHistoryPanels(t *testing.T) {
	s := seedWho(t, "josie")
	seedHistory(t, s)

	w := getWho(t, s, "tok-josie", "")
	if w.Code != 200 {
		t.Fatalf("who = %d: %s", w.Code, w.Body.String())
	}
	body := w.Body.String()
	for _, want := range []string{
		"The record",
		"The Rotmother", // the boss tally
		"bosses down",
		"realm-first",               // the boss_first got flagged
		"level 14",                  // the milestone chip
		"treasure",                  // the treasure stat tile
		"Treasures found",           // the showcase panel
		"Crown of the Drowned King", // the named hoard
		"first hoard",               // the realm-first callout picked it up
		"The trail",
		"/adventure/treasure_found:e:5", // the trail links back to the treasure dispatch
	} {
		if !strings.Contains(body, want) {
			t.Errorf("history render missing %q", want)
		}
	}
}

// TestWhoHistoryAbsent: an adventurer with no facts on file gets no empty
// scaffolding. Every veteran looks like this the day this ships — their past is
// prose in the feed and was never counted — so the blank state has to be a clean
// absence, not a wall of zeroes.
func TestWhoHistoryAbsent(t *testing.T) {
	s := seedWho(t, "josie")

	body := getWho(t, s, "tok-josie", "").Body.String()
	for _, leak := range []string{"The record", "The trail", "bosses down"} {
		if strings.Contains(body, leak) {
			t.Errorf("no-history page should not render %q", leak)
		}
	}
}
