// Package playlists' system.go implements the system-generated mix
// builder (M7 #352 slice 2). The cron loop and lazy fallback both call
// BuildSystemPlaylists; the helpers in this file (pickSeedArtists,
// pickRepresentativeTrack, tieBreakHash) are the pure parts.
package playlists

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/binary"
	"errors"
	"fmt"
	"log/slog"
	"math"
	"math/rand"
	"sort"
	"sync"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
	"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)

// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
// Defined locally so unit tests don't need a real DB.
type seedArtistRow struct {
	ArtistID pgtype.UUID
	Score    int64
}

// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
// SQL already orders by score DESC + artist_id and LIMIT 12, so this is
// just a column projection — but pulling it into a function keeps the
// call-site readable and makes the post-fetch path testable without
// a database.
func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID {
	out := make([]pgtype.UUID, 0, len(rows))
	for _, r := range rows {
		out = append(out, r.ArtistID)
	}
	return out
}

// tieBreakHash returns a deterministic 64-bit hash of (track_id, date_str).
// Used to break score ties and to drive the For-You tail sample's
// daily-deterministic ordering. Same inputs always produce the same
// output; different inputs almost always differ.
//
// Switched from FNV-1a (May 2026) because two date strings differing
// only in the last character (e.g. "2026-05-07" vs "2026-05-08") gave
// hash values whose RELATIVE ORDERING across our small candidate pools
// was identical — the dateStr-driven divergence concentrated in low
// bits that lost out to high-bit ordering during sort. SHA-256
// truncated to 8 bytes has full avalanche, so any single-byte input
// change roughly half-flips the output bits and reorders meaningfully.
// The hash isn't security-load-bearing; we just want strong avalanche
// for tiny input deltas.
func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
	h := sha256.New()
	if trackID.Valid {
		_, _ = h.Write(trackID.Bytes[:])
	}
	_, _ = h.Write([]byte(dateStr))
	sum := h.Sum(nil)
	return binary.BigEndian.Uint64(sum[:8])
}

// userIDHash returns a deterministic 64-bit hash of (user_id, dateStr).
// Same family as tieBreakHash, just keyed on user_id instead of
// track_id. Used to drive daily-deterministic seed picks and to seed
// the per-build scoring RNG: same (user, day) → same hash; different
// days → different hashes.
func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
	h := sha256.New()
	if userID.Valid {
		_, _ = h.Write(userID.Bytes[:])
	}
	_, _ = h.Write([]byte(dateStr))
	sum := h.Sum(nil)
	return binary.BigEndian.Uint64(sum[:8])
}

// pickDailySeeds takes a candidate pool of UUIDs and returns up to n
// of them, daily-deterministically shuffled. Each day gets a different
// ordering / selection, but within-day stability is preserved (same
// inputs always produce the same output).
//
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
// Degrades gracefully: <n candidates returns whatever is available in
// shuffled order.
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
	if len(pool) == 0 {
		return nil
	}
	shuffled := make([]pgtype.UUID, len(pool))
	copy(shuffled, pool)
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	rng.Shuffle(len(shuffled), func(i, j int) {
		shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
	})
	if len(shuffled) < n {
		n = len(shuffled)
	}
	return shuffled[:n]
}

// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
// own dedicated Home row — the best-performing surface (low skip / high
// completion) was buried as 3 tiles in the shared Playlists carousel, so
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
// deeper top-12 pool via a daily-deterministic shuffle so the set still
// rotates day to day rather than pinning the same six artists.
const songsLikeSeedCount = 6

// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
// the Songs-like mixes; For-You uses pickDailySeeds directly with
// forYouSeedCount.
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
	return pickDailySeeds(pool, userID, dateStr, songsLikeSeedCount)
}

// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
type rankedCandidate struct {
	TrackID  pgtype.UUID
	Score    float64
	PickKind string
}

// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
// Discover's taste-targeted-novelty arm (#1488).
const (
	pickKindTaste        = "taste"
	pickKindFresh        = "fresh"
	pickKindDormant      = "dormant"
	pickKindTasteUnheard = "taste_unheard"
	pickKindCrossUser    = "cross_user"
	pickKindRandom       = "random"
	pickKindTier1        = "tier1"
	pickKindTier2        = "tier2"
	pickKindTier3        = "tier3"
)

// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
// whole build, and it's what the metrics breakdown attributes skips to.
func pickKindForSeedTier(tier int32) string {
	switch tier {
	case 0:
		return pickKindTier1
	case 1:
		return pickKindTier2
	default:
		return pickKindTier3
	}
}

// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
	switch tier {
	case 1:
		return pickKindTier1
	case 2:
		return pickKindTier2
	default:
		return pickKindTier3
	}
}

const systemMixLength = 25

// systemMixWeights are the scoring weights used by the daily builds.
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
//
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
var (
	systemTuningMu   sync.RWMutex
	systemMixWeights = recommendation.ScoringWeights{
		BaseWeight:       1.0,
		LikeBoost:        2.0,
		RecencyWeight:    1.0,
		SkipPenalty:      2.0,
		JitterMagnitude:  0.1,
		ContextWeight:    0.5,
		SimilarityWeight: 1.5,
		// Taste profile (#796 phase 2): the daily mixes are the primary
		// taste-driven surface, so they lean on it. TasteMatchScore is in
		// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
		// while passive avoidance (negative) gently demotes.
		TasteWeight: 1.5,
		// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
		// uniform with radio pending trend data.
		ContextTimeWeight: 1.0,
	}
	systemTasteConfig = taste.DefaultConfig()
)

// SetSystemMixWeights installs the current daily_mix scoring weights.
// Called by the recsettings service at boot and on admin updates.
func SetSystemMixWeights(w recommendation.ScoringWeights) {
	systemTuningMu.Lock()
	defer systemTuningMu.Unlock()
	systemMixWeights = w
}

// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
	systemTuningMu.Lock()
	defer systemTuningMu.Unlock()
	systemTasteConfig = c
}

func currentSystemMixWeights() recommendation.ScoringWeights {
	systemTuningMu.RLock()
	defer systemTuningMu.RUnlock()
	return systemMixWeights
}

func currentTasteConfig() taste.Config {
	systemTuningMu.RLock()
	defer systemTuningMu.RUnlock()
	return systemTasteConfig
}

// forYouHeadN is the number of top-scored tracks that anchor the For-You
// playlist; forYouTailN is the number sampled from positions 2*headN
// onward, daily-deterministic via tieBreakHash. The 50 + 50 split
// (~50% from the tail) gives the user a substantially different mix
// day-to-day while preserving a recognizable anchor of strong
// similarity matches.
//
// Sized to 100 to match Discover so the shuffle-on-play default
// (system playlists shuffle when launched from a tile) has a deep
// enough pool that re-plays within a day feel varied. Libraries with
// a thin For-You candidate pool degrade gracefully: pickHeadAndTail
// returns top-N-by-score when the capped pool can't support a full
// head/tail split, exactly as Discover returns fewer than 100 when
// its buckets are thin. Songs-like-X keeps simple pickTopN (the
// seed-artist context already frames the "you'll like this" promise).
const (
	forYouHeadN = 50
	forYouTailN = 50
)

// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
const forYouSeedCount = 3

// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
	if numSeeds <= 1 {
		return []int{headN}
	}
	fractions := []float64{0.5, 0.3, 0.2}
	if numSeeds == 2 {
		fractions = []float64{0.6, 0.4}
	}
	if numSeeds > len(fractions) {
		// Not reachable at forYouSeedCount = 3; even split keeps any
		// future seed-count change from silently starving seeds.
		fractions = make([]float64, numSeeds)
		for i := range fractions {
			fractions[i] = 1.0 / float64(numSeeds)
		}
	}
	quotas := make([]int, numSeeds)
	assigned := 0
	for i := 0; i < numSeeds; i++ {
		quotas[i] = int(float64(headN) * fractions[i])
		assigned += quotas[i]
	}
	quotas[0] += headN - assigned
	return quotas
}

