package api

// The home hero — the four cards above the launcher's rows, and the one place the server
// says "this, tonight" rather than "here is a shelf".
//
// It used to be chosen on the television: take the first movies off whichever rows looked
// new or popular, rotate the starting point once a day. That was as good as the evidence
// the client had, which is very little. Emby's PremiereDate is frequently whatever a
// metadata agent guessed, nothing on the wire said whether a title was any good, and a
// series could only ever reach the hero as a random show off a shelf. So a poorly
// reviewed film imported last Tuesday led the launcher over the best-received release of
// the month, and the return of a household's favourite show passed unremarked.
//
// The gateway has the three pieces of evidence the television does not:
//
//   - **Radarr knows when a film actually came out.** `digitalRelease` is the date the
//     household could first have watched it, which is what a viewer means by "new". The
//     schedule row already prefers it over Emby's; the hero reads the same answer over a
//     backwards window instead of a forwards one.
//   - **Sonarr knows a premiere from an ordinary episode.** S01E01 is a new show, S02E01
//     is a returning one, and both are news in a way that the fourth episode of a show
//     somebody is already halfway through is not.
//   - **MDBList knows whether it is worth the evening.** By the point this runs those
//     scores are already attached to the cards, so ranking by them costs nothing.
//
// Two properties are what stop this becoming a second recommendation engine, and both
// are easy to give away:
//
//   - **It asks Emby for nothing.** The movie candidates are the rows already assembled
//     and their ratings are already attached, so the expensive half of the launcher is
//     reused rather than repeated. What it does read is the two *arr calendars, and those
//     are cached for the day behind a shared lock like the schedule rows' — one household
//     pays one miss each per day, and the three reads run together rather than in turn
//     because this is the tail of a response every television is waiting on.
//   - **Every card it produces is playable.** A premiere the household has not downloaded
//     yet, or a film Radarr is still waiting on, is news for the schedule row — the hero
//     exists to be pressed, and a lead card that does nothing is worse than no lead card.

import (
	"context"
	"encoding/json"
	"errors"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/ponzischeme89/memby/server/internal/radarr"
	"github.com/ponzischeme89/memby/server/internal/recommend"
	"github.com/ponzischeme89/memby/server/internal/sonarr"
)

const (
	heroRowID   = "hero"
	heroRowKind = "hero"

	// How far back a release can be and still be the reason a card leads. Digital
	// releases and premieres arrive in bursts, and a hero that empties out in a quiet
	// fortnight is a hero that falls back to the library — three weeks keeps it
	// populated without billing a six-week-old film as new.
	heroWindowDays = 21

	// The television draws four cards. The row carries a few more so one it cannot draw
	// — no artwork, a type this build predates — costs a card rather than a gap.
	heroRowLimit = 8

	// Candidates are capped before scoring. A launcher is a few hundred cards; this is a
	// guard against a future row type offering a thousand, not a limit anything reaches.
	heroCandidateLimit = 240
)

// The ranking. Recency and quality are deliberately close in weight: the request this
// answers is that a well-received release should be able to beat a fresher one that
// nobody liked, which needs quality to be worth roughly as much as a fortnight of age.
const (
	heroRecencyWeight = 0.55
	heroRatingWeight  = 0.45

	// What a title nobody has rated is worth. Deliberately near the middle rather than
	// zero: a good score is meant to *lift* a title above the merely recent, not to bury
	// everything MDBList has never been asked about — which, on a household that has just
	// turned ratings on, is the entire library.
	heroUnratedScore = 0.55

	// Radarr's cinema + 30 days is a guess (see effectiveRadarrRelease), and a guess
	// should not outrank a date somebody published.
	heroEstimatedPenalty = 0.12

	// Where "well reviewed" starts, for the one label that claims it.
	heroAcclaimedRating = 0.75
)

