// suggestions.go is the per-user artist-suggestion service behind the
// Discover request surface. Seeds from the taste profile (falling back to
// likes + completed plays for a user who has none yet), projects those seeds
// through artist_similarity_unmatched via a single CTE, and returns top-N
// out-of-library candidates with top-3 attribution seeds resolved to names.
//
// Originally M5c, which seeded from raw likes + plays. That signal grew
// without bound and counted skips as engagement, so a few heavily-played
// artists monopolized every slot and the surface entrenched harder the more
// the user listened. Reworked to seed from the taste profile in issue #2367;
// see internal/db/queries/recommendation.sql for the tiering.
package recommendation

import (
	"context"
	"crypto/md5"
	"encoding/hex"
	"fmt"
	"sort"
	"time"

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

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

// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds.
type ArtistSuggestion struct {
	MBID        string
	Name        string
	Score       float64
	Attribution []SeedContribution
	// MatchedTags are the candidate's own tags that overlap the user's taste
	// profile, strongest first (max 3) — the "matches: shoegaze, melancholic"
	// explanation (#2377). Empty when the candidate has no cached tags, which
	// is common and not an error: coverage is permanently partial (#2376).
	MatchedTags []string
	// TagOverlap is the [0,1] share of the candidate's tag mass the user likes.
	// Exposed for the admin tuning lab — seeing the term's actual distribution
	// is how the operator picks a weight rather than guessing at one.
	TagOverlap float64
}

// SeedContribution is one of the top-3 contributing seeds for a candidate.
type SeedContribution struct {
	ArtistID     pgtype.UUID
	Name         string
	Contribution float64
	IsLiked      bool
	PlayCount    int64
}

// SuggestArtists returns top-N artist suggestions for the user. limit is
// capped at 50 (default 12 when out of range).
//
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
// seed path only — the likes + completed-plays fallback used while the user
// has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying.
func SuggestArtists(
	ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
	halfLifeDays float64, limit int, tagOverlapWeight float64,
) ([]ArtistSuggestion, error) {
	if limit <= 0 || limit > 50 {
		limit = 12
	}
	if halfLifeDays <= 0 {
		halfLifeDays = 30
	}
	q := dbq.New(pool)
	// Over-fetch so there is something to rotate through. A deterministic
	// top-N over a score ordering shows the same faces every day until one is
	// requested away — the tail of the ranking was unreachable (#2367
	// mechanism 1). Selection from this pool happens in selectSuggestions.
	rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
		UserID:  userID,
		Column2: halfLifeDays,
		Limit:   int32(poolSizeFor(limit)),
	})
	if err != nil {
		return nil, fmt.Errorf("suggest: query: %w", err)
	}
	if len(rows) == 0 {
		return []ArtistSuggestion{}, nil
	}

	// Collect the union of top-3 seed IDs across all rows for one batched
	// name lookup. pgtype.UUID is a comparable struct so it works as a map
	// key directly.
	seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3)
	for _, r := range rows {
		for _, sid := range r.TopSeedIds {
			seedSet[sid] = struct{}{}
		}
	}
	seedIDs := make([]pgtype.UUID, 0, len(seedSet))
	for id := range seedSet {
		seedIDs = append(seedIDs, id)
	}
	artists, err := q.GetArtistsByIDs(ctx, seedIDs)
	if err != nil {
		return nil, fmt.Errorf("suggest: resolve seeds: %w", err)
	}
	nameByID := make(map[pgtype.UUID]string, len(artists))
	for _, a := range artists {
		nameByID[a.ID] = a.Name
	}

	out := make([]ArtistSuggestion, 0, len(rows))
	for _, r := range rows {
		attribution := make([]SeedContribution, 0, len(r.TopSeedIds))
		for i, sid := range r.TopSeedIds {
			if i >= len(r.TopContributions) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) {
				break
			}
			attribution = append(attribution, SeedContribution{
				ArtistID:     sid,
				Name:         nameByID[sid],
				Contribution: r.TopContributions[i],
				IsLiked:      r.TopIsLiked[i],
				PlayCount:    r.TopPlayCounts[i],
			})
		}
		out = append(out, ArtistSuggestion{
			MBID:        r.CandidateMbid,
			Name:        r.CandidateName,
			Score:       r.TotalScore,
			Attribution: attribution,
		})
	}

	// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
	// selection, so the rotation and diversity rules in selectSuggestions
	// operate on taste-blended scores — boosting only the twelve already
	// chosen by similarity would leave the actual re-ranking undone.
	//
	// A query error here is returned, NOT degraded past. Graceful degradation
	// is for expected absence — no taste profile yet, no cached tags for a
	// candidate — and both of those are handled explicitly as empty inputs
	// below. A failing query is neither: swallowing it would hide a broken DB
	// behind a subtly worse ranking that nothing reports.
	tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out)
	if err != nil {
		return nil, err
	}
	out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight)

	return selectSuggestions(out, limit, rotationDay(time.Now())), nil
}

// tasteTagLimit caps how many of the user's taste tags participate. The
// profile's long tail is near-zero weight and contributes nothing after
// normalization, so this bounds the query rather than the meaning.
const tasteTagLimit = 50

