package playlists

import (
	"context"
	"log/slog"
	"math/rand"
	"time"

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

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

// Discovery mixes (#419-423). One generic producer + per-mix spec.
// Replaces the five near-identical produceXxx functions that all
// fetched ranked (id, album_id, artist_id) rows, diversified,
// truncated, and emitted a single playlist.
//
// Day-keying is a per-mix property captured by `dailyRotate`:
//
//   - DeepCuts / OnThisDay — SQL already day-keys via
//     ORDER BY md5(t.id::text || $2::text), so the Go producer keeps
//     SQL order. `dailyRotate: false`.
//
//   - Rediscover / NewForYou / FirstListens — SQL accepts only $1
//     user_id and produces deterministic ordering. `dailyRotate:
//     true` applies a daily-deterministic rotate-left of the pool
//     BEFORE diversify+truncate so each day's top-100 surfaces a
//     different slice while contiguous-block ordering within each
//     slice is preserved (matters for FirstListens / NewForYou which
//     are album-coherent — rotation walks the album boundary cleanly
//     rather than scrambling within an album).
//
// Diversity is `true` for every mix: per-album <= 2 / per-artist <= 3.
// On thin libraries where the cap would chop the pool below 100,
// finishMix tops up from the uncapped raw pool so the mix still
// ships a full-length playlist — see topUpFromRaw.

// discoveryMixLen caps each mix at the same depth as For-You /
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100

// Minimum viable mix sizes (issue #1246): below the floor the variant
// is withheld entirely so Home renders its "listen more to unlock"
// placeholder — a playlist with a couple of songs reads as a build
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
// a lower floor because one legitimate new album (~5+ tracks) is a
// useful mix on its own; the scattered mixes need more to feel real.
const (
	discoveryMixMinLen      = 15
	discoveryMixMinLenAlbum = 5
)

// discoveryMixSpec describes one discovery mix. The unified producer
// reads the spec and runs a single code path for all variants.
type discoveryMixSpec struct {
	name      string
	variant   string
	diversify bool

	// dailyRotate, when true, applies a daily-deterministic offset
	// rotation to the candidate pool BEFORE diversify+truncate so the
	// top discoveryMixLen rotates day-over-day. Set on variants whose
	// SQL ORDER is invariant to dateStr (Rediscover, FirstListens).
	// Leave false when the SQL already day-keys (DeepCuts, OnThisDay)
	// or when day-over-day stability is the intended UX (NewForYou).
	dailyRotate bool

	// minLen is the minimum viable mix size: a finished pool below it
	// is withheld (no playlist row) so the client renders the locked
	// placeholder instead of a mix that looks built-wrong (#1246).
	minLen int

	// fetch returns the raw ranked rows. dateStr is supplied for
	// queries that accept it (passed as the second positional arg
	// historically); queries that don't accept it ignore the param.
	fetch func(context.Context, *dbq.Queries, pgtype.UUID, string) ([]discoverTrack, error)
}

// produceDiscoveryMix returns a systemPlaylistKind.Produce closure
// bound to the given spec. Registered in systemPlaylistRegistry; see
// the discoveryMixSpecs slice below for the concrete instances.
func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
	return func(
		ctx context.Context, q *dbq.Queries, logger *slog.Logger,
		userID pgtype.UUID, dateStr string, _ time.Time,
	) ([]builtPlaylist, error) {
		rows, err := spec.fetch(ctx, q, userID, dateStr)
		if err != nil {
			logger.Warn("system playlist: "+spec.variant+" query failed; skipping",
				"user_id", uuidStringPL(userID), "err", err)
			return nil, nil
		}
		pool := rows
		if spec.dailyRotate {
			pool = rotateForDay(pool, userID, dateStr)
		}
		tracks := finishMix(pool, spec.diversify)
		if len(tracks) < spec.minLen {
			logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding",
				"user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen)
			return nil, nil
		}
		return emit(spec.name, spec.variant, tracks), nil
	}
}

// rotateForDay rotates the pool left by a daily-deterministic offset
// so each day's downstream truncate-to-N surfaces a different slice
// while contiguous-block ordering inside the slice is preserved.
//
// Rotation happens WITHIN each contiguous same-pick-kind block
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
// rotation would hoist tier-3 filler above tier-1's exact fits.
// Untiered pools are a single block, which reduces to the original
// whole-pool rotation (same seed, same first draw). Empty /
// single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
	n := len(pool)
	if n <= 1 {
		return pool
	}
	rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
	out := make([]discoverTrack, 0, n)
	for start := 0; start < n; {
		end := start + 1
		for end < n && pool[end].PickKind == pool[start].PickKind {
			end++
		}
		block := pool[start:end]
		if len(block) > 1 {
			offset := rng.Intn(len(block))
			out = append(out, block[offset:]...)
			out = append(out, block[:offset]...)
		} else {
			out = append(out, block...)
		}
		start = end
	}
	return out
}

