// Package foryou keeps Tracearr-derived recommendation data warm in PostgreSQL.
//
// Imports and ranking happen away from television requests. PostgreSQL is also the
// queue: dirty_since records work that still needs doing, so a restart cannot lose it.
package foryou

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/ponzischeme89/memby/server/internal/emby"
	"github.com/ponzischeme89/memby/server/internal/recommend"
	"github.com/ponzischeme89/memby/server/internal/store"
	"github.com/ponzischeme89/memby/server/internal/tracearr"
)

const (
	importPageSize        = 100
	incrementalMaxPages   = 10
	incrementalMinPages   = 2
	unchangedPagesToStop  = 2
	preparedPoolReadLimit = 240
	preparedRowSize       = 20
	maxPreparedCandidates = 750

	// Increment only when stored eligibility, scoring, or explanation behavior changes.
	preparedAlgorithmVersion = "2026-07-31.2"
)

type ImportResult struct {
	Kind       string        `json:"kind"`
	Pages      int           `json:"pages"`
	Seen       int           `json:"seen"`
	Changed    int           `json:"changed"`
	Dirtied    int64         `json:"dirtied"`
	Removed    int64         `json:"removed"`
	Duration   time.Duration `json:"-"`
	DurationMs int64         `json:"durationMs"`
}

type Service struct {
	store         *store.Store
	tracearr      *tracearr.Client
	engine        *recommend.Engine
	emby          *emby.Client
	serviceCred   emby.Credentials
	log           *slog.Logger
	minRebuildAge time.Duration
	refreshAge    time.Duration
	location      *time.Location
	now           func() time.Time

	mu            sync.Mutex
	importRunning bool
	building      map[string]bool
}

// ConfigureHouseholdUsers enables background preparation for every enabled Emby user.
// The service token is kept server-side and is only used for read-only profile building.
func (s *Service) ConfigureHouseholdUsers(client *emby.Client, cred emby.Credentials) {
	s.emby = client
	s.serviceCred = cred
}

func New(
	st *store.Store,
	tracearrClient *tracearr.Client,
	engine *recommend.Engine,
	log *slog.Logger,
	minRebuildAge, refreshAge time.Duration,
) *Service {
	return &Service{
		store: st, tracearr: tracearrClient, engine: engine, log: log,
		minRebuildAge: minRebuildAge, refreshAge: refreshAge,
		location: time.Local, now: time.Now, building: map[string]bool{},
	}
}

// ConfigureTimeContext sets the household timezone used for day/time viewing habits.
func (s *Service) ConfigureTimeContext(location *time.Location) {
	if location != nil {
		s.location = location
		s.engine.Location = location
	}
}

func (s *Service) Running() bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.importRunning || len(s.building) > 0
}

func (s *Service) Stats(ctx context.Context) (store.ForYouStats, error) {
	return s.store.ForYouStats(ctx)
}

