// Package home builds the "start listening" shelves on the home page.
//
// The problem the home page solves is not "show the library" — four
// other views already do that, sorted and complete. It is the opposite:
// a complete, sorted library is exactly what gives you nothing to play,
// because every entry point into it is alphabetical and therefore
// identical every time you open the app.
//
// So a shelf here is a *reason*, not a filter. Each one answers a
// different question the user might be asking when they do not know
// what they want — what was I listening to, what is new, what do I keep
// coming back to, what have I forgotten, what fits the mood, what would
// I never pick myself — and each says which question it answered, since
// a row of covers with no explanation is just another grid.
//
// Two consequences of that framing show up throughout:
//
//   - Shelves are built from what the user actually did (play counts,
//     last played, import order) with random sampling only where there
//     is no signal to use. Randomness is the fallback, not the design.
//   - A shelf with nothing behind it is omitted rather than rendered
//     empty. A fresh library has no history, so its home page is
//     legitimately three shelves, and lying about that with empty rows
//     labelled "on repeat" would be worse than showing fewer.
package home

import (
	"context"
	"log/slog"
	"math/rand/v2"
	"strings"

	"yellowjacket/backend/database"
	"yellowjacket/backend/database/sql/sqlcgen"
	"yellowjacket/backend/library"
)

// Kind identifies what a shelf is built from, so the frontend can pick
// an icon and the e2e suite can assert on a shelf without matching
// display copy.
type Kind string

// Shelf kinds.
const (
	KindRecentlyPlayed Kind = "recently-played"
	KindRecentlyAdded  Kind = "recently-added"
	KindMostPlayed     Kind = "most-played"
	KindUnplayed       Kind = "unplayed"
	KindStale          Kind = "stale"
	KindArtist         Kind = "artist"
	KindGenre          Kind = "genre"
	KindRandom         Kind = "random"
)

// Shelf is one horizontal row on the home page.
type Shelf struct {
	// ID is stable within a response, for list keying.
	ID string `json:"id"`

	Kind Kind `json:"kind"`

	// Title is the row heading.
	Title string `json:"title"`

	// Subtitle says why these albums are here.  It is not decoration:
	// without it a shelf is indistinguishable from a random grid.
	Subtitle string `json:"subtitle"`

	Albums []library.Album `json:"albums"`
}

// Shelf sizing.
const (
	// shelfSize is how many albums one row holds.  Wide enough to be
	// worth scrolling, small enough that every shelf is a considered
	// selection rather than a dump of the library.
	shelfSize = 12

	// maxGenreShelves bounds how many genre rows appear, so a
	// heavily-tagged library does not turn the home page into the
	// genres view.
	maxGenreShelves = 2

	// genreCandidates is how many top genres to draw the genre shelves
	// from.  Sampling from a pool rather than taking the top two is
	// what stops the same two genres appearing forever.
	genreCandidates = 8

	// staleWindow is how long an album must go unplayed to count as
	// forgotten.  Six months is past "I listened to that recently" for
	// almost everyone without reaching back to things they no longer
	// own.
	staleWindow = "-6 months"

	// artistShelfMin is the fewest albums by one artist worth a shelf
	// of their own.
	artistShelfMin = 3
)

// Library is the album data the shelves are rendered from.  Narrow on
// purpose: the home page needs one list of albums, not the library
// package.
type Library interface {
	GetAlbums(libraryID int64) ([]library.Album, error)
}

// Service builds the home page's shelves.
type Service struct {
	logger *slog.Logger
	db     *database.DB
	lib    Library
}

// NewService builds the home service.
func NewService(
	logger *slog.Logger,
	db *database.DB,
	lib Library,
) *Service {
	return &Service{logger: logger, db: db, lib: lib}
}

// GetShelves returns the home page's rows, in display order.
//
// It is a single call rather than one per shelf because the shelves
// share an album lookup and because the page has nothing useful to
// render until it knows which rows exist — a page that pops rows in one
// at a time reflows under the user's cursor.
func (s *Service) GetShelves() ([]Shelf, error) {
	ctx := s.db.Ctx
	if ctx == nil {
		ctx = context.Background()
	}

	albums, err := s.lib.GetAlbums(0)
	if err != nil {
		return nil, err
	}

	if len(albums) == 0 {
		return []Shelf{}, nil
	}

	byID := make(map[int64]library.Album, len(albums))
	for _, album := range albums {
		byID[album.ID] = album
	}

	shelves := make([]Shelf, 0, 8) //nolint:mnd // rough capacity hint

	add := func(shelf Shelf, ok bool) {
		if !ok || len(shelf.Albums) == 0 {
			return
		}

		// A repeat is only a fault if a different row was possible. A
		// shelf showing the *entire* library answers every question with
		// the same albums because there are no others, and suppressing
		// those rows punishes a small library for being small — measured
		// against a fixed shelf size instead, this let the fixture
		// library keep three identical shelves while a library one album
		// larger lost them.
		if len(shelf.Albums) < len(albums) &&
			len(shelves) > 0 &&
			duplicates(shelf, shelves[len(shelves)-1]) {
			return
		}

		shelves = append(shelves, shelf)
	}

	add(s.recentlyPlayed(ctx, byID))
	add(s.recentlyAdded(ctx, byID))
	add(s.mostPlayed(ctx, byID))
	add(s.favouriteArtist(ctx, albums))

	for _, shelf := range s.genreShelves(ctx, byID) {
		add(shelf, true)
	}

	add(s.forgotten(ctx, byID))
	add(s.random(ctx, byID))

	return shelves, nil
}

