// profile.go assembles the per-user taste profile from graded play
// engagement (decayed over time) plus explicit-like bonuses, and
// atomic-replaces the taste_profile_* tables. Phase 1 only builds the
// object; candidate sourcing + scoring consume it in phase 2.
package taste

import (
	"bytes"
	"context"
	"fmt"
	"log/slog"
	"sort"
	"strings"

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

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

// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
// starting values; wiring these into the server RecommendationConfig is a
// later follow-up (phase 1 uses the defaults).
type Config struct {
	Engagement EngagementParams

	// HalfLifeDays: a play's engagement halves every this-many days, so the
	// profile tracks drift (recent behaviour dominates). WindowDays bounds
	// the plays fetched — beyond ~3 half-lives the decayed contribution is
	// negligible, so older plays are skipped for cost, not correctness.
	HalfLifeDays float64
	WindowDays   float64

	// Explicit-like bonuses. Likes stay the strongest single positive.
	ArtistLikeBonus float64
	TrackLikeBonus  float64
	TagLikeBonus    float64

	// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
	// #1490) relative to a track's raw ID3 genre when both fold into the tag
	// facet. Each enriched tag contributes base × tag.weight × this scale,
	// where base is the play's decayed engagement (or the tag-like bonus for
	// a liked track) and tag.weight is the normalized folksonomy strength in
	// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
	// the ID3 genre signal without swamping it. 0 disables the enriched
	// contribution entirely (falls back to genre-only).
	EnrichedTagScale float64

	// EraScale weights the decade/era facet (#1530): each play adds
	// base × this scale to its album's decade bucket ("1990s"), where base
	// is the decayed engagement (or the tag-like bonus for a liked track).
	// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
	// as the raw engagement; 0 disables the era facet entirely. Tunable in the
	// lab (mirrors EnrichedTagScale, #1520).
	EraScale float64

	// MoodScale weights the mood facet (#1534): each play adds base × this
	// scale to the canonical mood buckets (melancholic / chill / …) derived
	// from its enriched folksonomy tags via internal/mood. Coverage is partial
	// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
	// the mood facet entirely. Tunable in the lab (mirrors EraScale).
	MoodScale float64

	// ArtistFloor / TagFloor clamp how negative a single entity's weight may
	// go. Aggregation already protects an artist the user likes (one skip
	// nets out against many good plays); the floor additionally bounds the
	// magnitude so a heavily-skipped entity can't dominate as an extreme
	// negative.
	ArtistFloor float64
	TagFloor    float64

	// WeightEpsilon: entities with |weight| below this are dropped as noise.
	WeightEpsilon float64

	// Size caps (guard rails for pathological libraries). Persist the top
	// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags
	// and eras. Real libraries span ~a dozen decades, so the era caps rarely
	// bind — they exist for symmetry with the other facets.
	MaxArtists    int
	MaxNegArtists int
	MaxTags       int
	MaxNegTags    int
	MaxEras       int
	MaxNegEras    int
	MaxMoods      int
	MaxNegMoods   int
}

// DefaultConfig returns the tuned starting configuration.
func DefaultConfig() Config {
	return Config{
		Engagement:       DefaultEngagementParams(),
		HalfLifeDays:     75,
		WindowDays:       270,
		ArtistLikeBonus:  3.0,
		TrackLikeBonus:   1.0,
		TagLikeBonus:     0.5,
		EnrichedTagScale: 0.5,
		EraScale:         0.5,
		MoodScale:        0.5,
		ArtistFloor:      -3.0,
		TagFloor:         -3.0,
		WeightEpsilon:    0.05,
		MaxArtists:       500,
		MaxNegArtists:    150,
		MaxTags:          200,
		MaxNegTags:       60,
		MaxEras:          30,
		MaxNegEras:       15,
		MaxMoods:         20,
		MaxNegMoods:      10,
	}
}

// BuildTasteProfile recomputes and atomic-replaces the user's taste profile.
// Reads the decay-windowed play engagement + explicit likes, accumulates
// signed artist/tag weights, applies floor damping + size caps, and persists.
// Self-contained transaction for the replace. Returns an error only on a
// fatal DB failure; the daily caller logs and proceeds to playlist building
// regardless (best-effort).
//
// The decay reference time is the database clock (age is computed in SQL via
// now()), so no Go-side timestamp is threaded here; a phase-3 context model
// that needs a pinned reference time would add one.
func BuildTasteProfile(
	ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
	userID pgtype.UUID, cfg Config,
) error {
	q := dbq.New(pool)

	artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
	if err != nil {
		return err
	}
	applyFloor(artistW, cfg.ArtistFloor)
	applyFloor(tagW, cfg.TagFloor)
	// Era + mood share the tag floor (all text-keyed facets; a decade or mood
	// that's been heavily skipped shouldn't dominate as an extreme negative).
	applyFloor(eraW, cfg.TagFloor)
	applyFloor(moodW, cfg.TagFloor)

	strLess := func(a, b string) bool { return a < b }
	artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
	tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
	eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
	moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)

	if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
		return err
	}
	logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
		"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
	return nil
}

