// home.go is the M6a per-user composite home-page service. Reads five
// sections (recently added, rediscover albums, rediscover artists, most
// played tracks, last played artists) in parallel via goroutines and
// assembles them into a single payload for /api/home.
package recommendation

import (
	"context"
	"fmt"
	"sync"

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

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

// Section size caps. SPA splits these into rows on the page.
const (
	HomeRecentlyAddedLimit = 50
	// HomeRediscoverLimit is the OUTPUT count clients render. The SQL
	// query asks for rediscoverInnerLimit so the Go layer has headroom
	// to apply cross-section dedup (vs Most Played) and the diversity
	// cap (max 2 albums per artist) without undershooting.
	HomeRediscoverLimit = 10
	HomeMostPlayedLimit = 75
	HomeLastPlayedLimit = 25

	// rediscoverInnerLimit is the per-query cap; 3x HomeRediscoverLimit
	// gives enough slack that filtering rarely undershoots.
	rediscoverInnerLimit = 30

	// maxAlbumsPerArtistInRediscover prevents one liked-but-forgotten
	// artist from dominating the row. Two is enough variety; higher
	// reads as "this artist's discography" instead of "rediscover".
	maxAlbumsPerArtistInRediscover = 2

	// HomeYouMightLikeLimit is the rendered count for each You-might-like
	// row. The build persists a few more (youMightLikeAlbumsN/ArtistsN)
	// so the read-time cross-section dedup has headroom; this query asks
	// for the persisted depth and the Go layer trims to this after dedup.
	HomeYouMightLikeLimit = 10
	youMightLikeFetch     = 12
)

// HomePayload is the composite returned by HomeData. All slices are
// non-nil at return time so the API handler can encode `[]` rather than
// `null` for empty sections.
type HomePayload struct {
	RecentlyAddedAlbums []dbq.ListRecentlyAddedAlbumsWithArtistRow
	RediscoverAlbums    []dbq.ListRediscoverAlbumsForUserRow
	RediscoverArtists   []dbq.ListRediscoverArtistsForUserRow
	MostPlayedTracks    []dbq.ListMostPlayedTracksForUserRow
	LastPlayedArtists   []dbq.ListLastPlayedArtistsForUserRow
	YouMightLikeAlbums  []dbq.ListYouMightLikeAlbumsForUserRow
	YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow
}

// HomeData runs five queries in parallel and assembles the payload.
// Rediscover sections fan out to inner-limit results (track-derived +
// explicit-like eligibility, plus a daily-stable random fallback when
// the eligibility query is sparse), then the Go layer trims to
// HomeRediscoverLimit after applying cross-section dedup (vs Most
// Played) and the per-artist diversity cap. Any single query error
// fails the whole call (predictable empty-or-full UX for the SPA —
// partial payloads are not a feature).
func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*HomePayload, error) {
	q := dbq.New(pool)

	var (
		wg       sync.WaitGroup
		mu       sync.Mutex
		firstErr error
	)
	fail := func(stage string, err error) {
		mu.Lock()
		defer mu.Unlock()
		if firstErr == nil {
			firstErr = fmt.Errorf("home: %s: %w", stage, err)
		}
	}

	out := &HomePayload{
		RecentlyAddedAlbums: []dbq.ListRecentlyAddedAlbumsWithArtistRow{},
		RediscoverAlbums:    []dbq.ListRediscoverAlbumsForUserRow{},
		RediscoverArtists:   []dbq.ListRediscoverArtistsForUserRow{},
		MostPlayedTracks:    []dbq.ListMostPlayedTracksForUserRow{},
		LastPlayedArtists:   []dbq.ListLastPlayedArtistsForUserRow{},
		YouMightLikeAlbums:  []dbq.ListYouMightLikeAlbumsForUserRow{},
		YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{},
	}

	wg.Add(7)
	go func() {
		defer wg.Done()
		rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
		if err != nil {
			fail("recently_added", err)
			return
		}
		out.RecentlyAddedAlbums = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := q.ListMostPlayedTracksForUser(ctx, dbq.ListMostPlayedTracksForUserParams{
			UserID: userID, Limit: HomeMostPlayedLimit,
		})
		if err != nil {
			fail("most_played", err)
			return
		}
		out.MostPlayedTracks = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := q.ListLastPlayedArtistsForUser(ctx, dbq.ListLastPlayedArtistsForUserParams{
			UserID: userID, Limit: HomeLastPlayedLimit,
		})
		if err != nil {
			fail("last_played", err)
			return
		}
		out.LastPlayedArtists = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := loadRediscoverAlbums(ctx, q, userID)
		if err != nil {
			fail("rediscover_albums", err)
			return
		}
		out.RediscoverAlbums = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := loadRediscoverArtists(ctx, q, userID)
		if err != nil {
			fail("rediscover_artists", err)
			return
		}
		out.RediscoverArtists = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := q.ListYouMightLikeAlbumsForUser(ctx, dbq.ListYouMightLikeAlbumsForUserParams{
			UserID: userID, Limit: youMightLikeFetch,
		})
		if err != nil {
			fail("you_might_like_albums", err)
			return
		}
		out.YouMightLikeAlbums = rows
	}()
	go func() {
		defer wg.Done()
		rows, err := q.ListYouMightLikeArtistsForUser(ctx, dbq.ListYouMightLikeArtistsForUserParams{
			UserID: userID, Limit: youMightLikeFetch,
		})
		if err != nil {
			fail("you_might_like_artists", err)
			return
		}
		out.YouMightLikeArtists = rows
	}()
	wg.Wait()

	if firstErr != nil {
		return nil, firstErr
	}

	// Cross-section dedup + diversity cap, applied after the parallel
	// fan-out so the SQL stays simple and reusable. Both filters trim
	// the rediscover lists down toward HomeRediscoverLimit.
	out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
	out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks)
	// You-might-like dedups against what the user already engages with
	// (Most Played) and against the other Home rows so a tile doesn't
	// appear twice. Trims to HomeYouMightLikeLimit after dedup.
	out.YouMightLikeAlbums = applyYouMightLikeAlbumFilters(
		out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums)
	out.YouMightLikeArtists = applyYouMightLikeArtistFilters(
		out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists)
	// The taste roll-up surfaces top-similar entities, which for a heavy
	// listener are mostly ones they already play — so the dedup above can
	// leave the section thin (often a single artist). Top up from the much
	// larger pool of liked entities, applying the same exclusions so the
	// fallback neither duplicates a tile nor suggests an actively-played one.
	if len(out.YouMightLikeArtists) < HomeYouMightLikeLimit {
		out.YouMightLikeArtists = topUpYouMightLikeArtists(ctx, q, userID, out)
	}
	if len(out.YouMightLikeAlbums) < HomeYouMightLikeLimit {
		out.YouMightLikeAlbums = topUpYouMightLikeAlbums(ctx, q, userID, out)
	}
	return out, nil
}

