// Package web serves Pete's read-only news interface.
package web

import (
	"context"
	"embed"
	"errors"
	"html/template"
	"io/fs"
	"log/slog"
	"net/http"
	"strings"
	"sync"
	"time"

	"pete/internal/config"
)

//go:embed templates/*.html
var templateFS embed.FS

//go:embed static
var staticFS embed.FS

// Channel describes one section of the site.
type Channel struct {
	Slug  string // "gaming"
	Title string // "Gaming"
	Theme string // tailwind theme key: "gaming" | "tech" | "politics"
	Emoji string
	Blurb string
}

// channels in display order. Add a new entry here to add a section.
//
// This is the full catalogue, used to resolve a story's channel slug to its
// display metadata. It is NOT the list of live sections: an entry can be gated
// off (adventure), so route registration and the nav read s.channels instead.
var channels = []Channel{
	{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
	{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
	{Slug: "politics", Title: "Politics", Theme: "politics", Emoji: "🏛️", Blurb: "Policy, power, and the news of the day."},
	{Slug: "eu", Title: "EU", Theme: "eu", Emoji: "🇪🇺", Blurb: "Portugal and the wider European beat. Read-only — these stories don't post to Matrix."},
	{Slug: "music", Title: "Music", Theme: "music", Emoji: "🎵", Blurb: "Records, scenes, and the artists shaping the sound."},
	{Slug: "anime", Title: "Anime", Theme: "anime", Emoji: "🌸", Blurb: "Series, studios, and the wider world of anime and manga."},
	{Slug: "foss", Title: "FOSS", Theme: "foss", Emoji: "🐧", Blurb: "Kernel, distros, and the wider free and open source software world."},
	{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
	{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
	{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
	{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
}

// SourceInfo is the trimmed view of a configured feed for the settings panel.
type SourceInfo struct {
	Name    string `json:"name"`
	Channel string `json:"channel"`
}

// Server is the HTTP server for Pete's web UI.
type Server struct {
	cfg            config.WebConfig
	sources        []SourceInfo
	postingEnabled bool // false = web-only mode; hides Matrix-posting UI
	srv            *http.Server
	tpls           map[string]*template.Template // keyed by page name (e.g. "index", "channel")
	auth           *Authenticator                // nil when sign-in is disabled or unavailable
	tts            *ttsService                   // nil when server-side read-aloud is disabled
	adminSubs      map[string]bool               // OIDC subjects allowed to view /status
	pushHTTP       *http.Client                  // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
	adv            config.AdventureConfig        // gogobee adventure-news seam
	advPost        PriorityPoster                // posts priority adventure beats to Matrix; nil = web-only
	channels       []Channel                     // live sections: the catalogue minus anything gated off (adventure)

	// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
	// Guarded by metricsMu; never persisted (see metrics.go).
	metricsMu sync.Mutex
	saltDay   int64
	salt      [16]byte

	// The shared-table machinery. hub fans SSE frames out to the phones at a felt;
	// tableLocks is the striped optimisation over the DB's version column (see
	// games_table.go). Both are nil-safe to construct always: they cost nothing
	// until a table is opened.
	hub        *gamesHub
	tableLocks *stripedLocks
	tableGames []tableGame // the multiplayer engines, dispatched through games()
}

// New builds the server. Templates are parsed once at startup. Each page gets
// its own template set sharing a layout, which avoids `main` define collisions
// between pages.
//
// There are two layouts, and they are not the same site. The news pages hang off
// layout.html: Pete's face, the channel nav, search, the reader, the weather
// canvas. The casino hangs off games_layout.html, which shares the *design* —
// the palette vars, the fonts, the fat rounded cards — and none of the
// furniture. games.parodia.dev is its own place, not a news page with a felt on
// it, so it doesn't inherit a single control it has no use for.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
	sets := []struct {
		layout string
		shared []string
		pages  []string
	}{
		{"layout", []string{"_card"}, []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "who"}},
		{"games_layout", []string{"_chipbar"}, []string{"games", "games_door", "blackjack", "hangman", "solitaire", "trivia", "uno", "holdem"}},
	}
	tpls := make(map[string]*template.Template)
	for _, set := range sets {
		for _, p := range set.pages {
			files := []string{"templates/" + set.layout + ".html"}
			for _, s := range set.shared {
				files = append(files, "templates/"+s+".html")
			}
			files = append(files, "templates/"+p+".html")
			t, err := template.New("").Funcs(funcs).ParseFS(templateFS, files...)
			if err != nil {
				return nil, err
			}
			tpls[p] = t
		}
	}
	infos := make([]SourceInfo, 0, len(sources))
	for _, sc := range sources {
		if !sc.Enabled || sc.Name == "" {
			continue
		}
		infos = append(infos, SourceInfo{Name: sc.Name, Channel: sc.DirectRoute})
	}
	adminSubs := make(map[string]bool, len(cfg.AdminSubs))
	for _, sub := range cfg.AdminSubs {
		if sub != "" {
			adminSubs[sub] = true
		}
	}
	// The adventure section only exists when it's enabled: with the seam off,
	// there's no nav tab, no /adventure page and no /adventure feed, rather than
	// an empty section nobody can fill.
	live := make([]Channel, 0, len(channels))
	for _, ch := range channels {
		if ch.Slug == "adventure" && !adv.Enabled {
			continue
		}
		live = append(live, ch)
	}

	s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}, unoTable{}}}

	// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
	// provider is unreachable at boot we log and serve anonymously rather than
	// refusing to start the whole site.
	if cfg.Auth.Enabled {
		dctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		auth, err := newAuthenticator(dctx, cfg.Auth)
		cancel()
		if err != nil {
			slog.Error("web: OIDC auth init failed; sign-in disabled", "err", err)
		} else {
			s.auth = auth
			slog.Info("web: OIDC sign-in enabled", "issuer", cfg.Auth.Issuer)
		}
	}

	// Optional server-side neural read-aloud (Piper). Signed-in only, so it is
	// only useful alongside auth; newTTS logs and disables itself on any misconfig.
	if s.auth != nil {
		tts, err := newTTS(cfg.TTS)
		if err != nil {
			return nil, err
		}
		s.tts = tts
	} else if cfg.TTS.Enabled {
		slog.Warn("web: TTS enabled but auth is off; read-aloud is signed-in only, so it stays disabled")
	}

	mux := http.NewServeMux()

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, err
	}
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
	mux.HandleFunc("GET /img/{name}", s.handleImg)

	mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest)
	mux.HandleFunc("GET /sw.js", s.handleServiceWorker)

	mux.HandleFunc("GET /{$}", s.handleIndex)
	mux.HandleFunc("GET /weather", s.handleWeatherDemo)
	mux.HandleFunc("GET /api/search", s.handleSearch)
	mux.HandleFunc("GET /api/article", s.handleArticle)
	mux.HandleFunc("GET /api/related", s.handleRelated)
	mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
	mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
	for _, ch := range s.channels {
		ch := ch
		mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
			s.handleChannel(w, r, ch)
		})
		mux.HandleFunc("GET /"+ch.Slug+"/feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, ch.Slug) })
		mux.HandleFunc("GET /"+ch.Slug+"/feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, ch.Slug) })
	}
	// Public source-health page. The handler renders a trimmed reader view for
	// everyone and the full diagnostic view only for admins, so it lives outside
	// the auth block.
	mux.HandleFunc("GET /status", s.handleStatus)

	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
	// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
	mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)

	// The live board. Ingest is bearer-authed like the fact seam; the read side
	// is public because it renders on a public page anyway.
	mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
	mux.HandleFunc("GET /api/roster", s.handleRosterAPI)

	// The private, owner-only self-detail (inventory/house/pets). Bearer-authed
	// ingest like the roster; there is no public read — it is only ever served
	// back to its owner, inline on the detail page below.
	mux.HandleFunc("POST /api/ingest/detail", s.handleDetailIngest)

	// One adventurer's detail page and its live re-poll. Both public (stats +
	// gear); the page adds the owner's private extras when the signed-in viewer
	// owns it. Three path segments, so it never overlaps /adventure/{guid} (two)
	// or /adventure/art/{type}.
	mux.HandleFunc("GET /adventure/who/{token}", s.handleAdventureWho)
	mux.HandleFunc("GET /api/adventure/who/{token}", s.handleAdventureWhoAPI)

	// Per-dispatch permalink (the article_url every ingested story points at).
	// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
	// channel listing, registered in the channels loop above).
	mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)

	// Themed per-event emblem (card + og:image). A distinct path depth from
	// /adventure/{guid}, so the two patterns never overlap.
	mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)

	// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
	// and pushes the verdict back. Bearer-authed on the same ingest token as the
	// adventure seam, so like it, these sit outside the sign-in block — the caller
	// is a machine on the tailnet, not a browser.
	mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
	mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
	mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)

	// The mischief storefront's game-box wire: gogobee polls pending orders and
	// pushes a verdict. Bearer-authed on the same ingest token, same reason as the
	// escrow seam — the caller is a machine on the tailnet. The buyer-facing half
	// hangs off the auth block below.
	mux.HandleFunc("GET /api/mischief/pending", s.handleMischiefPending)
	mux.HandleFunc("POST /api/mischief/claim", s.handleMischiefClaim)

	// The equip queue's game-box wire: gogobee polls pending equip/unequip orders
	// and pushes a verdict. Same bearer token and same reason as the pair above —
	// the caller is a machine on the tailnet. The owner-facing half hangs off the
	// auth block below.
	mux.HandleFunc("GET /api/equip/pending", s.handleEquipPending)
	mux.HandleFunc("POST /api/equip/verdict", s.handleEquipVerdict)

	// The casino. Signed-in only — there is money in it — so these hang off the
	// auth block, and gamesReady() also insists on a Matrix server name: without
	// one, no player can be named to gogobee's ledger and the tables stay shut.
	if s.gamesReady() {
		s.casinoRoutes(mux)
	}

	if s.auth != nil {
		mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
		mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
		mux.HandleFunc("GET /auth/logout", s.auth.handleLogout)
		mux.HandleFunc("GET /api/preferences", s.handlePrefs)
		mux.HandleFunc("PUT /api/preferences", s.handlePrefs)
		mux.HandleFunc("POST /api/read", s.handleRead)
		mux.HandleFunc("POST /api/bookmark", s.handleBookmark)
		mux.HandleFunc("GET /api/state", s.handleState)
		mux.HandleFunc("GET /bookmarks", s.handleBookmarks)
		mux.HandleFunc("GET /for-you", s.handleForYou)

		// The mischief storefront, buyer side. Signed-in only — an order names a
		// buyer to gogobee's ledger — so like the casino it hangs off the auth
		// block. Only registered when the adventure seam is on; otherwise there is
		// no board to order a hit from.
		if s.adv.Enabled {
			mux.HandleFunc("GET /api/mischief/catalog", s.handleMischiefCatalog)
			mux.HandleFunc("POST /api/mischief/order", s.handleMischiefOrder)
			mux.HandleFunc("GET /api/mischief/orders", s.handleMischiefOrders)

			// The equip queue, owner side. Signed-in only — an order dresses a
			// specific owner's character — and gated on the adventure seam like the
			// storefront, since without a board there is no detail page to equip from.
			mux.HandleFunc("POST /api/equip/order", s.handleEquipOrder)
			mux.HandleFunc("GET /api/equip/orders", s.handleEquipOrders)
		}
		if s.cfg.Push.Enabled {
			mux.HandleFunc("POST /api/push/subscribe", s.handlePushSubscribe)
			mux.HandleFunc("POST /api/push/unsubscribe", s.handlePushUnsubscribe)
		}
		if s.tts != nil {
			mux.HandleFunc("POST /api/tts", s.handleTTS)
		}
	}

	s.srv = &http.Server{
		Addr:              cfg.ListenAddr,
		Handler:           s.hostRouter(mux),
		ReadHeaderTimeout: 5 * time.Second,
	}
	return s, nil
}