// pickQuotaHead walks the score-sorted capped pool best-first, taking
// candidates whose originating seed still has head quota; slots a thin
// seed can't fill spill best-first regardless of seed in a second
// pass. A nil seedOf (single-seed path, tests) attributes everything
// to seed 0, which reduces to plain top-headN.
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
	quotas := headQuotas(numSeeds, headN)
	taken := make([]int, len(quotas))
	inHead := make(map[pgtype.UUID]bool, headN)
	head := make([]recommendation.Candidate, 0, headN)
	for _, c := range capped {
		if len(head) >= headN {
			break
		}
		si := seedOf[c.Track.ID]
		if si < 0 || si >= len(quotas) {
			si = 0
		}
		if taken[si] >= quotas[si] {
			continue
		}
		taken[si]++
		head = append(head, c)
		inHead[c.Track.ID] = true
	}
	for _, c := range capped {
		if len(head) >= headN {
			break
		}
		if inHead[c.Track.ID] {
			continue
		}
		head = append(head, c)
		inHead[c.Track.ID] = true
	}
	return head
}

// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
// candidate's sampling weight halves every 50 ranks, so the 101st-best
// candidate is far likelier than the 401st-best instead of equal —
// freshness keeps its "you'll probably enjoy this" half.
const forYouTailHalfLifeRanks = 50.0

// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
// for the weighted sample; 1e6 buckets is plenty of resolution for
// pools of a few hundred.
const tailSampleUniverse uint64 = 1_000_000

// pickWeightedTail samples up to tailN candidates from the rank-
// ordered tail pool with exponentially rank-decaying weights, using
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
// tieBreakHash): same (track, day) → same key, so the sample is stable
// within a day and rotates across days. Replaces the uniform daily
// draw where rank 380 had the same chance as rank 101.
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
	if len(tailPool) <= tailN {
		return tailPool
	}
	type keyed struct {
		c   recommendation.Candidate
		key float64
	}
	keys := make([]keyed, len(tailPool))
	for i, c := range tailPool {
		u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
			float64(tailSampleUniverse+1)
		w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
		keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
	}
	sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
	out := make([]recommendation.Candidate, tailN)
	for i := 0; i < tailN; i++ {
		out[i] = keys[i].c
	}
	return out
}

// scoreAndSortCandidates scores every candidate with recommendation.Score
// and returns a new slice sorted by score DESC (ties broken by
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
// deterministic per (user, day) but rotates across days. Pure — no
// truncation, no cap.
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time) []recommendation.Candidate {
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	type scored struct {
		c     recommendation.Candidate
		score float64
	}
	// Pin candidate order by track id before drawing jitter. The candidate
	// query (LoadRadioCandidatesV2) has ORDER BY random() arms and no
	// stable outer ordering, so DB row order varies call-to-call. Since
	// the i-th candidate gets the i-th seeded jitter draw, an unstable
	// input order would assign different jitter to the same track across
	// same-day rebuilds and reorder near-ties — breaking the daily
	// determinism this function promises. Sorting by id first makes the
	// jitter assignment a function of (track, day) alone.
	ordered := make([]recommendation.Candidate, len(cands))
	copy(ordered, cands)
	sort.SliceStable(ordered, func(i, j int) bool {
		return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
	})
	weights := currentSystemMixWeights()
	pairs := make([]scored, len(ordered))
	for i, c := range ordered {
		pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
	}
	sort.SliceStable(pairs, func(i, j int) bool {
		if pairs[i].score != pairs[j].score {
			return pairs[i].score > pairs[j].score
		}
		return tieBreakHash(pairs[i].c.Track.ID, dateStr) < tieBreakHash(pairs[j].c.Track.ID, dateStr)
	})
	out := make([]recommendation.Candidate, len(pairs))
	for i, p := range pairs {
		out[i] = p.c
	}
	return out
}