// Caption wording is the gateway's, like MembyAirLabel and MembyLifecycleText. The
// television renders the string it is handed, so a new kind of hero card reads correctly
// on a build that predates it.
const (
	heroLabelSeriesPremiere = "SERIES PREMIERE"
	heroLabelSeasonPremiere = "NEW SEASON"
	heroLabelNewRelease     = "NEW RELEASE"
	heroLabelAcclaimed      = "HIGHLY RATED"
	heroLabelLibrary        = "FROM YOUR LIBRARY"
)

// The fields the hero adds to an item payload. Emby's JSON is otherwise forwarded
// verbatim; these are injected the way MembyRatings is.
const (
	heroLabelField  = "MembyHeroLabel"
	heroReasonField = "MembyHeroReason"
)

const heroReleasedCachePrefix = "radarr:released:v1:"
const heroPremiereCachePrefix = "sonarr:premieres:v1:"

type heroKind int

const (
	heroMovie heroKind = iota
	heroSeriesPremiere
	heroSeasonPremiere
)

// heroCandidate is one title the hero could lead with, and everything the ranking needs
// to decide whether it should.
type heroCandidate struct {
	ID   string
	Name string
	Kind heroKind
	Item json.RawMessage

	// ReleasedAt is the *effective* release: Radarr's digital date for a film, the
	// premiere's air date for a show, and Emby's PremiereDate only when nothing better
	// is known. Zero means nothing is known at all, which is an answer — such a title
	// ranks on quality alone rather than being excluded.
	ReleasedAt time.Time
	Estimated  bool

	// Rating is normalised onto 0..1 across whatever providers answered. Rated is false
	// when none did, which is a different thing from a score of zero.
	Rating float64
	Rated  bool
}

// heroRecency decays linearly across the window.
//
// A release in the future scores zero rather than more than one. The hero is a thing to
// be pressed, and a title that has not come out yet belongs to the schedule row — this
// only ever sees such a date because Radarr publishes a digital date before it arrives.
func heroRecency(released, now time.Time) float64 {
	if released.IsZero() || released.After(now) {
		return 0
	}
	age := now.Sub(released).Hours() / 24
	if age >= heroWindowDays {
		return 0
	}
	return 1 - age/heroWindowDays
}

func heroScore(candidate heroCandidate, now time.Time) float64 {
	rating := heroUnratedScore
	if candidate.Rated {
		rating = candidate.Rating
	}
	score := heroRecencyWeight*heroRecency(candidate.ReleasedAt, now) + heroRatingWeight*rating
	if candidate.Estimated {
		score -= heroEstimatedPenalty
	}
	return score
}

// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
// about without a network.
//
// The sort is stable and the tie-break is the order it was given, so the caller's own
// preference survives two titles the scorer cannot separate. Deduplication keeps the
// first appearance: a film that is both a Radarr release and a library card is the
// release, which is the more specific thing to say about it.
func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []heroCandidate {
	if limit <= 0 {
		return nil
	}
	type ranked struct {
		candidate heroCandidate
		position  int
		score     float64
	}
	seen := make(map[string]bool, len(candidates))
	scored := make([]ranked, 0, len(candidates))
	for position, candidate := range candidates {
		if candidate.ID == "" || seen[candidate.ID] || len(candidate.Item) == 0 {
			continue
		}
		seen[candidate.ID] = true
		scored = append(scored, ranked{
			candidate: candidate,
			position:  position,
			score:     heroScore(candidate, now),
		})
	}
	sort.SliceStable(scored, func(i, j int) bool {
		if scored[i].score != scored[j].score {
			return scored[i].score > scored[j].score
		}
		return scored[i].position < scored[j].position
	})
	if len(scored) > limit {
		scored = scored[:limit]
	}
	out := make([]heroCandidate, 0, len(scored))
	for _, entry := range scored {
		out = append(out, entry.candidate)
	}
	return out
}

// heroLabel is the caption the card wears, and it may only say what is actually known.
//
// The labels it replaced were the card's *position* — the first slot was captioned NEW
// RELEASE whatever was in it — which is how a 2019 film came to be announced as new. Each
// of these is a claim the candidate has already satisfied.
func heroLabel(candidate heroCandidate, now time.Time) string {
	switch candidate.Kind {
	case heroSeriesPremiere:
		return heroLabelSeriesPremiere
	case heroSeasonPremiere:
		return heroLabelSeasonPremiere
	}
	if heroRecency(candidate.ReleasedAt, now) > 0 {
		return heroLabelNewRelease
	}
	if candidate.Rated && candidate.Rating >= heroAcclaimedRating {
		return heroLabelAcclaimed
	}
	return heroLabelLibrary
}