func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, resultErr error) {
	s.mu.Lock()
	if s.importRunning {
		s.mu.Unlock()
		return result, errors.New("for you: a Tracearr import is already running")
	}
	s.importRunning = true
	s.mu.Unlock()
	defer func() {
		s.mu.Lock()
		s.importRunning = false
		s.mu.Unlock()
	}()

	started := time.Now().UTC()
	result.Kind = "incremental"
	count, err := s.store.TracearrSessionCount(ctx)
	if err != nil {
		return result, err
	}
	if count == 0 {
		full = true
	}
	if full {
		result.Kind = "full"
	}
	state, err := s.store.TracearrImportState(ctx)
	if err != nil {
		return result, err
	}
	defer func() {
		now := time.Now().UTC()
		if resultErr != nil {
			state.LastError = resultErr.Error()
		} else {
			state.LastError = ""
			state.LastIncrementalAt = &now
			if full {
				state.LastFullAt = &now
			}
		}
		if err := s.store.SetTracearrImportState(context.WithoutCancel(ctx), state); err != nil {
			s.log.Error("could not record Tracearr import state", "error", err)
		}
		result.Duration = time.Since(started)
		result.DurationMs = result.Duration.Milliseconds()
	}()

	unchangedPages := 0
	terminalIdentities := map[string]store.RecommendationIdentity{}
	for pageNumber := 1; ; pageNumber++ {
		page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
		if err != nil {
			return result, err
		}
		result.Pages++
		result.Seen += len(page.Data)

		imported := make([]store.TracearrSession, 0, len(page.Data))
		keys := make([]store.TracearrSessionKey, 0, len(page.Data))
		for _, session := range page.Data {
			value, ok := importedSession(session, s.tracearr.ConfiguredServerID())
			if !ok {
				continue
			}
			imported = append(imported, value)
			keys = append(keys, store.TracearrSessionKey{
				ServerID: value.ServerID, SessionID: value.SessionID,
			})
		}
		current, err := s.store.TracearrSessionSignals(ctx, keys)
		if err != nil {
			return result, err
		}
		pageChanged := false
		for _, session := range imported {
			key := store.TracearrSessionKey{
				ServerID: session.ServerID, SessionID: session.SessionID,
			}
			previous, exists := current[key]
			if !bytes.Equal(previous.Fingerprint, session.SourceFingerprint) {
				pageChanged = true
				result.Changed++
			}
			if tracearrSessionTerminal(session) && (!exists || !previous.Terminal) {
				identity := store.RecommendationIdentity{
					TracearrUserID: session.UserID,
					Username:       session.Username,
				}
				terminalIdentities[identity.TracearrUserID+"|"+
					strings.ToLower(identity.Username)] = identity
			}
		}
		if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
			return result, err
		}
		if pageChanged {
			unchangedPages = 0
		} else {
			unchangedPages++
		}

		reachedEnd := len(page.Data) == 0 ||
			(page.Meta.Total > 0 && pageNumber*importPageSize >= page.Meta.Total)
		if full && reachedEnd {
			break
		}
		if !full && (reachedEnd ||
			(pageNumber >= incrementalMinPages && unchangedPages >= unchangedPagesToStop) ||
			pageNumber >= incrementalMaxPages) {
			break
		}
	}

	if full {
		removed, err := s.store.DeleteTracearrSessionsNotSeenSince(
			ctx, s.tracearr.ConfiguredServerID(), started,
		)
		if err != nil {
			return result, err
		}
		result.Removed = removed
	}
	affected := make([]store.RecommendationIdentity, 0, len(terminalIdentities))
	for _, identity := range terminalIdentities {
		affected = append(affected, identity)
	}
	dirtied, err := s.store.MarkForYouProfilesDirtyByTracearrIdentity(ctx, affected)
	if err != nil {
		return result, err
	}
	result.Dirtied = dirtied
	s.log.Info("Tracearr import finished",
		"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
		"changed", result.Changed, "dirtied", result.Dirtied, "removed", result.Removed)
	return result, nil
}

func tracearrSessionTerminal(session store.TracearrSession) bool {
	state := strings.ToLower(strings.TrimSpace(session.State))
	return session.Watched || session.StoppedAt != nil ||
		state == "stopped" || state == "completed" || state == "complete" || state == "ended"
}