// capCandidatesByAlbumAndArtist trims a candidate list so no single
// album appears more than discoverMaxTracksPerAlbum times and no
// single artist appears more than discoverMaxTracksPerArtist times.
// Mirrors capByAlbumAndArtist (which operates on []discoverTrack);
// here the input/output is []recommendation.Candidate so For-You and
// Songs-like-X can apply the same diversity caps as Discover.
//
// Preserves input order. Reuses the discoverMax* constants —
// playlists across all three system variants get consistent
// diversity behavior.
func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommendation.Candidate {
	albumCount := map[pgtype.UUID]int{}
	artistCount := map[pgtype.UUID]int{}
	out := make([]recommendation.Candidate, 0, len(cands))
	for _, c := range cands {
		if albumCount[c.Track.AlbumID] >= discoverMaxTracksPerAlbum {
			continue
		}
		if artistCount[c.Track.ArtistID] >= discoverMaxTracksPerArtist {
			continue
		}
		albumCount[c.Track.AlbumID]++
		artistCount[c.Track.ArtistID]++
		out = append(out, c)
	}
	return out
}

// builtPlaylist is what a producer hands back: one playlist to
// materialize. A producer may return zero (no eligible tracks),
// one (For-You / Discover / each new kind), or many (Songs-like-X,
// one per seed artist) of these.
type builtPlaylist struct {
	Name         string
	Variant      string
	SeedArtistID pgtype.UUID // zero value for non-seeded kinds
	Tracks       []rankedCandidate
}

// systemPlaylistProducer computes the playlists for one kind for a
// given user/day. A returned error is fatal to the whole build
// (e.g. a required base query failed); per-item issues should be
// logged and yield fewer playlists, not an error.
type systemPlaylistProducer func(
	ctx context.Context, q *dbq.Queries, logger *slog.Logger,
	userID pgtype.UUID, dateStr string, now time.Time,
) ([]builtPlaylist, error)

type systemPlaylistKind struct {
	Key string
	// Singleton kinds produce exactly one playlist per user (For-You,
	// Discover, the discovery mixes) and so can be addressed by kind:
	// the generic /system/{kind}/{refresh,shuffle} endpoints + the
	// per-tile refresh affordance only apply to these. Non-singleton
	// kinds (songs_like_artist → up to 3 per user) are played by
	// playlist id and have no by-kind endpoint.
	Singleton bool
	Produce   systemPlaylistProducer
}

// RefreshableSystemKind reports whether `key` is a known singleton
// system-playlist kind — i.e. addressable by the generic by-kind
// refresh/shuffle endpoints and eligible for the per-tile refresh
// affordance. The API layer + clients use this so neither hardcodes
// the kind list.
func RefreshableSystemKind(key string) bool {
	for _, k := range systemPlaylistRegistry {
		if k.Key == key {
			return k.Singleton
		}
	}
	return false
}

// systemPlaylistRegistry is the single source of truth for which
// system playlists exist. BuildSystemPlaylists iterates it; the
// generic /api/playlists/system/{kind}/{refresh,shuffle} endpoints
// validate against it. Adding a new mix = a producer + one entry
// here (plus its candidate query). Order is the materialize order;
// it has no functional effect (atomic replace + per-playlist
// collage are order-independent).
var systemPlaylistRegistry = func() []systemPlaylistKind {
	out := []systemPlaylistKind{
		{Key: "for_you", Singleton: true, Produce: produceForYou},
		{Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes},
		{Key: "discover", Singleton: true, Produce: produceDiscover},
	}
	// The five discovery mixes share one Produce closure factory keyed
	// by a per-mix spec; spec list + factory live in system_mixes.go.
	for _, spec := range discoveryMixSpecs {
		out = append(out, systemPlaylistKind{
			Key:       spec.variant,
			Singleton: true,
			Produce:   produceDiscoveryMix(spec),
		})
	}
	return out
}()

// systemForYouSourceLimits is a deeper candidate pool than the radio
// default. On a self-hosted library without ListenBrainz similarity
// data the lb_similar / similar_artists sources contribute nothing,
// so the default (~130 raw, ~40 after dedup + diversity caps) can
// never fill For-You's 100-track head/tail. The raised random/tag
// fill keeps For-You ~100 deep regardless of LB enrichment; when LB
// data IS present the larger lb/similar K just makes it richer.
func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
	return recommendation.CandidateSourceLimits{
		LBSimilar:     80,
		SimilarArtist: 80,
		TagOverlap:    60,
		LikesOverlap:  40,
		RandomFill:    150,
		// For-You / You-might-like are the taste-driven surfaces, so pull a
		// deep slice of the user's top taste-profile artists into the pool
		// (#796 phase 2b). Empty for cold-start users (no profile yet).
		TasteOverlap: 80,
		// Household co-play (#1533): a deeper collaborative slice for the
		// taste surfaces. Empty on single-user servers.
		UserCoplay: 40,
	}
}

