package explore

import (
	"context"
	"database/sql"
	"math/rand/v2"
)

// mixBatchSize is how many tracks one GenerateMix call returns.
const mixBatchSize = 30

// mixGenreBoost is added to a candidate's similarity weight for each
// genre it shares with the seed, biasing the pick toward tracks that
// match on both artist and tag rather than artist alone.
const mixGenreBoost = 0.5

// mixSimilarArtistsPerSeed caps how many similar artists are expanded
// per distinct seed artist, so a seed with an unusually long tail in
// similar_artist_map doesn't turn one fallback trigger into hundreds
// of queries.
const mixSimilarArtistsPerSeed = 15

// mixSession is a dynamic mix in progress: the seed it was built from
// (fixed for the life of the session, so successive batches don't
// drift away from what the session started as) and what it has
// already handed out, so a batch doesn't repeat a track that just
// played.
type mixSession struct {
	seedPaths []string
	played    map[string]bool
}

// GenerateMix returns the next batch of tracks for a dynamic-mix queue
// fallback, built by expanding the seed's artists to their similar
// artists (weighted by how often each appears in the seed, sharpened
// by shared genre tags) and restricting candidates to what is actually
// in the library — a queue can only play files that exist.
//
// continuing extends the current mix session — regenerating from its
// original seed rather than seedPaths — instead of starting a fresh
// one. Pass false whenever the queue that just exhausted was not
// itself a mix batch (a real selection just ran out); pass true when
// it was (the mix keeps going indefinitely). label names the batch
// after its most-represented seed artist, for the "Playing from" UI.
func (e *Service) GenerateMix(
	ctx context.Context,
	seedPaths []string,
	continuing bool,
) (paths []string, label string, err error) {
	e.mixMu.Lock()
	defer e.mixMu.Unlock()

	if !continuing || e.mix == nil {
		e.mix = &mixSession{seedPaths: seedPaths, played: map[string]bool{}}
	}

	seed := e.mix.seedPaths
	if len(seed) == 0 {
		return nil, "", nil
	}

	artistCounts, topArtistName, genres := e.mixSeedProfile(ctx, seed)
	if len(artistCounts) == 0 {
		return nil, "", nil
	}

	candidates := e.mixCandidates(ctx, artistCounts, genres, seed, e.mix.played)

	// The session has played through everything this seed can offer —
	// rather than dead-ending an "indefinite" mix, start handing out
	// repeats.
	if len(candidates) == 0 && len(e.mix.played) > 0 {
		candidates = e.mixCandidates(ctx, artistCounts, genres, seed, nil)
	}

	if len(candidates) == 0 {
		return nil, "", nil
	}

	picked := weightedSample(candidates, mixBatchSize)
	for _, p := range picked {
		e.mix.played[p] = true
	}

	if topArtistName != "" {
		label = "a mix inspired by " + topArtistName
	} else {
		label = "a dynamic mix"
	}

	return picked, label, nil
}

// mixSeedProfile tallies the seed's artists by frequency, its genre
// tags, and names the most-represented artist for the UI label.
func (e *Service) mixSeedProfile(
	ctx context.Context,
	seedPaths []string,
) (artistCounts map[string]int, topArtistName string, genres map[string]bool) {
	artistCounts = map[string]int{}
	artistNames := map[string]string{}
	genres = map[string]bool{}

	for _, p := range seedPaths {
		artist, err := e.db.ReadQueries.GetArtistByFilePath(ctx, p)
		if err == nil && artist.ArtistMbid != "" {
			artistCounts[artist.ArtistMbid]++
			artistNames[artist.ArtistMbid] = artist.ArtistName
		}
	}

	for _, names := range e.genresByPath(ctx, seedPaths) {
		for _, g := range names {
			genres[g] = true
		}
	}

	var topCount int

	for mbid, count := range artistCounts {
		if count > topCount {
			topCount = count
			topArtistName = artistNames[mbid]
		}
	}

	return artistCounts, topArtistName, genres
}