func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
	if !s.beginBuild(sess.EmbyUserID) {
		return nil
	}
	defer s.endBuild(sess.EmbyUserID)

	_, poolBuiltAt, dirtySince, algorithmVersion, err :=
		s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
	if err != nil {
		return err
	}
	algorithmChanged := algorithmVersion != preparedAlgorithmVersion
	if !force && !algorithmChanged && poolBuiltAt != nil &&
		time.Since(*poolBuiltAt) < s.minRebuildAge {
		return nil
	}
	if !force && !algorithmChanged && dirtySince == nil && poolBuiltAt != nil &&
		time.Since(*poolBuiltAt) < s.refreshAge {
		return nil
	}

	tracearrUserID, storedUsername, err := s.store.ForYouTracearrIdentity(ctx, sess.EmbyUserID)
	if err != nil {
		return err
	}
	username := strings.TrimSpace(sess.Username)
	if username == "" {
		username = storedUsername
	}
	imported, err := s.store.TracearrSessionsForUser(ctx, tracearrUserID, username)
	if err != nil {
		return err
	}
	sessions := make([]tracearr.Session, 0, len(imported))
	for _, value := range imported {
		sessions = append(sessions, tracearrSession(value))
	}
	result, err := s.engine.PrepareForYou(ctx, credentials(sess), username, sessions)
	if err != nil {
		_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
		return err
	}

	profile, candidates, err := storedResult(sess.EmbyUserID, time.Now().UTC(), result)
	if err != nil {
		return err
	}
	for _, mapping := range result.Mappings {
		if err := s.store.UpdateTracearrSessionMapping(ctx, store.TracearrSessionKey{
			ServerID: mapping.ServerID, SessionID: mapping.SessionID,
		}, mapping.ItemID, mapping.SeriesID); err != nil {
			return err
		}
	}
	if err := s.store.ReplaceForYouPool(ctx, profile, candidates); err != nil {
		_ = s.store.SetForYouError(context.WithoutCancel(ctx), sess.EmbyUserID, err)
		return err
	}
	s.log.Info("For You pool rebuilt",
		"user", sess.EmbyUserID, "sessions", len(sessions), "candidates", len(candidates))
	return nil
}

func (s *Service) PreparedRows(
	ctx context.Context,
	sess store.Session,
	minutes int,
) ([]recommend.Row, bool, bool, error) {
	items, builtAt, err := s.store.PreparedForYou(
		ctx, sess.EmbyUserID, minutes, preparedPoolReadLimit,
	)
	if err != nil {
		return nil, false, false, err
	}
	if len(items) == 0 {
		return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
	}
	items = rankPreparedForTime(items, s.now(), s.location)
	rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
	stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
	return rows, len(rows) > 0, stale, nil
}

func rankPreparedForTime(
	items []store.PreparedForYouItem,
	now time.Time,
	location *time.Location,
) []store.PreparedForYouItem {
	if len(items) < 2 || len(items[0].ContextAffinity) == 0 {
		return items
	}
	var profile recommend.ContextAffinityProfile
	if err := json.Unmarshal(items[0].ContextAffinity, &profile); err != nil ||
		len(profile.Slots) == 0 {
		return items
	}
	type contextualItem struct {
		item       store.PreparedForYouItem
		score      float64
		contextRaw float64
		confidence float64
	}
	ranked := make([]contextualItem, 0, len(items))
	var maxContext float64
	for _, value := range items {
		decoded := recommend.Decode([]json.RawMessage{value.Payload})
		raw, confidence := 0.0, 0.0
		if len(decoded) == 1 && value.ReasonKind != "pick-up" {
			raw, confidence = profile.Score(decoded[0], now, location)
			if raw > maxContext {
				maxContext = raw
			}
		}
		ranked = append(ranked, contextualItem{
			item: value, score: value.BaseScore,
			contextRaw: raw, confidence: confidence,
		})
	}
	if maxContext == 0 {
		return items
	}
	for i := range ranked {
		ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
		if ranked[i].contextRaw > 0 && ranked[i].confidence >= 0.4 {
			ranked[i].item.RecommendationReason += " · fits what you watch around this time"
		}
	}
	sort.SliceStable(ranked, func(i, j int) bool {
		if ranked[i].score != ranked[j].score {
			return ranked[i].score > ranked[j].score
		}
		return ranked[i].item.BaseRank < ranked[j].item.BaseRank
	})
	out := make([]store.PreparedForYouItem, 0, len(ranked))
	for _, entry := range ranked {
		out = append(out, entry.item)
	}
	return out
}

func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
	if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
		s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
	}
}