// produceForYou: blend candidate pools from up to forYouSeedCount of
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
// then head+tail composition with per-seed head quotas (#1269). The
// base seed query failing is fatal; a per-seed candidate-load failure
// is logged and that seed just contributes nothing.
func produceForYou(
	ctx context.Context, q *dbq.Queries, logger *slog.Logger,
	userID pgtype.UUID, dateStr string, now time.Time,
) ([]builtPlaylist, error) {
	forYouSeeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
	if err != nil {
		return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
	}
	seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
	if len(seeds) == 0 {
		return nil, nil
	}
	zeroVec := recommendation.SessionVector{Seed: true}
	// Merge per-seed pools; first-seen wins on dedup, and seedOf
	// remembers which seed sourced each track for the head quotas.
	var merged []recommendation.Candidate
	seedOf := map[pgtype.UUID]int{}
	for i, seed := range seeds {
		cands, cerr := recommendation.LoadCandidatesFromSimilarity(
			ctx, q, userID, seed,
			1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
			zeroVec,
			seeds,
			systemForYouSourceLimits(),
		)
		if cerr != nil {
			logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
				"user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
			continue
		}
		for _, c := range cands {
			if _, seen := seedOf[c.Track.ID]; seen {
				continue
			}
			seedOf[c.Track.ID] = i
			merged = append(merged, c)
		}
	}
	if len(merged) == 0 {
		return nil, nil
	}
	tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
	if len(tracks) == 0 {
		return nil, nil
	}
	return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
}

// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
// through widening engagement windows (#1255) and every returned row
// shares the winning tier, stamped onto the built tracks as their
// pick_kind. The base seed-artist query failing is fatal; per-artist
// failures are logged + skipped.
func produceSeedMixes(
	ctx context.Context, q *dbq.Queries, logger *slog.Logger,
	userID pgtype.UUID, dateStr string, now time.Time,
) ([]builtPlaylist, error) {
	seedRows, err := q.PickSeedArtists(ctx, userID)
	if err != nil {
		return nil, fmt.Errorf("pick seed artists: %w", err)
	}
	seedTierKind := ""
	if len(seedRows) > 0 {
		seedTierKind = pickKindForSeedTier(seedRows[0].Tier)
	}
	seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
	for _, r := range seedRows {
		seedRowsLocal = append(seedRowsLocal, seedArtistRow{
			ArtistID: r.ArtistID, Score: r.Score,
		})
	}
	seedPool := pickSeedArtistsFromRows(seedRowsLocal)
	seeds := pickSeedArtistsForDay(seedPool, userID, dateStr)

	out := make([]builtPlaylist, 0, len(seeds))
	for _, artistID := range seeds {
		artistRow, aerr := q.GetArtistByID(ctx, artistID)
		if aerr != nil {
			logger.Warn("system playlist: seed artist load failed; skipping",
				"artist_id", uuidStringPL(artistID), "err", aerr)
			continue
		}
		seedTrack, terr := q.PickTopPlayedTrackForArtistByUser(ctx, dbq.PickTopPlayedTrackForArtistByUserParams{
			UserID: userID, ArtistID: artistID,
		})
		if terr != nil || !seedTrack.Valid {
			continue
		}
		zeroVec := recommendation.SessionVector{Seed: true}
		cands, cerr := recommendation.LoadCandidatesFromSimilarity(
			ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
			recommendation.DefaultCandidateSourceLimits(),
		)
		if cerr != nil {
			logger.Warn("system playlist: seed candidates load failed; skipping",
				"artist_id", uuidStringPL(artistID), "err", cerr)
			continue
		}
		// "Songs like X" excludes X's own songs.
		filtered := make([]recommendation.Candidate, 0, len(cands))
		for _, c := range cands {
			if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
				filtered = append(filtered, c)
			}
		}
		tracks := pickTopN(filtered, userID, dateStr, now, systemMixLength)
		if len(tracks) == 0 {
			continue
		}
		for i := range tracks {
			tracks[i].PickKind = seedTierKind
		}
		out = append(out, builtPlaylist{
			Name:         fmt.Sprintf("Songs like %s", artistRow.Name),
			Variant:      "songs_like_artist",
			SeedArtistID: artistID,
			Tracks:       tracks,
		})
	}
	return out, nil
}