// hostRouter puts the casino at the root of its own hostname without giving it
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
// the same port — Caddy sends both here — so a request that arrives on the games
// host has /games prefixed onto its path, and "/" lands on the lobby.
//
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
// the same plumbing whichever door you came in by, and the login round-trip in
// particular has to keep working on both hosts.
func (s *Server) hostRouter(mux http.Handler) http.Handler {
	host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
	if host == "" || !s.gamesReady() {
		return mux
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
			mux.ServeHTTP(w, r)
			return
		}
		// Rewrite a copy: the request's URL is shared with anything that logged it.
		r2 := r.Clone(r.Context())
		r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
		mux.ServeHTTP(w, r2)
	})
}

// isSharedPath marks the paths that mean the same thing on every host and must
// not be shuffled under /games.
func isSharedPath(p string) bool {
	for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
		if strings.HasPrefix(p, prefix) {
			return true
		}
	}
	return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
}

// hostOnly strips the port from a Host header.
func hostOnly(host string) string {
	if i := strings.IndexByte(host, ':'); i >= 0 {
		return host[:i]
	}
	return host
}

// Start runs the HTTP server and blocks until ctx is canceled.
func (s *Server) Start(ctx context.Context) {
	go func() {
		<-ctx.Done()
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = s.srv.Shutdown(shutdownCtx)
	}()
	slog.Info("web server listening", "addr", s.cfg.ListenAddr)
	if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("web server crashed", "err", err)
	}
}