func (s *Service) RefreshAsync(sess store.Session, force bool) {
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
		defer cancel()
		if err := s.Rebuild(ctx, sess, force); err != nil {
			s.log.Warn("For You background rebuild failed", "user", sess.EmbyUserID, "error", err)
		}
	}()
}

func (s *Service) RebuildAll(ctx context.Context, force bool) error {
	users, err := s.recommendationUsers(ctx)
	if err != nil {
		return err
	}
	for _, user := range users {
		if err := s.Rebuild(ctx, user, force); err != nil {
			s.log.Warn("For You user rebuild failed", "user", user.EmbyUserID, "error", err)
		}
	}
	return nil
}

// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
// startup migrations deterministic without rebuilding every fresh profile on every boot.
func (s *Service) RebuildOutdated(ctx context.Context) error {
	users, err := s.recommendationUsers(ctx)
	if err != nil {
		return err
	}
	for _, user := range users {
		_, _, _, version, stateErr := s.store.ForYouProfileTimes(ctx, user.EmbyUserID)
		if stateErr != nil {
			return stateErr
		}
		if version == preparedAlgorithmVersion {
			continue
		}
		if err := s.Rebuild(ctx, user, true); err != nil {
			s.log.Warn("outdated For You rebuild failed", "user", user.EmbyUserID, "error", err)
		}
	}
	return nil
}

func (s *Service) MarkAllDirty(ctx context.Context) {
	if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
		s.log.Warn("could not mark For You profiles dirty", "error", err)
	}
	users, err := s.store.ActiveRecommendationUsers(ctx)
	if err != nil {
		s.log.Warn("could not list For You users", "error", err)
		return
	}
	for _, user := range users {
		s.MarkDirty(ctx, user)
	}
}

func (s *Service) recommendationUsers(ctx context.Context) ([]store.Session, error) {
	active, err := s.store.ActiveRecommendationUsers(ctx)
	if err != nil {
		return nil, err
	}
	byID := make(map[string]store.Session, len(active))
	for _, user := range active {
		byID[user.EmbyUserID] = user
	}
	if s.emby == nil || strings.TrimSpace(s.serviceCred.Token) == "" {
		return active, nil
	}

	embyUsers, err := s.emby.Users(ctx, s.serviceCred)
	if err != nil {
		s.log.Warn("Emby household users unavailable; using signed-in users", "error", err)
		return active, nil
	}
	tracearrUsers, traceErr := s.allTracearrUsers(ctx)
	if traceErr != nil {
		s.log.Warn("Tracearr users unavailable; preparing Emby-only profiles", "error", traceErr)
	}
	traceByName := map[string]tracearr.User{}
	for _, user := range tracearrUsers {
		key := strings.ToLower(strings.TrimSpace(user.Username))
		current, exists := traceByName[key]
		if key != "" && (!exists || user.SessionCount > current.SessionCount) {
			traceByName[key] = user
		}
	}

	for _, user := range embyUsers {
		if user.Policy.IsDisabled || strings.TrimSpace(user.ID) == "" {
			continue
		}
		matched := traceByName[strings.ToLower(strings.TrimSpace(user.Name))]
		if traceErr == nil {
			if err := s.store.MatchRecommendationUser(
				ctx, user.ID, user.Name, matched.ID, matched.Username,
			); err != nil {
				return nil, err
			}
		} else if err := s.store.MarkForYouDirty(ctx, user.ID, user.Name); err != nil {
			return nil, err
		}
		byID[user.ID] = store.Session{
			EmbyUserID: user.ID,
			EmbyToken:  s.serviceCred.Token,
			Username:   user.Name,
			DeviceID:   "memby-for-you-builder",
			DeviceName: "Memby For You builder",
		}
	}
	out := make([]store.Session, 0, len(byID))
	for _, user := range byID {
		out = append(out, user)
	}
	sort.Slice(out, func(i, j int) bool {
		return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
	})
	return out, nil
}

