// you_might_like.go builds the per-user "You might like" Home rows
// (#790): in-library albums/artists the listener doesn't actively spin
// but is predicted to enjoy. It reuses the For-You candidate engine
// (similarity + like-weighted scoring) and rolls the per-track scores up
// to album/artist granularity, gated on a minimum listening history so a
// near-empty profile ships nothing rather than noise.
//
// Built in the same daily BuildSystemPlaylists pass and atomic-replaced
// alongside the system playlists; read back by /api/home.
package playlists

import (
	"context"
	"fmt"
	"log/slog"
	"math/rand"
	"sort"
	"time"

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

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

const (
	// Cold-start gate. Below this much *real* listening the similarity
	// roll-up degrades toward random fill, so You-might-like ships
	// nothing rather than arbitrary tiles. Both bars must clear: distinct
	// tracks rules out "played 3 songs on repeat," distinct artists rules
	// out "hammered one album." Conservative defaults; raise if early
	// rows feel random on thin libraries.
	youMightLikeMinDistinctTracks  = 20
	youMightLikeMinDistinctArtists = 5

	// Persisted depth — a few more than the rendered HomeYouMightLikeLimit
	// so the read-time cross-section dedup (vs Most Played / Rediscover)
	// has headroom before trimming.
	youMightLikeAlbumsN  = 12
	youMightLikeArtistsN = 12

	// One strongly-matched artist shouldn't fill the albums row.
	youMightLikeMaxAlbumsPerArtist = 2

	// Aggregation: sum of an entity's top-K track scores. Favors albums/
	// artists with several good matches over a single outlier track.
	youMightLikeAggTopK = 3
)

// youMightLikeResult carries one day's roll-up. built=false means the
// computation failed and the caller must leave any existing rows intact;
// built=true with nil slices means "explicitly empty" (cold-start gate or
// no candidates) and the caller should clear the user's rows.
type youMightLikeResult struct {
	albumIDs  []pgtype.UUID
	artistIDs []pgtype.UUID
	built     bool
}

// buildYouMightLike computes the user's daily album/artist rolls. Reads
// only — the caller persists inside the build tx. Never returns an error:
// a failure logs and yields built=false so the prior day's rows survive.
func buildYouMightLike(
	ctx context.Context, q *dbq.Queries, logger *slog.Logger,
	userID pgtype.UUID, dateStr string, now time.Time,
) youMightLikeResult {
	signal, err := q.CountListeningSignalForUser(ctx, userID)
	if err != nil {
		logger.Warn("you-might-like: listening-signal query failed; skipping",
			"user_id", uuidStringPL(userID), "err", err)
		return youMightLikeResult{built: false}
	}
	if signal.DistinctTracks < youMightLikeMinDistinctTracks ||
		signal.DistinctArtists < youMightLikeMinDistinctArtists {
		// Cold start: not enough listening to recommend from. built=true
		// clears any stale rows (normally none) and ships an empty row.
		return youMightLikeResult{built: true}
	}

	seeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
	if err != nil {
		logger.Warn("you-might-like: seed query failed; skipping",
			"user_id", uuidStringPL(userID), "err", err)
		return youMightLikeResult{built: false}
	}
	// One rotating seed is right here (unlike For-You's multi-seed
	// blend, #1269): the row is a short shelf, not a mix, and a single
	// neighborhood per day keeps it coherent.
	daily := pickDailySeeds(seeds, userID, dateStr, 1)
	if len(daily) == 0 {
		return youMightLikeResult{built: true}
	}
	seed := daily[0]

	zeroVec := recommendation.SessionVector{Seed: true}
	// You-might-like surfaces in-library artists the user does NOT actively
	// engage with, so it deliberately skips the taste_overlap arm — that arm
	// pulls top-taste (mostly already-played) artists, which would crowd the
	// pool with entities the read-time dedup then strips. For-You/radio keep it.
	ymlLimits := systemForYouSourceLimits()
	ymlLimits.TasteOverlap = 0
	cands, err := recommendation.LoadCandidatesFromSimilarity(
		ctx, q, userID, seed, 1, zeroVec,
		[]pgtype.UUID{seed}, ymlLimits,
	)
	if err != nil {
		logger.Warn("you-might-like: candidate load failed; skipping",
			"user_id", uuidStringPL(userID), "err", err)
		return youMightLikeResult{built: false}
	}

	albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now)
	return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true}
}