// loadTagInputs fetches both sides of the overlap comparison: the user's taste
// tags and the cached tags for exactly the candidates in this pool.
func loadTagInputs(
	ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion,
) (TagWeights, map[string]TagWeights, error) {
	tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
		UserID: userID,
		Limit:  tasteTagLimit,
	})
	if err != nil {
		return nil, nil, fmt.Errorf("suggest: taste tags: %w", err)
	}
	// No taste tags is a cold start, not a failure — return early and skip the
	// candidate-tag fetch entirely, since nothing could match.
	if len(tasteRows) == 0 {
		return nil, nil, nil
	}
	taste := make(TagWeights, len(tasteRows))
	for _, r := range tasteRows {
		taste[r.Tag] = r.Weight
	}

	mbids := make([]string, 0, len(pool))
	for _, s := range pool {
		mbids = append(mbids, s.MBID)
	}
	tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids)
	if err != nil {
		return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err)
	}
	byCandidate := make(map[string]TagWeights, len(pool))
	for _, r := range tagRows {
		if byCandidate[r.CandidateMbid] == nil {
			byCandidate[r.CandidateMbid] = TagWeights{}
		}
		byCandidate[r.CandidateMbid][r.Tag] = r.Weight
	}
	return taste, byCandidate, nil
}

// Pool multiplier: how many scored candidates to fetch per slot shown, so the
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
// from yesterday's without pulling the whole long tail (whose scores are noise)
// into every request.
const suggestionPoolFactor = 4

// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
// paying for it would buy churn rather than better suggestions.
const suggestionPoolMax = 60

func poolSizeFor(limit int) int {
	n := limit * suggestionPoolFactor
	if n > suggestionPoolMax {
		return suggestionPoolMax
	}
	return n
}

// rotationDay is the bucket the daily rotation hashes against. Server-local
// date, matching the `current_date` the Home rows already rotate on, so the
// whole product turns over at the same moment.
func rotationDay(now time.Time) string {
	return now.Format("2006-01-02")
}

// selectSuggestions turns the scored pool into the response.
//
// Pure by design — no DB, no clock — so the rotation and diversity rules are
// unit-testable without Postgres. Callers pass the day bucket in.
//
// Three rules, in order:
//
//  1. Diversity cap. Walking in score order, a candidate is dropped once its
//     dominant seed already owns maxPerSeed slots. Nothing capped this before,
//     so all twelve slots could be neighbours of one artist and the surface
//     read as a single narrow cluster (#2367 mechanism 4).
//  2. Head. The best few by score always lead, so the strongest matches are
//     never rotated out of sight. Mirrors For You's head/tail shape.
//  3. Tail. The rest of the slots are drawn from the remaining pool ordered by
//     md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
//     within a day, different tomorrow, and it needs no stored state.
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
	if len(pool) <= limit {
		return pool
	}
	preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))

	headCount := limit / 3
	if headCount > len(preferred) {
		headCount = len(preferred)
	}
	out := make([]ArtistSuggestion, 0, limit)
	out = append(out, preferred[:headCount]...)

	// Hash once per candidate, not once per comparison.
	rest := preferred[headCount:]
	tail := make([]rotatable, 0, len(rest))
	for _, s := range rest {
		tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
	}
	sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
	for _, r := range tail {
		if len(out) >= limit {
			break
		}
		out = append(out, r.suggestion)
	}
	// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
	// whose whole pool hangs off one or two seeds would otherwise get a
	// three-card surface, which is worse than the monoculture we were avoiding
	// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
	// in score order from what the cap set aside.
	for _, s := range overflow {
		if len(out) >= limit {
			break
		}
		out = append(out, s)
	}
	return out
}

// rotatable pairs a candidate with its precomputed daily rotation key.
type rotatable struct {
	key        string
	suggestion ArtistSuggestion
}

// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
// seed artist — enough for a strong affinity to be well represented, not enough
// for it to BE the deck. Floored at 1 so a small limit still returns something.
func maxPerSeedFor(limit int) int {
	n := limit / 4
	if n < 1 {
		return 1
	}
	return n
}

// partitionPerSeed splits the pool by whether each candidate's dominant
// (highest-contributing) seed still has allowance left. Input must be in score
// order; both outputs preserve it.
//
// Candidates with no attribution are always preferred — there is no seed to
// attribute them to, so they cannot be the cause of a monoculture.
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
	perSeed := make(map[pgtype.UUID]int, len(pool))
	preferred = make([]ArtistSuggestion, 0, len(pool))
	for _, s := range pool {
		if len(s.Attribution) == 0 {
			preferred = append(preferred, s)
			continue
		}
		dominant := s.Attribution[0].ArtistID
		if perSeed[dominant] >= maxPerSeed {
			overflow = append(overflow, s)
			continue
		}
		perSeed[dominant]++
		preferred = append(preferred, s)
	}
	return preferred, overflow
}

func rotationKey(mbid, day string) string {
	sum := md5.Sum([]byte(mbid + day))
	return hex.EncodeToString(sum[:])
}