func (s *Service) allTracearrUsers(ctx context.Context) ([]tracearr.User, error) {
	const pageSize = 100
	out := []tracearr.User{}
	for pageNumber := 1; ; pageNumber++ {
		page, err := s.tracearr.Users(ctx, pageNumber, pageSize)
		if err != nil {
			return nil, err
		}
		out = append(out, page.Data...)
		if len(page.Data) == 0 || page.Meta.Total <= pageNumber*pageSize {
			return out, nil
		}
	}
}

func buildPreparedRows(
	items []store.PreparedForYouItem,
	minutes, minRowItems int,
) []recommend.Row {
	if len(items) == 0 {
		return nil
	}
	eligibleItems := make([]store.PreparedForYouItem, 0, len(items))
	for _, item := range items {
		if item.ReasonKind == "pick-up" &&
			!strings.Contains(item.RecommendationReason, "season 1") {
			continue
		}
		eligibleItems = append(eligibleItems, item)
	}
	items = eligibleItems
	used := map[string]bool{}
	rows := make([]recommend.Row, 0, 6)
	appendRowWithMinimum := func(
		id, title string,
		candidates []store.PreparedForYouItem,
		minimum int,
	) bool {
		selected := make([]store.PreparedForYouItem, 0, preparedRowSize)
		for _, item := range candidates {
			if used[item.ItemID] {
				continue
			}
			selected = append(selected, item)
			if len(selected) == preparedRowSize {
				break
			}
		}
		if len(selected) < minimum {
			return false
		}
		rowItems := make([]json.RawMessage, 0, len(selected))
		for _, item := range selected {
			used[item.ItemID] = true
			rowItems = append(rowItems, recommend.EnrichPreparedRecommendation(
				item.Payload, item.RecommendationReason, item.CompatibilityLabel, minutes,
			))
		}
		rows = append(rows, recommend.Row{
			ID: id, Title: title, Kind: "for-you", Items: rowItems,
		})
		return true
	}
	appendRow := func(id, title string, candidates []store.PreparedForYouItem) bool {
		return appendRowWithMinimum(id, title, candidates, minRowItems)
	}

	pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
	for _, item := range items {
		// The reason guard also keeps an already-prepared pool from an older server
		// version from briefly promoting later-season lapses after deployment.
		if item.ReasonKind == "pick-up" &&
			strings.Contains(item.RecommendationReason, "season 1") {
			pickups = append(pickups, item)
		}
	}
	// A pickup is valuable even when only one genuinely abandoned, unfinished series
	// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
	appendRowWithMinimum("for-you:pick-up", "Pick this show up again", pickups, 1)

	topTitle := "Top picks for you"
	if minutes > 0 {
		topTitle = fmt.Sprintf("Top picks that fit in %d minutes", minutes)
	}
	appendRow("for-you:picks", topTitle, items)

	appendGroupedRows := func(
		prefix string,
		key func(store.PreparedForYouItem) string,
		title func(store.PreparedForYouItem) string,
		maxRows int,
		filter func(store.PreparedForYouItem) bool,
	) {
		groups := map[string][]store.PreparedForYouItem{}
		order := []string{}
		first := map[string]store.PreparedForYouItem{}
		for _, item := range items {
			if used[item.ItemID] || !filter(item) {
				continue
			}
			groupKey := key(item)
			if groupKey == "" {
				continue
			}
			if _, exists := groups[groupKey]; !exists {
				order = append(order, groupKey)
				first[groupKey] = item
			}
			groups[groupKey] = append(groups[groupKey], item)
		}
		added := 0
		for _, groupKey := range order {
			if added == maxRows {
				return
			}
			candidates := groups[groupKey]
			if len(candidates) < minRowItems {
				continue
			}
			if appendRow(prefix+rowKey(groupKey), title(first[groupKey]), candidates) {
				added++
			}
		}
	}

	appendGroupedRows(
		"for-you:because:",
		func(item store.PreparedForYouItem) string { return item.ReasonSourceItemID },
		func(item store.PreparedForYouItem) string {
			return "Because you finished " + item.ReasonSourceTitle
		},
		2,
		func(item store.PreparedForYouItem) bool {
			return strings.TrimSpace(item.ReasonSourceTitle) != ""
		},
	)
	appendGroupedRows(
		"for-you:genre:",
		func(item store.PreparedForYouItem) string {
			return strings.ToLower(strings.TrimSpace(item.ReasonGenre))
		},
		func(item store.PreparedForYouItem) string {
			return "More " + item.ReasonGenre + " for you"
		},
		2,
		func(item store.PreparedForYouItem) bool {
			return strings.TrimSpace(item.ReasonGenre) != ""
		},
	)
	beforeBed := make([]store.PreparedForYouItem, 0, len(items))
	for _, item := range items {
		if item.ReasonKind != "pick-up" && item.RuntimeMinutes >= 15 &&
			item.RuntimeMinutes <= 50 {
			decoded := recommend.Decode([]json.RawMessage{item.Payload})
			if len(decoded) == 1 && strings.EqualFold(decoded[0].Type, "Series") {
				beforeBed = append(beforeBed, item)
			}
		}
	}
	appendRow(
		"for-you:one-episode-before-bed",
		"One episode before bed",
		beforeBed,
	)

	// The prepared pool contains only unseen candidates. Taking the remaining strong
	// matches produces a genuine library-discovery shelf; request-time impression
	// fatigue then rotates repeatedly ignored posters out of its leading positions.
	hidden := make([]store.PreparedForYouItem, 0, len(items))
	for _, item := range items {
		if !used[item.ItemID] && item.ReasonKind != "pick-up" {
			hidden = append(hidden, item)
		}
	}
	appendRow("for-you:hidden", "Hidden in your library", hidden)

	compatible := make([]store.PreparedForYouItem, 0, len(items))
	for _, item := range items {
		if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
			compatible = append(compatible, item)
		}
	}
	appendRow("for-you:tv-ready", "Plays well on this TV", compatible)
	return rows
}

