package web

import (
	"crypto/subtle"
	"encoding/json"
	"fmt"
	"html/template"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"strings"
	"time"
	"unicode"

	"pete/internal/storage"
)

// AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract
// in pete_adventure_news_voice.md. Names are character names only (never Matrix
// handles) and Actors is the allow-list of the only names permitted to appear in
// rendered output.
type AdvFact struct {
	GUID       string   `json:"guid"`
	EventType  string   `json:"event_type"`
	Tier       string   `json:"tier"` // "priority" | "bulletin"
	Actors     []string `json:"actors"`
	Subject    string   `json:"subject"`
	Opponent   string   `json:"opponent"`
	Boss       string   `json:"boss"`
	Zone       string   `json:"zone"`
	Region     string   `json:"region"`
	Level      int      `json:"level"`
	Count      int      `json:"count"`
	Outcome    string   `json:"outcome"`
	Stakes     string   `json:"stakes"`
	ClassRace  string   `json:"class_race"`
	Milestone  string   `json:"milestone"`
	OccurredAt int64    `json:"occurred_at"`
	NoPush     bool     `json:"no_push"`
	// Headline/Lede are gogobee's LLM-authored prose, both optional. When present
	// and past the prose-guard they replace the template render; otherwise Pete
	// falls back to renderAdventure. gogobee is compute here, Pete is the editor:
	// the templates are no longer the renderer, they are the safety net. See
	// proseGuard and adventure_expansion_spec.md §2.
	Headline string `json:"headline,omitempty"`
	Lede     string `json:"lede,omitempty"`
}

// AdvPost is a priority adventure item to post live to Matrix. Kept minimal and
// web-local so the web package needs no dependency on internal/poster.
type AdvPost struct {
	GUID       string
	Headline   string
	Lede       string
	ImageURL   string
	ArticleURL string
	Source     string
	Channel    string
}

// PriorityPoster posts a priority adventure item to Matrix immediately. main
// adapts *poster.Queue.PostNow to this; nil in web-only/local modes.
type PriorityPoster func(AdvPost)

const advSource = "Pete"

// advBackfillEvent is the synthetic post_log event id used to retire a no_push
// (cold-start backfill) dispatch against the daily digest. It never went to
// Matrix; the row exists only so the digest skips it.
const advBackfillEvent = "adv-backfill"

