package store

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/jackc/pgx/v5"
)

const tracearrImportStateKey = "tracearr_import_state"

type TracearrSessionKey struct {
	ServerID  string
	SessionID string
}

type TracearrSessionSignal struct {
	Fingerprint []byte
	Terminal    bool
}

type RecommendationIdentity struct {
	TracearrUserID string
	Username       string
}

type TracearrSession struct {
	ServerID          string
	SessionID         string
	UserID            string
	Username          string
	State             string
	MediaType         string
	MediaTitle        string
	ShowTitle         string
	SeasonNumber      *int
	EpisodeNumber     *int
	ProductionYear    *int
	StartedAt         *time.Time
	StoppedAt         *time.Time
	DurationMs        int64
	ProgressMs        int64
	TotalDurationMs   int64
	Watched           bool
	Device            string
	Player            string
	Product           string
	Platform          string
	IsTranscode       bool
	VideoDecision     string
	AudioDecision     string
	SourceVideoCodec  string
	SourceAudioCodec  string
	EmbyItemID        string
	EmbySeriesID      string
	SourceFingerprint []byte
}

type TracearrImportState struct {
	LastIncrementalAt *time.Time `json:"lastIncrementalAt,omitempty"`
	LastFullAt        *time.Time `json:"lastFullAt,omitempty"`
	LastError         string     `json:"lastError,omitempty"`
}

type RecommendationProfile struct {
	EmbyUserID            string
	TracearrUserID        string
	TracearrUsername      string
	SourceSessionCount    int
	MeanCompletionRatio   float64
	TypicalSessionMinutes int
	GenreAffinity         json.RawMessage
	TitleAffinity         json.RawMessage
	StudioAffinity        json.RawMessage
	ContextAffinity       json.RawMessage
	CodecOutcomes         json.RawMessage
	WeightedProfile       json.RawMessage
	AlgorithmVersion      string
	SignalsThrough        *time.Time
	BuiltAt               time.Time
}

type ForYouCandidate struct {
	ItemID                string
	BaseRank              int
	BaseScore             float64
	RuntimeMinutes        int
	AffinityScore         float64
	CompatibilityScore    float64
	CompatibilityLabel    string
	ReasonKind            string
	ReasonGenre           string
	ReasonSourceSessionID string
	ReasonSourceItemID    string
	ReasonSourceTitle     string
	RecommendationReason  string
}

type PreparedForYouItem struct {
	ItemID               string
	BaseRank             int
	BaseScore            float64
	AffinityScore        float64
	Payload              json.RawMessage
	RuntimeMinutes       int
	CompatibilityScore   float64
	CompatibilityLabel   string
	RecommendationReason string
	ReasonKind           string
	ReasonGenre          string
	ReasonSourceItemID   string
	ReasonSourceTitle    string
	ContextAffinity      json.RawMessage
}

type ForYouStats struct {
	TracearrSessions int64      `json:"tracearrSessions"`
	Profiles         int64      `json:"profiles"`
	Candidates       int64      `json:"candidates"`
	LastFullImport   *time.Time `json:"lastFullImport,omitempty"`
}

func (s *Store) TracearrSessionCount(ctx context.Context) (int64, error) {
	var count int64
	if err := s.pool.QueryRow(ctx, `SELECT count(*) FROM tracearr_sessions`).Scan(&count); err != nil {
		return 0, fmt.Errorf("store: count tracearr sessions: %w", err)
	}
	return count, nil
}

