package web

import (
	"encoding/json"
	"fmt"
	"log/slog"
	"net/http"
	"time"

	"pete/internal/storage"
)

// The adventurer detail page.
//
// A click-through from the live board: anyone may see a mark's current stats and
// equipped gear (the same anonymity model as the board — a character name and a
// sheet, never a Matrix handle). The signed-in owner sees the same page enriched
// with their private inventory, vault, house, and pets.
//
// Both halves are still gogobee → Pete: the public detail rides the roster
// snapshot, the private detail rides its own push. Pete renders what it was
// given; it never reaches back into the game box.

// whoDetail is the public sheet as gogobee pushed it (RosterEntry.Detail).
type whoDetail struct {
	HPCurrent   int       `json:"hp_current"`
	HPMax       int       `json:"hp_max"`
	TempHP      int       `json:"temp_hp"`
	ArmorClass  int       `json:"armor_class"`
	Abilities   [6]int    `json:"abilities"`
	Modifiers   [6]int    `json:"modifiers"`
	Gear        []whoGear `json:"gear"`
	Supplies    int       `json:"supplies"`
	ThreatLevel int       `json:"threat_level"`
	Room        string    `json:"room"`
	Map         *whoMap   `json:"map"`
}

// whoMap is the fog-of-war zone graph as gogobee cut it: visited rooms with
// their true kind, plus the one-hop frontier of doors whose rooms are withheld
// (kind "unknown"). Pete lays it out and draws it; it never receives node
// labels or contents, only ids and kinds. See who_map.go.
type whoMap struct {
	ZoneID      string       `json:"zone_id"`
	CurrentNode string       `json:"current_node"`
	Visited     []string     `json:"visited"`
	Nodes       []whoMapNode `json:"nodes"`
	Edges       []whoMapEdge `json:"edges"`
}

type whoMapNode struct {
	ID   string `json:"id"`
	Kind string `json:"kind"`
}

type whoMapEdge struct {
	From string `json:"from"`
	To   string `json:"to"`
	Lock string `json:"lock"`
}

type whoGear struct {
	Slot       string `json:"slot"`
	Name       string `json:"name"`
	Tier       int    `json:"tier"`
	Condition  int    `json:"condition"`
	Masterwork bool   `json:"masterwork"`
}

// abilityRow is one ability line, pre-formatted for the template.
type abilityRow struct {
	Label  string
	Score  int
	ModStr string // "+2", "-1"
}

var abilityLabels = [6]string{"STR", "DEX", "CON", "INT", "WIS", "CHA"}

type whoPage struct {
	pageData
	Mark      RosterView
	HasDetail bool
	Detail    whoDetail
	Abilities []abilityRow
	MapView   *mapView // laid-out dungeon map, nil when not on a run or no graph
	HasSelf   bool
	Self      storage.PlayerDetail
	// The private panels, wrapped so a row knows where it is sitting. Bond state
	// only means something on a worn item — see itemRow.
	Worn      []itemRow
	Backpack  []itemRow
	VaultRows []itemRow
	BondsUsed int
	// History. Unlike everything above, these are not a gogobee snapshot — they
	// are counted from the facts Pete has been keeping since adventure_events
	// landed. HasHistory is false for an adventurer who hasn't done anything since
	// then, which includes every veteran on the day this shipped: their past is in
	// the story feed as prose and cannot be counted back out.
	Trophies    storage.TrophyCase
	HasHistory  bool
	Timeline    []timelineEntry
	MoreHistory bool // the trail was capped; there is older history than this
}

// itemRow is one item plus the one thing the item itself can't tell you: which
// panel it's in. It matters for bond state. An attunement item that is worn
// without a bond is *inert* — on you, doing nothing, and worth shouting about.
// The same item in a backpack isn't inert, it's just not worn yet; gogobee only
// tracks bonds on equipped rows, so its Attuned is undefined rather than false.
// Rendering both as "inert" would invent a problem the player doesn't have.
type itemRow struct {
	storage.ItemView
	Worn bool
	// EquipAction is the control this row offers its owner: "unequip" on anything
	// worn, "equip" on a backpack item the magic-item path will accept, "" on
	// everything else (vault items, mundane backpack gear). Empty means no button.
	EquipAction string
}