// handleAdventureIngest receives a game-event fact from gogobee, templates it
// into a deterministic story, publishes it to the /adventure section, and posts
// PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID.
func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	if !s.bearerOK(r) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	var f AdvFact
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if f.GUID == "" || f.EventType == "" {
		http.Error(w, "guid and event_type are required", http.StatusBadRequest)
		return
	}
	// Fact-guard: any player name we render must be in the actors allow-list.
	// gogobee pre-sanitizes, but Pete never trusts the channel — this is the
	// last line before a name reaches a public page.
	if !factGuard(f) {
		slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType)
		http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest)
		return
	}

	// Render the template first. It validates the event type, and it is the
	// fallback for every fact whose LLM prose is absent or fails the guard.
	headline, lede, ok := renderAdventure(f)
	if !ok {
		http.Error(w, "unknown event_type", http.StatusBadRequest)
		return
	}
	// Prefer gogobee's LLM prose when it is present and safe. Both fields must be
	// supplied — a half-authored dispatch is not a voice, and mixing an LLM
	// headline with a template lede reads as two writers. The guard is what makes
	// the untrusted prose safe to print; a rejection is worth seeing loudly, since
	// it is either a hallucinated name or someone who found an injection path.
	if f.Headline != "" && f.Lede != "" {
		if proseGuard(f.Headline, f.Lede, f.Actors) {
			headline, lede = f.Headline, f.Lede
		} else {
			slog.Warn("adventure ingest: prose-guard rejected LLM dispatch, using template",
				"guid", f.GUID, "event_type", f.EventType)
		}
	}

	// Idempotent: a re-delivered fact (gogobee retry) is a no-op success.
	if storage.IsGUIDSeen(f.GUID) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("duplicate"))
		return
	}

	// A fact with no occurred_at would otherwise be stored at the Unix epoch:
	// dated 1970 on the permalink, pinned to the bottom of the section, and
	// outside every digest window. Treat "missing" as "now".
	occurredAt := f.OccurredAt
	if occurredAt <= 0 {
		occurredAt = time.Now().Unix()
	}

	articleURL := s.advPermalink(f.GUID)
	imageURL := advArtURL(f.EventType)
	if err := storage.InsertStory(&storage.Story{
		GUID:        f.GUID,
		Headline:    headline,
		Lede:        lede,
		ImageURL:    imageURL,
		ArticleURL:  articleURL,
		Source:      advSource,
		Channel:     "adventure",
		Classified:  true,
		SeenAt:      occurredAt,
		PublishedAt: occurredAt,
	}); err != nil {
		slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err)
		http.Error(w, "insert failed", http.StatusInternalServerError)
		return
	}
	// Keep the fact itself, not just the sentence we made out of it. The story row
	// above is what people read; this is what the trophy case and timeline can
	// count. Best-effort on purpose: a dispatch that published is published, and
	// losing its structured twin costs a tally, not the news. Failing the request
	// here would make gogobee retry a fact we already have a story for.
	if err := storage.InsertAdventureEvent(&storage.AdvEvent{
		GUID:       f.GUID,
		EventType:  f.EventType,
		Tier:       f.Tier,
		Subject:    f.Subject,
		Opponent:   f.Opponent,
		Boss:       f.Boss,
		Zone:       f.Zone,
		Region:     f.Region,
		Level:      f.Level,
		Tally:      f.Count,
		Outcome:    f.Outcome,
		Milestone:  f.Milestone,
		Stakes:     f.Stakes,
		Actors:     f.Actors,
		OccurredAt: occurredAt,
	}); err != nil {
		slog.Error("adventure ingest: event record failed", "guid", f.GUID, "err", err)
	}

	slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier)

	// NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only
	// the live post isn't enough: the digest collects adventure rows that carry no
	// post_log entry, so a backfilled bulletin would still be swept into the next
	// roundup — the back-catalogue dump NoPush exists to prevent. Retire the guid
	// against the digest up front instead.
	if f.NoPush {
		storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent)
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
		return
	}

	// PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily
	// digest. Website section always gets the row above regardless of tier.
	if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" {
		// No ImageURL: the emblem is an SVG (Matrix clients often block SVG
		// media), and the link's og:image carries the preview instead.
		// No Source: the source tag exists to credit an outlet Pete is relaying
		// (`ars technica`). On his own reporting it renders as him signing his
		// own name under his own message.
		s.advPost(AdvPost{
			GUID:       f.GUID,
			Headline:   headline,
			Lede:       lede,
			ArticleURL: articleURL,
			Channel:    s.adv.Channel,
		})
	}

	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

// advEventMeta maps an event_type to a short display label and emoji used by the
// permalink page (and, later, OG art selection). Unknown types fall back to a
// neutral dispatch label so a new gogobee event never renders blank.
func advEventMeta(eventType string) (label, emoji string) {
	switch eventType {
	case "siege_start", "siege_win", "siege_loss":
		return "The Siege", "🏰"
	case "boss_first", "boss_kill":
		return "Boss down", "🐉"
	case "zone_first":
		return "First clear", "🗺️"
	case "zone_clear":
		return "Zone cleared", "🗺️"
	case "treasure_found":
		return "Treasure", "💎"
	case "death":
		return "In memoriam", "🪦"
	case "arrival":
		return "New arrival", "👋"
	case "standings", "rival_result":
		return "The rival board", "⚔️"
	case "pete_duel_loss", "pete_duel_win":
		return "Pete's duels", "🤝"
	case "milestone":
		return "Milestone", "🏅"
	case "retreat":
		return "Pulled out", "🎒"
	case "departure":
		return "Wandered off", "🚪"
	case "mischief_contract":
		return "Coin on their head", "😈"
	case "mischief_survived":
		return "They walked away", "🛡️"
	case "mischief_downed":
		return "The contract landed", "💀"
	case "mischief_fizzled":
		return "Nobody home", "🚪"
	}
	return "Dispatch", "📣"
}

// advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by
// handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses
// the external-image thumbnailer.
func advArtURL(eventType string) string {
	return "/adventure/art/" + eventType + ".svg"
}

// handleAdventureArt renders the themed emblem for an event type — an adventure
// gradient with the event's emoji and label. Deterministic and dependency-free
// (no external asset), so every dispatch card has visual identity instead of the
// blank placeholder that made the section look broken next to RSS cards.
func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	eventType := strings.TrimSuffix(r.PathValue("type"), ".svg")
	label, emoji := advEventMeta(eventType)
	w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8")
	w.Header().Set("Cache-Control", "public, max-age=86400")
	_, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label)))
}