// discoveryMixSpecs is the concrete spec list used by the registry in
// system.go. Adding a new mix = one entry here + the candidate query.
//
// Order has no functional effect (insert-time atomic replace is order
// independent); listed in the same order as the historical registry.
var discoveryMixSpecs = []discoveryMixSpec{
	{
		name: "Deep Cuts", variant: "deep_cuts",
		diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
		minLen: discoveryMixMinLen,
		fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
			rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
				UserID: uid, Column2: ds,
			})
			if err != nil {
				return nil, err
			}
			out := make([]discoverTrack, len(rows))
			for i, r := range rows {
				out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
			}
			return out, nil
		},
	},
	{
		name: "Rediscover", variant: "rediscover",
		diversify: true, dailyRotate: true, // SQL has no date arg
		minLen: discoveryMixMinLen,
		fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
			rows, err := q.ListRediscoverTracks(ctx, uid)
			if err != nil {
				return nil, err
			}
			out := make([]discoverTrack, len(rows))
			for i, r := range rows {
				out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
			}
			return out, nil
		},
	},
	{
		name: "New for you", variant: "new_for_you",
		diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
		minLen: discoveryMixMinLenAlbum,
		fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
			rows, err := q.ListNewForYouTracks(ctx, uid)
			if err != nil {
				return nil, err
			}
			out := make([]discoverTrack, len(rows))
			for i, r := range rows {
				out[i] = discoverTrack{
					ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
					PickKind: pickKindForMixTier(r.Tier),
				}
			}
			return out, nil
		},
	},
	{
		name: "On this day", variant: "on_this_day",
		diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
		minLen: discoveryMixMinLen,
		fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
			rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
				UserID: uid, Column2: ds,
			})
			if err != nil {
				return nil, err
			}
			out := make([]discoverTrack, len(rows))
			for i, r := range rows {
				out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
			}
			return out, nil
		},
	},
	{
		name: "First listens", variant: "first_listens",
		diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
		minLen: discoveryMixMinLenAlbum,
		fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
			rows, err := q.ListFirstListensTracks(ctx, uid)
			if err != nil {
				return nil, err
			}
			out := make([]discoverTrack, len(rows))
			for i, r := range rows {
				out[i] = discoverTrack{
					ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
					PickKind: pickKindForMixTier(r.Tier),
				}
			}
			return out, nil
		},
	},
}

// finishMix applies diversity caps (per-album <= 2 / per-artist <= 3)
// when diversify is set, with a top-up fallback when caps strip the
// pool below discoveryMixLen: the capped result is filled out with
// non-capped tracks (preserving original SQL order) until the target
// is hit or the raw pool runs out.
//
// The fallback matters on small / album-heavy libraries — the cap
// can chop a 200-row pool down to 40, and we'd rather ship a partly-
// diversified 100 than a strictly-diversified 40. On rich libraries
// the cap yields >= 100 and the top-up path never runs.
func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
	var pool []discoverTrack
	if diversify {
		capped := capByAlbumAndArtist(rows)
		if len(capped) >= discoveryMixLen {
			pool = capped[:discoveryMixLen]
		} else {
			pool = topUpFromRaw(capped, rows, discoveryMixLen)
		}
	} else {
		pool = rows
		if len(pool) > discoveryMixLen {
			pool = pool[:discoveryMixLen]
		}
	}
	if len(pool) == 0 {
		return nil
	}
	tracks := make([]rankedCandidate, len(pool))
	for i, t := range pool {
		tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
	}
	return tracks
}

// topUpFromRaw appends non-capped tracks from raw (in their original
// order) onto capped, skipping any already present, until the result
// reaches target or raw is exhausted. Preserves SQL ranking semantics
// for the non-diverse fill so the topped-up tail still trends best-
// first within each album.
func topUpFromRaw(capped, raw []discoverTrack, target int) []discoverTrack {
	if len(capped) >= target {
		return capped[:target]
	}
	seen := make(map[pgtype.UUID]struct{}, len(capped))
	for _, t := range capped {
		seen[t.ID] = struct{}{}
	}
	pool := capped
	for _, t := range raw {
		if _, in := seen[t.ID]; in {
			continue
		}
		pool = append(pool, t)
		if len(pool) >= target {
			break
		}
	}
	return pool
}

// emit wraps the finished track list in a single builtPlaylist (the
// discovery mixes are all singletons). nil tracks → no playlist.
func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist {
	if len(tracks) == 0 {
		return nil
	}
	return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}}
}