// heroReason is the second line: why this, over the rest of the library. It is allowed to
// be empty, and is empty precisely when there is nothing true to say — a card with no
// evidence behind it says nothing rather than inventing a reason.
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
	acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
	fresh := heroRecency(candidate.ReleasedAt, now) > 0
	switch {
	case candidate.Kind == heroSeriesPremiere && acclaimed:
		return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
	case candidate.Kind == heroSeriesPremiere:
		return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
	case candidate.Kind == heroSeasonPremiere:
		return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
	case fresh && acclaimed && candidate.Estimated:
		return "Well reviewed, and expected to have landed " +
			heroWhen(candidate.ReleasedAt, now, location)
	case fresh && acclaimed:
		return "Well reviewed, released " + heroWhen(candidate.ReleasedAt, now, location)
	case fresh && candidate.Estimated:
		return "Expected to have landed " + heroWhen(candidate.ReleasedAt, now, location)
	case fresh:
		return "Released " + heroWhen(candidate.ReleasedAt, now, location)
	case acclaimed:
		return "One of the best-reviewed titles in your library"
	default:
		return ""
	}
}

// heroWhen words a date the way somebody would say it out loud. Nothing here is more
// precise than the evidence: a digital release date carries no time of day, so a card
// never claims an hour.
func heroWhen(released, now time.Time, location *time.Location) string {
	if location == nil {
		location = time.UTC
	}
	released = released.In(location)
	today := localDayStart(now, location)
	day := localDayStart(released, location)
	switch days := int(today.Sub(day).Hours() / 24); {
	case days <= 0:
		return "today"
	case days == 1:
		return "yesterday"
	case days < 7:
		return "on " + released.Format("Monday")
	case days < 14:
		return "last week"
	default:
		return "this month"
	}
}

// heroRatingOf reads the scores already attached to the card.
//
// It is the *mean* of what the household's chosen providers said, normalised onto 0..1.
// The sources disagree about scale and about films — IMDb is generous, Rotten Tomatoes'
// critics are not — and averaging them is a better answer than nominating a favourite and
// letting one provider's blind spot decide what leads the launcher.
func heroRatingOf(raw json.RawMessage) (float64, bool) {
	var payload struct {
		Ratings         []movieRating `json:"MembyRatings"`
		CommunityRating *float64      `json:"CommunityRating"`
	}
	if json.Unmarshal(raw, &payload) != nil {
		return 0, false
	}
	var sum float64
	var count int
	for _, rating := range payload.Ratings {
		source, known := movieRatingSources[strings.ToLower(strings.TrimSpace(rating.Source))]
		if !known || source.Maximum <= 0 {
			continue
		}
		value, err := strconv.ParseFloat(strings.TrimSpace(rating.Score), 64)
		if err != nil || value <= 0 || value > source.Maximum {
			continue
		}
		sum += value / source.Maximum
		count++
	}
	if count > 0 {
		return sum / float64(count), true
	}
	// Emby's own CommunityRating is the fallback, and only here. The client is forbidden
	// from *drawing* it (a card naming a provider that was never asked is a lie), but
	// ordering four cards by it claims nothing to anybody — and it is what lets the hero
	// rank sensibly on a household that has not configured MDBList at all.
	if payload.CommunityRating != nil && *payload.CommunityRating > 0 && *payload.CommunityRating <= 10 {
		return *payload.CommunityRating / 10, true
	}
	return 0, false
}

// heroItemFacts pulls what the ranking needs out of an ordinary Emby item payload.
type heroItemFacts struct {
	ID       string
	Name     string
	Type     string
	Premiere time.Time
	Playable bool
}