// youMightLikeFallbackFetch is the liked-entity pool the fallback draws from.
// Generous so the dedup still leaves enough to fill the row.
const youMightLikeFallbackFetch = 100

// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
// artists the user has shown affinity for — explicit artist-likes PLUS the
// artists of liked albums and liked tracks (most users like albums/tracks far
// more than artists, so the broad pool is what actually fills the row).
// Excludes what's already shown on Home (the section itself, Rediscover,
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
// actively-played artist. Best-effort: a query error leaves the section as-is.
func topUpYouMightLikeArtists(
	ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
) []dbq.ListYouMightLikeArtistsForUserRow {
	excluded := mostPlayedArtistIDs(out.MostPlayedTracks)
	for _, r := range out.RediscoverArtists {
		excluded[r.Artist.ID] = struct{}{}
	}
	for _, r := range out.LastPlayedArtists {
		excluded[r.Artist.ID] = struct{}{}
	}
	result := out.YouMightLikeArtists
	for _, r := range result {
		excluded[r.Artist.ID] = struct{}{}
	}
	fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
		UserID: userID, Limit: youMightLikeFallbackFetch,
	})
	if err != nil {
		return result
	}
	for _, r := range fb {
		if _, dup := excluded[r.Artist.ID]; dup {
			continue
		}
		result = append(result, dbq.ListYouMightLikeArtistsForUserRow(r))
		if len(result) >= HomeYouMightLikeLimit {
			break
		}
	}
	return result
}