// produceDiscover: unheard tracks biased toward dormant artists.
// Bucket failures are handled inside buildDiscoverCandidates and
// redistribute as deficit; a returned error is non-fatal here
// (logged, yields no Discover) to match the prior behavior.
func produceDiscover(
	ctx context.Context, q *dbq.Queries, logger *slog.Logger,
	userID pgtype.UUID, dateStr string, _ time.Time,
) ([]builtPlaylist, error) {
	tracks, derr := buildDiscoverCandidates(ctx, q, logger, userID, dateStr)
	if derr != nil {
		logger.Warn("system playlist: discover candidates load failed; skipping",
			"user_id", uuidStringPL(userID), "err", derr)
		return nil, nil
	}
	if len(tracks) == 0 {
		return nil, nil
	}
	return []builtPlaylist{{Name: "Discover", Variant: "discover", Tracks: tracks}}, nil
}

// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
//
// Callable from both the cron loop (system_cron.go) and the lazy fallback.
// Returns nil when the user has no library activity to build from. Errors
// are captured in system_playlist_runs.last_error before returning.
func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, userID pgtype.UUID, now time.Time, dataDir string) error {
	q := dbq.New(pool)
	dateStr := now.UTC().Format("2006-01-02")
	dateOnly := pgtype.Date{Time: now.UTC(), Valid: true}

	// Concurrency guard: try to claim the run row.
	if _, err := q.TryClaimSystemPlaylistRun(ctx, userID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			logger.Debug("system playlist build: another run is in flight",
				"user_id", uuidStringPL(userID))
			return nil
		}
		return fmt.Errorf("claim run: %w", err)
	}

	// On any return path, mark the run finished. Failure capture is
	// best-effort; success is updated in the happy path.
	var buildErr error
	defer func() {
		if buildErr == nil {
			if err := q.FinishSystemPlaylistRun(ctx, dbq.FinishSystemPlaylistRunParams{
				UserID:      userID,
				LastRunAt:   pgtype.Timestamptz{Time: now.UTC(), Valid: true},
				LastRunDate: dateOnly,
			}); err != nil {
				logger.Warn("system playlist: failed to mark run finished; in_flight may be stuck",
					"user_id", uuidStringPL(userID), "err", err)
			}
		} else {
			errStr := buildErr.Error()
			if err := q.FailSystemPlaylistRun(ctx, dbq.FailSystemPlaylistRunParams{
				UserID:    userID,
				LastError: &errStr,
			}); err != nil {
				logger.Warn("system playlist: failed to record build failure",
					"user_id", uuidStringPL(userID), "err", err)
			}
		}
	}()

	// Run every registered system-playlist producer. Each returns the
	// playlists it wants materialized for this user/day (zero or more);
	// a returned error is fatal to the whole build (matches the prior
	// behavior where a seed-query failure aborted). Per-item issues are
	// logged inside the producer and just yield fewer playlists.
	var built []builtPlaylist
	for _, kind := range systemPlaylistRegistry {
		out, perr := kind.Produce(ctx, q, logger, userID, dateStr, now)
		if perr != nil {
			buildErr = fmt.Errorf("produce %s: %w", kind.Key, perr)
			return buildErr
		}
		built = append(built, out...)
	}

	// "You might like" Home rows reuse the same similarity engine but
	// persist to dedicated album/artist tables (not playlist_tracks).
	// Computed here (reads only) and atomic-replaced inside the tx below,
	// alongside the system playlists. A failed/gated computation is
	// handled by yml.built (leave prior rows vs. clear) — never fatal.
	yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now)

	// Atomic replace inside a transaction.
	tx, err := pool.Begin(ctx)
	if err != nil {
		buildErr = fmt.Errorf("begin tx: %w", err)
		return buildErr
	}
	defer func() { _ = tx.Rollback(ctx) }()
	qtx := dbq.New(tx)

	if err := qtx.DeleteSystemPlaylistsForUser(ctx, userID); err != nil {
		buildErr = fmt.Errorf("delete old system playlists: %w", err)
		return buildErr
	}

	// Track the playlist IDs we create so we can generate collage covers
	// for each one after the transaction commits. Collage generation
	// reads the playlist's tracks via a separate query that won't see
	// uncommitted rows, so the work happens post-commit.
	createdIDs := make([]pgtype.UUID, 0, len(built))

	for _, bp := range built {
		if len(bp.Tracks) == 0 {
			continue
		}
		id, err := insertSystemPlaylist(ctx, qtx, userID, bp.Name, bp.Variant,
			bp.SeedArtistID, bp.Tracks)
		if err != nil {
			buildErr = fmt.Errorf("insert %s (%s): %w", bp.Variant, bp.Name, err)
			return buildErr
		}
		createdIDs = append(createdIDs, id)
	}

	if yml.built {
		if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil {
			buildErr = fmt.Errorf("persist you-might-like: %w", err)
			return buildErr
		}
	}

	if err := tx.Commit(ctx); err != nil {
		buildErr = fmt.Errorf("commit tx: %w", err)
		return buildErr
	}

	// Post-commit: generate a 4-cell collage cover for each system
	// playlist from the contributing tracks' album covers. The collage
	// gracefully falls back to the FabledSword glyph in cells whose
	// album lacks cover art, so a partially-covered library still
	// produces a sensible visual rather than an empty placeholder.
	// Errors are non-fatal — the playlist is already committed; a
	// missing collage just leaves cover_path NULL until next build.
	if dataDir != "" {
		for _, plID := range createdIDs {
			if _, cerr := GenerateCollage(ctx, pool, plID, dataDir); cerr != nil {
				logger.Warn("system playlist: collage generation failed",
					"playlist_id", uuidStringPL(plID), "err", cerr)
			}
		}
	}
	return nil
}