func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
	var payload struct {
		ID           string `json:"Id"`
		Name         string `json:"Name"`
		Type         string `json:"Type"`
		PremiereDate string `json:"PremiereDate"`
		Source       string `json:"MembySource"`
		Playable     *bool  `json:"MembyPlayable"`
	}
	if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" {
		return heroItemFacts{}, false
	}
	facts := heroItemFacts{
		ID:   payload.ID,
		Name: strings.TrimSpace(payload.Name),
		Type: payload.Type,
		// A synthetic schedule card carries MembySource and is explicitly not playable.
		// Anything from Emby carries neither field, and is.
		Playable: strings.TrimSpace(payload.Source) == "" &&
			(payload.Playable == nil || *payload.Playable),
	}
	if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil {
		facts.Premiere = parsed
	}
	return facts, true
}

// parseEmbyDate accepts the shapes Emby writes a date in. A date it will not parse is
// simply unknown, which the ranking already has a behaviour for.
func parseEmbyDate(value string) (time.Time, error) {
	value = strings.TrimSpace(value)
	if value == "" {
		return time.Time{}, errNoDate
	}
	for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
		if parsed, err := time.Parse(layout, value); err == nil {
			return parsed, nil
		}
	}
	return time.Time{}, errNoDate
}

var errNoDate = errors.New("hero: unparsable date")

// injectHeroFields writes the caption and the reason onto one card, the way
// injectItemRatings writes the scores: through a map, so a field this build knows nothing
// about survives the round trip.
func injectHeroFields(raw json.RawMessage, label, reason string) json.RawMessage {
	if label == "" && reason == "" {
		return raw
	}
	var members map[string]json.RawMessage
	if json.Unmarshal(raw, &members) != nil || members == nil {
		return raw
	}
	if label != "" {
		if encoded, err := json.Marshal(label); err == nil {
			members[heroLabelField] = encoded
		}
	}
	if reason != "" {
		if encoded, err := json.Marshal(reason); err == nil {
			members[heroReasonField] = encoded
		}
	}
	out, err := json.Marshal(members)
	if err != nil {
		return raw
	}
	return out
}

// heroReleaseIndex answers "when did this film actually come out" for the candidates.
//
// It is keyed two ways because neither key is reliable on its own: a TMDB id is exact but
// only exists for a library item the import has resolved, and a normalised title/year is
// always available but can be wrong about a remake. The id is consulted first.
type heroReleaseIndex struct {
	byTMDB  map[string]radarrRelease
	byTitle map[string]radarrRelease
}

func newHeroReleaseIndex(movies []radarr.Movie) heroReleaseIndex {
	index := heroReleaseIndex{
		byTMDB:  make(map[string]radarrRelease, len(movies)),
		byTitle: make(map[string]radarrRelease, len(movies)*2),
	}
	for _, movie := range movies {
		release, ok := effectiveRadarrRelease(movie)
		if !ok {
			continue
		}
		if movie.TMDBID > 0 {
			index.byTMDB[strconv.Itoa(movie.TMDBID)] = release
		}
		title := normalizedShowTitle(movie.Title)
		if title == "" {
			continue
		}
		// Year-qualified first and never overwritten, so a remake cannot claim the
		// original's release date — the same rule seriesIndex applies.
		if movie.Year > 0 {
			if _, seen := index.byTitle[seriesIndexKey(title, movie.Year)]; !seen {
				index.byTitle[seriesIndexKey(title, movie.Year)] = release
			}
		}
		if _, seen := index.byTitle[title]; !seen {
			index.byTitle[title] = release
		}
	}
	return index
}

func (index heroReleaseIndex) lookup(tmdbID, title string, year int) (radarrRelease, bool) {
	if tmdbID != "" {
		if release, ok := index.byTMDB[tmdbID]; ok {
			return release, true
		}
	}
	key := normalizedShowTitle(title)
	if key == "" {
		return radarrRelease{}, false
	}
	if year > 0 {
		if release, ok := index.byTitle[seriesIndexKey(key, year)]; ok {
			return release, true
		}
	}
	release, ok := index.byTitle[key]
	return release, ok
}