// itemRows wraps gogobee's item views for one panel. panel is "worn", "backpack",
// or "vault"; it decides both the bond wording (only a worn item can be inert) and
// which equip control, if any, the row offers.
func itemRows(items []storage.ItemView, panel string) []itemRow {
	if len(items) == 0 {
		return nil
	}
	worn := panel == "worn"
	out := make([]itemRow, 0, len(items))
	for _, it := range items {
		row := itemRow{ItemView: it, Worn: worn}
		switch {
		case worn:
			// Everything in magic_item_equipped is a magic item and can come off; the
			// slot is the handle gogobee unequips by.
			row.EquipAction = storage.EquipActionUnequip
		case panel == "backpack" && it.ID != 0:
			// Only a wearable magic item carries a row id (gogobee sets it just in the
			// magic-item branch), so the id gates Equip to exactly the items the
			// magic-item path accepts — never mundane gear, never a vault item.
			row.EquipAction = storage.EquipActionEquip
		}
		out = append(out, row)
	}
	return out
}

// timelineEntry is one line of an adventurer's trail — a fact, rendered short,
// pointing at the dispatch that told it.
type timelineEntry struct {
	Emoji     string
	Label     string
	Line      string // "The Rotmother, in holymachina"
	When      string
	Permalink string
	Notable   bool // a realm-first or a death: the lines worth an eye
}

// timelineCap bounds the trail. An adventurer accrues facts for as long as they
// play, and the page renders the whole list server-side — this is the point where
// "their whole history" stops being a page and starts being a scroll nobody
// reads. The count in the trophy case stays honest either way; only the trail is
// clipped.
const timelineCap = 40

