// 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"
)

// 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

	// 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.
	MaxArtists    int
	MaxNegArtists int
	MaxTags       int
	MaxNegTags    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,
		ArtistFloor:     -3.0,
		TagFloor:        -3.0,
		WeightEpsilon:   0.05,
		MaxArtists:      500,
		MaxNegArtists:   150,
		MaxTags:         200,
		MaxNegTags:      60,
	}
}

// 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, err := accumulate(ctx, q, userID, cfg)
	if err != nil {
		return err
	}
	applyFloor(artistW, cfg.ArtistFloor)
	applyFloor(tagW, cfg.TagFloor)

	artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
	tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
		func(a, b string) bool { return a < b })

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

// accumulate sums decayed play engagement and like bonuses into artist/tag
// weight maps.
func accumulate(
	ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
) (map[pgtype.UUID]float64, map[string]float64, error) {
	plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
		UserID:  userID,
		Column2: cfg.WindowDays,
	})
	if err != nil {
		return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
	}
	artistW := make(map[pgtype.UUID]float64)
	tagW := 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
		}
	}

	likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
	if err != nil {
		return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
	}
	for _, lt := range likedTracks {
		artistW[lt.ArtistID] += cfg.TrackLikeBonus
		for _, tag := range splitGenres(lt.Genre) {
			tagW[tag] += cfg.TagLikeBonus
		}
	}

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

// 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 []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 := 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
}
