package web

import (
	"encoding/json"
	"fmt"
	"html/template"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"

	"pete/internal/storage"
)

const pageSize = 24

// StoryView is the trimmed-down record used in templates.
type StoryView struct {
	ID         int64
	Headline   string
	Lede       string
	ImageURL   string
	ArticleURL string
	Source     string
	SeenAt     time.Time
	Posted     bool
	Paywalled  bool   // source is gated and no archive workaround succeeded
	Channel    string // channel slug; also the theme key
	ReadMins   int    // estimated reading time in minutes; 0 = unknown (no chip)
	Views      int    // all-time reader-mode opens; 0 = none yet (no badge)
}

func toView(s storage.Story) StoryView {
	return StoryView{
		ID:         s.ID,
		Headline:   s.Headline,
		Lede:       s.Lede,
		ImageURL:   s.ImageURL,
		ArticleURL: s.ArticleURL,
		Source:     s.Source,
		SeenAt:     time.Unix(s.SeenAt, 0),
		Posted:     s.Posted,
		Paywalled:  s.Paywalled,
		Channel:    s.Channel,
	}
}

// decorate fills the ReadMins and Views fields on one or more groups of story
// cards using two cheap batch queries over the whole id set. Called just before
// render so every listing (home rails, channel pages, bookmarks, for-you) shows
// a reading-time chip and a read count without each list query having to carry
// those columns. Best-effort: a metrics hiccup just leaves the badges off.
func decorate(groups ...[]StoryView) {
	var ids []int64
	seen := make(map[int64]bool)
	for _, g := range groups {
		for _, v := range g {
			if v.ID > 0 && !seen[v.ID] {
				seen[v.ID] = true
				ids = append(ids, v.ID)
			}
		}
	}
	if len(ids) == 0 {
		return
	}
	lengths := storage.StoryContentLengths(ids)
	views := storage.StoryViewTotals(ids)
	for _, g := range groups {
		for i := range g {
			g[i].ReadMins = readMinutes(lengths[g[i].ID])
			g[i].Views = views[g[i].ID]
		}
	}
}

// readMinutes turns a character count into a rounded minutes-to-read estimate,
// assuming ~200 wpm and ~6 characters per word (5-letter words plus a space).
// Any non-empty body reads as at least "1 min".
func readMinutes(chars int) int {
	if chars <= 0 {
		return 0
	}
	mins := (chars + 600) / 1200 // round to nearest minute (200 wpm * 6 chars)
	if mins < 1 {
		mins = 1
	}
	return mins
}

type pageData struct {
	SiteTitle      string
	Channels       []Channel
	Active         string // slug of active channel, "" for landing
	Weather        Weather
	Phase          string       // when set, forces day/dawn/dusk/night and disables the clock-driven phase JS
	AllSources     template.JS  // JSON array of {name, channel} for the settings panel
	AuthEnabled    bool         // sign-in is available
	User           *SessionUser // nil for anonymous visitors
	UserPrefs      template.JS  // signed-in user's stored prefs blob (JSON), or "null"
	Path           string       // current request path, for post-login return
	PostingEnabled bool         // false = web-only mode; hide Matrix-posting UI
	IsAdmin        bool         // signed-in user is on the admin allowlist (shows /status link)
	PushEnabled    bool         // Web Push is configured (shows the notifications toggle to signed-in users)
	PushPublicKey  string       // VAPID public key handed to the client to subscribe
	TTS            template.JS  // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
	NoIndex        bool         // emit <meta name="robots" content="noindex"> — used by the adventure section
	OGImage        string       // absolute og:image URL for link unfurls (adventure emblem); "" = none
}

type channelPage struct {
	pageData
	Channel Channel
	Stories []StoryView
	Page    int
	HasPrev bool
	HasNext bool
	PrevURL string
	NextURL string
	Total   int

	// Roster is the live adventurer board, populated on the adventure section
	// only and only on page 1 — it is present tense, and page 2 of an archive is
	// not where anyone looks for what's happening right now.
	Roster      []RosterView
	RosterStale bool
	ShowRoster  bool
}