// duplicateThreshold is the share of a shelf that has to be in the
// shelf above before the second one is not worth showing.  Two thirds:
// one album in common between two rows of four is a coincidence, three
// is the same row with a different heading.
const duplicateThreshold = 2.0 / 3.0

// duplicateMinimum is the shortest shelf this rule judges at all.
//
// Below it the ratio says nothing: two rows of one album overlap by
// 100% whenever they agree at all.  The first version of this had no
// floor and no library-size guard, and collapsed a four-album library
// to a single shelf — caught by the existing tests, not by the one
// written for the change.
const duplicateMinimum = 3

// duplicates reports whether a shelf is substantially the shelf above
// it wearing a different reason.
//
// A small library has one signal, not six: everything recently played
// is also everything most played is also everything recently added, so
// "Pick up where you left off" and "On repeat" render the same four
// covers in a different order and the page reads as repeating itself
// (H-9).  This is the same rule as omitting an empty shelf, one step
// further: a shelf has to be worth its own row.
//
// Only the shelf immediately above is compared, deliberately.  Two rows
// that share content are only jarring when they are adjacent, and
// comparing against everything already shown would delete the genre and
// random shelves on any library small enough to reach this at all.
func duplicates(shelf, previous Shelf) bool {
	if len(shelf.Albums) < duplicateMinimum {
		return false
	}

	above := make(map[int64]struct{}, len(previous.Albums))
	for _, album := range previous.Albums {
		above[album.ID] = struct{}{}
	}

	shared := 0

	for _, album := range shelf.Albums {
		if _, ok := above[album.ID]; ok {
			shared++
		}
	}

	return float64(shared)/float64(len(shelf.Albums)) >= duplicateThreshold
}

// resolve turns album ids into albums, dropping any the library no
// longer has (a scan can remove an album between the two queries).
func resolve(ids []int64, byID map[int64]library.Album) []library.Album {
	out := make([]library.Album, 0, len(ids))

	for _, id := range ids {
		if album, ok := byID[id]; ok {
			out = append(out, album)
		}
	}

	return out
}

func (s *Service) recentlyPlayed(
	ctx context.Context,
	byID map[int64]library.Album,
) (Shelf, bool) {
	ids, err := s.db.ReadQueries.HomeRecentlyPlayedAlbums(ctx, shelfSize)
	if err != nil {
		s.logger.Warn("home: recently played", "error", err)

		return Shelf{}, false
	}

	return Shelf{
		ID:       "recently-played",
		Kind:     KindRecentlyPlayed,
		Title:    "Pick up where you left off",
		Subtitle: "The last albums you played",
		Albums:   resolve(ids, byID),
	}, true
}

func (s *Service) recentlyAdded(
	ctx context.Context,
	byID map[int64]library.Album,
) (Shelf, bool) {
	ids, err := s.db.ReadQueries.HomeRecentlyAddedAlbums(ctx, shelfSize)
	if err != nil {
		s.logger.Warn("home: recently added", "error", err)

		return Shelf{}, false
	}

	return Shelf{
		ID:       "recently-added",
		Kind:     KindRecentlyAdded,
		Title:    "Fresh in your library",
		Subtitle: "Most recently added",
		Albums:   resolve(ids, byID),
	}, true
}

func (s *Service) mostPlayed(
	ctx context.Context,
	byID map[int64]library.Album,
) (Shelf, bool) {
	ids, err := s.db.ReadQueries.HomeMostPlayedAlbums(ctx, shelfSize)
	if err != nil {
		s.logger.Warn("home: most played", "error", err)

		return Shelf{}, false
	}

	return Shelf{
		ID:       "most-played",
		Kind:     KindMostPlayed,
		Title:    "On repeat",
		Subtitle: "What you play the most",
		Albums:   resolve(ids, byID),
	}, true
}