// handleAdventureWho serves one adventurer's detail page. Public; 404s when the
// token isn't on the current board — the same liveness gate the storefront uses,
// so a stale or guessed token never resolves to a page.
func (s *Server) handleAdventureWho(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	token := r.PathValue("token")
	entry, ok, err := storage.RosterEntryByToken(token)
	if err != nil {
		slog.Error("who: roster lookup failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if !ok {
		http.NotFound(w, r)
		return
	}
	s.track(r, "adventure")

	base := s.base(r)
	base.Active = "adventure"
	base.NoIndex = true // names a player character; keep out of search indexes

	page := whoPage{
		pageData: base,
		Mark:     toRosterView(entry),
	}
	if d, abil, ok := decodeWhoDetail(entry.Detail); ok {
		page.HasDetail = true
		page.Detail = d
		page.Abilities = abil
		page.MapView = buildMapView(d.Map)
	}

	// History: one read feeds both the trophy case and the trail. Keyed on the
	// character *name* rather than the page token, because that is what a fact
	// carries — see the adventure_events schema note on why there is no id to use.
	// Best-effort: a page that can't count trophies is still a page.
	// Unlimited on purpose: the trophy case counts these, and a limit would cap the
	// tally rather than the list. The trail is clipped below, after the counting.
	if events, err := storage.EventsBySubject(entry.Name, 0); err != nil {
		slog.Error("who: history lookup failed", "token", token, "err", err)
	} else if len(events) > 0 {
		page.HasHistory = true
		page.Trophies = storage.BuildTrophyCase(entry.Name, events)
		page.MoreHistory = len(events) > timelineCap
		if page.MoreHistory {
			events = events[:timelineCap]
		}
		page.Timeline = buildTimeline(s, events)
	}

	// Owner enrichment: only when the signed-in user's localpart owns this exact
	// page token. The ownership is proven by a row gogobee pushed, never by
	// reversing the token, so no visitor can unlock another player's self extras.
	if s.auth != nil {
		if u := s.auth.userFromRequest(r); u != nil {
			if self, ok, err := storage.PlayerDetailByOwner(buyerLocalpart(u), token); err == nil && ok {
				page.HasSelf = true
				page.Self = self
				page.Worn = itemRows(self.Equipped, "worn")
				page.Backpack = itemRows(self.Inventory, "backpack")
				page.VaultRows = itemRows(self.Vault, "vault")
				for _, it := range self.Equipped {
					if it.Attuned {
						page.BondsUsed++
					}
				}
			}
		}
	}

	s.render(w, "who", page)
}

// handleAdventureWhoAPI serves the public detail as JSON for the page's live
// re-poll, so an open tab tracks HP / room / supplies as they move. Public — the
// same exposure as the rendered page. The private self extras are not included:
// inventory changes rarely and stays server-rendered behind the ownership check.
func (s *Server) handleAdventureWhoAPI(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	token := r.PathValue("token")
	entry, ok, err := storage.RosterEntryByToken(token)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	if !ok {
		// Gone from the board (expedition ended, opted out): tell the poller so it
		// can stop, rather than 404-ing an open tab into an error.
		_ = json.NewEncoder(w).Encode(map[string]any{"live": false})
		return
	}
	d, abil, hasDetail := decodeWhoDetail(entry.Detail)
	_ = json.NewEncoder(w).Encode(map[string]any{
		"live":       true,
		"mark":       toRosterView(entry),
		"has_detail": hasDetail,
		"detail":     d,
		"abilities":  abil,
	})
}

// buildTimeline renders facts into trail lines.
//
// The line is built from the fact, not from the dispatch's headline, on purpose:
// the headline is a *news* sentence written for the moment it landed ("First
// ever: Josie brings down the Rotmother"), and forty of those stacked in a column
// read as a wall of shouting. The trail wants the noun, not the announcement. It
// also saves a join back to stories for every row.
func buildTimeline(s *Server, events []storage.AdvEvent) []timelineEntry {
	out := make([]timelineEntry, 0, len(events))
	for _, e := range events {
		label, emoji := advEventMeta(e.EventType)
		out = append(out, timelineEntry{
			Emoji:     emoji,
			Label:     label,
			Line:      timelineLine(e),
			When:      time.Unix(e.OccurredAt, 0).UTC().Format("Jan 2, 2006"),
			Permalink: s.advPermalink(e.GUID),
			// A realm-first hoard is the treasure worth an eye; a plain find rides
			// the quiet border like any other bulletin.
			Notable: e.EventType == "boss_first" || e.EventType == "zone_first" || e.EventType == "death" ||
				(e.EventType == "treasure_found" && e.Tier == "priority"),
		})
	}
	return out
}

// timelineLine is the short "what" of a fact: the monster, the zone, the rival.
// Empty is fine — the label and emoji already carry the event, and inventing
// filler for a fact that carries no nouns would just be noise.
func timelineLine(e storage.AdvEvent) string {
	inZone := func(s string) string {
		if e.Zone == "" {
			return s
		}
		if s == "" {
			return e.Zone
		}
		return s + ", in " + e.Zone
	}
	switch e.EventType {
	case "boss_first", "boss_kill", "siege_start", "siege_win", "siege_loss",
		"mischief_survived", "mischief_downed":
		return inZone(e.Boss)
	case "zone_first", "zone_clear", "retreat", "departure", "death":
		if e.Region != "" && e.Zone != "" {
			return e.Zone + " — " + e.Region
		}
		return e.Zone
	case "rival_result", "pete_duel_win", "pete_duel_loss":
		if e.Opponent != "" {
			return "vs " + e.Opponent
		}
		return ""
	case "milestone":
		return e.Milestone
	case "treasure_found":
		// The item is the point; name it, and say where it came out of.
		return inZone(e.Stakes)
	}
	return inZone("")
}

// decodeWhoDetail unpacks the public detail blob and builds the ability rows.
// Returns ok=false when there is no detail (a snapshot from before the detail
// push), so the page can fall back to the summary the board already has.
func decodeWhoDetail(raw json.RawMessage) (whoDetail, []abilityRow, bool) {
	if len(raw) == 0 {
		return whoDetail{}, nil, false
	}
	var d whoDetail
	if err := json.Unmarshal(raw, &d); err != nil {
		return whoDetail{}, nil, false
	}
	rows := make([]abilityRow, 0, 6)
	for i := 0; i < 6; i++ {
		rows = append(rows, abilityRow{
			Label:  abilityLabels[i],
			Score:  d.Abilities[i],
			ModStr: fmt.Sprintf("%+d", d.Modifiers[i]),
		})
	}
	return d, rows, true
}
