package recommendation

import (
	"context"
	"math"

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

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

// Taste-match tuning. The taste profile (written by internal/taste) holds
// signed, unbounded artist/tag weights; these scales squash them into a
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
// rest toward zero (as a per-user max-normalisation would). A weight at the
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
const (
	tasteArtistScale  = 4.0
	tasteTagScale     = 3.0
	tasteArtistShare  = 0.7
	tasteTagShare     = 0.3
	tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
)

// TasteProfile is the read-side view of a user's learned taste: signed
// weights over artists and genre tags. The zero value (and any unknown
// artist/tag) contributes 0, so cold-start users get no taste effect.
type TasteProfile struct {
	artists map[pgtype.UUID]float64
	tags    map[string]float64
}

// LoadTasteProfile reads the user's taste profile from the taste_profile_*
// tables (written daily by internal/taste). Returns an empty profile with no
// error when the user has none.
func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (TasteProfile, error) {
	arts, err := q.ListTasteProfileArtistsForUser(ctx, dbq.ListTasteProfileArtistsForUserParams{
		UserID: userID, Limit: tasteProfileLimit,
	})
	if err != nil {
		return TasteProfile{}, err
	}
	tags, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
		UserID: userID, Limit: tasteProfileLimit,
	})
	if err != nil {
		return TasteProfile{}, err
	}
	p := TasteProfile{
		artists: make(map[pgtype.UUID]float64, len(arts)),
		tags:    make(map[string]float64, len(tags)),
	}
	for _, a := range arts {
		p.artists[a.ArtistID] = a.Weight
	}
	for _, t := range tags {
		p.tags[t.Tag] = t.Weight
	}
	return p, nil
}

// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
// the artist's taste weight and the average of its genre tags' weights, each
// tanh-squashed. Absent artist/tags contribute 0.
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
	a := math.Tanh(p.artists[artistID] / tasteArtistScale)

	var tg float64
	if genre != nil {
		tags := splitGenres(*genre)
		if len(tags) > 0 {
			var sum float64
			for _, t := range tags {
				sum += p.tags[t]
			}
			tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
		}
	}
	return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
}

// clampUnit constrains x to [-1, 1].
func clampUnit(x float64) float64 {
	if x < -1 {
		return -1
	}
	if x > 1 {
		return 1
	}
	return x
}