// heroPremiere is one thing Sonarr calls a premiere, resolved onto the Emby series the
// household can actually play.
type heroPremiere struct {
	EmbySeriesID string
	SeasonNumber int
	AiredAt      time.Time
}

// sonarrPremieres picks the premieres out of a calendar window.
//
// A premiere is the *first episode of a season* — S01E01 is a new show and S02E01 is a
// returning one, and the answered design question is that both are news where the fourth
// episode of something already in Continue Watching is not. Three filters do the work and
// each removes a card that would misfire:
//
//   - Season 0 is specials. A Christmas special is not a premiere.
//   - HasFile is required, because a hero card exists to be pressed.
//   - The show must be one Emby holds, or the card has no page and no artwork.
//
// The most recent premiere per series wins: a show that premiered and then returned
// inside one window is one card about its newer season, not two.
func sonarrPremieres(
	episodes []sonarr.Episode,
	series seriesIndex,
	from, until time.Time,
) []heroPremiere {
	best := map[string]heroPremiere{}
	order := make([]string, 0, len(episodes))
	for _, episode := range episodes {
		if episode.EpisodeNumber != 1 || episode.SeasonNumber < 1 || !episode.HasFile {
			continue
		}
		if episode.AirDateUTC == nil {
			continue
		}
		aired := *episode.AirDateUTC
		if aired.Before(from) || aired.After(until) {
			continue
		}
		embyID := series.lookup(episode.Series.Title, episode.Series.Year)
		if embyID == "" {
			continue
		}
		existing, seen := best[embyID]
		if !seen {
			order = append(order, embyID)
		}
		if seen && !aired.After(existing.AiredAt) {
			continue
		}
		best[embyID] = heroPremiere{
			EmbySeriesID: embyID,
			SeasonNumber: episode.SeasonNumber,
			AiredAt:      aired,
		}
	}
	out := make([]heroPremiere, 0, len(order))
	for _, embyID := range order {
		out = append(out, best[embyID])
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].AiredAt.After(out[j].AiredAt) })
	return out
}

// heroRow composes the row. It is the only impure part of the feature, and every failure
// inside it costs a signal rather than the hero: a Radarr that will not answer means
// films fall back to Emby's premiere dates, a Sonarr that will not answer means no
// premieres, and neither means the launcher gets the ranking it had before.
func (s *Server) heroRow(
	ctx context.Context,
	rows []recommend.Row,
	now time.Time,
) *recommend.Row {
	location := s.cfg.RadarrLocation
	if location == nil {
		location = time.Local
	}
	candidates := s.heroCandidates(ctx, rows, now)
	ranked := rankHeroCandidates(candidates, now, heroRowLimit)
	if len(ranked) == 0 {
		return nil
	}
	items := make([]json.RawMessage, 0, len(ranked))
	for _, candidate := range ranked {
		items = append(items, injectHeroFields(
			candidate.Item,
			heroLabel(candidate, now),
			heroReason(candidate, now, location),
		))
	}
	return &recommend.Row{
		ID:    heroRowID,
		Title: "Featured",
		Kind:  heroRowKind,
		Items: items,
	}
}

// heroCandidates gathers everything eligible, premieres first.
//
// Premieres lead the input order so that they win a tie against a film of identical
// score — a returning show is the more time-sensitive piece of news, and the scorer
// cannot see that.
func (s *Server) heroCandidates(
	ctx context.Context,
	rows []recommend.Row,
	now time.Time,
) []heroCandidate {
	movies, facts := heroMovieCandidates(rows)

	// Three independent reads, and on the one cache miss a day two of them are *arr round
	// trips. They run together rather than in turn because this is the tail of the home
	// response: every television in the house is waiting on it, and there is no reason for
	// Sonarr's answer to be behind Radarr's.
	var (
		releases  heroReleaseIndex
		premieres []heroCandidate
		providers map[string]string
		wg        sync.WaitGroup
	)
	wg.Add(3)
	go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
	go func() { defer wg.Done(); premieres = s.heroPremiereCandidates(ctx, now) }()
	go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
	wg.Wait()

	for index := range movies {
		fact := facts[movies[index].ID]
		// Radarr's digital date is preferred over Emby's PremiereDate wherever there is
		// one. That preference is the point of the feature: Emby's date is the
		// theatrical release where it is right at all, and is a metadata agent's guess
		// where it is not, so ranking "new releases" by it puts films in an order that
		// has nothing to do with when the household could first watch them.
		if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
			movies[index].ReleasedAt = release.at
			movies[index].Estimated = release.estimated
		}
	}

	return append(premieres, movies...)
}