type indexPage struct {
	pageData
	JustPosted []StoryView
	Stats      []channelStat
	Latest     []StoryView
	ForYou     []StoryView // personalized rail; empty for anon / no-history users
	Trending   []StoryView // most-read this week; empty until reads accumulate
}

type channelStat struct {
	Channel       Channel
	LastPostedAt  time.Time // zero if never posted
	HasLastPosted bool
	PostsToday    int
	Total         int
}

// jsForScript marks already-valid JSON as safe to embed in an inline <script>,
// after neutralizing the byte sequences that could break out of that context
// (</script>, HTML comments, and the JS line separators U+2028/U+2029). Without
// this, a stored prefs blob containing "</script>" could inject markup.
func jsForScript(b []byte) template.JS {
	r := strings.NewReplacer(
		"<", "\\u003c",
		">", "\\u003e",
		"&", "\\u0026",
		" ", "\\u2028",
		" ", "\\u2029",
	)
	return template.JS(r.Replace(string(b)))
}

func (s *Server) base(r *http.Request) pageData {
	srcJSON, err := json.Marshal(s.sources)
	if err != nil {
		srcJSON = []byte("[]")
	}
	d := pageData{
		SiteTitle:      s.cfg.SiteTitle,
		Channels:       s.channels,
		Weather:        currentWeather(time.Now()),
		AllSources:     jsForScript(srcJSON),
		AuthEnabled:    s.auth != nil,
		UserPrefs:      template.JS("null"),
		Path:           r.URL.Path,
		PostingEnabled: s.postingEnabled,
		PushEnabled:    s.auth != nil && s.cfg.Push.Enabled,
		PushPublicKey:  s.cfg.Push.VAPIDPublicKey,
		TTS:            template.JS("null"),
	}
	if s.tts != nil {
		d.TTS = s.tts.clientConfig()
	}
	if s.auth != nil {
		if u := s.auth.userFromRequest(r); u != nil {
			d.User = u
			d.IsAdmin = s.adminSubs[u.Sub]
			if blob, err := storage.GetUserPrefs(u.Sub); err == nil && blob != "" {
				d.UserPrefs = jsForScript([]byte(blob))
			}
		}
	}
	return d
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	s.track(r, "home")
	const (
		justPostedLimit = 6
		latestLimit     = 16
		trendingLimit   = 8
	)

	postedRows, err := storage.ListRecentlyPosted(justPostedLimit)
	if err != nil {
		slog.Error("web: list recently posted failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	justPosted := make([]StoryView, 0, len(postedRows))
	for _, r := range postedRows {
		justPosted = append(justPosted, toView(r))
	}

	latestRows, err := storage.ListAllClassified(latestLimit, 0)
	if err != nil {
		slog.Error("web: list all classified failed", "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	latest := make([]StoryView, 0, len(latestRows))
	for _, r := range latestRows {
		latest = append(latest, toView(r))
	}

	dayStart := time.Now().Add(-24 * time.Hour).Unix()
	stats := make([]channelStat, 0, len(s.channels))
	for _, ch := range s.channels {
		total, _ := storage.CountClassifiedByChannel(ch.Slug)
		lastTs := storage.GetLastPostTime(ch.Slug)
		stat := channelStat{
			Channel:    ch,
			PostsToday: storage.CountPostsInWindow(ch.Slug, dayStart),
			Total:      total,
		}
		if lastTs > 0 {
			stat.LastPostedAt = time.Unix(lastTs, 0)
			stat.HasLastPosted = true
		}
		stats = append(stats, stat)
	}

	// Popular this week: most-read stories over the trailing 7 UTC days. Empty
	// until reads accumulate, so the section simply doesn't render on a fresh DB.
	var trending []StoryView
	if trendRows, err := storage.TrendingStories(trendingLimit, storage.UnixDay()-6); err != nil {
		slog.Error("web: trending query failed", "err", err)
	} else {
		for _, row := range trendRows {
			trending = append(trending, toView(row))
		}
	}

	data := indexPage{
		pageData:   s.base(r),
		JustPosted: justPosted,
		Stats:      stats,
		Latest:     latest,
		Trending:   trending,
	}
	// For signed-in users with some read/bookmark history, lead with a small
	// personalized rail. ForYou returns nothing for anon / no-history users, so
	// the section simply doesn't render for them.
	if s.auth != nil {
		if u := s.auth.userFromRequest(r); u != nil {
			const forYouLimit = 8
			if fy, err := storage.ForYou(u.Sub, forYouLimit); err != nil {
				slog.Error("web: for-you rail failed", "sub", u.Sub, "err", err)
			} else {
				for _, row := range fy {
					data.ForYou = append(data.ForYou, toView(row))
				}
			}
		}
	}
	decorate(data.Trending, data.ForYou, data.JustPosted, data.Latest)
	s.render(w, "index", data)
}

type forYouPage struct {
	pageData
	Stories []StoryView
}

// handleForYou renders the dedicated personalized feed. Anonymous visitors are
// sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request) {
	u := s.auth.userFromRequest(r)
	if u == nil {
		http.Redirect(w, r, "/auth/login?next=/for-you", http.StatusSeeOther)
		return
	}
	s.track(r, "for-you")
	const limit = 30
	rows, err := storage.ForYou(u.Sub, limit)
	if err != nil {
		slog.Error("web: for-you page failed", "sub", u.Sub, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	views := make([]StoryView, 0, len(rows))
	for _, row := range rows {
		views = append(views, toView(row))
	}
	decorate(views)
	base := s.base(r)
	base.Active = "for-you"
	s.render(w, "for-you", forYouPage{pageData: base, Stories: views})
}

func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channel) {
	s.track(r, ch.Slug)
	page := 1
	if p := r.URL.Query().Get("page"); p != "" {
		if n, err := strconv.Atoi(p); err == nil && n > 0 {
			page = n
		}
	}
	offset := (page - 1) * pageSize
	rows, err := storage.ListClassifiedByChannel(ch.Slug, pageSize+1, offset)
	if err != nil {
		slog.Error("web: list failed", "channel", ch.Slug, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	hasNext := len(rows) > pageSize
	if hasNext {
		rows = rows[:pageSize]
	}
	views := make([]StoryView, 0, len(rows))
	for _, r := range rows {
		views = append(views, toView(r))
	}
	total, _ := storage.CountClassifiedByChannel(ch.Slug)
	decorate(views)

	base := s.base(r)
	base.Active = ch.Slug
	// The adventure section names player characters; keep it out of search
	// indexes to bound the cached-forever exposure (see plan gap #5).
	base.NoIndex = ch.Slug == "adventure"
	data := channelPage{
		pageData: base,
		Channel:  ch,
		Stories:  views,
		Page:     page,
		HasPrev:  page > 1,
		HasNext:  hasNext,
		PrevURL:  fmt.Sprintf("/%s?page=%d", ch.Slug, page-1),
		NextURL:  fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
		Total:    total,
	}
	if ch.Slug == "adventure" && page == 1 {
		data.Roster, data.RosterStale, _ = s.roster()
		data.ShowRoster = true
	}
	s.render(w, "channel", data)
}

type bookmarksPage struct {
	pageData
	Stories []StoryView
	Page    int
	HasPrev bool
	HasNext bool
	PrevURL string
	NextURL string
	Total   int
}

// handleBookmarks lists the signed-in user's bookmarked stories. Anonymous
// visitors are sent to sign-in (the route is only registered when auth is on).
func (s *Server) handleBookmarks(w http.ResponseWriter, r *http.Request) {
	u := s.auth.userFromRequest(r)
	if u == nil {
		http.Redirect(w, r, "/auth/login?next=/bookmarks", http.StatusSeeOther)
		return
	}
	s.track(r, "bookmarks")
	page := 1
	if p := r.URL.Query().Get("page"); p != "" {
		if n, err := strconv.Atoi(p); err == nil && n > 0 {
			page = n
		}
	}
	offset := (page - 1) * pageSize
	rows, err := storage.ListBookmarks(u.Sub, pageSize+1, offset)
	if err != nil {
		slog.Error("web: list bookmarks failed", "sub", u.Sub, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	hasNext := len(rows) > pageSize
	if hasNext {
		rows = rows[:pageSize]
	}
	views := make([]StoryView, 0, len(rows))
	for _, row := range rows {
		views = append(views, toView(row))
	}
	decorate(views)
	total, _ := storage.CountBookmarks(u.Sub)

	base := s.base(r)
	base.Active = "bookmarks"
	data := bookmarksPage{
		pageData: base,
		Stories:  views,
		Page:     page,
		HasPrev:  page > 1,
		HasNext:  hasNext,
		PrevURL:  fmt.Sprintf("/bookmarks?page=%d", page-1),
		NextURL:  fmt.Sprintf("/bookmarks?page=%d", page+1),
		Total:    total,
	}
	s.render(w, "bookmarks", data)
}

var (
	demoVariants    = []string{"clear", "clouds", "rain", "storm", "hail", "snow", "fog", "haze", "wind", "aurora", "petals", "jacaranda", "motes", "leaves"}
	demoIntensities = []string{"light", "medium", "heavy"}
	demoPhases      = []string{"day", "dawn", "dusk", "night"}
)

type weatherDemoPage struct {
	pageData
	Variants    []string
	Intensities []string
	Phases      []string
	Variant     string
	Intensity   string
	PhaseSel    string
}

func pickOr(v string, allowed []string, fallback string) string {
	for _, a := range allowed {
		if v == a {
			return v
		}
	}
	return fallback
}

func seasonForVariant(v string) string {
	switch v {
	case "rain":
		return "winter"
	case "petals", "jacaranda":
		return "spring"
	case "motes", "haze":
		return "summer"
	case "leaves", "wind":
		return "autumn"
	case "hail":
		return "winter"
	}
	return ""
}

func (s *Server) handleWeatherDemo(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	variant := pickOr(q.Get("variant"), demoVariants, "jacaranda")
	intensity := pickOr(q.Get("intensity"), demoIntensities, "heavy")
	phase := pickOr(q.Get("phase"), demoPhases, "day")

	base := s.base(r)
	base.Weather = Weather{Season: seasonForVariant(variant), Variant: variant, Intensity: intensity}
	base.Phase = phase

	s.render(w, "weather", weatherDemoPage{
		pageData:    base,
		Variants:    demoVariants,
		Intensities: demoIntensities,
		Phases:      demoPhases,
		Variant:     variant,
		Intensity:   intensity,
		PhaseSel:    phase,
	})
}

type searchResult struct {
	Headline     string `json:"headline"`
	Lede         string `json:"lede,omitempty"`
	ArticleURL   string `json:"article_url"`
	ThumbURL     string `json:"thumb_url,omitempty"`
	Source       string `json:"source"`
	Channel      string `json:"channel"`
	ChannelTitle string `json:"channel_title"`
	ChannelTheme string `json:"channel_theme"`
	ChannelEmoji string `json:"channel_emoji"`
	TimeAgo      string `json:"time_ago"`
	Posted       bool   `json:"posted"`
}

func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	if q == "" {
		_, _ = w.Write([]byte(`{"results":[]}`))
		return
	}
	const searchLimit = 12
	rows, err := storage.SearchStories(q, searchLimit)
	if err != nil {
		slog.Error("web: search failed", "q", q, "err", err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}

// toSearchResults maps stories to the JSON shape shared by /api/search and
// /api/related, resolving each story's channel to its display metadata.
func toSearchResults(rows []storage.Story) []searchResult {
	channelByName := make(map[string]Channel, len(channels))
	for _, ch := range channels {
		channelByName[ch.Slug] = ch
	}
	results := make([]searchResult, 0, len(rows))
	for _, row := range rows {
		ch, ok := channelByName[row.Channel]
		if !ok {
			ch = Channel{Slug: row.Channel, Title: row.Channel, Theme: row.Channel}
		}
		results = append(results, searchResult{
			Headline:     row.Headline,
			Lede:         row.Lede,
			ArticleURL:   row.ArticleURL,
			ThumbURL:     thumbURL(row.ImageURL),
			Source:       row.Source,
			Channel:      ch.Slug,
			ChannelTitle: ch.Title,
			ChannelTheme: ch.Theme,
			ChannelEmoji: ch.Emoji,
			TimeAgo:      shortTimeAgo(time.Unix(row.SeenAt, 0)),
			Posted:       row.Posted,
		})
	}
	return results
}

// handleRelated returns stories textually similar to a given story, for the
// "You might also like" rail in reader mode. It is public (reader mode works
// for anonymous visitors too); for signed-in users it drops already-read
// stories so recommendations stay fresh.
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
	if err != nil || id <= 0 {
		http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
		return
	}
	const relatedLimit = 6
	// Over-fetch so dropping already-read stories doesn't starve the rail.
	rows, err := storage.RelatedStories(id, relatedLimit*2)
	if err != nil {
		slog.Error("web: related failed", "id", id, "err", err)
		http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
		return
	}
	if s.auth != nil && len(rows) > 0 {
		if u := s.auth.userFromRequest(r); u != nil {
			ids := make([]int64, 0, len(rows))
			for _, row := range rows {
				ids = append(ids, row.ID)
			}
			if read, _, err := storage.UserStoryState(u.Sub, ids); err == nil {
				kept := rows[:0]
				for _, row := range rows {
					if !read[row.ID] {
						kept = append(kept, row)
					}
				}
				rows = kept
			}
		}
	}
	if len(rows) > relatedLimit {
		rows = rows[:relatedLimit]
	}
	_ = json.NewEncoder(w).Encode(map[string]any{"results": toSearchResults(rows)})
}

// handleArticle serves the stored full text of a single story for reader mode.
// The client already has headline/image/source/time from the card's data
// attributes, so this returns just the body text (and the lede as a fallback
// for stories ingested before content was captured).
func (s *Server) handleArticle(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	id, err := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("id")), 10, 64)
	if err != nil || id <= 0 {
		http.Error(w, `{"error":"bad id"}`, http.StatusBadRequest)
		return
	}
	content, lede, found, err := storage.GetStoryReaderText(id)
	if err != nil {
		slog.Error("web: article read failed", "id", id, "err", err)
		http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
		return
	}
	if !found {
		http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
		return
	}
	// Opening a story in reader mode is our per-story read signal. found==true
	// means the id passed the same visibility filter the listings use, so this
	// can't be driven to inflate counts for hidden/unclassified stories. Fired
	// in the background so serving the body never waits on the write.
	go storage.RecordStoryView(id)
	_ = json.NewEncoder(w).Encode(map[string]any{"content": content, "lede": lede})
}

func shortTimeAgo(t time.Time) string {
	d := time.Since(t)
	switch {
	case d < time.Minute:
		return "just now"
	case d < time.Hour:
		return fmt.Sprintf("%dm ago", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%dh ago", int(d.Hours()))
	case d < 7*24*time.Hour:
		return fmt.Sprintf("%dd ago", int(d.Hours())/24)
	default:
		return t.Format("Jan 2")
	}
}

func (s *Server) render(w http.ResponseWriter, page string, data any) {
	tpl, ok := s.tpls[page]
	if !ok {
		http.Error(w, "unknown page", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := tpl.ExecuteTemplate(w, "layout", data); err != nil {
		slog.Error("web: template render failed", "page", page, "err", err)
	}
}

var funcs = template.FuncMap{
	"thumb": thumbURL,
	"channel": func(slug string) Channel {
		for _, ch := range channels {
			if ch.Slug == slug {
				return ch
			}
		}
		return Channel{Slug: slug, Title: slug, Theme: slug}
	},
	"dict": func(pairs ...any) map[string]any {
		m := make(map[string]any, len(pairs)/2)
		for i := 0; i+1 < len(pairs); i += 2 {
			key, ok := pairs[i].(string)
			if !ok {
				continue
			}
			m[key] = pairs[i+1]
		}
		return m
	},
	"timeAgo": func(t time.Time) string {
		d := time.Since(t)
		switch {
		case d < time.Minute:
			return "just now"
		case d < time.Hour:
			return fmt.Sprintf("%dm ago", int(d.Minutes()))
		case d < 24*time.Hour:
			return fmt.Sprintf("%dh ago", int(d.Hours()))
		case d < 7*24*time.Hour:
			return fmt.Sprintf("%dd ago", int(d.Hours())/24)
		default:
			return t.Format("Jan 2")
		}
	},
}