// pickTopN sorts candidates by score DESC, applies per-album (<=2) /
// per-artist (<=3) diversity caps matching Discover's behavior, then
// truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
	sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
	capped := capCandidatesByAlbumAndArtist(sorted)
	if len(capped) > n {
		capped = capped[:n]
	}
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	weights := currentSystemMixWeights()
	out := make([]rankedCandidate, len(capped))
	for i, c := range capped {
		out[i] = rankedCandidate{
			TrackID: c.Track.ID,
			Score:   recommendation.Score(c.Inputs, weights, now, rng.Float64),
		}
	}
	return out
}

// pickHeadAndTail picks headN taste anchors from the score-sorted pool
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
// positions 2*headN onward. Caps applied before the head/tail split.
// Used by For-You only.
//
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
// related (every candidate passed the similarity filter) but isn't among
// the obvious top hits. Sampling from there gives the user variety they'll
// probably enjoy without resorting to genuinely random unrelated tracks.
//
// Falls back to standard pickTopN behavior when the candidate pool is too
// small to support a meaningful head/tail split (capped pool <=
// headN+tailN, or no candidates at or beyond position 2*headN).
func pickHeadAndTail(
	cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
	userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
) []rankedCandidate {
	sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
	capped := capCandidatesByAlbumAndArtist(sorted)
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	weights := currentSystemMixWeights()

	total := headN + tailN
	if len(capped) <= total {
		// Pool too small for a head/tail split — return up to total entries.
		// All marked taste: top-N-by-score IS the taste mechanism; no
		// exploration sampling happens on this path.
		if len(capped) < total {
			total = len(capped)
		}
		out := make([]rankedCandidate, total)
		for i := 0; i < total; i++ {
			out[i] = rankedCandidate{
				TrackID:  capped[i].Track.ID,
				Score:    recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
				PickKind: pickKindTaste,
			}
		}
		return out
	}

	head := pickQuotaHead(capped, seedOf, numSeeds, headN)
	inHead := make(map[pgtype.UUID]bool, len(head))
	for _, c := range head {
		inHead[c.Track.ID] = true
	}

	tailStart := 2 * headN
	if tailStart >= len(capped) {
		tailStart = headN
	}

	// The tail pool keeps its rank order (position drives the sampling
	// weight); head members are excluded — a quota walk can reach past
	// tailStart when a seed's candidates rank deep.
	tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
	for _, c := range capped[tailStart:] {
		if inHead[c.Track.ID] {
			continue
		}
		tailPool = append(tailPool, c)
	}
	tail := pickWeightedTail(tailPool, dateStr, tailN)

	// Combine: head first (score-sorted under quotas), then the fresh
	// sample. "First similar, then surprise" reads naturally in
	// playback. Head entries are the taste picks; tail entries are the
	// freshness injection (#1249) — the split the metrics page
	// attributes skips to.
	combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
	combined = append(combined, head...)
	combined = append(combined, tail...)

	out := make([]rankedCandidate, len(combined))
	for i, c := range combined {
		kind := pickKindTaste
		if i >= len(head) {
			kind = pickKindFresh
		}
		out[i] = rankedCandidate{
			TrackID:  c.Track.ID,
			Score:    recommendation.Score(c.Inputs, weights, now, rng.Float64),
			PickKind: kind,
		}
	}
	return out
}