func rowKey(value string) string {
	value = strings.TrimSpace(strings.ToLower(value))
	var b strings.Builder
	for _, r := range value {
		if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
			b.WriteRune(r)
		}
	}
	if b.Len() == 0 {
		sum := sha256.Sum256([]byte(value))
		return fmt.Sprintf("%x", sum[:6])
	}
	return b.String()
}

// ImportIfDue runs an import only when the persisted state says one is owed. Due-ness is
// measured from the last recorded import rather than from process uptime, so a restart —
// a redeploy, a crash loop, a container the healthcheck bounced — cannot buy an extra
// pass, and a gateway that is restarted more often than fullEvery still reconciles.
func (s *Service) ImportIfDue(
	ctx context.Context,
	importEvery, fullEvery time.Duration,
) (ImportResult, bool, error) {
	state, err := s.store.TracearrImportState(ctx)
	if err != nil {
		return ImportResult{}, false, err
	}
	full, due := importDue(state, time.Now().UTC(), importEvery, fullEvery)
	if !due {
		s.log.Debug("Tracearr import not due",
			"lastIncremental", state.LastIncrementalAt, "lastFull", state.LastFullAt)
		return ImportResult{}, false, nil
	}
	result, err := s.Import(ctx, full)
	return result, err == nil, err
}

// importDue is the whole scheduling rule, kept pure so it can be tested without a
// database. A stamp in the future is treated as due: a clock correction must not be able
// to strand the importer for an arbitrary length of time.
func importDue(
	state store.TracearrImportState,
	now time.Time,
	importEvery, fullEvery time.Duration,
) (full bool, due bool) {
	elapsed := func(at *time.Time) (time.Duration, bool) {
		if at == nil {
			return 0, false
		}
		return now.Sub(*at), true
	}
	if fullEvery > 0 {
		since, recorded := elapsed(state.LastFullAt)
		if !recorded || since >= fullEvery || since < 0 {
			return true, true
		}
	}
	if importEvery > 0 {
		since, recorded := elapsed(state.LastIncrementalAt)
		if !recorded || since >= importEvery || since < 0 {
			return false, true
		}
	}
	return false, false
}