// topUpYouMightLikeAlbums is the album-row counterpart to
// topUpYouMightLikeArtists, drawing from the user's liked albums.
func topUpYouMightLikeAlbums(
	ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
) []dbq.ListYouMightLikeAlbumsForUserRow {
	excluded := mostPlayedAlbumIDs(out.MostPlayedTracks)
	for _, r := range out.RediscoverAlbums {
		excluded[r.Album.ID] = struct{}{}
	}
	result := out.YouMightLikeAlbums
	for _, r := range result {
		excluded[r.Album.ID] = struct{}{}
	}
	fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
		UserID: userID, Limit: youMightLikeFallbackFetch,
	})
	if err != nil {
		return result
	}
	for _, r := range fb {
		if _, dup := excluded[r.Album.ID]; dup {
			continue
		}
		result = append(result, dbq.ListYouMightLikeAlbumsForUserRow(r))
		if len(result) >= HomeYouMightLikeLimit {
			break
		}
	}
	return result
}

// applyYouMightLikeAlbumFilters drops albums the user actively plays
// (Most Played) or that already appear in Rediscover, then trims to
// HomeYouMightLikeLimit. Build-time rank order is preserved.
func applyYouMightLikeAlbumFilters(
	rows []dbq.ListYouMightLikeAlbumsForUserRow,
	mostPlayed []dbq.ListMostPlayedTracksForUserRow,
	rediscover []dbq.ListRediscoverAlbumsForUserRow,
) []dbq.ListYouMightLikeAlbumsForUserRow {
	excluded := mostPlayedAlbumIDs(mostPlayed)
	for _, r := range rediscover {
		excluded[r.Album.ID] = struct{}{}
	}
	out := make([]dbq.ListYouMightLikeAlbumsForUserRow, 0, HomeYouMightLikeLimit)
	for _, r := range rows {
		if _, dup := excluded[r.Album.ID]; dup {
			continue
		}
		out = append(out, r)
		if len(out) >= HomeYouMightLikeLimit {
			break
		}
	}
	return out
}

// applyYouMightLikeArtistFilters drops artists the user actively plays
// (Most Played), recently played (Last Played), or that already appear in
// Rediscover, then trims to HomeYouMightLikeLimit.
func applyYouMightLikeArtistFilters(
	rows []dbq.ListYouMightLikeArtistsForUserRow,
	mostPlayed []dbq.ListMostPlayedTracksForUserRow,
	rediscover []dbq.ListRediscoverArtistsForUserRow,
	lastPlayed []dbq.ListLastPlayedArtistsForUserRow,
) []dbq.ListYouMightLikeArtistsForUserRow {
	excluded := mostPlayedArtistIDs(mostPlayed)
	for _, r := range rediscover {
		excluded[r.Artist.ID] = struct{}{}
	}
	for _, r := range lastPlayed {
		excluded[r.Artist.ID] = struct{}{}
	}
	out := make([]dbq.ListYouMightLikeArtistsForUserRow, 0, HomeYouMightLikeLimit)
	for _, r := range rows {
		if _, dup := excluded[r.Artist.ID]; dup {
			continue
		}
		out = append(out, r)
		if len(out) >= HomeYouMightLikeLimit {
			break
		}
	}
	return out
}

// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear
// in the Most Played row. Used to dedup Rediscover so it doesn't list
// albums the user is actively spinning.
func mostPlayedAlbumIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} {
	out := make(map[pgtype.UUID]struct{}, len(rows))
	for _, r := range rows {
		out[r.Track.AlbumID] = struct{}{}
	}
	return out
}

// mostPlayedArtistIDs returns the set of artist IDs whose tracks appear
// in the Most Played row.
func mostPlayedArtistIDs(rows []dbq.ListMostPlayedTracksForUserRow) map[pgtype.UUID]struct{} {
	out := make(map[pgtype.UUID]struct{}, len(rows))
	for _, r := range rows {
		out[r.Track.ArtistID] = struct{}{}
	}
	return out
}

