package web

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

	"pete/internal/config"
	"pete/internal/storage"
)

// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
	t.Helper()
	storage.Close()
	if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { storage.Close() })

	var posted []AdvPost
	adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
	// Mirror the production poster's side effect: queue.PostNow records a
	// post_log row, which is exactly what marks a beat "posted" and thus
	// excludes it from the bulletin digest.
	poster := func(p AdvPost) {
		posted = append(posted, p)
		storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
	}
	s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
		nil, true, adv, poster)
	if err != nil {
		t.Fatal(err)
	}
	return s, &posted
}

func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
	t.Helper()
	body, _ := json.Marshal(f)
	req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	rw := httptest.NewRecorder()
	s.handleAdventureIngest(rw, req)
	return rw
}

// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
	const token = "s3cret-token"
	s, posted := newAdvServer(t, token)

	f := AdvFact{
		GUID: "death:abc:1000", EventType: "death", Tier: "priority",
		Actors: []string{"Brannigan"}, Subject: "Brannigan",
		Zone: "the Underforge", Level: 14, OccurredAt: 1000,
	}
	if rw := postFact(t, s, token, f); rw.Code != 200 {
		t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
	}

	got, err := storage.GetStoryByGUID("death:abc:1000")
	if err != nil || got == nil {
		t.Fatalf("story not stored: %v", err)
	}
	if got.Channel != "adventure" || got.Source != advSource {
		t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
	}
	if got.Headline != "We lost Brannigan in the Underforge." {
		t.Errorf("headline = %q", got.Headline)
	}
	if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
		t.Errorf("article_url = %q", got.ArticleURL)
	}
	if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
		t.Fatalf("priority post not delivered: %+v", *posted)
	}

	// Idempotent re-delivery: no error, no second post.
	if rw := postFact(t, s, token, f); rw.Code != 200 {
		t.Fatalf("dup ingest status = %d", rw.Code)
	}
	if len(*posted) != 1 {
		t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
	}
}

// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
	const token = "t"
	s, _ := newAdvServer(t, token)
	f := AdvFact{
		GUID: "death:abc:1000", EventType: "death", Tier: "priority",
		Actors: []string{"Brannigan"}, Subject: "Brannigan",
		Zone: "the Underforge", Level: 14, OccurredAt: 1000,
	}
	if rw := postFact(t, s, token, f); rw.Code != 200 {
		t.Fatalf("ingest status = %d", rw.Code)
	}

	req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
	req.SetPathValue("guid", "death:abc:1000")
	rw := httptest.NewRecorder()
	s.handleAdventureStory(rw, req)
	if rw.Code != 200 {
		t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
	}
	body := rw.Body.String()
	if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
		t.Errorf("permalink missing headline; body=%s", body)
	}
	if !bytes.Contains([]byte(body), []byte("In memoriam")) {
		t.Errorf("permalink missing event label; body=%s", body)
	}

	// Unknown guid 404s.
	req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
	req2.SetPathValue("guid", "nope:1")
	rw2 := httptest.NewRecorder()
	s.handleAdventureStory(rw2, req2)
	if rw2.Code != 404 {
		t.Errorf("unknown guid status = %d, want 404", rw2.Code)
	}
}

// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
	// 14:30 UTC, targeting 17:00 → 2h30m today.
	now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
	if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
		t.Errorf("before hour: got %v", got)
	}
	// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
	now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
	if got := durUntilNextHour(now, 17); got != 24*time.Hour {
		t.Errorf("at hour: got %v", got)
	}
	// 20:00, targeting 17:00 → tomorrow, 21h.
	now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
	if got := durUntilNextHour(now, 17); got != 21*time.Hour {
		t.Errorf("after hour: got %v", got)
	}
}

// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
	const token = "t"
	s, posted := newAdvServer(t, token)
	now := time.Now()

	// Two bulletins + one priority (which posts live and must be excluded).
	postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
		Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
	postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
		Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
	postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
		Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
	if len(*posted) != 1 {
		t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
	}

	s.postDailyDigest(now.UTC())
	if len(*posted) != 2 {
		t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
	}
	dg := (*posted)[1]
	if !strings.Contains(dg.Headline, "2 dispatches") {
		t.Errorf("digest headline = %q", dg.Headline)
	}
	if !strings.HasPrefix(dg.GUID, "adv-digest:") {
		t.Errorf("digest guid = %q", dg.GUID)
	}

	// Re-running finds nothing new (bulletins marked digested).
	s.postDailyDigest(now.UTC())
	if len(*posted) != 2 {
		t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
	}
}

// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
	first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
		Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
	hl, _, ok := renderAdventure(first)
	if !ok || !strings.Contains(hl, "very first time") {
		t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
	}

	repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
		Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
	hl2, _, ok := renderAdventure(repeat)
	if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
		t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
	}

	// The permalink label distinguishes the two, so a repeat's page isn't stamped
	// "First clear".
	if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
		t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
	}
}

// TestRenderTreasure: a story-grade find names the item and its zone; a
// priority find is billed as a realm-first, and the rarity from outcome rides
// into the lede.
func TestRenderTreasure(t *testing.T) {
	hoard := AdvFact{EventType: "treasure_found", Tier: "priority", Subject: "Josie",
		Zone: "The Ossuary", Stakes: "Crown of the Drowned King", Outcome: "legendary", Level: 7}
	hl, lede, ok := renderAdventure(hoard)
	if !ok || !strings.Contains(hl, "First ever") || !strings.Contains(hl, "Crown of the Drowned King") {
		t.Errorf("hoard headline = %q (ok=%v)", hl, ok)
	}
	if !strings.Contains(lede, "legendary") {
		t.Errorf("hoard lede dropped the rarity: %q", lede)
	}

	find := AdvFact{EventType: "treasure_found", Tier: "bulletin", Subject: "Josie",
		Zone: "The Sump", Stakes: "Ring of Nine Sorrows"}
	hl2, _, ok := renderAdventure(find)
	if !ok || !strings.Contains(hl2, "Josie") || !strings.Contains(hl2, "Ring of Nine Sorrows") ||
		!strings.Contains(hl2, "The Sump") || strings.Contains(hl2, "First ever") {
		t.Errorf("plain find headline = %q (ok=%v)", hl2, ok)
	}

	if lbl, emoji := advEventMeta("treasure_found"); lbl != "Treasure" || emoji == "" {
		t.Errorf("treasure meta = %q/%q", lbl, emoji)
	}
}

func TestAdventureArtAndMeta(t *testing.T) {
	const token = "t"
	s, _ := newAdvServer(t, token)

	// Emblem endpoint returns SVG with the event's emoji.
	areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
	areq.SetPathValue("type", "death.svg")
	arw := httptest.NewRecorder()
	s.handleAdventureArt(arw, areq)
	if arw.Code != 200 {
		t.Fatalf("art status = %d", arw.Code)
	}
	if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
		t.Errorf("art content-type = %q", ct)
	}
	if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
		t.Errorf("art body missing emblem: %s", b)
	}

	// Ingest sets the card image to the local emblem path.
	f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
		Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
	if rw := postFact(t, s, token, f); rw.Code != 200 {
		t.Fatalf("ingest status = %d", rw.Code)
	}
	got, _ := storage.GetStoryByGUID("death:abc:1000")
	if got == nil || got.ImageURL != "/adventure/art/death.svg" {
		t.Errorf("story image = %q", got.ImageURL)
	}

	// Permalink page is noindex with an og:image.
	preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
	preq.SetPathValue("guid", "death:abc:1000")
	prw := httptest.NewRecorder()
	s.handleAdventureStory(prw, preq)
	body := prw.Body.String()
	if !strings.Contains(body, `name="robots" content="noindex"`) {
		t.Error("permalink not noindex")
	}
	if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
		t.Errorf("permalink missing og:image; body=%s", body)
	}
}

// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
	s, _ := newAdvServer(t, "right")
	f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
	if rw := postFact(t, s, "", f); rw.Code != 401 {
		t.Errorf("no token: status = %d, want 401", rw.Code)
	}
	if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
		t.Errorf("wrong token: status = %d, want 401", rw.Code)
	}
}

// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
	const token = "t"
	s, posted := newAdvServer(t, token)
	f := AdvFact{
		GUID: "death:evil:1", EventType: "death", Tier: "priority",
		Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
		Zone: "the Underforge", Level: 3, OccurredAt: 1,
	}
	if rw := postFact(t, s, token, f); rw.Code != 400 {
		t.Errorf("fact-guard: status = %d, want 400", rw.Code)
	}
	if storage.IsGUIDSeen("death:evil:1") {
		t.Error("guarded fact was stored")
	}
	if len(*posted) != 0 {
		t.Error("guarded fact was posted")
	}
}

// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
	storage.Close()
	if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { storage.Close() })
	s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
	if err != nil {
		t.Fatal(err)
	}
	f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
	if rw := postFact(t, s, "anything", f); rw.Code != 404 {
		t.Errorf("disabled: status = %d, want 404", rw.Code)
	}
}

// TestRenderMischief: gogobee's four mischief event types must all render. An
// unknown event_type is a 400 at ingest, which gogobee retries and then parks
// forever — so "Pete deploys first" only helps if Pete actually knows the types.
//
// It also pins the anonymity contract, which is the feature's whole social
// engine: an unsigned contract must not name the buyer, and a survival must.
func TestRenderMischief(t *testing.T) {
	anon := AdvFact{EventType: "mischief_contract", Tier: "priority",
		Subject: "Josie", Boss: "Elite", Stakes: "€350", Level: 14}
	hl, lede, ok := renderAdventure(anon)
	if !ok {
		t.Fatal("mischief_contract did not render — ingest would 400")
	}
	if strings.Contains(hl+lede, "Brannigan") {
		t.Error("anonymous contract leaked a buyer name")
	}
	if !strings.Contains(hl, "€350") {
		t.Errorf("contract headline lost the stakes: %q", hl)
	}

	signed := anon
	signed.Opponent = "Brannigan"
	hl, _, ok = renderAdventure(signed)
	if !ok || !strings.Contains(hl, "Brannigan") {
		t.Errorf("signed contract should name the buyer: %q (ok=%v)", hl, ok)
	}

	// The unseal: a survival names the buyer whether or not they signed.
	survived := AdvFact{EventType: "mischief_survived", Tier: "priority",
		Subject: "Josie", Opponent: "Brannigan", Boss: "Bone Colossus", Stakes: "€228"}
	hl, _, ok = renderAdventure(survived)
	if !ok || !strings.Contains(hl, "Brannigan") {
		t.Errorf("survival must unseal the buyer: %q (ok=%v)", hl, ok)
	}

	// A downed target with an anonymous buyer stays anonymous — being maimed
	// doesn't buy you the name.
	downed := AdvFact{EventType: "mischief_downed", Tier: "priority",
		Subject: "Josie", Boss: "Bone Colossus", Level: 14}
	hl, lede, ok = renderAdventure(downed)
	if !ok {
		t.Fatal("mischief_downed did not render")
	}
	if strings.Contains(hl+lede, "Brannigan") {
		t.Error("anonymous buyer named on a downed contract")
	}

	if _, _, ok := renderAdventure(AdvFact{EventType: "mischief_fizzled", Subject: "Josie", Stakes: "€315"}); !ok {
		t.Error("mischief_fizzled did not render")
	}

	for _, et := range []string{"mischief_contract", "mischief_survived", "mischief_downed", "mischief_fizzled"} {
		if lbl, _ := advEventMeta(et); lbl == "Dispatch" {
			t.Errorf("%s has no permalink label", et)
		}
	}
}
