package web

import (
	"context"
	"fmt"
	"log/slog"
	"strings"
	"time"

	"pete/internal/storage"
)

// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
// that were seen since the last digest but never posted live — exactly the
// bulletins — and posts a single warm roundup to the adventure channel. The
// website already carries each one as its own card; the digest is the Matrix-only
// nudge so quiet-but-real activity surfaces without one ping per event.

const (
	// digestWindow bounds how far back a digest looks. Wider than a day so a
	// missed run (process down over a digest hour) still sweeps up the gap;
	// re-collection is prevented by MarkAdventureDigested, not by the window.
	digestWindow = 48 * time.Hour
	// digestCap bounds a single digest. Far past the observed volume (a dormant
	// community, per the voice spec); a runaway just truncates with a log line.
	digestCap = 40
	// digestPreview is how many headlines the roundup lede lists by name before
	// collapsing the rest to "…and N more".
	digestPreview = 4
)

// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
// section is enabled AND there's a live Matrix poster AND a channel to post to —
// i.e. website-only and local modes never post a digest.
func (s *Server) StartAdventureDigest(ctx context.Context) {
	if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
		return
	}
	go s.runAdventureDigest(ctx)
}

// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
// digest at a predictable time of day across restarts.
func (s *Server) runAdventureDigest(ctx context.Context) {
	hour := s.adv.DigestHourOrDefault()
	slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
	for {
		wait := durUntilNextHour(time.Now().UTC(), hour)
		select {
		case <-ctx.Done():
			return
		case <-time.After(wait):
			s.postDailyDigest(time.Now().UTC())
		}
	}
}

// durUntilNextHour returns the duration from now to the next occurrence of the
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
// at the hour doesn't double-fire.
func durUntilNextHour(now time.Time, hour int) time.Duration {
	next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
	if !next.After(now) {
		next = next.Add(24 * time.Hour)
	}
	return next.Sub(now)
}

// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
// and marks them digested so they don't recur. Silent when there's nothing new —
// a dormant realm should stay quiet, not ship an empty digest.
func (s *Server) postDailyDigest(now time.Time) {
	since := now.Add(-digestWindow).Unix()
	bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
	if err != nil {
		slog.Error("adventure digest: query failed", "err", err)
		return
	}
	if len(bulletins) == 0 {
		return
	}
	if total > len(bulletins) {
		slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
	}

	date := now.Format("2006-01-02")
	eventID := "adv-digest:" + date
	headline, lede := buildDigest(bulletins, total)

	s.advPost(AdvPost{
		GUID:       eventID,
		Headline:   headline,
		Lede:       lede,
		ArticleURL: s.digestURL(date),
		Channel:    s.adv.Channel,
	})

	guids := make([]string, len(bulletins))
	for i, b := range bulletins {
		guids[i] = b.GUID
	}
	storage.MarkAdventureDigested(guids, eventID)
	slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
}

// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
// total is how many bulletins the window actually holds, which is larger than
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
// have to describe the realm, not the slice. Headlines come from stored,
// fact-guarded rows, so no player-controlled text is introduced here.
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
	if total == 1 {
		return "Today in the realm: one dispatch.",
			fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
	}
	headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)

	shown := bulletins
	if len(shown) > digestPreview {
		shown = shown[:digestPreview]
	}
	parts := make([]string, len(shown))
	for i, b := range shown {
		parts[i] = trimHeadline(b.Headline)
	}
	list := strings.Join(parts, " ")
	more := ""
	if total > len(shown) {
		more = fmt.Sprintf(" …and %d more.", total-len(shown))
	}
	return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
}

// trimHeadline ensures a headline ends with sentence punctuation so the joined
// lede reads as prose rather than a run-on.
func trimHeadline(h string) string {
	h = strings.TrimSpace(h)
	if h == "" {
		return ""
	}
	if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
		h += "."
	}
	return h
}

// digestURL points the roundup at the section, with a per-day query param so the
// canonical-URL dedup treats each day's digest as distinct (it strips only
// tracking params, keeping ?digest=).
func (s *Server) digestURL(date string) string {
	return s.siteURL("/adventure?digest=" + date)
}