// accumulate sums decayed play engagement and like bonuses into
// artist/tag/era/mood weight maps.
func accumulate(
	ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
	plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
		UserID:  userID,
		Column2: cfg.WindowDays,
	})
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
	}
	// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
	// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
	// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
	// play window; a track with no cached tags simply contributes genre only.
	playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
		UserID:  userID,
		Column2: int32(cfg.WindowDays),
	})
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
	}
	playedTags := groupTagsByTrack(playedTagRows)

	artistW := make(map[pgtype.UUID]float64)
	tagW := make(map[string]float64)
	eraW := make(map[string]float64)
	moodW := make(map[string]float64)
	for _, p := range plays {
		e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
		artistW[p.ArtistID] += e
		for _, tag := range splitGenres(p.Genre) {
			tagW[tag] += e
		}
		foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
		foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
		foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
	}

	likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
	}
	likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
	}
	likedTags := groupTagsByTrack(likedTagRows)
	for _, lt := range likedTracks {
		artistW[lt.ArtistID] += cfg.TrackLikeBonus
		for _, tag := range splitGenres(lt.Genre) {
			tagW[tag] += cfg.TagLikeBonus
		}
		foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
		foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
		foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
	}

	likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
	if err != nil {
		return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
	}
	for _, aid := range likedArtists {
		artistW[aid] += cfg.ArtistLikeBonus
	}
	return artistW, tagW, eraW, moodW, nil
}

// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
// each play/like can fold in its track's enriched tags in one lookup.
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
	m := make(map[pgtype.UUID][]dbq.TrackTag)
	for _, r := range rows {
		m[r.TrackID] = append(m[r.TrackID], r)
	}
	return m
}

// foldEnrichedTags adds each enriched tag to tagW weighted by
// base × tag.weight × scale — base is the play's decayed engagement or the
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
// disables the enriched contribution (genre-only fallback).
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
	if scale == 0 {
		return
	}
	for _, t := range tags {
		tagW[t.Tag] += base * t.Weight * scale
	}
}

// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
// dials the facet's strength. scale=0 disables the era facet; undated tracks
// (decade "") contribute nothing so they don't pollute the profile.
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
	if scale == 0 {
		return
	}
	if decade := decadeOf(d); decade != "" {
		eraW[decade] += base * scale
	}
}

// foldMoods adds a track's canonical mood buckets (derived from its enriched
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
// the facet's strength. scale=0 disables the mood facet; tracks with no
// mood-word tags contribute nothing.
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
	if scale == 0 {
		return
	}
	names := make([]string, len(tags))
	for i, t := range tags {
		names[i] = t.Tag
	}
	for _, m := range mood.Of(names) {
		moodW[m] += base * scale
	}
}

// decadeOf maps an album release date to a decade bucket like "1990s", or ""
// for an absent/invalid date. Mirrored by the recommendation scorer so a
// candidate's era is derived the same way it was learned.
func decadeOf(d pgtype.Date) string {
	if !d.Valid {
		return ""
	}
	return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}

