package recommendation

import (
	"context"
	"encoding/json"
	"time"

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

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

// LoadCandidates fetches the candidate pool for radio scoring. Combines
// the existing track+stats query with a one-shot bulk fetch of the user's
// active contextual_likes, mapping each candidate to its max similarity
// against currentVector. Pass currentVector with Seed=true to short-circuit
// the contextual term to 0 (cold-start path).
func LoadCandidates(
	ctx context.Context,
	q *dbq.Queries,
	userID, seedID pgtype.UUID,
	recentlyPlayedHours int,
	currentVector SessionVector,
) ([]Candidate, error) {
	rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
		UserID:  userID,
		ID:      seedID,
		Column3: float64(recentlyPlayedHours),
	})
	if err != nil {
		return nil, err
	}

	likes, err := loadContextualLikesByTrack(ctx, q, userID)
	if err != nil {
		return nil, err
	}

	profile, err := LoadTasteProfile(ctx, q, userID)
	if err != nil {
		return nil, err
	}

	affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
	if err != nil {
		return nil, err
	}

	out := make([]Candidate, 0, len(rows))
	for _, r := range rows {
		var lpt *time.Time
		if r.LastPlayedAt.Valid {
			t := r.LastPlayedAt.Time
			lpt = &t
		}
		ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
		out = append(out, Candidate{
			Track: r.Track,
			Inputs: ScoringInputs{
				IsGeneralLiked:       r.IsLiked,
				LastPlayedAt:         lpt,
				PlayCount:            int(r.PlayCount),
				SkipCount:            int(r.SkipCount),
				ContextualMatchScore: ctxScore,
				// Fallback path: mood is scored only in the primary
				// (similarity) loader — loading per-candidate tags over this
				// near-whole-library pool isn't worth it (nil moods → 0).
				TasteMatchScore:      profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
				ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
			},
		})
	}
	return out, nil
}

// CandidateSourceLimits controls per-source K values for the M4c
// similarity-driven pool. Defaults via DefaultCandidateSourceLimits().
type CandidateSourceLimits struct {
	LBSimilar     int
	SimilarArtist int
	TagOverlap    int
	LikesOverlap  int
	RandomFill    int
	// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
	// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
	// users have an empty profile, so it contributes nothing anyway).
	TasteOverlap int
	// UserCoplay (#1533): tracks by artists co-played across the instance
	// with the seed's artist (source='user_cooccurrence'). Empty on
	// single-user servers, so it contributes nothing there.
	UserCoplay int
}

// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
func DefaultCandidateSourceLimits() CandidateSourceLimits {
	return CandidateSourceLimits{
		LBSimilar:     30,
		SimilarArtist: 30,
		TagOverlap:    20,
		LikesOverlap:  20,
		RandomFill:    30,
		TasteOverlap:  20,
		UserCoplay:    20,
	}
}

// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
//
// Caller (radio handler) falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
	ctx context.Context,
	q *dbq.Queries,
	userID, seedID pgtype.UUID,
	recentlyPlayedHours int,
	currentVector SessionVector,
	exclude []pgtype.UUID,
	limits CandidateSourceLimits,
) ([]Candidate, error) {
	if exclude == nil {
		exclude = []pgtype.UUID{}
	}
	rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{
		UserID:  userID,
		ID:      seedID,
		Column3: int64(recentlyPlayedHours),
		Column4: exclude,
		Limit:   int32(limits.LBSimilar),
		Limit_2: int32(limits.SimilarArtist),
		Limit_3: int32(limits.TagOverlap),
		Limit_4: int32(limits.LikesOverlap),
		Limit_5: int32(limits.RandomFill),
		Limit_6: int32(limits.TasteOverlap),
		Limit_7: int32(limits.UserCoplay),
	})
	if err != nil {
		return nil, err
	}

	likes, err := loadContextualLikesByTrack(ctx, q, userID)
	if err != nil {
		return nil, err
	}

	profile, err := LoadTasteProfile(ctx, q, userID)
	if err != nil {
		return nil, err
	}

	affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
	if err != nil {
		return nil, err
	}

	trackIDs := make([]pgtype.UUID, 0, len(rows))
	for _, r := range rows {
		trackIDs = append(trackIDs, r.Track.ID)
	}
	moods, err := loadCandidateMoods(ctx, q, trackIDs)
	if err != nil {
		return nil, err
	}

	out := make([]Candidate, 0, len(rows))
	for _, r := range rows {
		var lpt *time.Time
		if r.LastPlayedAt.Valid {
			t := r.LastPlayedAt.Time
			lpt = &t
		}
		// sqlc returns SimilarityScore as interface{} (couldn't infer the
		// type through max(...) over a UNION). Type-assert; default to 0
		// on the (impossible-but-defensive) case where it's nil/wrong type.
		var simScore float64
		if v, ok := r.SimilarityScore.(float64); ok {
			simScore = v
		}
		ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
		out = append(out, Candidate{
			Track: r.Track,
			Inputs: ScoringInputs{
				IsGeneralLiked:       r.IsLiked,
				LastPlayedAt:         lpt,
				PlayCount:            int(r.PlayCount),
				SkipCount:            int(r.SkipCount),
				ContextualMatchScore: ctxScore,
				SimilarityScore:      simScore,
				TasteMatchScore: profile.Match(
					r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
				ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
			},
		})
	}
	return out, nil
}

// loadCandidateMoods fetches the enriched tags for the given candidate tracks
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
// are absent from the map (→ no mood signal). Empty input short-circuits.
func loadCandidateMoods(
	ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
) (map[pgtype.UUID][]string, error) {
	if len(trackIDs) == 0 {
		return map[pgtype.UUID][]string{}, nil
	}
	rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
	if err != nil {
		return nil, err
	}
	tagsByTrack := make(map[pgtype.UUID][]string)
	for _, r := range rows {
		tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
	}
	out := make(map[pgtype.UUID][]string, len(tagsByTrack))
	for id, tags := range tagsByTrack {
		if m := mood.Of(tags); len(m) > 0 {
			out[id] = m
		}
	}
	return out, nil
}

// loadContextualLikesByTrack fetches the user's active contextual_likes in
// one query and groups them by track_id. Rows whose session_vector fails
// to unmarshal are skipped with no error (don't poison scoring over one
// bad row); the SQL query already filters NULL vectors.
func loadContextualLikesByTrack(
	ctx context.Context,
	q *dbq.Queries,
	userID pgtype.UUID,
) (map[pgtype.UUID][]SessionVector, error) {
	rows, err := q.ListActiveContextualLikesForUser(ctx, userID)
	if err != nil {
		return nil, err
	}
	out := make(map[pgtype.UUID][]SessionVector, len(rows))
	for _, r := range rows {
		var v SessionVector
		if err := json.Unmarshal(r.SessionVector, &v); err != nil {
			continue
		}
		out[r.TrackID] = append(out[r.TrackID], v)
	}
	return out, nil
}