// applyRediscoverAlbumFilters trims the inner-limit query result down
// to HomeRediscoverLimit via:
//  1. Cross-section dedup vs Most Played (drop albums actively played).
//  2. Diversity cap: at most maxAlbumsPerArtistInRediscover per artist.
//  3. Final trim to HomeRediscoverLimit.
//
// Input order is preserved (daily-stable hash from the SQL), so the
// row stays stable for the day even after filtering.
func applyRediscoverAlbumFilters(
	rows []dbq.ListRediscoverAlbumsForUserRow,
	mostPlayed []dbq.ListMostPlayedTracksForUserRow,
) []dbq.ListRediscoverAlbumsForUserRow {
	excludedAlbums := mostPlayedAlbumIDs(mostPlayed)
	perArtist := make(map[pgtype.UUID]int, len(rows))
	out := make([]dbq.ListRediscoverAlbumsForUserRow, 0, HomeRediscoverLimit)
	for _, r := range rows {
		if _, dup := excludedAlbums[r.Album.ID]; dup {
			continue
		}
		if perArtist[r.Album.ArtistID] >= maxAlbumsPerArtistInRediscover {
			continue
		}
		perArtist[r.Album.ArtistID]++
		out = append(out, r)
		if len(out) >= HomeRediscoverLimit {
			break
		}
	}
	return out
}

// applyRediscoverArtistFilters trims the artist list via cross-section
// dedup vs Most Played, then truncates to HomeRediscoverLimit.
// No diversity cap needed (one row per artist by construction).
func applyRediscoverArtistFilters(
	rows []dbq.ListRediscoverArtistsForUserRow,
	mostPlayed []dbq.ListMostPlayedTracksForUserRow,
) []dbq.ListRediscoverArtistsForUserRow {
	excludedArtists := mostPlayedArtistIDs(mostPlayed)
	out := make([]dbq.ListRediscoverArtistsForUserRow, 0, HomeRediscoverLimit)
	for _, r := range rows {
		if _, dup := excludedArtists[r.Artist.ID]; dup {
			continue
		}
		out = append(out, r)
		if len(out) >= HomeRediscoverLimit {
			break
		}
	}
	return out
}

// loadRediscoverAlbums runs the primary eligibility query (track-
// derived + explicit album-likes UNION'd) and tops up with the
// fallback (random sample of any liked albums) if the primary returns
// fewer than rediscoverInnerLimit. Both use the same daily-stable
// hash ordering. The Go layer (applyRediscoverAlbumFilters) trims to
// HomeRediscoverLimit after cross-section dedup + diversity cap.
func loadRediscoverAlbums(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverAlbumsForUserRow, error) {
	primary, err := q.ListRediscoverAlbumsForUser(ctx, dbq.ListRediscoverAlbumsForUserParams{
		UserID: userID, Limit: rediscoverInnerLimit,
	})
	if err != nil {
		return nil, err
	}
	if len(primary) >= rediscoverInnerLimit {
		return primary, nil
	}
	fallback, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
		UserID: userID, Limit: rediscoverInnerLimit,
	})
	if err != nil {
		return nil, err
	}
	seen := make(map[pgtype.UUID]struct{}, len(primary))
	for _, r := range primary {
		seen[r.Album.ID] = struct{}{}
	}
	for _, r := range fallback {
		if _, dup := seen[r.Album.ID]; dup {
			continue
		}
		primary = append(primary, dbq.ListRediscoverAlbumsForUserRow(r))
		if len(primary) >= rediscoverInnerLimit {
			break
		}
		seen[r.Album.ID] = struct{}{}
	}
	return primary, nil
}

func loadRediscoverArtists(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) ([]dbq.ListRediscoverArtistsForUserRow, error) {
	primary, err := q.ListRediscoverArtistsForUser(ctx, dbq.ListRediscoverArtistsForUserParams{
		UserID: userID, Limit: rediscoverInnerLimit,
	})
	if err != nil {
		return nil, err
	}
	if len(primary) >= rediscoverInnerLimit {
		return primary, nil
	}
	fallback, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
		UserID: userID, Limit: rediscoverInnerLimit,
	})
	if err != nil {
		return nil, err
	}
	seen := make(map[pgtype.UUID]struct{}, len(primary))
	for _, r := range primary {
		seen[r.Artist.ID] = struct{}{}
	}
	for _, r := range fallback {
		if _, dup := seen[r.Artist.ID]; dup {
			continue
		}
		primary = append(primary, dbq.ListRediscoverArtistsForUserRow(r))
		if len(primary) >= rediscoverInnerLimit {
			break
		}
		seen[r.Artist.ID] = struct{}{}
	}
	return primary, nil
}