// scoredEntity is an album or artist with its aggregated score.
type scoredEntity struct {
	id    pgtype.UUID
	score float64
}

// rollUpCandidates scores every candidate track (systemMixWeights, jitter
// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the
// scores up to album and artist via sum-of-top-K. Returns the top-N album
// and artist IDs, with a per-artist cap on the album list.
func rollUpCandidates(
	cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
) (albumIDs, artistIDs []pgtype.UUID) {
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	weights := currentSystemMixWeights()
	albumScores := map[pgtype.UUID][]float64{}
	artistScores := map[pgtype.UUID][]float64{}
	albumArtist := map[pgtype.UUID]pgtype.UUID{}
	for _, c := range cands {
		s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
		if c.Track.AlbumID.Valid {
			albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
			albumArtist[c.Track.AlbumID] = c.Track.ArtistID
		}
		if c.Track.ArtistID.Valid {
			artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s)
		}
	}

	rankedAlbums := capAlbumsPerArtist(
		rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist)
	albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN)
	artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN)
	return albumIDs, artistIDs
}

// rankEntities aggregates each entity's track scores (sum-of-top-K) and
// returns them sorted by score DESC, ties broken deterministically by
// tieBreakHash(id, dateStr) so ordering is stable within a day.
func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity {
	out := make([]scoredEntity, 0, len(scoresByEntity))
	for id, scores := range scoresByEntity {
		out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)})
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].score != out[j].score {
			return out[i].score > out[j].score
		}
		return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr)
	})
	return out
}

// sumTopK sorts scores DESC and sums the highest k.
func sumTopK(scores []float64, k int) float64 {
	sort.Sort(sort.Reverse(sort.Float64Slice(scores)))
	sum := 0.0
	for i := 0; i < len(scores) && i < k; i++ {
		sum += scores[i]
	}
	return sum
}

// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist,
// preserving input (score) order.
func capAlbumsPerArtist(
	albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int,
) []scoredEntity {
	perArtist := map[pgtype.UUID]int{}
	out := make([]scoredEntity, 0, len(albums))
	for _, a := range albums {
		art := albumArtist[a.id]
		if art.Valid {
			if perArtist[art] >= maxPerArtist {
				continue
			}
			perArtist[art]++
		}
		out = append(out, a)
	}
	return out
}

// topNEntityIDs truncates to n and projects the IDs in rank order.
func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID {
	if len(entities) > n {
		entities = entities[:n]
	}
	out := make([]pgtype.UUID, 0, len(entities))
	for _, e := range entities {
		out = append(out, e.id)
	}
	return out
}

// persistYouMightLike atomic-replaces the user's You-might-like rows
// inside the build tx. Called only when the roll-up was freshly built;
// a failed computation leaves the prior rows untouched.
func persistYouMightLike(
	ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult,
) error {
	if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil {
		return fmt.Errorf("delete you-might-like albums: %w", err)
	}
	for i, id := range r.albumIDs {
		if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{
			UserID: userID, AlbumID: id, Rank: int32(i),
		}); err != nil {
			return fmt.Errorf("insert you-might-like album: %w", err)
		}
	}
	if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil {
		return fmt.Errorf("delete you-might-like artists: %w", err)
	}
	for i, id := range r.artistIDs {
		if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{
			UserID: userID, ArtistID: id, Rank: int32(i),
		}); err != nil {
			return fmt.Errorf("insert you-might-like artist: %w", err)
		}
	}
	return nil
}