// mixCandidates builds the weighted pool of library tracks to draw a
// batch from: every owned track by a similar artist, weighted by that
// artist's similarity score times how often the seed artist it came
// from appears in the seed, boosted for a shared genre tag, excluding
// the seed itself and anything already excluded (typically what the
// mix has already played).
func (e *Service) mixCandidates(
	ctx context.Context,
	artistCounts map[string]int,
	seedGenres map[string]bool,
	seedPaths []string,
	exclude map[string]bool,
) map[string]float64 {
	excludeSeed := make(map[string]bool, len(seedPaths))
	for _, p := range seedPaths {
		excludeSeed[p] = true
	}

	candidates := map[string]float64{}

	// Every candidate path's genres, in one query.  This used to be a
	// single-row lookup *per candidate* inside two nested loops -
	// twenty seed artists by twenty similar artists by thirty paths is
	// twelve thousand queries to assemble one mix.
	pathGenres := e.genresByPath(ctx, e.similarArtistPaths(ctx, artistCounts))

	for seedArtistMBID, count := range artistCounts {
		similar, err := e.SimilarArtists(seedArtistMBID)
		if err != nil {
			continue
		}

		if len(similar) > mixSimilarArtistsPerSeed {
			similar = similar[:mixSimilarArtistsPerSeed]
		}

		for _, s := range similar {
			if s.ArtistMBID == "" {
				continue
			}

			paths, err := e.db.ReadQueries.GetFilePathsByArtistMBID(
				ctx,
				sql.NullString{String: s.ArtistMBID, Valid: true},
			)
			if err != nil {
				continue
			}

			weight := s.Score * float64(count)

			for _, p := range paths {
				if excludeSeed[p] || exclude[p] {
					continue
				}

				for _, g := range pathGenres[p] {
					if seedGenres[g] {
						weight += mixGenreBoost

						break
					}
				}

				candidates[p] += weight
			}
		}
	}

	return candidates
}

// genresByPath returns the genres of many files in one query.
func (e *Service) genresByPath(
	ctx context.Context, paths []string,
) map[string][]string {
	out := make(map[string][]string, len(paths))

	if len(paths) == 0 {
		return out
	}

	rows, err := e.db.ReadQueries.GetGenreNamesByFilePaths(ctx, paths)
	if err != nil {
		return out
	}

	for _, row := range rows {
		out[row.FilePath] = append(out[row.FilePath], row.Name)
	}

	return out
}

// similarArtistPaths collects every owned file by an artist similar to
// one of the seeds, so their genres can be fetched in one go.
func (e *Service) similarArtistPaths(
	ctx context.Context, artistCounts map[string]int,
) []string {
	var paths []string

	for seedArtistMBID := range artistCounts {
		similar, err := e.SimilarArtists(seedArtistMBID)
		if err != nil {
			continue
		}

		if len(similar) > mixSimilarArtistsPerSeed {
			similar = similar[:mixSimilarArtistsPerSeed]
		}

		for _, sim := range similar {
			if sim.ArtistMBID == "" {
				continue
			}

			p, err := e.db.ReadQueries.GetFilePathsByArtistMBID(
				ctx, sql.NullString{String: sim.ArtistMBID, Valid: true},
			)
			if err != nil {
				continue
			}

			paths = append(paths, p...)
		}
	}

	return paths
}

// weightedSample picks up to n distinct keys from weights without
// replacement, biased toward higher weight (roulette-wheel selection).
// A key with zero or negative weight is never picked.
func weightedSample(weights map[string]float64, n int) []string {
	type entry struct {
		key    string
		weight float64
	}

	pool := make([]entry, 0, len(weights))

	var total float64

	for k, w := range weights {
		if w <= 0 {
			continue
		}

		pool = append(pool, entry{k, w})
		total += w
	}

	picked := make([]string, 0, min(n, len(pool)))

	for len(picked) < n && len(pool) > 0 {
		r := rand.Float64() * total
		idx := 0

		for i, e := range pool {
			r -= e.weight
			if r <= 0 {
				idx = i

				break
			}
		}

		picked = append(picked, pool[idx].key)
		total -= pool[idx].weight
		pool = append(pool[:idx], pool[idx+1:]...)
	}

	return picked
}