// heroMovieCandidates reads the assembled rows. Nothing is fetched: these are the same
// payloads the launcher is about to be sent, ratings already attached.
func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]heroItemFacts) {
	candidates := make([]heroCandidate, 0, heroCandidateLimit)
	facts := make(map[string]heroItemFacts, heroCandidateLimit)
	seen := make(map[string]bool, heroCandidateLimit)
	for _, row := range rows {
		// Continue Watching is what somebody is already in the middle of, which is the
		// opposite of what a hero is for; the schedule rows are not playable at all.
		if row.Kind == "continue" || row.Kind == "schedule" || row.Kind == "movie-schedule" {
			continue
		}
		for _, raw := range row.Items {
			if len(candidates) >= heroCandidateLimit {
				return candidates, facts
			}
			fact, ok := heroFactsOf(raw)
			if !ok || seen[fact.ID] || !fact.Playable ||
				!strings.EqualFold(fact.Type, "Movie") {
				continue
			}
			seen[fact.ID] = true
			facts[fact.ID] = fact
			rating, rated := heroRatingOf(raw)
			candidates = append(candidates, heroCandidate{
				ID:         fact.ID,
				Name:       fact.Name,
				Kind:       heroMovie,
				Item:       raw,
				ReleasedAt: fact.Premiere,
				Rating:     rating,
				Rated:      rated,
			})
		}
	}
	return candidates, facts
}

func heroYearOf(fact heroItemFacts) int {
	if fact.Premiere.IsZero() {
		return 0
	}
	return fact.Premiere.Year()
}

// heroProviderIDs resolves the candidates onto TMDB ids so Radarr can be matched exactly.
// A failure costs the exact match and leaves the title/year fallback.
func (s *Server) heroProviderIDs(
	ctx context.Context,
	facts map[string]heroItemFacts,
) map[string]string {
	out := make(map[string]string, len(facts))
	if s.store == nil || len(facts) == 0 {
		return out
	}
	ids := make([]string, 0, len(facts))
	for id := range facts {
		ids = append(ids, id)
	}
	refs, err := s.store.LibraryProviderIDs(ctx, ids)
	if err != nil {
		s.loggerFor(ctx).Warn("hero provider ids unavailable", "error", err)
		return out
	}
	for id, ref := range refs {
		if tmdb := strings.TrimSpace(providerID(ref.ProviderIDs, "tmdb")); tmdb != "" {
			out[id] = tmdb
		}
	}
	return out
}

// heroReleaseIndex reads Radarr over the window that has already happened, cached for the
// day beside the schedule row's forward-looking one.
func (s *Server) heroReleaseIndex(ctx context.Context, now time.Time) heroReleaseIndex {
	empty := heroReleaseIndex{
		byTMDB:  map[string]radarrRelease{},
		byTitle: map[string]radarrRelease{},
	}
	if s.radarr == nil {
		return empty
	}
	location := s.cfg.RadarrLocation
	if location == nil {
		location = time.Local
	}
	dayStart := localDayStart(now.In(location), location)
	key := heroReleasedCachePrefix + dayStart.Format("2006-01-02")
	if raw, err := s.cache.Get(ctx, key); err == nil {
		var movies []radarr.Movie
		if json.Unmarshal(raw, &movies) == nil {
			return newHeroReleaseIndex(movies)
		}
	}

	s.radarrMu.Lock()
	defer s.radarrMu.Unlock()
	if raw, err := s.cache.Get(ctx, key); err == nil {
		var movies []radarr.Movie
		if json.Unmarshal(raw, &movies) == nil {
			return newHeroReleaseIndex(movies)
		}
	}

	// The cinema fallback is cinema + 30 days, so a film whose digital date is unknown
	// but which is inside the window had its cinema date up to 30 days before that.
	movies, err := s.radarr.Calendar(
		ctx,
		dayStart.AddDate(0, 0, -(heroWindowDays+radarrTheatricalDelayDays)),
		dayStart.AddDate(0, 0, 1),
	)
	if err != nil {
		s.loggerFor(ctx).Warn("hero release calendar failed", "error", err)
		return empty
	}
	if body, marshalErr := json.Marshal(movies); marshalErr == nil {
		if cacheErr := s.cache.Set(ctx, key, body, s.cfg.RadarrTTL); cacheErr != nil {
			s.loggerFor(ctx).Warn("hero release cache write failed", "error", cacheErr)
		}
	}
	return newHeroReleaseIndex(movies)
}