func (s *Service) Schedule(
	ctx context.Context,
	importEvery, fullEvery time.Duration,
	rebuildHour int,
) {
	// One ticker asks "is anything owed?"; the persisted stamps decide what and whether.
	// Two independent tickers measured process uptime, which is what let a restart reset
	// the cadence and a bounced container import far more often than configured.
	checkEvery := importEvery
	if checkEvery <= 0 || (fullEvery > 0 && fullEvery < checkEvery) {
		checkEvery = fullEvery
	}
	var importC <-chan time.Time
	if checkEvery > 0 {
		importTicker := time.NewTicker(checkEvery)
		importC = importTicker.C
		defer importTicker.Stop()
		s.log.Info("Tracearr auto-import scheduled",
			"incremental", importEvery.String(), "full", fullEvery.String())
	} else {
		s.log.Info("Tracearr auto-import disabled")
	}
	nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
	rebuildTimer := time.NewTimer(time.Until(nextRebuild))
	defer rebuildTimer.Stop()
	s.log.Info("For You daily rebuild scheduled", "next", nextRebuild)
	for {
		select {
		case <-ctx.Done():
			return
		case <-importC:
			if _, _, err := s.ImportIfDue(ctx, importEvery, fullEvery); err != nil {
				s.log.Warn("scheduled Tracearr import failed", "error", err)
			}
		case <-rebuildTimer.C:
			if err := s.RebuildAll(ctx, true); err != nil {
				s.log.Warn("scheduled daily For You rebuild failed", "error", err)
			}
			rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
		}
	}
}

func nextDailyRebuild(now time.Time, location *time.Location, hour int) time.Time {
	if location == nil {
		location = time.UTC
	}
	local := now.In(location)
	next := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, location)
	if !next.After(local) {
		next = next.AddDate(0, 0, 1)
	}
	return next
}

func (s *Service) beginBuild(userID string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.building[userID] {
		return false
	}
	s.building[userID] = true
	return true
}

func (s *Service) endBuild(userID string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	delete(s.building, userID)
}

func importedSession(session tracearr.Session, configuredServerID string) (store.TracearrSession, bool) {
	if strings.TrimSpace(session.ID) == "" {
		return store.TracearrSession{}, false
	}
	serverID := strings.TrimSpace(session.ServerID)
	if serverID == "" {
		serverID = strings.TrimSpace(configuredServerID)
	}
	if serverID == "" {
		serverID = "default"
	}
	startedAt := parsedTime(session.StartedAt)
	stoppedAt := parsedTime(session.StoppedAt)
	fingerprintRaw, _ := json.Marshal(session)
	fingerprint := sha256.Sum256(fingerprintRaw)
	return store.TracearrSession{
		ServerID: serverID, SessionID: session.ID, UserID: session.User.ID,
		Username: session.User.Username, State: session.State,
		MediaType: session.MediaType, MediaTitle: session.MediaTitle,
		ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
		EpisodeNumber: session.EpisodeNumber, ProductionYear: session.Year,
		StartedAt: startedAt, StoppedAt: stoppedAt,
		DurationMs: int64(session.DurationMs), ProgressMs: int64(session.ProgressMs),
		TotalDurationMs: int64(session.TotalDurationMs), Watched: session.Watched,
		Device: session.Device, Player: session.Player, Product: session.Product,
		Platform: session.Platform, IsTranscode: session.IsTranscode,
		VideoDecision: session.VideoDecision, AudioDecision: session.AudioDecision,
		SourceVideoCodec: session.SourceVideoCodec, SourceAudioCodec: session.SourceAudioCodec,
		SourceFingerprint: fingerprint[:],
	}, true
}