// forgotten is two shelves' worth of intent in one row: albums played
// long ago, and — for a library with no history at all — albums never
// played.  Both answer "what am I ignoring", which is the shelf a large
// library benefits from most.
func (s *Service) forgotten(
	ctx context.Context,
	byID map[int64]library.Album,
) (Shelf, bool) {
	stale, err := s.db.ReadQueries.HomeStaleAlbums(
		ctx,
		sqlcgen.HomeStaleAlbumsParams{Datetime: staleWindow, Limit: shelfSize},
	)
	if err != nil {
		s.logger.Warn("home: stale albums", "error", err)

		stale = nil
	}

	if len(stale) > 0 {
		return Shelf{
			ID:       "forgotten",
			Kind:     KindStale,
			Title:    "You haven't played this in a while",
			Subtitle: "Last played over six months ago",
			Albums:   resolve(stale, byID),
		}, true
	}

	unplayed, err := s.db.ReadQueries.HomeUnplayedAlbums(ctx, shelfSize)
	if err != nil {
		s.logger.Warn("home: unplayed albums", "error", err)

		return Shelf{}, false
	}

	return Shelf{
		ID:       "forgotten",
		Kind:     KindUnplayed,
		Title:    "Never played",
		Subtitle: "In your library, still unheard",
		Albums:   resolve(unplayed, byID),
	}, true
}

func (s *Service) random(
	ctx context.Context,
	byID map[int64]library.Album,
) (Shelf, bool) {
	ids, err := s.db.ReadQueries.HomeRandomAlbums(ctx, shelfSize)
	if err != nil {
		s.logger.Warn("home: random albums", "error", err)

		return Shelf{}, false
	}

	return Shelf{
		ID:       "random",
		Kind:     KindRandom,
		Title:    "Take a chance",
		Subtitle: "A handful of albums at random",
		Albums:   resolve(ids, byID),
	}, true
}

// favouriteArtist builds a shelf around whoever the user plays most,
// which is the one recommendation here that reads as personal rather
// than statistical.
func (s *Service) favouriteArtist(
	ctx context.Context,
	albums []library.Album,
) (Shelf, bool) {
	rows, err := s.db.ReadQueries.HomeTopArtists(ctx, artistPoolSize)
	if err != nil {
		s.logger.Warn("home: top artists", "error", err)

		return Shelf{}, false
	}

	// Sampling from the top few rather than always taking first place
	// keeps the shelf from being a permanent fixture about one artist.
	rand.Shuffle(len(rows), func(i, j int) {
		rows[i], rows[j] = rows[j], rows[i]
	})

	for _, row := range rows {
		name := strings.TrimSpace(row.ArtistName)
		if name == "" {
			continue
		}

		byArtist := make([]library.Album, 0, shelfSize)

		for _, album := range albums {
			if strings.EqualFold(album.ArtistName, name) {
				byArtist = append(byArtist, album)
			}
		}

		if len(byArtist) < artistShelfMin {
			continue
		}

		if len(byArtist) > shelfSize {
			byArtist = byArtist[:shelfSize]
		}

		return Shelf{
			ID:       "artist",
			Kind:     KindArtist,
			Title:    "More from " + name,
			Subtitle: "One of your most played artists",
			Albums:   byArtist,
		}, true
	}

	return Shelf{}, false
}

// artistPoolSize is how many top artists the favourite-artist shelf
// picks from.
const artistPoolSize = 5

// genreShelves picks a couple of genres the library actually has depth
// in, sampled from the top handful so the page varies between visits.
func (s *Service) genreShelves(
	ctx context.Context,
	byID map[int64]library.Album,
) []Shelf {
	rows, err := s.db.ReadQueries.HomeTopGenres(ctx, genreCandidates)
	if err != nil {
		s.logger.Warn("home: top genres", "error", err)

		return nil
	}

	rand.Shuffle(len(rows), func(i, j int) {
		rows[i], rows[j] = rows[j], rows[i]
	})

	shelves := make([]Shelf, 0, maxGenreShelves)

	for _, row := range rows {
		if len(shelves) >= maxGenreShelves {
			break
		}

		genre := strings.TrimSpace(row.Genre)
		if genre == "" {
			continue
		}

		ids, err := s.db.ReadQueries.HomeAlbumsByGenre(
			ctx,
			sqlcgen.HomeAlbumsByGenreParams{Name: genre, Limit: shelfSize},
		)
		if err != nil {
			s.logger.Warn("home: albums by genre", "genre", genre, "error", err)

			continue
		}

		found := resolve(ids, byID)
		if len(found) == 0 {
			continue
		}

		shelves = append(shelves, Shelf{
			ID:       "genre-" + genre,
			Kind:     KindGenre,
			Title:    genre,
			Subtitle: "Because your library is full of it",
			Albums:   found,
		})
	}

	return shelves
}
