package web

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

	"pete/internal/storage"
)

// The live adventurer board.
//
// Everything else Pete publishes about the realm is an *accomplishment* — a
// death, a clear, a milestone. Those are clippings: they read as archive the
// instant they land, however fast we deliver them, and no refresh interval fixes
// that. The board is the opposite kind of thing. It is state that is currently
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
// hearing from gogobee.
//
// Direction of travel is gogobee → Pete, like every other adventure payload:
// Pete has no route back into the game box's network and we are not opening one.
// gogobee pushes the whole board; we replace ours with it.

const (
	// rosterStaleAfter — how old a snapshot can get before the board stops
	// claiming to be live. gogobee pushes every couple of minutes, so this is
	// several missed pushes, not one unlucky one.
	//
	// This matters more than it looks: if gogobee dies mid-expedition, the last
	// snapshot says "Josie is in holymachina" and would say so forever. A board
	// that lies confidently is worse than one that admits it lost the wire —
	// especially once players can act on it (see the target-list note below).
	rosterStaleAfter = 12 * time.Minute

	// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
	// exists so a malformed or hostile push can't spool unbounded rows.
	rosterMaxEntries = 500
)

// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
//
// Balances ride the same tick as the board but live in a separate keyspace: they
// are keyed by localpart (a buyer's own sign-in name), not by the anonymous
// roster token, and Pete only ever reads one back for the authenticated user
// asking about themselves. So the board stays anonymous while the storefront can
// still grey out tiers a buyer plainly can't afford.
type rosterPush struct {
	SnapshotAt  int64                     `json:"snapshot_at"`
	Adventurers []storage.RosterEntry     `json:"adventurers"`
	Balances    []storage.MischiefBalance `json:"balances,omitempty"`
	Tiers       []storage.MischiefTier    `json:"tiers,omitempty"`
}

// RosterView is one row as the page renders it.
//
// Token is the mark's anonymous roster token, exposed so the storefront can name
// a target without ever knowing their real handle. It is safe on a public page by
// design — non-reversible, stable, and already how gogobee keys the board.
type RosterView struct {
	Token     string
	Name      string
	Level     int
	ClassRace string
	OnRun     bool
	Zone      string
	Region    string
	Where     string // "holymachina, day 3" | "in town"
	Idle      string // "quiet for 2 days" — only when idle
}

// handleRosterIngest replaces the board with gogobee's latest snapshot.
func (s *Server) handleRosterIngest(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 push rosterPush
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if len(push.Adventurers) > rosterMaxEntries {
		http.Error(w, "roster too large", http.StatusBadRequest)
		return
	}
	// A snapshot with no timestamp can't be aged, so it could never go stale —
	// it would sit on the page claiming to be live forever. Treat it as now.
	if push.SnapshotAt <= 0 {
		push.SnapshotAt = time.Now().Unix()
	}

	// Never trust the channel with a name. Same rule as factGuard: gogobee
	// pre-sanitizes to character names, and Pete checks anyway, because this is
	// the last thing standing between the payload and a public page.
	for i, e := range push.Adventurers {
		if e.Token == "" || e.Name == "" {
			http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
			return
		}
		if e.Status != "expedition" && e.Status != "idle" {
			http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
			return
		}
	}

	if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
		slog.Error("roster ingest: replace failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	// Advisory balances are best-effort: a board that landed is worth keeping
	// even if the balances behind it didn't, so a failure here is logged, not
	// fatal. Stale affordability only ever bounces an order, never miscounts money.
	if err := storage.ReplaceUserEuro(push.Balances, push.SnapshotAt); err != nil {
		slog.Error("roster ingest: balance replace failed", "err", err)
	}
	// The tier catalog is likewise best-effort and only refreshed when the push
	// actually carried one, so a gogobee build that predates the storefront (no
	// tiers field) can't wipe a catalog a newer one already established.
	if len(push.Tiers) > 0 {
		if err := storage.ReplaceMischiefTiers(push.Tiers); err != nil {
			slog.Error("roster ingest: tier replace failed", "err", err)
		}
	}
	slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers), "balances", len(push.Balances))
	w.WriteHeader(http.StatusOK)
}

// detailPush is the payload gogobee POSTs to /api/ingest/detail: the private,
// owner-only expansion for every player, in its own keyspace (keyed by localpart)
// and on its own endpoint so a fat inventory can't blow the roster push's budget.
type detailPush struct {
	SnapshotAt int64                  `json:"snapshot_at"`
	Players    []storage.PlayerDetail `json:"players"`
}

// handleDetailIngest replaces the private self-detail set with gogobee's latest
// push. Bearer-authed like the roster; the data is owner-private and only ever
// served back to the one authenticated user it belongs to (see PlayerDetailByOwner).
func (s *Server) handleDetailIngest(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
	}

	// A generous cap: this carries every player's inventory + vault, heavier than
	// the board, but still a bounded realm — the ceiling only stops a runaway push.
	var push detailPush
	if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&push); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	if len(push.Players) > rosterMaxEntries {
		http.Error(w, "detail set too large", http.StatusBadRequest)
		return
	}
	if push.SnapshotAt <= 0 {
		push.SnapshotAt = time.Now().Unix()
	}

	if err := storage.ReplacePlayerDetail(push.Players, push.SnapshotAt); err != nil {
		slog.Error("detail ingest: replace failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	slog.Info("detail ingest: self-view set replaced", "players", len(push.Players))
	w.WriteHeader(http.StatusOK)
}

// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
// open tab goes live without a reload. Public — same exposure as the rendered
// page, no more.
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
	if !s.adv.Enabled {
		http.NotFound(w, r)
		return
	}
	views, stale, _ := s.roster()
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", "no-store")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"stale":       stale,
		"adventurers": views,
	})
}

// roster loads the board and decides whether it is still live. A stale board is
// still returned — the page shows it dimmed and says so, rather than blanking,
// because "here is who was out when we lost contact" beats an empty page.
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
	entries, snapshotAt, err := storage.LoadRoster()
	if err != nil {
		slog.Error("roster: load failed", "err", err)
		return nil, true, 0
	}
	stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
	for _, e := range entries {
		views = append(views, toRosterView(e))
	}
	return views, stale, snapshotAt
}

func toRosterView(e storage.RosterEntry) RosterView {
	v := RosterView{
		Token:     e.Token,
		Name:      e.Name,
		Level:     e.Level,
		ClassRace: e.ClassRace,
		OnRun:     e.Status == "expedition",
		Zone:      e.Zone,
		Region:    e.Region,
	}
	if v.OnRun {
		where := e.Zone
		if e.Region != "" {
			where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
		}
		if e.Day > 0 {
			where = fmt.Sprintf("%s — day %d", where, e.Day)
		}
		v.Where = where
		return v
	}
	v.Where = "in town"
	v.Idle = humanIdle(e.IdleHours)
	return v
}

// humanIdle renders the idle clock the way a person would say it. Deliberately
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
func humanIdle(hours int) string {
	switch {
	case hours <= 0:
		return ""
	case hours < 2:
		return "just got back"
	case hours < 24:
		return fmt.Sprintf("quiet for %dh", hours)
	case hours < 48:
		return "quiet for a day"
	default:
		return fmt.Sprintf("quiet for %d days", hours/24)
	}
}