func tracearrSession(session store.TracearrSession) tracearr.Session {
	value := tracearr.Session{
		ID: session.SessionID, ServerID: session.ServerID, State: session.State,
		MediaType: session.MediaType, MediaTitle: session.MediaTitle,
		ShowTitle: session.ShowTitle, SeasonNumber: session.SeasonNumber,
		EpisodeNumber: session.EpisodeNumber, Year: session.ProductionYear,
		DurationMs:      tracearr.FlexibleInt64(session.DurationMs),
		ProgressMs:      tracearr.FlexibleInt64(session.ProgressMs),
		TotalDurationMs: tracearr.FlexibleInt64(session.TotalDurationMs),
		Watched:         session.Watched, Device: session.Device, Player: session.Player,
		Product: session.Product, Platform: session.Platform,
		IsTranscode: session.IsTranscode, VideoDecision: session.VideoDecision,
		AudioDecision: session.AudioDecision, SourceVideoCodec: session.SourceVideoCodec,
		SourceAudioCodec: session.SourceAudioCodec,
	}
	if session.StartedAt != nil {
		value.StartedAt = session.StartedAt.UTC().Format(time.RFC3339Nano)
	}
	if session.StoppedAt != nil {
		value.StoppedAt = session.StoppedAt.UTC().Format(time.RFC3339Nano)
	}
	value.User.ID = session.UserID
	value.User.Username = session.Username
	return value
}

func storedResult(
	userID string,
	builtAt time.Time,
	result recommend.PreparedResult,
) (store.RecommendationProfile, []store.ForYouCandidate, error) {
	genre, err := json.Marshal(result.Profile.GenreAffinity)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	title, err := json.Marshal(result.Profile.TitleAffinity)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	studio, err := json.Marshal(result.Profile.StudioAffinity)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	codecs, err := json.Marshal(result.Profile.CodecOutcomes)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	contextAffinity, err := json.Marshal(result.Profile.ContextAffinity)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	weightedProfile, err := json.Marshal(result.Profile.Weighted)
	if err != nil {
		return store.RecommendationProfile{}, nil, err
	}
	profile := store.RecommendationProfile{
		EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
		TracearrUsername:      result.Profile.TracearrUsername,
		SourceSessionCount:    result.Profile.SourceSessionCount,
		MeanCompletionRatio:   result.Profile.MeanCompletionRatio,
		TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
		GenreAffinity:         genre, TitleAffinity: title, StudioAffinity: studio,
		ContextAffinity: contextAffinity, CodecOutcomes: codecs,
		WeightedProfile:  weightedProfile,
		AlgorithmVersion: preparedAlgorithmVersion,
		SignalsThrough:   result.Profile.SignalsThrough, BuiltAt: builtAt,
	}
	candidateCount := min(len(result.Candidates), maxPreparedCandidates)
	candidates := make([]store.ForYouCandidate, 0, candidateCount)
	for _, value := range result.Candidates[:candidateCount] {
		candidates = append(candidates, store.ForYouCandidate{
			ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
			RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
			CompatibilityScore: value.CompatibilityScore,
			CompatibilityLabel: value.CompatibilityLabel, ReasonKind: value.ReasonKind,
			ReasonGenre:           value.ReasonGenre,
			ReasonSourceSessionID: value.ReasonSourceSessionID,
			ReasonSourceItemID:    value.ReasonSourceItemID,
			ReasonSourceTitle:     value.ReasonSourceTitle,
			RecommendationReason:  value.RecommendationReason,
		})
	}
	return profile, candidates, nil
}

func parsedTime(value string) *time.Time {
	parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
	if err != nil {
		return nil
	}
	parsed = parsed.UTC()
	return &parsed
}

func credentials(sess store.Session) emby.Credentials {
	return emby.Credentials{
		UserID: sess.EmbyUserID, Token: sess.EmbyToken,
		DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
	}
}