// insertSystemPlaylist inserts a kind='system' playlists row plus its
// playlist_tracks via AppendPlaylistTrack (slice 1's existing query, which
// does the snapshot lookup from tracks/albums/artists). Then recomputes
// rollups. Returns the new playlist's ID so the caller can drive
// post-commit collage generation.
//
// Pass seed_artist_id with Valid=false (zero pgtype.UUID) for the For-You
// variant; the CreateSystemPlaylist query persists NULL.
//
// cover_path is always inserted as NULL — the post-commit collage step
// in BuildSystemPlaylists overrides it with a 4-cell composite of the
// contributing tracks' album art.
func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID,
	name, variant string, seedArtistID pgtype.UUID,
	tracks []rankedCandidate) (pgtype.UUID, error) {
	variantStr := variant
	p, err := qtx.CreateSystemPlaylist(ctx, dbq.CreateSystemPlaylistParams{
		UserID:        userID,
		Name:          name,
		SystemVariant: &variantStr,
		SeedArtistID:  seedArtistID,
		CoverPath:     nil,
	})
	if err != nil {
		return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
	}

	for _, t := range tracks {
		var pickKind *string
		if t.PickKind != "" {
			k := t.PickKind
			pickKind = &k
		}
		if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
			PlaylistID: p.ID,
			TrackID:    t.TrackID,
			PickKind:   pickKind,
		}); err != nil {
			// Track may have been deleted between candidate-load and insert;
			// skip silently rather than failing the whole build.
			if errors.Is(err, pgx.ErrNoRows) {
				continue
			}
			return pgtype.UUID{}, fmt.Errorf("append track %s: %w", uuidStringPL(t.TrackID), err)
		}
	}

	if err := qtx.UpdatePlaylistRollups(ctx, p.ID); err != nil {
		return pgtype.UUID{}, fmt.Errorf("update rollups: %w", err)
	}
	return p.ID, nil
}

// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
// (pgtype.UUID's String method exists on some pgx versions but not others;
// also the playlists package needs this independent of test helpers.)
// Suffix `PL` distinguishes it from any test helper named uuidString.
// uuidLessPL reports whether a sorts before b by raw 16-byte value. Used
// to pin candidate order deterministically before jitter assignment.
func uuidLessPL(a, b pgtype.UUID) bool {
	return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
}

func uuidStringPL(u pgtype.UUID) string {
	if !u.Valid {
		return "<nil>"
	}
	return fmt.Sprintf("%x-%x-%x-%x-%x",
		u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