// advArtSVG is the emblem template: %s = emoji, %s = label. 1200×630 (the OG
// card ratio) so the same image works as a link-preview image.
const advArtSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
  <defs>
    <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#7c5ce8"/>
      <stop offset="1" stop-color="#5836b8"/>
    </linearGradient>
  </defs>
  <rect width="1200" height="630" fill="url(#g)"/>
  <text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
  <text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
</svg>`

// advStoryPage is the per-story permalink view. It reuses the shared layout so a
// dispatch reads like the rest of the site, with an adventure-themed hero.
type advStoryPage struct {
	pageData
	EventLabel string
	Emoji      string
	Headline   string
	Body       string
	Region     string
	When       string
	Permalink  string
}

// handleAdventureStory serves the server-rendered permalink for one dispatch
// (the article_url every ingested story points at). Public and cacheable; 404s
// when the section is disabled or the guid is unknown. Character names in the
// stored headline/body already passed the ingest fact-guard, so nothing
// player-controlled reaches here unchecked.
func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	guid := r.PathValue("guid")
	st, err := storage.GetStoryByGUID(guid)
	if err != nil || st == nil || st.Channel != "adventure" {
		http.NotFound(w, r)
		return
	}
	s.track(r, "adventure")

	// event_type is encoded in the guid prefix (e.g. "death:<hash>:<ts>"); fall
	// back to the neutral dispatch meta when it isn't a known type.
	eventType, _, _ := strings.Cut(guid, ":")
	label, emoji := advEventMeta(eventType)

	body := st.Content
	if strings.TrimSpace(body) == "" {
		body = st.Lede // template-only dispatches carry the write-up in the lede
	}

	base := s.base(r)
	base.Active = "adventure"
	base.NoIndex = true // player-named page; keep out of search indexes (gap #5)
	if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" {
		base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls
	}
	s.render(w, "story", advStoryPage{
		pageData:   base,
		EventLabel: label,
		Emoji:      emoji,
		Headline:   st.Headline,
		Body:       body,
		Region:     "", // reserved: region isn't stored on the row yet
		When:       time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"),
		Permalink:  s.advPermalink(guid),
	})
}

// bearerOK checks the Authorization: Bearer header against the configured ingest
// token in constant time.
func (s *Server) bearerOK(r *http.Request) bool {
	const prefix = "Bearer "
	h := r.Header.Get("Authorization")
	if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" {
		return false
	}
	got := strings.TrimPrefix(h, prefix)
	return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1
}

// siteURL makes a root-relative path absolute against BaseURL. Links that go out
// to Matrix need the absolute form to survive safeHref; when BaseURL isn't
// configured the relative form is all we have, and it's still fine on-site.
func (s *Server) siteURL(path string) string {
	return strings.TrimRight(s.cfg.BaseURL, "/") + path
}

// advPermalink builds the per-story Pete permalink used as article_url (the card
// link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a
// stray "/" or "?" would otherwise produce a link that routes somewhere else.
func (s *Server) advPermalink(guid string) string {
	return s.siteURL("/adventure/" + url.PathEscape(guid))
}

// factGuard verifies every player-name field we might render is present in the
// actors allow-list. Boss/zone/region/milestone are game-authored content, not
// player-controlled, so they are not guarded.
func factGuard(f AdvFact) bool {
	allow := make(map[string]bool, len(f.Actors))
	for _, a := range f.Actors {
		if a != "" {
			allow[a] = true
		}
	}
	if f.Subject != "" && !allow[f.Subject] {
		return false
	}
	if f.Opponent != "" && !allow[f.Opponent] {
		return false
	}
	return true
}

// Length caps for LLM-authored prose, enforced before render. The 64 KiB body
// cap on the ingest request is a transport limit, not a prose limit — a
// dispatch is a headline and a short paragraph, and anything past these is
// malformed, not a valid long story. Over the cap falls back to the template.
const (
	maxDispatchHeadline = 200
	maxDispatchLede     = 800
)

// proseGuard decides whether gogobee's LLM-authored headline+lede is safe to
// print. factGuard checks the STRUCTURED Subject/Opponent fields; that was the
// whole safety story while Pete's own templates were the renderer, because a
// template can print nothing Pete did not interpolate. LLM prose breaks that
// assumption — the guard would be validating fields that are no longer the thing
// being rendered — so this checks the RENDERED TEXT itself.
//
// Two rejections, both falling back to the template:
//   - Over the length caps: a runaway or padded generation, not a dispatch.
//   - Naming a known adventurer the fact did not authorize: any character name
//     Pete holds on the current board that is absent from the fact's Actors
//     allow-list. Character names are player-chosen, so a hallucinated or
//     injected name is a live way to put words in a real person's mouth on a
//     public page. Boss/zone/region are game-authored, never on the board, so
//     they never trip this.
//
// The name half is best-effort: an empty board (KnownCharacterNames nil) leaves
// only the length caps, which is the correct degraded behaviour — with no known
// names there is nothing to impersonate that Pete could recognise anyway.
func proseGuard(headline, lede string, actors []string) bool {
	if len(headline) > maxDispatchHeadline || len(lede) > maxDispatchLede {
		return false
	}
	allow := make(map[string]bool, len(actors))
	for _, a := range actors {
		if a != "" {
			allow[strings.ToLower(a)] = true
		}
	}
	text := strings.ToLower(headline + "\n" + lede)
	for name := range storage.KnownCharacterNames() {
		if allow[name] {
			continue
		}
		if containsWholeWord(text, name) {
			return false
		}
	}
	return true
}

// containsWholeWord reports whether needle appears in haystack bounded by
// non-letter/digit runes (or the string edges). Both are already lowercased.
// Bounding avoids a short character name ("Al") matching inside an unrelated
// word ("Alabama") while still catching it as a standalone name; it is
// deliberately rune-aware so a non-ASCII player name still bounds correctly,
// where a stdlib \b would not.
func containsWholeWord(haystack, needle string) bool {
	if needle == "" {
		return false
	}
	from := 0
	for {
		i := strings.Index(haystack[from:], needle)
		if i < 0 {
			return false
		}
		start := from + i
		end := start + len(needle)
		beforeOK := start == 0 || !isWordRune(lastRune(haystack[:start]))
		afterOK := end == len(haystack) || !isWordRune(firstRune(haystack[end:]))
		if beforeOK && afterOK {
			return true
		}
		from = start + 1
		if from >= len(haystack) {
			return false
		}
	}
}

func isWordRune(r rune) bool {
	return unicode.IsLetter(r) || unicode.IsDigit(r)
}

func firstRune(s string) rune {
	for _, r := range s {
		return r
	}
	return 0
}

func lastRune(s string) rune {
	var last rune
	for _, r := range s {
		last = r
	}
	return last
}

// renderAdventure returns the deterministic headline + lede for a fact. Copied
// verbatim from the voice spec (pete_adventure_news_voice.md). Template-only —
// no LLM — so output is safe and reproducible. ok is false for an unknown type.
func renderAdventure(f AdvFact) (headline, lede string, ok bool) {
	atLevel := ""
	if f.Level > 0 {
		atLevel = fmt.Sprintf(", at level %d", f.Level)
	}
	switch f.EventType {
	case "siege_start":
		return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss),
			fmt.Sprintf("Folks, this is the big one — %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true
	case "siege_win":
		return fmt.Sprintf("The town holds! %s turned back.", f.Boss),
			fmt.Sprintf("What a turnout — %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true
	case "siege_loss":
		return fmt.Sprintf("Heavy news: %s broke through.", f.Boss),
			fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time — heads up, everyone.", f.Boss), true
	case "boss_first":
		return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss),
			fmt.Sprintf("History in %s today — %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true
	case "boss_kill":
		return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss),
			fmt.Sprintf("Another clean run in %s today. Routine for %s by now — but still worth a nod.", f.Zone, f.Subject), true
	case "zone_first", "zone_clear":
		// gogobee splits the realm's first-ever clear (zone_first, priority) from a
		// later repeat (zone_clear, bulletin); they share a lede but differ in
		// headline. Fall back to the tier for a legacy zone_first that predates the
		// split.
		if f.EventType == "zone_first" || f.Tier == "priority" {
			headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone)
		} else {
			headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone)
		}
		inRegion := ""
		if f.Region != "" {
			inRegion = " in " + f.Region
		}
		return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true
	case "treasure_found":
		// A story-grade find pulled from a dungeon. stakes is the item's name,
		// outcome its rarity, and the priority tier marks a realm-first hoard
		// nobody had ever pulled before — the same split zone_first uses.
		inZone := f.Zone
		if inZone == "" {
			inZone = "the dungeon"
		}
		rarity := ""
		if f.Outcome != "" {
			rarity = strings.ToLower(f.Outcome) + " "
		}
		if f.Tier == "priority" {
			return fmt.Sprintf("First ever: %s pulls %s out of %s.", f.Subject, f.Stakes, inZone),
				fmt.Sprintf("Nobody had laid hands on %s before today. %s found the %shoard deep in %s%s, first in the realm to do it. Some haul.", f.Stakes, f.Subject, rarity, inZone, atLevel), true
		}
		return fmt.Sprintf("%s turned up %s in %s.", f.Subject, f.Stakes, inZone),
			fmt.Sprintf("%s came back from %s with %s to show for it%s. A %sfind like that is worth a mention. Nice one.", f.Subject, inZone, f.Stakes, atLevel, rarity), true
	case "death":
		return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone),
			fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true
	case "retreat":
		// An expedition that came apart without killing anyone. Until gogobee
		// started sending these, the feed had no way to say "it went badly and
		// everyone lived" — so it never said it, and the classes that retreat
		// often simply never appeared. Warm, not a failure notice: everyone came
		// home, and that is the part Pete leads with.
		howFar := "barely a day in"
		if f.Count > 1 {
			howFar = fmt.Sprintf("%d days in", f.Count)
		}
		return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone),
			fmt.Sprintf("%s turned around %s — %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.",
				f.Subject, howFar, f.Zone, atLevel), true
	case "departure":
		// A bored adventurer let themselves out. Nobody sent them — they got
		// restless waiting on a player who wasn't coming, took the cheap supplies
		// they could afford, and went. Pete plays it straight and a little fond;
		// the joke tells itself, and the player it's about may well be reading.
		return fmt.Sprintf("%s got bored and left without waiting.", f.Subject),
			fmt.Sprintf("No orders, no escort, no fuss — %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true
	case "mischief_contract":
		// Somebody paid to have a monster sent after an adventurer who is out in a
		// dungeon right now. Anonymous unless the buyer paid extra to sign it, and
		// the anonymity is the story: Pete reports the money, not the name he
		// doesn't have. Opponent carries the buyer only when it's public.
		if f.Opponent != "" {
			return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject),
				fmt.Sprintf("No secret about it — %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.",
					f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true
		}
		return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject),
			fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs — and we all find out who signed the cheque.",
				f.Stakes, strings.ToLower(f.Boss), f.Subject), true
	case "mischief_survived":
		// The unseal. A survival is the only thing that names an anonymous buyer,
		// and it is the whole brake on casual griefing — so Pete leads with it.
		return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent),
			fmt.Sprintf("%s came for %s, and %s is the one still standing — %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.",
				f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true
	case "mischief_downed":
		who := "Nobody's saying who paid for it"
		if f.Opponent != "" {
			who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent)
		}
		return fmt.Sprintf("%s didn't walk away.", f.Subject),
			fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home — alive, which is more than the contract asked for, but that expedition's finished. %s.",
				f.Boss, f.Subject, atLevel, who), true
	case "mischief_fizzled":
		return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject),
			fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.",
				f.Subject, f.Subject), true
	case "arrival":
		return fmt.Sprintf("Welcome to the realm, %s!", f.Subject),
			fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true
	case "standings":
		return "The rival board's been shaken up.",
			fmt.Sprintf("%s is on the move — here's where the standings sit today.", f.Subject), true
	case "rival_result":
		return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent),
			fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true
	case "pete_duel_loss":
		if f.Tier == "priority" {
			headline = fmt.Sprintf("You got me, %s.", f.Subject)
		} else {
			headline = fmt.Sprintf("%s got the better of me again.", f.Subject)
		}
		return headline, fmt.Sprintf("Credit where it's due — %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true
	case "pete_duel_win":
		return fmt.Sprintf("Held my ground against %s today.", f.Subject),
			fmt.Sprintf("Closer than the record will show, honestly — %s pushed me. Rematch whenever you like.", f.Subject), true
	case "milestone":
		return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone),
			fmt.Sprintf("One for the books — %s just reached %s. The long road continues.", f.Subject, f.Milestone), true
	}
	return "", "", false
}