// heroPremiereCandidates reads Sonarr's recent calendar and turns each premiere into the
// Emby series card the household can play.
func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []heroCandidate {
	if s.sonarr == nil || s.store == nil {
		return nil
	}
	location := s.cfg.SonarrLocation
	if location == nil {
		location = time.Local
	}
	dayStart := localDayStart(now.In(location), location)
	key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")

	var episodes []sonarr.Episode
	if raw, err := s.cache.Get(ctx, key); err == nil {
		_ = json.Unmarshal(raw, &episodes)
	}
	if episodes == nil {
		s.sonarrMu.Lock()
		if raw, err := s.cache.Get(ctx, key); err == nil {
			_ = json.Unmarshal(raw, &episodes)
		}
		if episodes == nil {
			fetched, err := s.sonarr.Calendar(
				ctx,
				dayStart.AddDate(0, 0, -heroWindowDays),
				dayStart.AddDate(0, 0, 1),
			)
			if err != nil {
				s.sonarrMu.Unlock()
				s.loggerFor(ctx).Warn("hero premiere calendar failed", "error", err)
				return nil
			}
			episodes = fetched
			if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
				if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
					s.loggerFor(ctx).Warn("hero premiere cache write failed", "error", cacheErr)
				}
			}
		}
		s.sonarrMu.Unlock()
	}

	premieres := sonarrPremieres(
		episodes,
		s.embySeriesIndex(ctx),
		now.AddDate(0, 0, -heroWindowDays),
		now,
	)
	if len(premieres) == 0 {
		return nil
	}
	ids := make([]string, 0, len(premieres))
	for _, premiere := range premieres {
		ids = append(ids, premiere.EmbySeriesID)
	}
	payloads, err := s.store.LibraryItemsByID(ctx, ids)
	if err != nil {
		s.loggerFor(ctx).Warn("hero premiere series unavailable", "error", err)
		return nil
	}
	// The imported catalogue is shared by the household and deliberately carries no user
	// data, so these cards arrive without ratings. Decorating them is one indexed read
	// and is what lets a premiere be ranked on the same terms as a film.
	s.decorateItemRatings(ctx, payloads)

	byID := make(map[string]json.RawMessage, len(payloads))
	for _, raw := range payloads {
		if id := itemIDOf(raw); id != "" {
			byID[id] = raw
		}
	}
	candidates := make([]heroCandidate, 0, len(premieres))
	for _, premiere := range premieres {
		raw, ok := byID[premiere.EmbySeriesID]
		if !ok {
			continue
		}
		fact, ok := heroFactsOf(raw)
		if !ok {
			continue
		}
		kind := heroSeasonPremiere
		if premiere.SeasonNumber == 1 {
			kind = heroSeriesPremiere
		}
		rating, rated := heroRatingOf(raw)
		candidates = append(candidates, heroCandidate{
			ID:         fact.ID,
			Name:       fact.Name,
			Kind:       kind,
			Item:       raw,
			ReleasedAt: premiere.AiredAt,
			Rating:     rating,
			Rated:      rated,
		})
	}
	return candidates
}