// persist atomic-replaces the user's profile rows inside one transaction.
func persist(
	ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
	artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
) error {
	tx, err := pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("taste: begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback(ctx) }()
	qtx := dbq.New(tx)

	if err := qtx.DeleteTasteProfileArtistsForUser(ctx, userID); err != nil {
		return fmt.Errorf("taste: delete artists: %w", err)
	}
	for _, a := range artists {
		if err := qtx.InsertTasteProfileArtist(ctx, dbq.InsertTasteProfileArtistParams{
			UserID: userID, ArtistID: a.Key, Weight: a.Weight,
		}); err != nil {
			return fmt.Errorf("taste: insert artist: %w", err)
		}
	}
	if err := qtx.DeleteTasteProfileTagsForUser(ctx, userID); err != nil {
		return fmt.Errorf("taste: delete tags: %w", err)
	}
	for _, t := range tags {
		if err := qtx.InsertTasteProfileTag(ctx, dbq.InsertTasteProfileTagParams{
			UserID: userID, Tag: t.Key, Weight: t.Weight,
		}); err != nil {
			return fmt.Errorf("taste: insert tag: %w", err)
		}
	}
	if err := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
		return fmt.Errorf("taste: delete eras: %w", err)
	}
	for _, e := range eras {
		if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
			UserID: userID, Era: e.Key, Weight: e.Weight,
		}); err != nil {
			return fmt.Errorf("taste: insert era: %w", err)
		}
	}
	if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
		return fmt.Errorf("taste: delete moods: %w", err)
	}
	for _, m := range moods {
		if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
			UserID: userID, Mood: m.Key, Weight: m.Weight,
		}); err != nil {
			return fmt.Errorf("taste: insert mood: %w", err)
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return fmt.Errorf("taste: commit: %w", err)
	}
	return nil
}

// weighted is a key with its signed taste weight.
type weighted[K comparable] struct {
	Key    K
	Weight float64
}

// rankWeighted drops near-zero entries, sorts by weight DESC (ties broken by
// keyLess for determinism), and caps to the top topN plus the most-negative
// botN so both ends of the signal survive without unbounded table growth.
func rankWeighted[K comparable](
	m map[K]float64, eps float64, topN, botN int, keyLess func(a, b K) bool,
) []weighted[K] {
	all := make([]weighted[K], 0, len(m))
	for k, w := range m {
		if w >= eps || w <= -eps {
			all = append(all, weighted[K]{Key: k, Weight: w})
		}
	}
	sort.Slice(all, func(i, j int) bool {
		if all[i].Weight != all[j].Weight {
			return all[i].Weight > all[j].Weight
		}
		return keyLess(all[i].Key, all[j].Key)
	})
	if topN+botN >= len(all) {
		return all
	}
	out := make([]weighted[K], 0, topN+botN)
	out = append(out, all[:topN]...)
	out = append(out, all[len(all)-botN:]...)
	return out
}

// applyFloor clamps every weight in m up to floor (no entity goes more
// negative than the floor).
func applyFloor[K comparable](m map[K]float64, floor float64) {
	for k, w := range m {
		if w < floor {
			m[k] = floor
		}
	}
}

// uuidLess orders two UUIDs by raw bytes (deterministic tie-break).
func uuidLess(a, b pgtype.UUID) bool {
	return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
}

// uuidString renders a pgtype.UUID canonically for log lines.
func uuidString(u pgtype.UUID) string {
	if !u.Valid {
		return "<nil>"
	}
	return fmt.Sprintf("%x-%x-%x-%x-%x",
		u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}

// splitGenres splits a track's denormalized genre string on the common
// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors
// the recommendation package's splitter (kept local to avoid a dependency).
func splitGenres(genre *string) []string {
	if genre == nil || *genre == "" {
		return nil
	}
	parts := strings.FieldsFunc(*genre, func(r rune) bool {
		return r == ';' || r == ','
	})
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}