func (s *Store) TracearrFingerprints(
	ctx context.Context,
	keys []TracearrSessionKey,
) (map[TracearrSessionKey][]byte, error) {
	out := make(map[TracearrSessionKey][]byte, len(keys))
	if len(keys) == 0 {
		return out, nil
	}
	servers := make([]string, 0, len(keys))
	ids := make([]string, 0, len(keys))
	for _, key := range keys {
		servers = append(servers, key.ServerID)
		ids = append(ids, key.SessionID)
	}
	rows, err := s.pool.Query(ctx, `
		SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint
		FROM tracearr_sessions current
		JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
		  ON current.server_id = wanted.server_id
		 AND current.tracearr_session_id = wanted.session_id`,
		servers, ids)
	if err != nil {
		return nil, fmt.Errorf("store: tracearr fingerprints: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var key TracearrSessionKey
		var fingerprint []byte
		if err := rows.Scan(&key.ServerID, &key.SessionID, &fingerprint); err != nil {
			return nil, err
		}
		out[key] = fingerprint
	}
	return out, rows.Err()
}

func (s *Store) TracearrSessionSignals(
	ctx context.Context,
	keys []TracearrSessionKey,
) (map[TracearrSessionKey]TracearrSessionSignal, error) {
	out := make(map[TracearrSessionKey]TracearrSessionSignal, len(keys))
	if len(keys) == 0 {
		return out, nil
	}
	servers := make([]string, 0, len(keys))
	ids := make([]string, 0, len(keys))
	for _, key := range keys {
		servers = append(servers, key.ServerID)
		ids = append(ids, key.SessionID)
	}
	rows, err := s.pool.Query(ctx, `
		SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint,
			(current.watched OR current.stopped_at IS NOT NULL OR
			 lower(current.state) IN ('stopped', 'completed', 'complete', 'ended'))
		FROM tracearr_sessions current
		JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
		  ON current.server_id = wanted.server_id
		 AND current.tracearr_session_id = wanted.session_id`,
		servers, ids)
	if err != nil {
		return nil, fmt.Errorf("store: tracearr session signals: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var key TracearrSessionKey
		var signal TracearrSessionSignal
		if err := rows.Scan(
			&key.ServerID, &key.SessionID, &signal.Fingerprint, &signal.Terminal,
		); err != nil {
			return nil, err
		}
		out[key] = signal
	}
	return out, rows.Err()
}

func (s *Store) UpsertTracearrSessions(
	ctx context.Context,
	sessions []TracearrSession,
	seenAt time.Time,
) error {
	if len(sessions) == 0 {
		return nil
	}
	batch := &pgx.Batch{}
	for _, session := range sessions {
		batch.Queue(`
			INSERT INTO tracearr_sessions (
				server_id, tracearr_session_id, tracearr_user_id, username, state,
				media_type, media_title, show_title, season_number, episode_number,
				production_year, started_at, stopped_at, duration_ms, progress_ms,
				total_duration_ms, watched, device, player, product, platform,
				is_transcode, video_decision, audio_decision, source_video_codec,
				source_audio_codec, emby_item_id, emby_series_id, source_fingerprint,
				source_seen_at, imported_at, updated_at
			) VALUES (
				$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
				$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,now(),now()
			)
			ON CONFLICT (server_id, tracearr_session_id) DO UPDATE SET
				tracearr_user_id = EXCLUDED.tracearr_user_id,
				username = EXCLUDED.username,
				state = EXCLUDED.state,
				media_type = EXCLUDED.media_type,
				media_title = EXCLUDED.media_title,
				show_title = EXCLUDED.show_title,
				season_number = EXCLUDED.season_number,
				episode_number = EXCLUDED.episode_number,
				production_year = EXCLUDED.production_year,
				started_at = EXCLUDED.started_at,
				stopped_at = EXCLUDED.stopped_at,
				duration_ms = EXCLUDED.duration_ms,
				progress_ms = EXCLUDED.progress_ms,
				total_duration_ms = EXCLUDED.total_duration_ms,
				watched = EXCLUDED.watched,
				device = EXCLUDED.device,
				player = EXCLUDED.player,
				product = EXCLUDED.product,
				platform = EXCLUDED.platform,
				is_transcode = EXCLUDED.is_transcode,
				video_decision = EXCLUDED.video_decision,
				audio_decision = EXCLUDED.audio_decision,
				source_video_codec = EXCLUDED.source_video_codec,
				source_audio_codec = EXCLUDED.source_audio_codec,
				source_fingerprint = EXCLUDED.source_fingerprint,
				source_seen_at = EXCLUDED.source_seen_at,
				updated_at = CASE
					WHEN tracearr_sessions.source_fingerprint IS DISTINCT FROM EXCLUDED.source_fingerprint
					THEN now() ELSE tracearr_sessions.updated_at END`,
			session.ServerID, session.SessionID, session.UserID, session.Username,
			session.State, session.MediaType, session.MediaTitle, session.ShowTitle,
			session.SeasonNumber, session.EpisodeNumber, session.ProductionYear,
			session.StartedAt, session.StoppedAt, session.DurationMs, session.ProgressMs,
			session.TotalDurationMs, session.Watched, session.Device, session.Player,
			session.Product, session.Platform, session.IsTranscode, session.VideoDecision,
			session.AudioDecision, session.SourceVideoCodec, session.SourceAudioCodec,
			session.EmbyItemID, session.EmbySeriesID, session.SourceFingerprint, seenAt)
	}
	results := s.pool.SendBatch(ctx, batch)
	defer results.Close()
	for range sessions {
		if _, err := results.Exec(); err != nil {
			return fmt.Errorf("store: upsert tracearr sessions: %w", err)
		}
	}
	return nil
}

func (s *Store) DeleteTracearrSessionsNotSeenSince(
	ctx context.Context,
	serverID string,
	cutoff time.Time,
) (int64, error) {
	if serverID == "" {
		tag, err := s.pool.Exec(ctx, `DELETE FROM tracearr_sessions WHERE source_seen_at < $1`, cutoff)
		if err != nil {
			return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
		}
		return tag.RowsAffected(), nil
	}
	tag, err := s.pool.Exec(ctx,
		`DELETE FROM tracearr_sessions WHERE server_id = $1 AND source_seen_at < $2`,
		serverID, cutoff)
	if err != nil {
		return 0, fmt.Errorf("store: reconcile tracearr sessions: %w", err)
	}
	return tag.RowsAffected(), nil
}

func (s *Store) TracearrSessionsForUser(
	ctx context.Context,
	tracearrUserID, username string,
) ([]TracearrSession, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT server_id, tracearr_session_id, tracearr_user_id, username, state,
			media_type, media_title, show_title, season_number, episode_number,
			production_year, started_at, stopped_at, duration_ms, progress_ms,
			total_duration_ms, watched, device, player, product, platform,
			is_transcode, video_decision, audio_decision, source_video_codec,
			source_audio_codec, emby_item_id, emby_series_id, source_fingerprint
		FROM tracearr_sessions
		WHERE ($1 <> '' AND tracearr_user_id = $1)
		   OR ($1 = '' AND lower(username) = lower($2))
		ORDER BY started_at DESC NULLS LAST, tracearr_session_id DESC`,
		tracearrUserID, username)
	if err != nil {
		return nil, fmt.Errorf("store: tracearr user sessions: %w", err)
	}
	defer rows.Close()
	out := []TracearrSession{}
	for rows.Next() {
		var session TracearrSession
		if err := rows.Scan(
			&session.ServerID, &session.SessionID, &session.UserID, &session.Username,
			&session.State, &session.MediaType, &session.MediaTitle, &session.ShowTitle,
			&session.SeasonNumber, &session.EpisodeNumber, &session.ProductionYear,
			&session.StartedAt, &session.StoppedAt, &session.DurationMs, &session.ProgressMs,
			&session.TotalDurationMs, &session.Watched, &session.Device, &session.Player,
			&session.Product, &session.Platform, &session.IsTranscode,
			&session.VideoDecision, &session.AudioDecision, &session.SourceVideoCodec,
			&session.SourceAudioCodec, &session.EmbyItemID, &session.EmbySeriesID,
			&session.SourceFingerprint,
		); err != nil {
			return nil, err
		}
		out = append(out, session)
	}
	return out, rows.Err()
}

func (s *Store) UpdateTracearrSessionMapping(
	ctx context.Context,
	key TracearrSessionKey,
	itemID, seriesID string,
) error {
	_, err := s.pool.Exec(ctx, `
		UPDATE tracearr_sessions
		SET emby_item_id = $3, emby_series_id = $4
		WHERE server_id = $1 AND tracearr_session_id = $2
		  AND (emby_item_id, emby_series_id) IS DISTINCT FROM ($3, $4)`,
		key.ServerID, key.SessionID, itemID, seriesID)
	if err != nil {
		return fmt.Errorf("store: map tracearr session: %w", err)
	}
	return nil
}

func (s *Store) ForYouTracearrIdentity(ctx context.Context, userID string) (string, string, error) {
	var tracearrUserID, username string
	err := s.pool.QueryRow(ctx, `
		SELECT tracearr_user_id, tracearr_username
		FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
		Scan(&tracearrUserID, &username)
	if errors.Is(err, pgx.ErrNoRows) {
		return "", "", nil
	}
	if err != nil {
		return "", "", fmt.Errorf("store: For You Tracearr identity: %w", err)
	}
	return tracearrUserID, username, nil
}

func (s *Store) TracearrImportState(ctx context.Context) (TracearrImportState, error) {
	var raw []byte
	err := s.pool.QueryRow(ctx,
		`SELECT value FROM app_settings WHERE key = $1`, tracearrImportStateKey).Scan(&raw)
	if errors.Is(err, pgx.ErrNoRows) {
		return TracearrImportState{}, nil
	}
	if err != nil {
		return TracearrImportState{}, fmt.Errorf("store: read tracearr import state: %w", err)
	}
	var state TracearrImportState
	if err := json.Unmarshal(raw, &state); err != nil {
		return state, fmt.Errorf("store: decode tracearr import state: %w", err)
	}
	return state, nil
}

func (s *Store) SetTracearrImportState(ctx context.Context, state TracearrImportState) error {
	raw, err := json.Marshal(state)
	if err != nil {
		return err
	}
	_, err = s.pool.Exec(ctx, `
		INSERT INTO app_settings (key, value, updated_at)
		VALUES ($1, $2::jsonb, now())
		ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
		tracearrImportStateKey, string(raw))
	if err != nil {
		return fmt.Errorf("store: write tracearr import state: %w", err)
	}
	return nil
}

func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT DISTINCT ON (emby_user_id)
			token_hash, emby_user_id, emby_token, username, server_id, device_id,
			device_name, client_version, client_protocol, client_capabilities, last_seen_at
		FROM sessions
		ORDER BY emby_user_id, last_seen_at DESC`)
	if err != nil {
		return nil, fmt.Errorf("store: active recommendation users: %w", err)
	}
	defer rows.Close()
	out := []Session{}
	for rows.Next() {
		var session Session
		if err := rows.Scan(
			&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
			&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
			&session.ClientProtocol, &session.ClientCapabilities, &session.LastSeenAt,
		); err != nil {
			return nil, err
		}
		out = append(out, session)
	}
	return out, rows.Err()
}

func (s *Store) MarkForYouDirty(ctx context.Context, userID, username string) error {
	_, err := s.pool.Exec(ctx, `
		INSERT INTO recommendation_user_profiles (emby_user_id, tracearr_username, dirty_since)
		VALUES ($1, $2, now())
		ON CONFLICT (emby_user_id) DO UPDATE SET
			tracearr_username = CASE WHEN $2 <> '' THEN $2 ELSE recommendation_user_profiles.tracearr_username END,
			dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
		userID, username)
	if err != nil {
		return fmt.Errorf("store: mark For You dirty: %w", err)
	}
	return nil
}

func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
	_, err := s.pool.Exec(ctx, `
		UPDATE recommendation_user_profiles
		SET dirty_since = coalesce(dirty_since, now())`)
	if err != nil {
		return fmt.Errorf("store: mark all For You profiles dirty: %w", err)
	}
	return nil
}

func (s *Store) MarkForYouProfilesDirtyByTracearrIdentity(
	ctx context.Context,
	identities []RecommendationIdentity,
) (int64, error) {
	if len(identities) == 0 {
		return 0, nil
	}
	userIDs := make([]string, 0, len(identities))
	usernames := make([]string, 0, len(identities))
	for _, identity := range identities {
		if identity.TracearrUserID != "" {
			userIDs = append(userIDs, identity.TracearrUserID)
		}
		if identity.Username != "" {
			usernames = append(usernames, strings.ToLower(identity.Username))
		}
	}
	tag, err := s.pool.Exec(ctx, `
		UPDATE recommendation_user_profiles
		SET dirty_since = coalesce(dirty_since, now())
		WHERE tracearr_user_id = ANY($1)
		   OR lower(tracearr_username) = ANY($2)`,
		userIDs, usernames)
	if err != nil {
		return 0, fmt.Errorf("store: mark affected For You profiles dirty: %w", err)
	}
	return tag.RowsAffected(), nil
}

func (s *Store) MatchRecommendationUser(
	ctx context.Context,
	embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
) error {
	_, err := s.pool.Exec(ctx, `
		INSERT INTO recommendation_user_profiles (
			emby_user_id, tracearr_user_id, tracearr_username, dirty_since
		) VALUES ($1, $2, CASE WHEN $3 <> '' THEN $3 ELSE $4 END, now())
		ON CONFLICT (emby_user_id) DO UPDATE SET
			tracearr_user_id = EXCLUDED.tracearr_user_id,
			tracearr_username = CASE
				WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username
				ELSE $4
			END,
			dirty_since = CASE
				WHEN recommendation_user_profiles.tracearr_user_id IS DISTINCT FROM EXCLUDED.tracearr_user_id
				  OR recommendation_user_profiles.tracearr_username IS DISTINCT FROM
					CASE WHEN EXCLUDED.tracearr_username <> '' THEN EXCLUDED.tracearr_username ELSE $4 END
				THEN coalesce(recommendation_user_profiles.dirty_since, now())
				ELSE recommendation_user_profiles.dirty_since
			END`,
		embyUserID, tracearrUserID, tracearrUsername, embyUsername)
	if err != nil {
		return fmt.Errorf("store: match recommendation user: %w", err)
	}
	return nil
}

func (s *Store) ForYouProfileTimes(
	ctx context.Context,
	userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, algorithmVersion string, err error) {
	err = s.pool.QueryRow(ctx, `
		SELECT built_at, pool_built_at, dirty_since, algorithm_version
		FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
		Scan(&builtAt, &poolBuiltAt, &dirtySince, &algorithmVersion)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, nil, nil, "", nil
	}
	if err != nil {
		return nil, nil, nil, "", fmt.Errorf("store: For You freshness: %w", err)
	}
	return builtAt, poolBuiltAt, dirtySince, algorithmVersion, nil
}

func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
	message := ""
	if buildErr != nil {
		message = buildErr.Error()
	}
	_, err := s.pool.Exec(ctx, `
		INSERT INTO recommendation_user_profiles (emby_user_id, last_error, dirty_since)
		VALUES ($1, $2, now())
		ON CONFLICT (emby_user_id) DO UPDATE SET last_error = $2,
			dirty_since = coalesce(recommendation_user_profiles.dirty_since, now())`,
		userID, message)
	return err
}

func (s *Store) ReplaceForYouPool(
	ctx context.Context,
	profile RecommendationProfile,
	candidates []ForYouCandidate,
) error {
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("store: begin For You rebuild: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()

	_, err = tx.Exec(ctx, `
		INSERT INTO recommendation_user_profiles (
			emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
			mean_completion_ratio, typical_session_minutes, genre_affinity,
			title_affinity, studio_affinity, context_affinity, codec_outcomes,
			weighted_profile, algorithm_version, signals_through, built_at, pool_built_at, dirty_since, last_error
		) VALUES (
			$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,
			$12::jsonb,$13,$14,$15,$15,NULL,''
		)
		ON CONFLICT (emby_user_id) DO UPDATE SET
			tracearr_user_id = EXCLUDED.tracearr_user_id,
			tracearr_username = EXCLUDED.tracearr_username,
			source_session_count = EXCLUDED.source_session_count,
			mean_completion_ratio = EXCLUDED.mean_completion_ratio,
			typical_session_minutes = EXCLUDED.typical_session_minutes,
			genre_affinity = EXCLUDED.genre_affinity,
			title_affinity = EXCLUDED.title_affinity,
			studio_affinity = EXCLUDED.studio_affinity,
			context_affinity = EXCLUDED.context_affinity,
			codec_outcomes = EXCLUDED.codec_outcomes,
			weighted_profile = EXCLUDED.weighted_profile,
			algorithm_version = EXCLUDED.algorithm_version,
			signals_through = EXCLUDED.signals_through,
			built_at = EXCLUDED.built_at,
			pool_built_at = EXCLUDED.pool_built_at,
			dirty_since = NULL,
			last_error = ''`,
		profile.EmbyUserID, profile.TracearrUserID, profile.TracearrUsername,
		profile.SourceSessionCount, profile.MeanCompletionRatio,
		profile.TypicalSessionMinutes, string(profile.GenreAffinity),
		string(profile.TitleAffinity), string(profile.StudioAffinity),
		string(profile.ContextAffinity), string(profile.CodecOutcomes),
		string(profile.WeightedProfile), profile.AlgorithmVersion,
		profile.SignalsThrough, profile.BuiltAt)
	if err != nil {
		return fmt.Errorf("store: write For You profile: %w", err)
	}
	if _, err := tx.Exec(ctx,
		`DELETE FROM for_you_candidates WHERE emby_user_id = $1`, profile.EmbyUserID); err != nil {
		return fmt.Errorf("store: clear For You pool: %w", err)
	}

	batch := &pgx.Batch{}
	for _, candidate := range candidates {
		batch.Queue(`
			INSERT INTO for_you_candidates (
				emby_user_id, item_id, base_rank, base_score, runtime_minutes,
				affinity_score, compatibility_score, compatibility_label, reason_kind,
				reason_genre, reason_source_session_id, reason_source_item_id,
				reason_source_title, recommendation_reason, built_at
			) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
			profile.EmbyUserID, candidate.ItemID, candidate.BaseRank, candidate.BaseScore,
			candidate.RuntimeMinutes, candidate.AffinityScore, candidate.CompatibilityScore,
			candidate.CompatibilityLabel, candidate.ReasonKind, candidate.ReasonGenre,
			candidate.ReasonSourceSessionID, candidate.ReasonSourceItemID,
			candidate.ReasonSourceTitle, candidate.RecommendationReason, profile.BuiltAt)
	}
	results := tx.SendBatch(ctx, batch)
	for range candidates {
		if _, err := results.Exec(); err != nil {
			_ = results.Close()
			return fmt.Errorf("store: insert For You candidates: %w", err)
		}
	}
	if err := results.Close(); err != nil {
		return fmt.Errorf("store: close For You candidate batch: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("store: commit For You rebuild: %w", err)
	}
	return nil
}

func (s *Store) PreparedForYou(
	ctx context.Context,
	userID string,
	minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
	rows, err := s.pool.Query(ctx, `
		SELECT fc.item_id, fc.base_rank, fc.base_score, fc.affinity_score,
			li.payload, fc.runtime_minutes,
			fc.compatibility_score, fc.compatibility_label,
			fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
			fc.reason_source_item_id, fc.reason_source_title, p.context_affinity,
			p.pool_built_at
		FROM for_you_candidates fc
		JOIN library_items li ON li.id = fc.item_id
		JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
		WHERE fc.emby_user_id = $1
		  AND (
			$2 = 0 OR fc.reason_kind = 'pick-up' OR
			(fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2)
		  )
		ORDER BY fc.base_rank
		LIMIT $3`,
		userID, minutes, limit)
	if err != nil {
		return nil, nil, fmt.Errorf("store: prepared For You: %w", err)
	}
	defer rows.Close()
	out := []PreparedForYouItem{}
	var builtAt *time.Time
	for rows.Next() {
		var payload []byte
		var item PreparedForYouItem
		var rowBuiltAt *time.Time
		if err := rows.Scan(
			&item.ItemID, &item.BaseRank, &item.BaseScore, &item.AffinityScore,
			&payload, &item.RuntimeMinutes,
			&item.CompatibilityScore, &item.CompatibilityLabel,
			&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
			&item.ReasonSourceItemID, &item.ReasonSourceTitle, &item.ContextAffinity,
			&rowBuiltAt,
		); err != nil {
			return nil, nil, err
		}
		item.Payload = json.RawMessage(payload)
		out = append(out, item)
		if builtAt == nil {
			builtAt = rowBuiltAt
		}
	}
	return out, builtAt, rows.Err()
}

func (s *Store) ForYouStats(ctx context.Context) (ForYouStats, error) {
	var stats ForYouStats
	if err := s.pool.QueryRow(ctx, `
		SELECT
			(SELECT count(*) FROM tracearr_sessions),
			(SELECT count(*) FROM recommendation_user_profiles),
			(SELECT count(*) FROM for_you_candidates)`).
		Scan(&stats.TracearrSessions, &stats.Profiles, &stats.Candidates); err != nil {
		return stats, fmt.Errorf("store: For You stats: %w", err)
	}
	state, err := s.TracearrImportState(ctx)
	if err != nil {
		return stats, err
	}
	stats.LastFullImport = state.LastFullAt
	return stats, nil
}
