package explore

import (
	"context"
	"database/sql"
	"log/slog"
	"math"
	"sort"
	"strings"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"

	"yellowjacket/backend/database"
	"yellowjacket/backend/database/sql/sqlcgen"
	"yellowjacket/backend/events"
	"yellowjacket/backend/jobs"
)

// backgroundMBRate is the sustained requests-per-second granted to
// post-scan backfills on the MusicBrainz limiters.  It is MB's own
// documented ceiling rather than the interactive lane's burst rate,
// because a backfill is the one caller that sustains it for an hour.
const backgroundMBRate = 1.0

// Service is the Wails-bound service for the explore feature.
// It owns the lifecycle of all explore-related components: the
// MusicBrainz client, ListenBrainz client, rate limiter, and
// response cache.  Its exported methods form the binding surface
// that the frontend calls via generated TypeScript stubs.
type Service struct {
	mb         *MusicBrainzClient
	lb         *ListenBrainzClient
	lrclib     *LRCLibClient
	cache      *Cache
	index      *SearchIndex
	artProxy   *CoverArtProxy
	artistImg  *ArtistImageProvider
	libMBID    *LibraryMBIDIndex
	caaLimiter *RateLimiter
	// albumComplete answers whether a local album is complete; see
	// SetAlbumComplete.  Nil until wired, which every path tolerates.
	albumComplete AlbumCompleteFunc
	db            *database.DB
	logger        *slog.Logger
	ctx           context.Context

	// searchMu guards searchCancel, which cancels the currently
	// in-flight SearchLocal so a superseded query releases the shared
	// SQLite connection immediately instead of running to completion.
	searchMu     sync.Mutex
	searchCancel context.CancelFunc

	// discogSF collapses concurrent lazy discography fetches for the same
	// artist (the detail page fires top-tracks and top-releases at once)
	// into a single background fetch + one ArtistDiscographyReady event.
	discogSF singleflight.Group

	// similarSF collapses concurrent lazy similar-artist fetches for the
	// same artist into a single LB labs call + one ArtistSimilarReady event.
	similarSF singleflight.Group

	// releasesSF collapses concurrent lazy BrowseReleases fetches for the
	// same release group (e.g. the album page firing while a prefetch is
	// already in flight) into one MusicBrainz browse + one
	// AlbumReleasesReady event.
	releasesSF singleflight.Group

	// mixMu guards mix, the in-progress dynamic-mix queue-fallback
	// session (see mix.go). There is only ever one — this is a
	// single-user desktop app with one queue.
	mixMu sync.Mutex
	mix   *mixSession
}

// NewExploreService creates a Service backed by the given
// database.  It instantiates the rate limiter, cache, MusicBrainz
// client, and ListenBrainz client internally.
func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
	cache := NewCache(db, logger.WithGroup("cache"))
	lbLimiter := NewRateLimiter()
	// Cover Art Archive has its own rate limits, separate from LB.
	// Allow 8 concurrent fetches so album art loads quickly.
	caaLimiter := NewRateLimiterBurst(8, 8)
	// MB search limiter: 3 tokens/sec, burst of 1.  This spaces the
	// three concurrent search goroutines ~333ms apart instead of
	// firing all at once.  MusicBrainz uses an all-or-nothing rate
	// limit — exceeding 1/sec average causes 503 on ALL requests,
	// which triggers the library's retry loop (up to 5 × 1s waits).
	// Staggering avoids the 503 entirely while keeping total phase-1
	// latency under 1.5s (333ms stagger + ~1s MB response).
	// The background lane paces post-scan backfills at MusicBrainz's
	// own documented 1/sec and, more importantly, makes them yield: an
	// artist page opened mid-backfill is not queued behind it.
	mbSearchLimiter := NewRateLimiterBurst(3, 1).
		WithBackgroundLane(backgroundMBRate)
	// MB background limiter: strict 1/sec for sustained image resolution calls.
	mbBackgroundLimiter := NewRateLimiter().
		WithBackgroundLane(backgroundMBRate)
	mb := NewMusicBrainzClient(cache, mbSearchLimiter, logger.WithGroup("musicbrainz"))
	lb := NewListenBrainzClient(lbLimiter, cache, logger.WithGroup("listenbrainz"))
	lrclib := NewLRCLibClient(cache, logger.WithGroup("lrclib"))
	artProxy := NewCoverArtProxy(db, caaLimiter)
	artistImg := NewArtistImageProvider(
		db, cache, mbBackgroundLimiter, logger.WithGroup("artist-image"),
	)
	index := NewSearchIndex(db, lb, artistImg, logger.WithGroup("search-index"))
	index.SetMusicBrainz(mb)
	index.MarkReadyIfPopulated() // make index queryable immediately if data exists

	libMBID := NewLibraryMBIDIndex(db)

	logger.Info("explore service created")

	return &Service{
		mb:         mb,
		lb:         lb,
		lrclib:     lrclib,
		cache:      cache,
		index:      index,
		artProxy:   artProxy,
		artistImg:  artistImg,
		libMBID:    libMBID,
		caaLimiter: caaLimiter,
		db:         db,
		logger:     logger,
		ctx:        context.Background(),
	}
}

// MusicBrainz returns the shared cached MB client so other services
// (e.g. autotag) can reuse it without spinning up a second limiter.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) MusicBrainz() *MusicBrainzClient {
	return e.mb
}

// CAALimiter returns the shared Cover Art Archive rate limiter.
// Consumers must respect it for any fresh CAA HTTP GETs.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) CAALimiter() *RateLimiter {
	return e.caaLimiter
}

// The v3 service lifecycle hook that fills e.ctx lives in
// servicestartup.go, behind a build tag: naming
// application.ServiceOptions is what would drag cgo into cmd/indexbuild.

// StartIndexBuild kicks off the background search index build.
// Call this after the library scan completes so the indexer doesn't
// starve the scan for DB access.
func (e *Service) StartIndexBuild() {
	e.index.StartBuild(e.ctx)
}

// RefreshListenCounts folds any newly-published incremental listen dumps
// into the index's popularity numbers, in the background.  No-op when
// offline, when a full build is running, when there is no baseline
// import, or when the last refresh was within the weekly cadence.  Fully
// local — downloads the small daily dumps but makes no ListenBrainz API
// calls.
func (e *Service) RefreshListenCounts() {
	go e.index.RefreshListenCounts(e.ctx, listensCatchupInterval)
}

// StopIndexBuild cancels the background search index build.
// Call before a full rescan to free the DB for the scan.
func (e *Service) StopIndexBuild() {
	e.index.StopBuild()
}

// CoreCatalogImported reports whether a prebuilt catalog artifact has
// been merged into this index.
func (e *Service) CoreCatalogImported() bool {
	return e.index.artifactAlreadyMerged()
}

// SetJobRegistry wires the background job registry into the search
// index so its build reports progress and controls to the frontend.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (e *Service) SetJobRegistry(reg *jobs.Registry) {
	e.index.SetJobRegistry(reg)
}

// AdoptPausedIndexBuild re-registers a build paused in a previous
// session so it appears in the jobs panel, still paused.
func (e *Service) AdoptPausedIndexBuild() {
	e.index.AdoptPausedBuild()
}

// IndexImportComplete reports whether the dump import has finished all
// of its stages.  Distinct from IsIndexReady, which only means the index
// holds enough rows to answer queries — a partially imported index is
// ready but not complete.  Used by the headless builder to decide
// whether another run is needed.
func (e *Service) IndexImportComplete() bool {
	return e.index.ImportComplete()
}

// IndexBaselineSeries returns the incremental listens series the index's
// popularity is caught up to.  A change across a refresh means new data
// was folded in.
func (e *Service) IndexBaselineSeries() int {
	return e.index.BaselineSeries()
}

// IndexLastImported returns when the dump import last completed, or the
// zero time if it never has.
func (e *Service) IndexLastImported() time.Time {
	return e.index.LastImported()
}

// PrepareIndexRebuild clears the completion marker so the next build
// re-imports from the newest published dump.
func (e *Service) PrepareIndexRebuild() {
	e.index.PrepareRebuild()
}

// RefreshIndexNow folds newly published incremental listens dumps into
// the index synchronously.  Pass 0 to bypass the cadence gate.
func (e *Service) RefreshIndexNow(minInterval time.Duration) {
	e.index.RefreshNow(e.ctx, minInterval)
}

// IsIndexReady returns true once the index has been populated.
func (e *Service) IsIndexReady() bool {
	return e.index.IsReady()
}

// WaitForIndexIdle blocks until no index build or artist indexing
// goroutine is running.  Does not cancel a running build.
func (e *Service) WaitForIndexIdle() {
	e.index.WaitForIdle()
}

// PopulateLocalCrossReferences updates the local_*_id columns on
// explore_index after a library scan.
func (e *Service) PopulateLocalCrossReferences() {
	e.index.PopulateLocalCrossReferences()
}

// PopulateLocalCrossReferencesIfNeeded runs the library→index sync only
// when it has not run since the last library change.  Use it on the
// unchanged-library launch path so the write-heavy re-sync is skipped in
// steady state; the scan-completion path calls the unconditional form.
func (e *Service) PopulateLocalCrossReferencesIfNeeded() {
	if e.index.hasMeta(localXrefReadyKey) {
		return
	}

	e.index.PopulateLocalCrossReferences()
}

// BackfillLibraryDiscographies enriches owned artists that have not had
// their discography fetched yet, in the background.  Idempotent and
// bounded — the query only returns unenriched artists and each is marked
// discog_fetched on success, so this is cheap (an empty query) once every
// owned artist is covered and safe to call on every scan and launch.
func (e *Service) BackfillLibraryDiscographies() {
	go e.index.BackfillLibraryDiscographies(e.ctx)
}

// releaseGroupMBIDBackfillMaxPerRun bounds how many pending release
// MBIDs a single run resolves, mirroring discogBackfillMaxPerRun.
const releaseGroupMBIDBackfillMaxPerRun = 500

// BackfillReleaseGroupMBIDs resolves release groups whose scan only
// found a release-level MBID (MUSICBRAINZ_ALBUMID — many taggers write
// this instead of, or in addition to, MUSICBRAINZ_RELEASEGROUPID) into
// the release-group MBID everything else on the album page is keyed
// by. Bounded and resumable, in the background: a scan can't afford a
// live MusicBrainz call, so `library.updateMBIDs` stashes the release
// MBID in `pending_release_mbid` instead, and this is what resolves it
// — the same "defer the network call out of the scan path" shape as
// BackfillLibraryDiscographies.
func (e *Service) BackfillReleaseGroupMBIDs() {
	go e.backfillReleaseGroupMBIDs(e.ctx)
}

// pendingReleaseMBIDs is the albums this pass has work to do on.
//
// It is separate from the pass, and returns its error rather than
// logging it, so that a test can assert the statement runs against the
// real schema. That is not a general preference -- it is this
// statement's history: it named `release_groups`, a table plan 013
// renamed to `albums`, so it failed on every launch since e7748f1 and
// the pass returned quietly having done nothing. A test of the pass
// as a whole cannot see that, because a query error and an empty
// library are the same early return.
func (e *Service) pendingReleaseMBIDs(
	ctx context.Context,
) ([]sqlcgen.GetAlbumsWithPendingReleaseMBIDRow, error) {
	return e.db.ReadQueries.GetAlbumsWithPendingReleaseMBID(
		ctx, releaseGroupMBIDBackfillMaxPerRun,
	)
}

func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
	pending, err := e.pendingReleaseMBIDs(ctx)
	if err != nil {
		e.logger.Warn("release-group mbid backfill: query failed", "error", err)

		return
	}

	if len(pending) == 0 {
		return
	}

	// Same two rules as the discography backfill: nobody is waiting on
	// this, so it yields the MB limiter, and it is visible and
	// stoppable while it runs.
	ctx = WithBackgroundPriority(ctx)

	job, ctx := startBackfillJob(
		ctx, e.index.jobRegistry(), rgMBIDBackfillJobID,
		"Matching albums to the catalog",
		"Resolving release MBIDs found while scanning",
		len(pending),
	)

	defer func() { job.finish(ctx) }()

	for i, p := range pending {
		if ctx.Err() != nil {
			return
		}

		job.progress(i, len(pending))

		release, err := e.mb.LookupRelease(ctx, p.PendingReleaseMbid.String)
		if err != nil || release.ReleaseGroupMBID == "" {
			// Left alone rather than cleared: LookupRelease caches its
			// answer (success or a release with no group) for 7 days,
			// so a retry on the next run is cheap, and a future rescan
			// that finds a real release-group tag still wins normally.
			continue
		}

		// The writer, not ReadQueries: an UPDATE issued on the
		// query-only pool fails at runtime with "attempt to write a
		// readonly database".
		if err := e.db.Queries.ResolveAlbumPendingReleaseMBID(
			ctx,
			sqlcgen.ResolveAlbumPendingReleaseMBIDParams{
				Mbid: sql.NullString{
					String: release.ReleaseGroupMBID,
					Valid:  true,
				},
				ID: p.ID,
			},
		); err != nil {
			e.logger.Warn("release-group mbid backfill: update failed", "error", err)
		}
	}
}

// InvalidateLibrarySync clears the "ready" markers guarding the gated
// library-sync steps so they re-run on the next launch.  Call after a
// mutation that changes owned content outside a scan (e.g. removing a
// library), which would otherwise leave stale in_library flags and
// orphaned lyric-index rows.
func (e *Service) InvalidateLibrarySync() {
	e.index.deleteMeta(localXrefReadyKey)
	e.index.deleteMeta(lyricsIndexReadyKey)
}

// GetIndexStatus returns the current search index build status.
func (e *Service) GetIndexStatus() IndexStatus {
	return e.index.GetIndexStatus()
}

// InvalidateIndexDiscographies clears the discography build
// timestamp so the next index build re-runs Tiers 2-4.  Call
// after a library rescan that may have populated new MBIDs.
func (e *Service) InvalidateIndexDiscographies() {
	e.index.InvalidateDiscographies()
}

// beginSearch cancels any SearchLocal still in flight and returns a
// fresh context (plus its cancel func) scoped to the new search.
// Callers must defer the returned cancel so the context's resources
// are released once the search returns.
func (e *Service) beginSearch() (context.Context, context.CancelFunc) {
	e.searchMu.Lock()
	defer e.searchMu.Unlock()

	if e.searchCancel != nil {
		e.searchCancel()
	}

	ctx, cancel := context.WithCancel(e.ctx)
	e.searchCancel = cancel

	return ctx, cancel
}

// SearchLocal queries only the local FTS5 index and returns fully
// ranked results instantly with no network calls.  This is the
// primary interactive search path: now that the index is populated
// from the MetaBrainz dumps it covers essentially every popular
// entity, so the frontend drives search entirely from here.  The
// old MusicBrainz network pipeline (Search) is retained for a future
// opt-in "search online" affordance but is no longer called on the
// hot path.
//
// Returns nil if the index has no hits for the query, so the caller
// can fall back to whatever owned-library matches it already has.
func (e *Service) SearchLocal(query string) *MBSearchResult {
	searchStart := time.Now()

	// Abandon any search still running: the query has changed, so its
	// results are already stale.  Cancelling interrupts its in-flight
	// FTS query and frees the single shared SQLite connection for this
	// one, instead of letting superseded searches back up behind it.
	ctx, cancel := e.beginSearch()
	defer cancel()

	sqlStart := time.Now()
	indexHits := e.index.Search(ctx, query, indexSearchLimit)
	sqlDur := time.Since(sqlStart)

	// Superseded mid-query: drop this result so the caller's version
	// check isn't racing a partial one, and skip the post-processing.
	if ctx.Err() != nil {
		return nil
	}

	if len(indexHits) == 0 {
		e.logger.Info("searchlocal complete (no hits)",
			"query", query,
			"sql", sqlDur.Round(time.Microsecond),
			"total", time.Since(searchStart).Round(time.Microsecond),
		)

		return nil
	}

	postStart := time.Now()

	var result MBSearchResult

	mergeIndexHits(query, &result, indexHits)
	e.resolveRecordingReleaseGroups(result.Recordings)

	mergeDur := time.Since(postStart)

	// Remove special-purpose artists from local results too.
	if len(result.Artists) > 0 {
		filtered := result.Artists[:0]
		for _, a := range result.Artists {
			if !mbSpecialPurposeArtists[a.MBID] {
				filtered = append(filtered, a)
			}
		}

		result.Artists = filtered
	}

	// Boost exact/substring name matches so a precise query ranks the
	// obvious entity first, mirroring the MB pipeline's Phase 5.
	boostStart := time.Now()

	e.boostNameMatches(query, &result)

	boostDur := time.Since(boostStart)

	// Cap counts but skip the minBlendedScore filter: this path has no
	// MB results to calibrate against, so we surface whatever local
	// matches exist rather than dropping low-popularity ones.
	if len(result.Artists) > maxResults {
		result.Artists = result.Artists[:maxResults]
	}

	if len(result.ReleaseGroups) > maxResults {
		result.ReleaseGroups = result.ReleaseGroups[:maxResults]
	}

	if len(result.Recordings) > maxResults {
		result.Recordings = result.Recordings[:maxResults]
	}

	// Resolve the top-result cards via intent scoring (index-only —
	// no network), same as the MB pipeline's Phase 7.
	topStart := time.Now()
	result.TopResults = e.resolveTopResults(query, &result)
	topDur := time.Since(topStart)

	postDur := time.Since(postStart)

	e.logger.Info("searchlocal complete",
		"query", query,
		"hits", len(indexHits),
		"artists", len(result.Artists),
		"releaseGroups", len(result.ReleaseGroups),
		"recordings", len(result.Recordings),
		"sql", sqlDur.Round(time.Microsecond),
		"post", postDur.Round(time.Microsecond),
		"post.merge", mergeDur.Round(time.Microsecond),
		"post.boost", boostDur.Round(time.Microsecond),
		"post.topResults", topDur.Round(time.Microsecond),
		"total", time.Since(searchStart).Round(time.Microsecond),
	)

	return &result
}

// ---------------------------------------------------------------------------
// MusicBrainz lookup
// ---------------------------------------------------------------------------

// LookupArtist fetches a single MusicBrainz artist by MBID.
// Checks the local index first — has name, type, country,
// disambiguation, sort_name for indexed artists.  Falls back to
// MB API for unknown artists and backfills the index for next time.
func (e *Service) LookupArtist(mbid string) (*MBArtist, error) {
	if indexed := e.index.LookupArtistByMBID(mbid); indexed != nil && indexed.Title != "" {
		artist := &MBArtist{
			MBID:           mbid,
			Name:           indexed.Title,
			SortName:       indexed.SortName,
			Type:           indexed.ArtistType,
			Country:        indexed.Country,
			Disambiguation: indexed.Disambiguation,
			Popularity:     indexed.Popularity,
			HasPopularity:  indexed.Popularity > 0,
			ListenerCount:  indexed.ListenerCount,
			InLibrary:      indexed.InLibrary || indexed.LocalArtistID > 0,
			LocalID:        indexed.LocalArtistID,
		}

		return artist, nil
	}

	return e.mb.LookupArtist(e.ctx, mbid)
}

// LookupReleaseGroup fetches a single MusicBrainz release group by MBID.
func (e *Service) LookupReleaseGroup(mbid string) (*MBReleaseGroup, error) {
	// Try the index first — has title, type, secondary_types, date, artist.
	if indexed := e.index.LookupReleaseGroupByMBID(mbid); indexed != nil && indexed.Title != "" {
		var secondary []string
		if indexed.SecondaryTypes != "" {
			secondary = strings.Split(indexed.SecondaryTypes, ",")
		}

		rg := &MBReleaseGroup{
			MBID:             mbid,
			Title:            indexed.Title,
			ArtistCredit:     indexed.ArtistName,
			ArtistMBID:       indexed.ArtistMBID,
			Popularity:       indexed.Popularity,
			ListenerCount:    indexed.ListenerCount,
			PrimaryType:      indexed.PrimaryType,
			SecondaryTypes:   secondary,
			FirstReleaseDate: indexed.ReleaseDate,
			InLibrary:        indexed.InLibrary || indexed.LocalReleaseGroupID > 0,
			LocalID:          indexed.LocalReleaseGroupID,
			TotalTracks:      indexed.TotalTracks,
		}

		// Background: fetch full MB data once per RG to fill in fields
		// the dump doesn't carry (notably secondary_types, used by the
		// album page's version scorer).  Gated on DiscogFetched — not on
		// "secondary_types == ''" — so a genuinely studio album (which
		// has no secondary types) is looked up once and marked, instead
		// of re-firing on every page visit.  The result is written back
		// to the index so the next visit reads it locally.
		if !indexed.DiscogFetched {
			go func() {
				fetched, err := e.mb.LookupReleaseGroup(e.ctx, mbid)
				if err == nil && fetched != nil {
					e.index.PersistReleaseGroupLookup(fetched)
				}
			}()
		}

		return rg, nil
	}

	return e.mb.LookupReleaseGroup(e.ctx, mbid)
}

// ---------------------------------------------------------------------------
// MusicBrainz browse
// ---------------------------------------------------------------------------

// BrowseReleaseGroups fetches release groups for a given artist MBID.
// Checks the local index first for instant results, then fetches from
// MusicBrainz for complete data (secondary types, precise dates).
// Also adds results to the search index (Tier 5: organic growth).
func (e *Service) BrowseReleaseGroups(artistMBID string) ([]MBReleaseGroup, error) {
	// Try the index first — returns instantly if the artist is indexed.
	if indexed := e.index.TopReleaseGroupsByArtist(artistMBID, 200); len(indexed) > 0 {
		out := make([]MBReleaseGroup, 0, len(indexed))

		for _, r := range indexed {
			var secondary []string
			if r.SecondaryTypes != "" {
				secondary = strings.Split(r.SecondaryTypes, ",")
			}

			out = append(out, MBReleaseGroup{
				MBID:             r.MBID,
				Title:            r.Title,
				ArtistCredit:     r.ArtistName,
				ArtistMBID:       r.ArtistMBID,
				Popularity:       r.Popularity,
				ListenerCount:    r.ListenerCount,
				PrimaryType:      r.PrimaryType,
				SecondaryTypes:   secondary,
				FirstReleaseDate: r.ReleaseDate,
				InLibrary:        r.InLibrary || r.LocalReleaseGroupID > 0,
				LocalID:          r.LocalReleaseGroupID,
			})
		}

		// Complete the discography in the background if the full browse
		// has never run for this artist, so the next visit has the tail
		// and the secondary types.  Marked per artist rather than
		// inferred from "does any row carry a secondary type", which is
		// permanently false for an artist whose releases are all plain
		// albums — that test re-browsed such an artist on every visit,
		// forever.
		if !e.index.artistBrowsed(artistMBID) {
			go func() { e.index.browseFullDiscography(e.ctx, artistMBID) }()
		}

		return out, nil
	}

	// Not indexed yet.  Don't block on a live MusicBrainz browse: the top
	// sections already trigger EnsureArtistDiscography (via
	// TopReleaseGroupsForArtist), which fetches the same release groups
	// from ListenBrainz and writes them into the index.  Kick that off (or
	// join the in-flight fetch) and return empty — the ArtistDiscographyReady
	// event signals the caller to re-read this section from the index.
	e.ensureDiscographyAsync(artistMBID)

	return nil, nil
}

// BrowseReleases fetches releases for a given release group MBID.
//
// Local-first, non-blocking: a warm response cache is served instantly;
// on a miss the request does NOT block on a live MusicBrainz browse
// (which pulls every version's full tracklist and can take seconds).
// Instead it kicks off a background fetch and returns empty — the
// AlbumReleasesReady event signals the caller to re-fetch once the cache
// is warm.
func (e *Service) BrowseReleases(releaseGroupMBID string) ([]MBRelease, error) {
	if releases, ok := e.mb.BrowseReleasesCached(releaseGroupMBID); ok {
		e.markReleasesInLibrary(releases)

		return releases, nil
	}

	e.ensureReleasesAsync(releaseGroupMBID)

	return nil, nil
}

// markReleasesInLibrary collects all recording MBIDs across all releases
// and checks them against the local library in a single query, setting the
// InLibrary flag on each track so the tracklist renderer can show the
// library-status indicator without a per-track roundtrip.
func (e *Service) markReleasesInLibrary(releases []MBRelease) {
	var trackMBIDs []string

	for _, rel := range releases {
		for _, t := range rel.Tracks {
			if t.MBID != "" {
				trackMBIDs = append(trackMBIDs, t.MBID)
			}
		}
	}

	if len(trackMBIDs) == 0 {
		return
	}

	found := e.libMBID.CheckMBIDs(trackMBIDs)

	for i := range releases {
		for j := range releases[i].Tracks {
			if _, ok := found[releases[i].Tracks[j].MBID]; ok {
				releases[i].Tracks[j].InLibrary = true
			}
		}
	}
}

// ensureReleasesAsync fetches a release group's releases + tracklists into
// the response cache in the background and emits AlbumReleasesReady when
// done.  Concurrent calls for the same release group (e.g. a prefetch
// already in flight when the user opens the album) collapse into one
// MusicBrainz browse + one event via the singleflight.
func (e *Service) ensureReleasesAsync(releaseGroupMBID string) {
	if releaseGroupMBID == "" {
		return
	}

	go func() {
		_, _, _ = e.releasesSF.Do(releaseGroupMBID, func() (any, error) {
			_, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID)
			if err != nil {
				e.logger.Warn("explore: background browse releases failed",
					"releaseGroupMBID", releaseGroupMBID,
					"error", err,
				)
				events.Emit(e.ctx, events.AlbumReleasesFailed, releaseGroupMBID)

				return nil, nil
			}

			events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)

			return nil, nil
		})
	}()
}

// PrefetchReleases warms the local response cache for a set of release
// groups in the background so opening any of them is instant.  Called from
// the artist page once its top-releases / discography render — album
// navigation almost always originates there.  Already-cached groups are
// skipped; a cap bounds how many live fetches a single artist view can
// trigger so the MusicBrainz rate limiter isn't flooded.
// A release group the user owns *completely* is skipped outright: since
// tag-derived completeness landed, such an album opens with no catalog
// call at all — identity from its MBID, tracklist from its own files —
// so warming the most expensive request in the app on its behalf buys
// nothing. The skip is not merely an optimisation; those slots go to
// albums that will actually need the browse.
func (e *Service) PrefetchReleases(releaseGroupMBIDs []string) {
	const maxPrefetch = 8

	fired := 0

	for _, mbid := range releaseGroupMBIDs {
		if mbid == "" {
			continue
		}

		if _, ok := e.mb.BrowseReleasesCached(mbid); ok {
			continue
		}

		if e.ownedAndComplete(mbid) {
			continue
		}

		e.ensureReleasesAsync(mbid)

		fired++
		if fired >= maxPrefetch {
			return
		}
	}
}

// AlbumCompleteFunc answers "is the local album with this id complete",
// i.e. do its files declare a track total the library actually has.
type AlbumCompleteFunc func(albumID int64) bool

// SetAlbumComplete injects the completeness check.  It is injected
// rather than imported because `library` and `explore` do not depend on
// each other in either direction today, and one prefetch heuristic is
// not a reason to introduce that edge — the alternative, re-deriving
// "complete" from SQL here, would be a second definition of it.
func (e *Service) SetAlbumComplete(fn AlbumCompleteFunc) {
	e.albumComplete = fn
}

// ownedAndComplete reports whether a release group is one the user owns
// in full.  Unknown completeness is not complete: an untagged library
// declares no totals at all, and treating that as complete would skip
// the prefetch for the libraries that need it most.
func (e *Service) ownedAndComplete(releaseGroupMBID string) bool {
	if e.albumComplete == nil {
		return false
	}

	indexed := e.index.LookupReleaseGroupByMBID(releaseGroupMBID)
	if indexed == nil || indexed.LocalReleaseGroupID == 0 {
		return false
	}

	return e.albumComplete(indexed.LocalReleaseGroupID)
}

// ---------------------------------------------------------------------------
// ListenBrainz
// ---------------------------------------------------------------------------

// topRecordingsToWire projects indexed recordings to the wire type.
func topRecordingsToWire(indexed []SearchIndexResult) []LBTopRecording {
	out := make([]LBTopRecording, len(indexed))
	for i, r := range indexed {
		out[i] = LBTopRecording{
			RecordingMBID:    r.MBID,
			ArtistName:       r.ArtistName,
			TrackName:        r.Title,
			TotalListenCount: r.Popularity,
			CAAReleaseMBID:   r.CAAReleaseMBID,
			ReleaseName:      r.ReleaseName,
			Length:           r.Duration,
			InLibrary:        r.InLibrary || r.LocalRecordingID > 0,
			LocalID:          r.LocalRecordingID,
		}
	}

	return out
}

// TopRecordingsForArtist returns the most-listened recordings for an
// artist.  Serves instantly from the local index when available; when the
// artist isn't indexed yet it returns empty immediately and fetches the
// discography in the background, emitting ArtistDiscographyReady so the
// caller can re-fetch — the request never blocks on a live fetch.
func (e *Service) TopRecordingsForArtist(artistMBID string) ([]LBTopRecording, error) {
	if indexed := e.index.TopRecordingsByArtist(artistMBID, 50); len(indexed) > 0 {
		out := topRecordingsToWire(indexed)
		e.resolveTopRecordingReleaseGroups(out)

		return out, nil
	}

	e.ensureDiscographyAsync(artistMBID)

	return nil, nil
}

// resolveTopRecordingReleaseGroups fills each top recording's parent
// release group (from its CAA release MBID) in a single local index
// query, so a top-track row can link to its album page with the track
// highlighted.
func (e *Service) resolveTopRecordingReleaseGroups(recs []LBTopRecording) {
	var caaMBIDs []string

	for i := range recs {
		if recs[i].CAAReleaseMBID != "" {
			caaMBIDs = append(caaMBIDs, recs[i].CAAReleaseMBID)
		}
	}

	if len(caaMBIDs) == 0 {
		return
	}

	rgByCAA := e.index.ReleaseGroupMBIDsForCAAReleaseMBIDs(caaMBIDs)

	for i := range recs {
		if rg, ok := rgByCAA[recs[i].CAAReleaseMBID]; ok {
			recs[i].ReleaseGroupMBID = rg
		}
	}
}

// topReleaseGroupsToWire projects indexed release groups to the wire type.
func topReleaseGroupsToWire(indexed []SearchIndexResult) []LBTopReleaseGroup {
	out := make([]LBTopReleaseGroup, len(indexed))
	for i, r := range indexed {
		out[i] = LBTopReleaseGroup{
			ReleaseGroupMBID: r.MBID,
			Title:            r.Title,
			ArtistName:       r.ArtistName,
			TotalListenCount: r.Popularity,
			Type:             r.PrimaryType,
			Date:             r.ReleaseDate,
			CAAReleaseMBID:   r.CAAReleaseMBID,
			InLibrary:        r.InLibrary || r.LocalReleaseGroupID > 0,
			LocalID:          r.LocalReleaseGroupID,
		}
	}

	return out
}

// TopReleaseGroupsForArtist returns the most-listened release groups for
// an artist.  Same non-blocking contract as TopRecordingsForArtist: index
// hit is instant, a miss kicks off a background discography fetch and
// returns empty, and ArtistDiscographyReady signals when to re-fetch.
func (e *Service) TopReleaseGroupsForArtist(artistMBID string) ([]LBTopReleaseGroup, error) {
	if indexed := e.index.TopReleaseGroupsByArtist(artistMBID, 50); len(indexed) > 0 {
		return topReleaseGroupsToWire(indexed), nil
	}

	e.ensureDiscographyAsync(artistMBID)

	return nil, nil
}

// ensureDiscographyAsync fetches an artist's discography into the index in
// the background and emits ArtistDiscographyReady when done.  Concurrent
// calls for the same artist collapse into one fetch and one event via the
// singleflight, so the detail page firing both top sections at once costs
// a single ListenBrainz round-trip.
func (e *Service) ensureDiscographyAsync(artistMBID string) {
	if artistMBID == "" {
		return
	}

	go func() {
		_, _, _ = e.discogSF.Do(artistMBID, func() (any, error) {
			e.index.EnsureArtistDiscography(e.ctx, artistMBID)

			events.Emit(e.ctx, events.ArtistDiscographyReady, artistMBID)

			return nil, nil
		})
	}()
}

// SimilarArtists returns artists similar to the given artist MBID.
func (e *Service) SimilarArtists(artistMBID string) ([]LBSimilarArtist, error) {
	// Try the pre-computed similar_artist_map first (instant, no API call).
	// Populated by the dump patch pass for library artists and, on first
	// view, by the lazy persist below for everyone else.
	rows, err := e.db.QueryContext(`
		SELECT similar_artist_mbid, similar_artist_name, score
		FROM similar_artist_map
		WHERE source_artist_mbid = ?
		ORDER BY score DESC
	`, artistMBID)
	if err == nil {
		defer func() { _ = rows.Close() }()

		var results []LBSimilarArtist

		for rows.Next() {
			var a LBSimilarArtist
			if err := rows.Scan(&a.ArtistMBID, &a.Name, &a.Score); err == nil {
				results = append(results, a)
			}
		}

		if len(results) > 0 {
			return results, nil
		}
	}

	// Not cached — don't block on the live LB labs call.  Fetch and persist
	// in the background, then emit ArtistSimilarReady so the caller re-reads
	// this section from similar_artist_map.  Later views are served locally.
	e.ensureSimilarArtistsAsync(artistMBID)

	return nil, nil
}

// ensureSimilarArtistsAsync fetches an artist's similar artists from the LB
// labs API into similar_artist_map in the background and emits
// ArtistSimilarReady when done.  Concurrent calls for the same artist
// collapse into one fetch and one event via the singleflight.
//
// This is the *only* thing that fills similar_artist_map now — the
// owned-artist backfill used to do it for every artist up front, which
// is a request each for a section nobody had asked to see.  Marking
// similar_at here is what keeps that mark meaning what it says.
func (e *Service) ensureSimilarArtistsAsync(artistMBID string) {
	if artistMBID == "" {
		return
	}

	go func() {
		_, _, _ = e.similarSF.Do(artistMBID, func() (any, error) {
			similar, err := e.lb.SimilarArtists(e.ctx, artistMBID)
			if err == nil {
				e.index.PersistSimilarArtists(artistMBID, similar)
				e.index.markArtistSimilar(artistMBID)

				events.Emit(e.ctx, events.ArtistSimilarReady, artistMBID)
			}

			return nil, nil
		})
	}()
}

// GetArtistPlayCount returns the total LB listen count for an artist.
// Returns 0 if unknown.
func (e *Service) GetArtistPlayCount(artistMBID string) int {
	// Try the local index first (instant).
	if pop := e.index.GetPopularity(artistMBID); pop > 0 {
		return pop
	}

	// Fall back to LB API.
	pop, err := e.lb.ArtistPopularity(e.ctx, []string{artistMBID})
	if err != nil || len(pop) == 0 {
		return 0
	}

	// Backfill index for next time.
	go e.index.BackfillPopularity(pop)

	return pop[artistMBID].ListenCount
}

// GetLibrarySimilarArtists returns similar artists to the given
// MBID that are also in the user's local library.  Uses the
// pre-computed similar_artist_map table (populated during Tier 4
// index build) joined with the artists table.  No API calls.
//
// The artists table allows multiple rows with the same MBID
// (different artist credits like "A feat. B" that resolve to the
// same MB artist), so we use EXISTS instead of JOIN to avoid
// duplicating similar_artist_map rows.
func (e *Service) GetLibrarySimilarArtists(artistMBID string) []LBSimilarArtist {
	rows, err := e.db.QueryContext(`
		SELECT s.similar_artist_mbid, s.similar_artist_name, s.score
		FROM similar_artist_map s
		WHERE s.source_artist_mbid = ?
		  AND EXISTS (
		    SELECT 1 FROM artists a
		    WHERE a.mbid = s.similar_artist_mbid
		  )
		ORDER BY s.score DESC
	`, artistMBID)
	if err != nil {
		return nil
	}

	defer func() { _ = rows.Close() }()

	var result []LBSimilarArtist

	for rows.Next() {
		var a LBSimilarArtist

		if err := rows.Scan(&a.ArtistMBID, &a.Name, &a.Score); err == nil {
			result = append(result, a)
		}
	}

	return result
}

// ---------------------------------------------------------------------------
// Cover Art Archive
// ---------------------------------------------------------------------------

// CoverArtURL returns the Cover Art Archive URL for a release's
// front cover at the default 250px size.
func (e *Service) CoverArtURL(releaseMBID string) string {
	return CoverArtURL(releaseMBID)
}

// CoverArtGroupURL returns the Cover Art Archive URL for a release
// group's front cover at the default 250px size.  This is the
// correct endpoint for search results, which return release group
// MBIDs rather than individual release MBIDs.
func (e *Service) CoverArtGroupURL(releaseGroupMBID string) string {
	return CoverArtGroupURL(releaseGroupMBID)
}

// GetThumbnail returns a base64 data URL for the release group's
// cover art.  Checks local library art first (by album+artist
// name), then disk cache, then Cover Art Archive.
// Returns "" if no cover art is available.
func (e *Service) GetThumbnail(releaseGroupMBID, albumName, artistName string) string {
	return e.artProxy.GetThumbnail(releaseGroupMBID, albumName, artistName)
}

// GetTrackThumbnail returns cover art for a track.  Accepts both
// the track's CAA release MBID and the resolved parent release
// group MBID (either may be empty).  Tries the RG first to reuse
// discography cache; falls back to the release-level CAA endpoint
// when the RG isn't known — useful when the track's preferred CAA
// release doesn't belong to any RG currently in the index.
func (e *Service) GetTrackThumbnail(
	releaseMBID, releaseGroupMBID, albumName, artistName string,
) string {
	return e.artProxy.GetTrackThumbnail(releaseMBID, releaseGroupMBID, albumName, artistName)
}

// GetCandidateThumbnail returns CAA-only cover art for an autotag
// candidate, skipping the library-by-name index so embedded ID3
// art on the user's existing files doesn't pollute the candidate
// preview.  Disk cache → network on RG → network on release.
func (e *Service) GetCandidateThumbnail(releaseMBID, releaseGroupMBID string) string {
	return e.artProxy.GetCandidateThumbnail(releaseMBID, releaseGroupMBID)
}

// TrackThumbnailRequest is a single item in a batch track thumbnail
// request.  Either ReleaseMBID or ReleaseGroupMBID may be empty;
// the proxy tries whichever is present.
type TrackThumbnailRequest struct {
	Key              string `json:"key"` // stable key used in the returned map
	ReleaseMBID      string `json:"releaseMbid"`
	ReleaseGroupMBID string `json:"releaseGroupMbid"`
	AlbumName        string `json:"albumName"`
	ArtistName       string `json:"artistName"`
}

// GetTrackThumbnails returns ONLY cached/local art for track
// requests, keyed by the caller-provided Key so callers can map
// results back to rows in their UI.
func (e *Service) GetTrackThumbnails(requests []TrackThumbnailRequest) map[string]string {
	result := make(map[string]string, len(requests))

	for _, req := range requests {
		dataURL := e.artProxy.GetTrackThumbnailCached(
			req.ReleaseMBID, req.ReleaseGroupMBID, req.AlbumName, req.ArtistName,
		)
		if dataURL != "" {
			result[req.Key] = dataURL
		}
	}

	return result
}

// ResolveReleaseGroupMBIDs takes a list of CAA release MBIDs (from
// recording metadata) and returns a map of release MBID → release
// group MBID.  The frontend uses this to fetch track cover art via
// the parent release group, reusing whatever cache exists for the
// album already.
func (e *Service) ResolveReleaseGroupMBIDs(caaReleaseMBIDs []string) map[string]string {
	return e.index.ReleaseGroupMBIDsForCAAReleaseMBIDs(caaReleaseMBIDs)
}

// ThumbnailRequest is a single item in a batch thumbnail request.
type ThumbnailRequest struct {
	MBID       string `json:"mbid"`
	AlbumName  string `json:"albumName"`
	ArtistName string `json:"artistName"`
}

// GetThumbnails fetches multiple thumbnails in one call and returns
// a map of MBID → base64 data URL.  Entries with no art are omitted.
// GetThumbnails returns ONLY cached/local art instantly — no network
// fetches.  For items missing from the cache, the frontend should
// call GetThumbnail() individually so results stream in rather than
// blocking on a batch.
func (e *Service) GetThumbnails(requests []ThumbnailRequest) map[string]string {
	result := make(map[string]string, len(requests))

	for _, req := range requests {
		dataURL := e.artProxy.GetThumbnailCached(req.MBID, req.AlbumName, req.ArtistName)
		if dataURL != "" {
			result[req.MBID] = dataURL
		}
	}

	return result
}

// GetArtistImageURL returns a base64 data URL for the artist's
// photo.  Cached on disk — first call resolves via MB/Wikidata and
// fetches from Wikimedia Commons, subsequent calls are instant.
// Returns "" if no image is available.
func (e *Service) GetArtistImageURL(artistMBID string) string {
	// Bound method: someone is looking at this artist right now, so it
	// takes the interactive lane.
	return e.artistImg.GetArtistImage(e.ctx, artistMBID)
}

// GetArtistImageCached returns a base64 data URL for the artist's
// photo ONLY if it's already on disk — no MB/Wikidata resolution
// or Wikimedia fetch.  Returns "" if not cached.
func (e *Service) GetArtistImageCached(artistMBID string) string {
	return e.artistImg.GetCachedImage(artistMBID)
}

// GetArtistImageCachedPath returns the asset-handler URL path for
// the artist's cached medium thumbnail, e.g.
// "/artist-images/b1/b10bbbfc-.../primary_md.jpg".  No base64, no
// network calls — just a disk existence check.  Returns "" if no
// image is cached.
func (e *Service) GetArtistImageCachedPath(artistMBID string) string {
	_, medium, _, _ := e.artistImg.GetImageURLs(artistMBID)

	return medium
}

// CheckLibraryMBIDs returns which of the given MBIDs exist in the
// local music library.  Returns a map of MBID → entity type
// ("artist", "release_group", "recording").
//
// It has no frontend caller — `downloadcatalog.go` is the one consumer,
// asking about a single MBID at a time.
func (e *Service) CheckLibraryMBIDs(mbids []string) map[string]string {
	return e.libMBID.CheckMBIDs(mbids)
}

// GetArtistMBID returns the MusicBrainz ID for a local library
// artist by name, or "" if not found or no MBID tagged.
func (e *Service) GetArtistMBID(artistName string) string {
	return e.libMBID.GetArtistMBID(artistName)
}

// GetArtistImagesCachedPaths resolves artist portraits for many MBIDs
// in one call, returning MBID → asset-handler path for the medium
// thumbnail.  Disk existence checks only: no MusicBrainz, no Wikidata,
// no Wikimedia, no network of any kind.  MBIDs with no cached portrait
// are omitted rather than returned empty.
//
// It exists because the resolving entry point (GetArtistImageURL) was
// being used where a cache check belonged: a page rendering a dozen
// search results paid a full MB → Wikidata → Wikipedia → Wikimedia
// resolution for every artist whose portrait was already on disk.  Its
// predecessor could not serve that — it keyed on artist *name* through
// the library's own MBID map, so it only ever answered for artists the
// user already owned, which on a catalog search is nearly none of them.
//
// Paths rather than base64: a portrait is ~128 kB as a data URL, and
// the asset handler serves the same bytes without crossing the IPC
// boundary or being retained by a JS string.
func (e *Service) GetArtistImagesCachedPaths(mbids []string) map[string]string {
	result := make(map[string]string, len(mbids))

	for _, mbid := range mbids {
		if mbid == "" {
			continue
		}

		if _, medium, _, _ := e.artistImg.GetImageURLs(mbid); medium != "" {
			result[mbid] = medium
		}
	}

	return result
}

// ---------------------------------------------------------------------------
// Index result merging
// ---------------------------------------------------------------------------

// Index-hit relevance approximation tiers.  MB results derive their
// relevance from MusicBrainz's Lucene score; index hits have none, so
// we approximate it from how cleanly the query matches the title or
// artist credit.  The floor is non-zero because the FTS index already
// matched *something* (a per-word or alias hit).
const (
	indexRelevanceFloor = 0.15
	indexRelExact       = 1.0
	indexRelPrefix      = 0.8
	indexRelWord        = 0.6
	indexRelSubstring   = 0.4
)

// mergeIndexHits injects local popularity index results into the
// MBSearchResult.  Index hits for entity types not already present (by
// MBID) are scored on the SAME blended scale as the reranked MB results
// (text relevance + log-popularity + personalization), then merged and
// re-sorted by that score.  This replaces an earlier approach that
// scored index hits with a half-scaled popularity number and blindly
// prepended them — two incompatible scales that the downstream
// minBlendedScore filter and final sort then compared as if equal.
func mergeIndexHits(query string, result *MBSearchResult, hits []SearchIndexResult) {
	if len(hits) == 0 {
		return
	}

	// Build MBID sets for existing results.
	artistMBIDs := make(map[string]bool, len(result.Artists))
	for _, a := range result.Artists {
		artistMBIDs[a.MBID] = true
	}

	rgMBIDs := make(map[string]bool, len(result.ReleaseGroups))
	for _, rg := range result.ReleaseGroups {
		rgMBIDs[rg.MBID] = true
	}

	recMBIDs := make(map[string]bool, len(result.Recordings))
	for _, r := range result.Recordings {
		recMBIDs[r.MBID] = true
	}

	// Per-entity-type max popularity over the combined population
	// (existing MB results + incoming index hits) so the blended score
	// normalizes popularity consistently across both sources.  This max
	// may differ slightly from the per-list max the MB rerank used; the
	// single comparable scale is worth that minor drift.
	maxArtistPop, maxRGPop, maxRecPop := 0, 0, 0

	for _, a := range result.Artists {
		maxArtistPop = max(maxArtistPop, a.Popularity)
	}

	for _, rg := range result.ReleaseGroups {
		maxRGPop = max(maxRGPop, rg.Popularity)
	}

	for _, r := range result.Recordings {
		maxRecPop = max(maxRecPop, r.Popularity)
	}

	for _, h := range hits {
		switch h.EntityType {
		case "artist":
			maxArtistPop = max(maxArtistPop, h.Popularity)
		case "release_group":
			maxRGPop = max(maxRGPop, h.Popularity)
		case "recording":
			maxRecPop = max(maxRecPop, h.Popularity)
		}
	}

	// Collect new entries from index that MB didn't return.
	var (
		newArtists []MBArtist
		newRGs     []MBReleaseGroup
		newRecs    []MBRecording
	)

	for _, h := range hits {
		switch h.EntityType {
		case "artist":
			if !artistMBIDs[h.MBID] {
				artist := artistFromIndex(h)
				artist.Score = indexHitBlendedScore(
					query, h.Title, "", h.Popularity, maxArtistPop,
					artist.InLibrary, h.IsSimilar,
				)

				newArtists = append(newArtists, artist)

				artistMBIDs[h.MBID] = true
			}

		case "release_group":
			if !rgMBIDs[h.MBID] {
				rg := releaseGroupFromIndex(h)
				rg.Score = indexHitBlendedScore(
					query, h.Title, h.ArtistName, h.Popularity, maxRGPop,
					rg.InLibrary, h.IsSimilar,
				)

				newRGs = append(newRGs, rg)
				rgMBIDs[h.MBID] = true
			}

		case "recording":
			if !recMBIDs[h.MBID] {
				rec := recordingFromIndex(h)
				rec.Score = indexHitBlendedScore(
					query, h.Title, h.ArtistName, h.Popularity, maxRecPop,
					rec.InLibrary, h.IsSimilar,
				)

				newRecs = append(newRecs, rec)

				recMBIDs[h.MBID] = true
			}
		}
	}

	// Merge and re-sort each list by the now-comparable blended Score.
	if len(newArtists) > 0 {
		result.Artists = append(result.Artists, newArtists...)
		sort.SliceStable(result.Artists, func(i, j int) bool {
			return result.Artists[i].Score > result.Artists[j].Score
		})
	}

	if len(newRGs) > 0 {
		result.ReleaseGroups = append(result.ReleaseGroups, newRGs...)
		sort.SliceStable(result.ReleaseGroups, func(i, j int) bool {
			return result.ReleaseGroups[i].Score > result.ReleaseGroups[j].Score
		})
	}

	if len(newRecs) > 0 {
		result.Recordings = append(result.Recordings, newRecs...)
		sort.SliceStable(result.Recordings, func(i, j int) bool {
			return result.Recordings[i].Score > result.Recordings[j].Score
		})
	}
}

// The three functions below are the one definition of what an index row
// looks like as a card.  They were inline in mergeIndexHits, which is
// the only place that needed them until the shelves did; a shelf builds
// the same cards from the same rows and must not grow a second, subtly
// different projection of them.  Score is deliberately not set here —
// it is a property of a *search*, and a shelf has no query to be
// relevant to.

func artistFromIndex(h SearchIndexResult) MBArtist {
	return MBArtist{
		MBID:           h.MBID,
		Name:           h.Title,
		Type:           h.ArtistType,
		Country:        h.Country,
		Disambiguation: h.Disambiguation,
		SortName:       h.SortName,
		HasPopularity:  h.Popularity > 0,
		Popularity:     h.Popularity,
		ListenerCount:  h.ListenerCount,
		InLibrary:      h.InLibrary || h.LocalArtistID > 0,
		LocalID:        h.LocalArtistID,
	}
}

func releaseGroupFromIndex(h SearchIndexResult) MBReleaseGroup {
	var secondary []string
	if h.SecondaryTypes != "" {
		secondary = strings.Split(h.SecondaryTypes, ",")
	}

	return MBReleaseGroup{
		MBID:             h.MBID,
		Title:            h.Title,
		ArtistCredit:     h.ArtistName,
		ArtistMBID:       h.ArtistMBID,
		Popularity:       h.Popularity,
		ListenerCount:    h.ListenerCount,
		PrimaryType:      h.PrimaryType,
		SecondaryTypes:   secondary,
		FirstReleaseDate: h.ReleaseDate,
		InLibrary:        h.InLibrary || h.LocalReleaseGroupID > 0,
		LocalID:          h.LocalReleaseGroupID,
	}
}

func recordingFromIndex(h SearchIndexResult) MBRecording {
	return MBRecording{
		MBID:           h.MBID,
		Title:          h.Title,
		Length:         h.Duration,
		ArtistCredit:   h.ArtistName,
		ArtistMBID:     h.ArtistMBID,
		Popularity:     h.Popularity,
		ListenerCount:  h.ListenerCount,
		CAAReleaseMBID: h.CAAReleaseMBID,
		ReleaseName:    h.ReleaseName,
		InLibrary:      h.InLibrary || h.LocalRecordingID > 0,
		LocalID:        h.LocalRecordingID,
	}
}

// indexHitBlendedScore scores a local index hit on the same 0–100
// blended scale the MB rerank uses, so merged results sort and filter
// consistently regardless of source.
func indexHitBlendedScore(
	query, title, artist string,
	pop, maxPop int,
	inLibrary, isSimilar bool,
) int {
	rel := indexHitRelevance(query, title, artist)

	personal := 0.0

	switch {
	case inLibrary:
		personal = personalInLibrary
	case isSimilar:
		personal = personalSimilar
	}

	return int(blendedScoreFull(rel, pop, maxPop, personal) * 100) //nolint:mnd
}

// indexHitRelevance approximates a 0..1 text-relevance for an index hit
// from how cleanly the query matches its title or artist credit, taking
// the stronger of the two fields.
func indexHitRelevance(query, title, artist string) float64 {
	q := normalizeForMatch(query)
	if q == "" {
		return indexRelevanceFloor
	}

	best := indexRelevanceFloor

	for _, field := range [2]string{title, artist} {
		if field == "" {
			continue
		}

		f := normalizeForMatch(field)

		switch {
		case f == q:
			best = max(best, indexRelExact)
		case strings.HasPrefix(f, q):
			best = max(best, indexRelPrefix)
		case containsWord(f, q):
			best = max(best, indexRelWord)
		case strings.Contains(f, q):
			best = max(best, indexRelSubstring)
		}
	}

	return best
}

// ---------------------------------------------------------------------------
// Filtering and capping
// ---------------------------------------------------------------------------

func filterAndCap(result *MBSearchResult) {
	// Filter artists: remove SPAs, low-scoring results, and
	// low-popularity garbage when better alternatives exist.
	if len(result.Artists) > 0 {
		filtered := result.Artists[:0]

		// Find the max popularity among artists to calibrate the
		// garbage threshold.  If ANY artist has real popularity,
		// suppress zero-popularity results.
		maxPop := 0
		for _, a := range result.Artists {
			if a.Popularity > maxPop {
				maxPop = a.Popularity
			}
		}

		for _, a := range result.Artists {
			if mbSpecialPurposeArtists[a.MBID] {
				continue
			}

			if a.Score < minBlendedScore {
				continue
			}

			// Drop very-low-popularity results when the result
			// set contains meaningfully popular alternatives.
			if maxPop >= minPopularityFloor && a.HasPopularity &&
				a.Popularity < minPopularityFloor {
				continue
			}

			filtered = append(filtered, a)
		}

		result.Artists = filtered
	}

	// Filter release groups by minimum blended score + popularity floor.
	if len(result.ReleaseGroups) > 0 {
		filtered := result.ReleaseGroups[:0]

		for _, r := range result.ReleaseGroups {
			if r.Score >= minBlendedScore {
				filtered = append(filtered, r)
			}
		}

		result.ReleaseGroups = filtered
	}

	// Filter recordings by minimum blended score.
	if len(result.Recordings) > 0 {
		filtered := result.Recordings[:0]

		for _, r := range result.Recordings {
			if r.Score >= minBlendedScore {
				filtered = append(filtered, r)
			}
		}

		result.Recordings = filtered
	}

	// Cap each slice.
	if len(result.Artists) > maxResults {
		result.Artists = result.Artists[:maxResults]
	}

	if len(result.ReleaseGroups) > maxResults {
		result.ReleaseGroups = result.ReleaseGroups[:maxResults]
	}

	if len(result.Recordings) > maxResults {
		result.Recordings = result.Recordings[:maxResults]
	}
}

// ---------------------------------------------------------------------------
// Search result limits and thresholds
// ---------------------------------------------------------------------------

const (
	// indexSearchLimit is the number of results to fetch from the local
	// popularity index.  Larger than maxResults because results are
	// filtered and the index is the primary search domain.
	indexSearchLimit = 60

	// maxResults caps each entity slice after filtering.
	maxResults = 15

	// minBlendedScore is the absolute floor — no result survives
	// below this regardless of popularity.
	minBlendedScore = 15

	// minPopularityFloor is the minimum popularity required when
	// higher-popularity alternatives exist.  Results below this
	// threshold are dropped unless every result in that entity type
	// is below it (to avoid empty results for niche queries).
	minPopularityFloor = 50

	relevanceWeight       = 0.35
	popularityWeight      = 0.50
	personalizationWeight = 0.15

	// Personalization signal values (0.0–1.0).
	personalInLibrary = 1.0
	personalSimilar   = 0.5
)

// tierBonus maps artist name-match tiers to percentage score multipliers.
// Applied as: score = score * (1 + multiplier).  The spread is aggressive:
// close matches get amplified so popularity can dominate among them,
// while distant matches get heavily penalized to suppress garbage.
//
//nolint:gochecknoglobals
var tierBonus = map[int]float64{
	0: 0.25,  // exact match: +25%
	1: 0.15,  // starts with: +15%
	2: -0.10, // substring (query buried in name): -10%
	3: -0.30, // no substring match: -30%
}

// rgTierBonus maps release group match tiers to percentage multipliers.
// More aggressive spread to suppress results that match neither title
// nor artist credit.
//
//nolint:gochecknoglobals
var rgTierBonus = map[int]float64{
	0: 0.20,  // artist credit exact match: +20%
	1: 0.12,  // artist credit contains query: +12%
	2: 0.05,  // title exact match: +5%
	3: 0.0,   // title contains query: no change
	4: -0.25, // no match: -25%
}

// mbSpecialPurposeArtists is a set of MusicBrainz Special Purpose
// Artist MBIDs that should be excluded from search results.  These
// are placeholder entries (e.g. [unknown], [anonymous]) that
// accumulate thousands of recordings and artificially high
// popularity, polluting search results.
//
// See: https://musicbrainz.org/doc/Style/Unknown_and_untitled/Special_purpose_artist
//
//nolint:gochecknoglobals
var mbSpecialPurposeArtists = map[string]bool{
	"125ec42a-7229-4250-afc5-e057484327fe": true, // [unknown]
	"f731ccc4-e22a-43af-a747-64213f8768e7": true, // [anonymous]
	"33cf029c-63b0-41a0-9855-be2a3665fb3b": true, // [data]
	"314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc": true, // [dialogue]
	"eec63d3c-3b81-4ad4-b1e4-7c147c4d2b61": true, // [no artist]
	"9be7f096-97ec-4615-8957-8c3b659f51b4": true, // [traditional]
	"80a8851f-444c-4539-892b-ad2a49f7f0d0": true, // [Church bells]
	"ae636985-40e8-4fe2-80cb-9c1a21c6e30a": true, // Various Artists (SPA, accumulates bogus popularity)
	"89ad4ac3-39f7-470e-963a-56509c546377": true, // Various Artists (regular MBID, same issue)
}

// boostNameMatches re-sorts artists and release groups so that
// exact or substring name matches rank above results that only
// matched on common words like "the".  Without this, a search
// for "the teenagers" would rank The Beatles above The Teenagers
// because The Beatles' massive popularity compensates for their
// weak text relevance on the word "the".
//
// The boost is applied after popularity reranking so it acts as
// a final tiebreaker that respects user intent.
func (e *Service) boostNameMatches(query string, result *MBSearchResult) {
	q := strings.ToLower(strings.TrimSpace(query))
	if q == "" {
		return
	}

	// Apply tier multiplier to artist scores.  Percentage-based so the
	// boost scales with the artist's existing score — a popular
	// near-match can overcome an unpopular exact match when the
	// popularity gap is proportionally larger than the tier difference.
	if len(result.Artists) > 1 {
		for i := range result.Artists {
			tier := nameMatchTier(q, strings.ToLower(result.Artists[i].Name))
			result.Artists[i].Score = int(
				float64(result.Artists[i].Score) * (1.0 + tierBonus[tier]),
			)
		}

		sort.SliceStable(result.Artists, func(i, j int) bool {
			return result.Artists[i].Score > result.Artists[j].Score
		})

		// For same-named artists in tier 0, resolve ordering via
		// a targeted LB popularity lookup.
		e.disambiguateSameNameArtists(q, result.Artists)
	}

	// Apply tier multiplier to release group scores.
	if len(result.ReleaseGroups) > 1 {
		for i := range result.ReleaseGroups {
			tier := rgMatchTier(q,
				strings.ToLower(result.ReleaseGroups[i].Title),
				strings.ToLower(result.ReleaseGroups[i].ArtistCredit))
			result.ReleaseGroups[i].Score = int(
				float64(result.ReleaseGroups[i].Score) * (1.0 + rgTierBonus[tier]),
			)
		}

		sort.SliceStable(result.ReleaseGroups, func(i, j int) bool {
			return result.ReleaseGroups[i].Score > result.ReleaseGroups[j].Score
		})
	}
}

// disambiguateSameNameArtists resolves ordering among artists that
// share the exact same name as the query, by popularity descending.
// It runs entirely from the local index — no network: index-sourced
// artist rows already carry their listen count, and a single
// GetPopularityBatch fills any that don't.  Keeping this offline is
// what lets SearchLocal honour its "no network calls" contract; a live
// lookup here previously stalled the hot search path.
func (e *Service) disambiguateSameNameArtists(query string, artists []MBArtist) {
	// Find the contiguous block of tier-0 same-name artists at the front.
	var sameNameEnd int

	for sameNameEnd < len(artists) {
		if strings.ToLower(artists[sameNameEnd].Name) != query {
			break
		}

		sameNameEnd++
	}

	if sameNameEnd < 2 {
		return // 0 or 1 same-name artists — nothing to disambiguate
	}

	// Collect MBIDs so the index can fill popularity for any rows that
	// don't already carry it (e.g. artists sourced from the MB pipeline).
	mbids := make([]string, 0, sameNameEnd)
	for i := range sameNameEnd {
		if artists[i].MBID != "" {
			mbids = append(mbids, artists[i].MBID)
		}
	}

	if len(mbids) < 2 {
		return
	}

	// Index-only popularity lookup — no network.
	var indexPop map[string]int
	if batch := e.index.GetPopularityBatch(mbids); batch != nil {
		indexPop = batch.Popularity
	}

	// Prefer the index popularity, falling back to whatever listen count
	// the artist row already carries when the index has none.
	popOf := func(a MBArtist) int {
		if p, ok := indexPop[a.MBID]; ok && p > 0 {
			return p
		}

		return a.Popularity
	}

	// Re-sort the same-name block by popularity descending.
	sort.SliceStable(artists[:sameNameEnd], func(i, j int) bool {
		return popOf(artists[i]) > popOf(artists[j])
	})
}

// nameMatchTier returns a tier value for how well a name matches
// the query.  Lower is better:
//
//	0 = exact match ("the teenagers" == "the teenagers")
//	1 = name starts with query ("the teenagers" in "the teenagers feat. X")
//	2 = query is a substring ("the teenagers" in "al supersonic & the teenagers")
//	3 = no substring match (only individual words matched)
func nameMatchTier(query, name string) int {
	if name == query {
		return 0
	}

	if strings.HasPrefix(name, query) {
		return 1
	}

	if strings.Contains(name, query) {
		return 2
	}

	return 3
}

// rgMatchTier returns a tier for release groups considering both
// the title and artist credit.  An album by "Hop Along" called
// "Painted Shut" should rank above a tribute album called
// "A Hop Along Tribute" by Various Artists.
//
//	0 = artist credit matches query exactly ("hop along" == "hop along")
//	1 = artist credit starts with or contains query
//	2 = title matches query exactly
//	3 = title starts with or contains query
//	4 = no match in either field
func rgMatchTier(query, title, artistCredit string) int {
	// Artist credit match is stronger — it means the album is BY
	// the searched artist, not just mentioning them in the title.
	if artistCredit == query {
		return 0
	}

	if strings.Contains(artistCredit, query) {
		return 1
	}

	// Title match — the album name contains the query.
	if title == query {
		return 2
	}

	if strings.Contains(title, query) {
		return 3
	}

	return 4
}

// ---------------------------------------------------------------------------
// Top Results — intent-scored cards
// ---------------------------------------------------------------------------

const (
	// topResultsMax is the maximum number of top-result cards to
	// return.  Bounded because they occupy expensive horizontal
	// screen real estate above the main search lists.
	topResultsMax = 5

	// topResultsPerCatMax caps how many cards from a single
	// category can appear in the final selection.  Keeps the row
	// from being all-artists or all-recordings on lopsided queries.
	topResultsPerCatMax = 2

	// topResultsMinScore is the absolute floor for a candidate's
	// final score (quality * prior).  Nothing below this survives,
	// regardless of category or rank.
	topResultsMinScore = 0.08

	// topResultsCandidates is how many candidates per category
	// feed into intent scoring.  Larger = more chances to surface
	// a better card, smaller = faster and less susceptible to
	// main-rerank noise.
	topResultsCandidates = 10

	// topResultsExactScanLimit caps how deep into each main result
	// list we'll scan for exact title/artist matches that didn't
	// make the top-N rerank.  This is the safety net for the case
	// where MB returns dozens of identically-relevant candidates
	// (covers of a popular song) and the rerank fails to surface
	// the canonical version because its popularity isn't indexed.
	topResultsExactScanLimit = 50

	// topResultsExactCap is how many exact-match candidates per
	// category can enter the candidate pool from the dedicated
	// ExactMatches retrieval source.
	topResultsExactCap = 3

	// topResultsClickDecay is the half-life of a per-query click
	// boost in days.  Longer = stickier, shorter = more
	// responsive to recent intent.
	topResultsClickDecay = 30.0

	// topResultsRowConfidence is the minimum gap between the
	// winning category's intent prior and the runner-up before we
	// show the row at all.  Below this we hide the row entirely
	// — it's better to show nothing than a wrong guess.
	topResultsRowConfidence = 0.12

	// Feature weights for candidate quality scoring.  Sum is not
	// required to be 1.0 because the final score is multiplied
	// by the intent prior separately.  Tune these against
	// specific query cases that behave wrong.
	fwExactTitle   = 1.00 // normalized title matches query exactly
	fwExactArtist  = 0.90 // artist name matches query exactly
	fwPrefixTitle  = 0.60 // title starts with query
	fwContainsWord = 0.40 // title contains query as a whole word
	fwContainsAny  = 0.20 // title has query as any substring
	fwListenLog    = 0.80 // log-scaled listen count (0 when 0 listens)
	fwListenerLog  = 0.60 // log-scaled listener count
	fwInLibrary    = 0.50 // owned by the user
	fwSimilar      = 0.20 // similar to an owned artist
	fwClusterBig   = 0.15 // release-group is a known canonical (many releases)
	fwOfficialOnly = 0.10 // official-status release only (not a bootleg)

	// priorAlpha controls how much the intent prior influences
	// final ranking.  Higher values make category dominance
	// more decisive; lower values let individual quality scores
	// win across categories.
	priorAlpha = 1.5
)

// resolveTopResults computes intent-scored top result cards from the
// already-reranked search results plus a dedicated exact-match
// retrieval source.  Returns 0-5 cards sorted by final score
// descending.
//
// Pipeline:
//  1. Retrieve candidates from three sources: top-N per category from
//     the main reranked result + exact title/artist matches from the
//     local index.  Union them, deduping by MBID.
//  2. Score each candidate using a featurized additive scorer with
//     explicit named features.  Quality is purely candidate-side; no
//     cross-candidate normalization.
//  3. Compute a category intent prior from the catalog signals
//     (listen-count distribution per category, query shape rules,
//     exact-match counts).  Multiply quality by prior^alpha.
//  4. Sort by final score, apply per-category caps and the row-level
//     confidence threshold.  Return up to topResultsMax cards.
func (e *Service) resolveTopResults(query string, result *MBSearchResult) []TopResult {
	q := strings.ToLower(strings.TrimSpace(query))
	if q == "" {
		return nil
	}

	// Stage 1: gather candidates.
	clicksStart := time.Now()
	clicks := e.getSearchClicks(q)
	clicksDur := time.Since(clicksStart)

	exactStart := time.Now()
	exactMatches := e.index.ExactMatches(q, topResultsExactCap)
	exactDur := time.Since(exactStart)

	if clicksDur+exactDur > 100*time.Millisecond {
		e.logger.Info("search top results: slow candidate gather",
			"query", query,
			"getSearchClicks", clicksDur.Round(time.Microsecond),
			"exactMatches", exactDur.Round(time.Microsecond),
		)
	}

	gatherStart := time.Now()
	candidates := e.gatherTopCandidates(q, result, exactMatches, clicks)
	gatherDur := time.Since(gatherStart)

	if gatherDur > 100*time.Millisecond {
		e.logger.Info("search top results: slow gatherTopCandidates",
			"query", query,
			"candidates", len(candidates),
			"elapsed", gatherDur.Round(time.Microsecond),
		)
	}

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

	// Identify candidates that hit an exact-match feature so the
	// intent prior can boost their categories accordingly.  This
	// is what catches Blue October's "Calling You" — even if the
	// local index never heard of Blue October, the MB result list
	// has the recording with title == query, and the prior should
	// know that strengthens the recording category.  Composite
	// matches (query contains both the title and the artist of a
	// recording or album) are treated the same way.
	var exactCandidates []topCandidate

	for _, c := range candidates {
		isExact := isExactNameMatch(q, c.topResult.Name) ||
			isExactNameMatch(q, c.topResult.ArtistCredit) ||
			isCompositeMatch(q, c.topResult.Name, c.topResult.ArtistCredit)
		if isExact {
			exactCandidates = append(exactCandidates, c)
		}
	}

	// Stage 2: compute the category intent prior.
	prior := e.computeIntentPrior(q, result, exactMatches, exactCandidates)

	// Confidence gate: hide the row entirely if no category clearly
	// dominates.  Better to show nothing than a wrong guess.
	//
	// Override: if any candidate hits an exact match against an
	// entity with non-zero listener count, the row should always
	// show.  An exact match is itself a confidence signal — even
	// when shape and listener-distribution don't agree.
	confident := priorConfidence(prior) >= topResultsRowConfidence
	if !confident {
		for _, c := range exactCandidates {
			if c.qualityScore >= 1.0 { // exact match contributes >= fwExactTitle
				confident = true

				break
			}
		}
	}

	if !confident {
		e.logger.Info("search top results: prior too flat, hiding row",
			"query", query,
			"candidates", len(candidates),
			"exact_candidates", len(exactCandidates),
			"prior_artist", prior.artist,
			"prior_album", prior.album,
			"prior_recording", prior.recording,
		)

		return nil
	}

	// Stage 3: combine quality with prior.
	for i := range candidates {
		c := &candidates[i]

		var p float64

		switch c.category {
		case "artist":
			p = prior.artist
		case "release_group":
			p = prior.album
		case "recording":
			p = prior.recording
		}

		c.finalScore = c.qualityScore * math.Pow(p, priorAlpha)
	}

	// Stage 4: sort, dedupe by MBID, apply caps.
	sort.SliceStable(candidates, func(i, j int) bool {
		return candidates[i].finalScore > candidates[j].finalScore
	})

	catCount := make(map[string]int, 3) //nolint:mnd
	seen := make(map[string]bool, len(candidates))

	var selected []TopResult

	for _, c := range candidates {
		if len(selected) >= topResultsMax {
			break
		}

		if c.finalScore < topResultsMinScore {
			break
		}

		if catCount[c.category] >= topResultsPerCatMax {
			continue
		}

		if seen[c.topResult.MBID] {
			continue
		}

		c.topResult.IntentScore = c.finalScore
		selected = append(selected, c.topResult)
		catCount[c.category]++
		seen[c.topResult.MBID] = true
	}

	if len(selected) > 0 {
		topName := selected[0].Name
		if selected[0].ArtistCredit != "" {
			topName = topName + " — " + selected[0].ArtistCredit
		}

		e.logger.Info("search top results selected",
			"query", query,
			"count", len(selected),
			"candidates", len(candidates),
			"exact_candidates", len(exactCandidates),
			"prior_artist", prior.artist,
			"prior_album", prior.album,
			"prior_recording", prior.recording,
			"top", topName,
			"top_score", selected[0].IntentScore,
		)
	}

	rgResolveStart := time.Now()

	e.resolveTopResultReleaseGroups(selected)

	if d := time.Since(rgResolveStart); d > 100*time.Millisecond {
		e.logger.Info("search top results: slow RG resolve",
			"query", query,
			"selected", len(selected),
			"elapsed", d.Round(time.Microsecond),
		)
	}

	return selected
}

// resolveTopResultReleaseGroups resolves each recording top-result's parent
// release group (from its CAA release MBID) so a track click can open the
// album page with the track highlighted — the same behaviour as clicking a
// track anywhere else.  Uses a single local index query; recordings whose
// release can't be resolved simply keep an empty ReleaseGroupMBID and fall
// back to a name-only navigation on the frontend.
func (e *Service) resolveTopResultReleaseGroups(results []TopResult) {
	var caaMBIDs []string

	for i := range results {
		if results[i].EntityType == "recording" && results[i].CAAReleaseMBID != "" {
			caaMBIDs = append(caaMBIDs, results[i].CAAReleaseMBID)
		}
	}

	if len(caaMBIDs) == 0 {
		return
	}

	rgByCAA := e.index.ReleaseGroupMBIDsForCAAReleaseMBIDs(caaMBIDs)

	for i := range results {
		if results[i].EntityType != "recording" {
			continue
		}

		if rg, ok := rgByCAA[results[i].CAAReleaseMBID]; ok {
			results[i].ReleaseGroupMBID = rg
		}
	}
}

// resolveRecordingReleaseGroups fills each recording's parent release
// group (from its CAA release MBID) in a single local index query, so
// a track shown in search results can link to its album page with the
// track highlighted.  Recordings whose release can't be resolved keep
// an empty ReleaseGroupMBID and fall back to non-linked text.
func (e *Service) resolveRecordingReleaseGroups(recordings []MBRecording) {
	var caaMBIDs []string

	for i := range recordings {
		if recordings[i].CAAReleaseMBID != "" {
			caaMBIDs = append(caaMBIDs, recordings[i].CAAReleaseMBID)
		}
	}

	if len(caaMBIDs) == 0 {
		return
	}

	rgByCAA := e.index.ReleaseGroupMBIDsForCAAReleaseMBIDs(caaMBIDs)

	for i := range recordings {
		if rg, ok := rgByCAA[recordings[i].CAAReleaseMBID]; ok {
			recordings[i].ReleaseGroupMBID = rg
		}
	}
}

// topCandidate is a single scored candidate flowing through the
// top-results pipeline.  qualityScore is the per-candidate signal
// without category bias; finalScore is qualityScore multiplied by
// the category prior at selection time.
type topCandidate struct {
	topResult    TopResult
	category     string
	qualityScore float64
	finalScore   float64
}

// intentPrior is a probability distribution over the three entity
// categories: how likely the user is searching for an artist, an
// album, or a recording.  Sums to 1.0.
type intentPrior struct {
	artist    float64
	album     float64
	recording float64
}

// gatherTopCandidates builds the candidate pool from the top-N
// per category of the main reranked result plus exact-match results
// from two sources: the dedicated local-index ExactMatches lookup
// and any results in the MB list whose title/artist exactly equal
// the query.  Each candidate is scored once with the featurized
// quality scorer.  Duplicates (same MBID) are deduped, keeping the
// highest quality score.
func (e *Service) gatherTopCandidates(
	q string,
	result *MBSearchResult,
	exactMatches []SearchIndexResult,
	clicks map[string]searchClick,
) []topCandidate {
	candidates := make([]topCandidate, 0, topResultsCandidates*3+len(exactMatches))
	byMBID := make(map[string]int, cap(candidates))

	add := func(cand topCandidate) {
		if cand.topResult.MBID == "" {
			return
		}

		if existing, ok := byMBID[cand.topResult.MBID]; ok {
			if cand.qualityScore > candidates[existing].qualityScore {
				candidates[existing] = cand
			}

			return
		}

		byMBID[cand.topResult.MBID] = len(candidates)
		candidates = append(candidates, cand)
	}

	// Source 1: top-N artists from the main rerank.
	limit := topResultsCandidates
	if limit > len(result.Artists) {
		limit = len(result.Artists)
	}

	for i := range limit {
		a := result.Artists[i]

		quality := e.scoreArtistCandidate(q, &a, clicks)
		add(topCandidate{
			topResult: TopResult{
				EntityType: "artist",
				MBID:       a.MBID,
				Name:       a.Name,
				ArtistType: a.Type,
				Country:    a.Country,
				InLibrary:  a.InLibrary,
				LocalID:    a.LocalID,
			},
			category:     "artist",
			qualityScore: quality,
		})
	}

	// Source 1b: scan the entire artist list (capped at
	// topResultsExactScanLimit) for exact name matches that didn't
	// make the top-N rerank.  Without this, an artist with a
	// perfect name match buried at position 12 by the rerank
	// could never become a top-result candidate.
	scanLimit := topResultsExactScanLimit
	if scanLimit > len(result.Artists) {
		scanLimit = len(result.Artists)
	}

	for i := topResultsCandidates; i < scanLimit; i++ {
		a := result.Artists[i]
		if !isExactNameMatch(q, a.Name) {
			continue
		}

		quality := e.scoreArtistCandidate(q, &a, clicks)
		add(topCandidate{
			topResult: TopResult{
				EntityType: "artist",
				MBID:       a.MBID,
				Name:       a.Name,
				ArtistType: a.Type,
				Country:    a.Country,
				InLibrary:  a.InLibrary,
				LocalID:    a.LocalID,
			},
			category:     "artist",
			qualityScore: quality,
		})
	}

	// Source 2: top-N release groups from the main rerank.
	limit = topResultsCandidates
	if limit > len(result.ReleaseGroups) {
		limit = len(result.ReleaseGroups)
	}

	for i := range limit {
		rg := result.ReleaseGroups[i]

		quality := e.scoreReleaseGroupCandidate(q, &rg, clicks)

		year := ""
		if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd
			year = rg.FirstReleaseDate[:4]
		}

		add(topCandidate{
			topResult: TopResult{
				EntityType:   "release_group",
				MBID:         rg.MBID,
				Name:         rg.Title,
				ArtistCredit: rg.ArtistCredit,
				ArtistMBID:   rg.ArtistMBID,
				PrimaryType:  rg.PrimaryType,
				Year:         year,
				InLibrary:    rg.InLibrary,
				LocalID:      rg.LocalID,
			},
			category:     "release_group",
			qualityScore: quality,
		})
	}

	// Source 2b: scan the rest of the release-group list for
	// exact title or artist matches.  Same rationale as Source 1b.
	// Also catches composite matches (e.g. "abbey road beatles").
	scanLimit = topResultsExactScanLimit
	if scanLimit > len(result.ReleaseGroups) {
		scanLimit = len(result.ReleaseGroups)
	}

	for i := topResultsCandidates; i < scanLimit; i++ {
		rg := result.ReleaseGroups[i]

		exactTitle := isExactNameMatch(q, rg.Title)
		exactArtist := isExactNameMatch(q, rg.ArtistCredit)
		composite := isCompositeMatch(q, rg.Title, rg.ArtistCredit)

		if !exactTitle && !exactArtist && !composite {
			continue
		}

		quality := e.scoreReleaseGroupCandidate(q, &rg, clicks)
		if composite && !exactTitle && !exactArtist {
			quality += fwExactTitle
		}

		year := ""
		if len(rg.FirstReleaseDate) >= 4 { //nolint:mnd
			year = rg.FirstReleaseDate[:4]
		}

		add(topCandidate{
			topResult: TopResult{
				EntityType:   "release_group",
				MBID:         rg.MBID,
				Name:         rg.Title,
				ArtistCredit: rg.ArtistCredit,
				ArtistMBID:   rg.ArtistMBID,
				PrimaryType:  rg.PrimaryType,
				Year:         year,
				InLibrary:    rg.InLibrary,
				LocalID:      rg.LocalID,
			},
			category:     "release_group",
			qualityScore: quality,
		})
	}

	// Source 3: top-N recordings from the main rerank.
	limit = topResultsCandidates
	if limit > len(result.Recordings) {
		limit = len(result.Recordings)
	}

	for i := range limit {
		r := result.Recordings[i]

		quality := e.scoreRecordingCandidate(q, &r, clicks)
		add(topCandidate{
			topResult: TopResult{
				EntityType:     "recording",
				MBID:           r.MBID,
				Name:           r.Title,
				ArtistCredit:   r.ArtistCredit,
				ArtistMBID:     r.ArtistMBID,
				Length:         r.Length,
				CAAReleaseMBID: r.CAAReleaseMBID,
				ReleaseName:    r.ReleaseName,
				InLibrary:      r.InLibrary,
				LocalID:        r.LocalID,
			},
			category:     "recording",
			qualityScore: quality,
		})
	}

	// Source 3b: scan the rest of the recording list for exact
	// matches.  This is the critical fix for the case where MB
	// returns 75 recordings all with relevance 100 — the rerank
	// can only differentiate them by popularity (which may be
	// missing for many), so a popular exact match like Blue
	// October's "Calling You" might land at position 11+.  By
	// scanning the full list for exact matches, we surface them
	// regardless of where the rerank put them.
	//
	// Also catches "composite" matches: when the query contains
	// both the recording title AND the artist credit (e.g.
	// "calling you blue october"), the recording is a strong
	// candidate even though neither field equals the full query.
	scanLimit = topResultsExactScanLimit
	if scanLimit > len(result.Recordings) {
		scanLimit = len(result.Recordings)
	}

	for i := topResultsCandidates; i < scanLimit; i++ {
		r := result.Recordings[i]

		exactTitle := isExactNameMatch(q, r.Title)
		exactArtist := isExactNameMatch(q, r.ArtistCredit)
		composite := isCompositeMatch(q, r.Title, r.ArtistCredit)

		if !exactTitle && !exactArtist && !composite {
			continue
		}

		quality := e.scoreRecordingCandidate(q, &r, clicks)

		// Composite matches don't get the exact-title feature
		// from the scorer (because neither field equals the
		// query), so add the bonus explicitly here so they
		// compete with title-only exact matches.
		if composite && !exactTitle && !exactArtist {
			quality += fwExactTitle
		}

		add(topCandidate{
			topResult: TopResult{
				EntityType:     "recording",
				MBID:           r.MBID,
				Name:           r.Title,
				ArtistCredit:   r.ArtistCredit,
				ArtistMBID:     r.ArtistMBID,
				Length:         r.Length,
				CAAReleaseMBID: r.CAAReleaseMBID,
				ReleaseName:    r.ReleaseName,
				InLibrary:      r.InLibrary,
				LocalID:        r.LocalID,
			},
			category:     "recording",
			qualityScore: quality,
		})
	}

	// Source 4: exact matches from the local index.  These bypass
	// the main rerank entirely so a high-popularity entity buried
	// at position 8 in the MB result list still gets surfaced.
	for _, m := range exactMatches {
		quality := e.scoreExactMatch(q, &m, clicks)

		switch m.EntityType {
		case "artist":
			add(topCandidate{
				topResult: TopResult{
					EntityType: "artist",
					MBID:       m.MBID,
					Name:       m.Title,
					ArtistType: m.ArtistType,
					Country:    m.Country,
					InLibrary:  m.InLibrary || m.LocalArtistID > 0,
					LocalID:    m.LocalArtistID,
				},
				category:     "artist",
				qualityScore: quality,
			})
		case "release_group":
			year := ""
			if len(m.ReleaseDate) >= 4 { //nolint:mnd
				year = m.ReleaseDate[:4]
			}

			add(topCandidate{
				topResult: TopResult{
					EntityType:   "release_group",
					MBID:         m.MBID,
					Name:         m.Title,
					ArtistCredit: m.ArtistName,
					ArtistMBID:   m.ArtistMBID,
					PrimaryType:  m.PrimaryType,
					Year:         year,
					InLibrary:    m.InLibrary || m.LocalReleaseGroupID > 0,
					LocalID:      m.LocalReleaseGroupID,
				},
				category:     "release_group",
				qualityScore: quality,
			})
		case "recording":
			add(topCandidate{
				topResult: TopResult{
					EntityType:   "recording",
					MBID:         m.MBID,
					Name:         m.Title,
					ArtistCredit: m.ArtistName,
					ArtistMBID:   m.ArtistMBID,
					Length:       m.Duration,
					InLibrary:    m.InLibrary || m.LocalRecordingID > 0,
					LocalID:      m.LocalRecordingID,
				},
				category:     "recording",
				qualityScore: quality,
			})
		}
	}

	return candidates
}

// scoreArtistCandidate computes the featurized quality score for an
// artist top-result candidate.  Pure additive — no cross-candidate
// normalization, no popularity squaring.
func (e *Service) scoreArtistCandidate(
	q string,
	a *MBArtist,
	clicks map[string]searchClick,
) float64 {
	name := strings.ToLower(a.Name)
	qn := normalizeForMatch(q)
	nn := normalizeForMatch(a.Name)

	score := 0.0

	switch {
	case nn == qn:
		score += fwExactTitle
	case strings.HasPrefix(name, q):
		score += fwPrefixTitle
	case containsWord(name, q):
		score += fwContainsWord
	case strings.Contains(name, q):
		score += fwContainsAny
	}

	score += fwListenLog * normLog(a.Popularity)
	score += fwListenerLog * normLog(a.ListenerCount)

	if a.InLibrary {
		score += fwInLibrary
	}

	if cb := clicks[a.MBID]; cb.count > 0 {
		score += clickFeature(cb)
	}

	return score
}

// scoreReleaseGroupCandidate computes the featurized quality score
// for a release-group top-result candidate.
func (e *Service) scoreReleaseGroupCandidate(
	q string,
	rg *MBReleaseGroup,
	clicks map[string]searchClick,
) float64 {
	title := strings.ToLower(rg.Title)
	credit := strings.ToLower(rg.ArtistCredit)
	qn := normalizeForMatch(q)
	tn := normalizeForMatch(rg.Title)
	cn := normalizeForMatch(rg.ArtistCredit)

	score := 0.0

	switch {
	case tn == qn:
		score += fwExactTitle
	case cn == qn && len(qn) >= 3: //nolint:mnd
		score += fwExactArtist
	case strings.HasPrefix(title, q):
		score += fwPrefixTitle
	case containsWord(title, q):
		score += fwContainsWord
	case strings.Contains(title, q):
		score += fwContainsAny
	}

	score += fwListenLog * normLog(rg.Popularity)
	score += fwListenerLog * normLog(rg.ListenerCount)

	if rg.InLibrary {
		score += fwInLibrary
	}

	// Penalize "Various Artists" compilations — they tend to dominate
	// covers searches without being what the user wants.
	if strings.Contains(credit, "various artists") {
		score *= 0.5 //nolint:mnd
	}

	if cb := clicks[rg.MBID]; cb.count > 0 {
		score += clickFeature(cb)
	}

	return score
}

// scoreRecordingCandidate computes the featurized quality score for
// a recording top-result candidate.
func (e *Service) scoreRecordingCandidate(
	q string,
	r *MBRecording,
	clicks map[string]searchClick,
) float64 {
	title := strings.ToLower(r.Title)
	qn := normalizeForMatch(q)
	tn := normalizeForMatch(r.Title)
	cn := normalizeForMatch(r.ArtistCredit)

	score := 0.0

	switch {
	case tn == qn:
		score += fwExactTitle
	case cn == qn && len(qn) >= 3: //nolint:mnd
		score += fwExactArtist
	case strings.HasPrefix(title, q):
		score += fwPrefixTitle
	case containsWord(title, q):
		score += fwContainsWord
	case strings.Contains(title, q):
		score += fwContainsAny
	}

	score += fwListenLog * normLog(r.Popularity)
	score += fwListenerLog * normLog(r.ListenerCount)

	if r.InLibrary {
		score += fwInLibrary
	}

	if cb := clicks[r.MBID]; cb.count > 0 {
		score += clickFeature(cb)
	}

	return score
}

// scoreExactMatch computes a featurized quality score for a
// candidate sourced from ExactMatches.  Always assigns the exact
// match feature bonus on top of the standard quality features so
// that exact matches reliably outrank fuzzy ones.
func (e *Service) scoreExactMatch(
	q string,
	m *SearchIndexResult,
	clicks map[string]searchClick,
) float64 {
	title := strings.ToLower(m.Title)
	credit := strings.ToLower(m.ArtistName)

	score := 0.0

	switch {
	case title == q:
		score += fwExactTitle
	case credit == q:
		score += fwExactArtist
	default:
		// Shouldn't happen — ExactMatches only returns rows whose
		// title or artist matches.  Defensive fallback.
		score += fwContainsWord
	}

	score += fwListenLog * normLog(m.Popularity)
	score += fwListenerLog * normLog(m.ListenerCount)

	if m.InLibrary || m.LocalArtistID > 0 || m.LocalReleaseGroupID > 0 || m.LocalRecordingID > 0 {
		score += fwInLibrary
	}

	if cb := clicks[m.MBID]; cb.count > 0 {
		score += clickFeature(cb)
	}

	return score
}

// computeIntentPrior derives a category probability distribution
// from the query shape and the catalog signals available in the
// candidate pool.  Returns weights summing to ~1.0.
//
// Strategy: start with a uniform prior, then apply signal-based
// adjustments.  The strongest signals (exact name match against a
// popular artist, dominant track-cover-wave pattern) bias the prior
// hard; weaker signals (query length, listen-count distribution)
// nudge it.  Finally normalize to a probability distribution.
//
// The exactCandidates parameter is the list of candidates that hit
// an exact-match feature (either via the local index ExactMatches
// retrieval or via the MB result-list scan in gatherTopCandidates).
// These provide the strongest evidence we have for "the user means
// this category" and dominate weaker signals.
func (e *Service) computeIntentPrior(
	q string,
	result *MBSearchResult,
	exactMatches []SearchIndexResult,
	exactCandidates []topCandidate,
) intentPrior {
	// Start with a slight lean toward recordings — most music
	// searches in practice are for songs.  Mild enough that
	// other signals can override.
	weights := intentPrior{
		artist:    1.0,
		album:     1.0,
		recording: 1.2, //nolint:mnd
	}

	// Signal: query length (word count).  Single-word queries skew
	// strongly artist; long queries skew strongly toward
	// titles (album or recording).
	wordCount := len(strings.Fields(q))

	switch {
	case wordCount == 1:
		weights.artist *= 2.0    //nolint:mnd
		weights.album *= 0.7     //nolint:mnd
		weights.recording *= 0.7 //nolint:mnd
	case wordCount >= 4: //nolint:mnd
		weights.artist *= 0.5    //nolint:mnd
		weights.album *= 1.2     //nolint:mnd
		weights.recording *= 1.3 //nolint:mnd
	}

	// Signal: exact matches in the local index.  An exact match
	// against a popular artist is the strongest evidence we
	// have for "the user means this artist".  Scale by listener
	// count so a popular exact match dominates and an obscure
	// one doesn't move the needle.
	//
	// As above, a Title match is evidence for the entity's own
	// category; an ArtistName match is evidence for the artist
	// category specifically, even when the matching row is a
	// release_group/recording — otherwise an artist's own catalog
	// entries in the local index would outvote the artist itself.
	// Take the strongest boost per target instead of multiplying
	// once per matching row.
	localOwnCategoryBoost := map[string]float64{}

	localArtistNameBoost := 0.0

	for _, m := range exactMatches {
		titleMatch := isExactNameMatch(q, m.Title)
		artistMatch := isExactNameMatch(q, m.ArtistName)

		if !titleMatch && !artistMatch {
			continue
		}

		// Confidence boost scales with log listener count.
		boost := 1.0 + 1.5*normLog(m.ListenerCount) //nolint:mnd

		if titleMatch && boost > localOwnCategoryBoost[m.EntityType] {
			localOwnCategoryBoost[m.EntityType] = boost
		}

		if artistMatch && boost > localArtistNameBoost {
			localArtistNameBoost = boost
		}
	}

	for cat, boost := range localOwnCategoryBoost {
		switch cat {
		case "artist":
			weights.artist *= boost
		case "release_group":
			weights.album *= boost
		case "recording":
			weights.recording *= boost
		}
	}

	if localArtistNameBoost > 0 {
		weights.artist *= localArtistNameBoost
	}

	// Signal: exact-match candidates discovered in the MB result
	// list (Source 1b/2b/3b in gatherTopCandidates).  These cover
	// the case where the local index doesn't have the entity but
	// MB does — e.g. Blue October's "Calling You" when Blue
	// October isn't yet a known artist.  Same scaling as
	// index-sourced exact matches.
	//
	// A title match is evidence for that candidate's own category.
	// An artist-credit match is evidence for the *artist* category
	// specifically, regardless of what kind of entity carries the
	// credit — searching an artist's exact name naturally surfaces
	// their whole discography in the release/recording pools, and
	// crediting each of those to "album"/"recording" would drown
	// out the one true artist candidate. Take the strongest boost
	// per target rather than multiplying once per matching item, so
	// an artist with many releases doesn't compound the signal.
	var (
		ownCategoryBoost  = map[string]float64{}
		artistCreditBoost float64
	)

	for _, c := range exactCandidates {
		var listeners int

		switch c.category {
		case "artist":
			listeners = artistListenerByMBID(result.Artists, c.topResult.MBID)
		case "release_group":
			listeners = rgListenerByMBID(result.ReleaseGroups, c.topResult.MBID)
		case "recording":
			listeners = recListenerByMBID(result.Recordings, c.topResult.MBID)
		}

		boost := 1.0 + 1.0*normLog(listeners) //nolint:mnd

		if isExactNameMatch(q, c.topResult.Name) && boost > ownCategoryBoost[c.category] {
			ownCategoryBoost[c.category] = boost
		}

		if isExactNameMatch(q, c.topResult.ArtistCredit) && boost > artistCreditBoost {
			artistCreditBoost = boost
		}
	}

	for cat, boost := range ownCategoryBoost {
		switch cat {
		case "artist":
			weights.artist *= boost
		case "release_group":
			weights.album *= boost
		case "recording":
			weights.recording *= boost
		}
	}

	if artistCreditBoost > 0 {
		weights.artist *= artistCreditBoost
	}

	// Signal: many recordings in the result list with the same
	// title as the query → cover-wave pattern → strong recording.
	titleMatches := 0

	for _, r := range result.Recordings {
		if isExactNameMatch(q, r.Title) {
			titleMatches++
		}
	}

	if titleMatches >= 5 { //nolint:mnd
		weights.recording *= 1.8 //nolint:mnd
	} else if titleMatches >= 2 { //nolint:mnd
		weights.recording *= 1.3 //nolint:mnd
	}

	// Signal: aggregate listener count per category in the
	// candidate pool.  Sum the top 5 per category and use the
	// proportional split as a soft nudge.  Recordings naturally
	// have higher listen counts than albums (each play increments
	// the recording, not the album), so we use *listener* count
	// rather than *listen* count to dampen that bias.
	artistListeners := sumTopListeners(artistListenerCounts(result.Artists), 5)  //nolint:mnd
	albumListeners := sumTopListeners(rgListenerCounts(result.ReleaseGroups), 5) //nolint:mnd
	recListeners := sumTopListeners(recListenerCounts(result.Recordings), 5)     //nolint:mnd

	totalListeners := artistListeners + albumListeners + recListeners
	if totalListeners > 0 {
		// Apply as a 0.5x nudge so it doesn't override stronger
		// signals.  We'd rather trust shape and exact matches
		// than raw listener distributions.
		weights.artist *= 1.0 + 0.5*float64(artistListeners)/float64(totalListeners) //nolint:mnd
		weights.album *= 1.0 + 0.5*float64(albumListeners)/float64(totalListeners)   //nolint:mnd
		weights.recording *= 1.0 + 0.5*float64(recListeners)/float64(totalListeners) //nolint:mnd
	}

	// Normalize to a probability distribution.
	total := weights.artist + weights.album + weights.recording
	if total <= 0 {
		return intentPrior{artist: 1.0 / 3.0, album: 1.0 / 3.0, recording: 1.0 / 3.0} //nolint:mnd
	}

	return intentPrior{
		artist:    weights.artist / total,
		album:     weights.album / total,
		recording: weights.recording / total,
	}
}

// artistListenerByMBID returns the listener count for the artist
// with the given MBID, or 0 when not found.
func artistListenerByMBID(arts []MBArtist, mbid string) int {
	for _, a := range arts {
		if a.MBID == mbid {
			return a.ListenerCount
		}
	}

	return 0
}

func rgListenerByMBID(rgs []MBReleaseGroup, mbid string) int {
	for _, rg := range rgs {
		if rg.MBID == mbid {
			return rg.ListenerCount
		}
	}

	return 0
}

func recListenerByMBID(recs []MBRecording, mbid string) int {
	for _, r := range recs {
		if r.MBID == mbid {
			return r.ListenerCount
		}
	}

	return 0
}

// priorConfidence returns the difference between the largest and
// second-largest values in the prior, as a quick proxy for "how
// sure is the prior about its top pick".  Range is 0 (totally flat,
// i.e. uniform 1/3) to 1 (one category at 1.0, others at 0).
func priorConfidence(p intentPrior) float64 {
	vals := [3]float64{p.artist, p.album, p.recording}

	maxVal := vals[0]
	for _, v := range vals[1:] {
		if v > maxVal {
			maxVal = v
		}
	}

	secondMax := 0.0
	for _, v := range vals {
		if v < maxVal && v > secondMax {
			secondMax = v
		}
	}

	return maxVal - secondMax
}

// normLog normalizes a count to [0,1] against a fixed reference scale,
// so the value is stable across queries (unlike the blended rerank,
// which normalizes against each result set's dynamic max).
func normLog(n int) float64 {
	const maxScale = 50_000_000 // top-tier artists have ~10-150M listens

	return logNormalize(n, maxScale)
}

// clickFeature returns the additive feature contribution from a
// per-query click record.  Bounded so a click streak can't
// dominate the rest of the score.
func clickFeature(c searchClick) float64 {
	daysSince := time.Since(c.lastClicked).Hours() / 24.0 //nolint:mnd
	recency := 1.0 / (1.0 + daysSince/topResultsClickDecay)
	boost := math.Log2(float64(c.count)+1) * recency * 0.3 //nolint:mnd

	if boost > 0.6 { //nolint:mnd
		return 0.6
	}

	return boost
}

// artistListenerCounts and friends extract the per-entity listener
// count slice for the listener-distribution prior signal.
func artistListenerCounts(arts []MBArtist) []int {
	out := make([]int, len(arts))
	for i, a := range arts {
		out[i] = a.ListenerCount
	}

	return out
}

func rgListenerCounts(rgs []MBReleaseGroup) []int {
	out := make([]int, len(rgs))
	for i, rg := range rgs {
		out[i] = rg.ListenerCount
	}

	return out
}

func recListenerCounts(recs []MBRecording) []int {
	out := make([]int, len(recs))
	for i, r := range recs {
		out[i] = r.ListenerCount
	}

	return out
}

// sumTopListeners returns the sum of the top n entries in xs.
// Used by the listener-distribution prior signal.
func sumTopListeners(xs []int, n int) int {
	if len(xs) == 0 {
		return 0
	}

	sorted := make([]int, len(xs))
	copy(sorted, xs)

	sort.Slice(sorted, func(i, j int) bool {
		return sorted[i] > sorted[j]
	})

	if n > len(sorted) {
		n = len(sorted)
	}

	sum := 0
	for i := range n {
		sum += sorted[i]
	}

	return sum
}

// containsWord checks if text contains word as a whole word bounded
// by spaces, hyphens, or string boundaries.
func containsWord(text, word string) bool {
	idx := strings.Index(text, word)
	if idx < 0 {
		return false
	}

	// Check left boundary.
	if idx > 0 {
		c := text[idx-1]
		if c != ' ' && c != '-' && c != '(' && c != '[' {
			return false
		}
	}

	// Check right boundary.
	end := idx + len(word)
	if end < len(text) {
		c := text[end]
		if c != ' ' && c != '-' && c != ')' && c != ']' {
			return false
		}
	}

	return true
}

// isExactNameMatch returns true when the (already lowercased) query
// is equal to the (raw-cased) name after lowercasing and trimming.
// Punctuation is normalized so "Party in the U.S.A." matches
// "party in the usa".  Used by the top-results pipeline to find
// exact matches anywhere in the main result lists, not just in the
// top-N positions the main rerank produced.
func isExactNameMatch(q, name string) bool {
	if name == "" {
		return false
	}

	return normalizeForMatch(name) == normalizeForMatch(q)
}

// isCompositeMatch returns true when the query contains both `title`
// and `artist` as normalized substrings — e.g. "calling you blue
// october" composes "calling you" + "blue october" so the user
// probably wants Blue October's "Calling You".  Both fragments must
// be at least 3 characters to be considered.
//
// This is the heuristic version of entity linking: instead of
// training a model to identify "title + artist" multi-entity
// queries, we just notice when a candidate's title and artist both
// appear inside the user's query.
func isCompositeMatch(q, title, artist string) bool {
	if len(title) < 3 || len(artist) < 3 { //nolint:mnd
		return false
	}

	qn := normalizeForMatch(q)
	tn := normalizeForMatch(title)
	an := normalizeForMatch(artist)

	if tn == "" || an == "" || qn == "" {
		return false
	}

	// Both fragments must appear in the query.  Order doesn't
	// matter — "calling you blue october" and "blue october
	// calling you" should both match.
	return strings.Contains(qn, tn) && strings.Contains(qn, an)
}

// normalizeForMatch lowercases, trims, and strips ASCII punctuation
// other than internal whitespace so titles like "Party in the U.S.A.",
// "Party In the U.S.A", and "party in the usa" all collapse to the
// same normalized form.  Cheap O(n) — no regex.
func normalizeForMatch(s string) string {
	s = strings.ToLower(strings.TrimSpace(s))

	var b strings.Builder
	b.Grow(len(s))

	prevSpace := false

	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z',
			r >= '0' && r <= '9',
			r >= 0x80: // keep non-ASCII as-is
			b.WriteRune(r)

			prevSpace = false
		case r == ' ' || r == '\t':
			if !prevSpace && b.Len() > 0 {
				b.WriteByte(' ')

				prevSpace = true
			}
		default:
			// Drop punctuation entirely (not even replaced with
			// a space).  This collapses "U.S.A." to "usa" so it
			// matches the dot-less form.
		}
	}

	out := b.String()
	if prevSpace && len(out) > 0 {
		out = out[:len(out)-1]
	}

	return out
}

type searchClick struct {
	count       int
	lastClicked time.Time
}

// getSearchClicks returns click history for a query.
func (e *Service) getSearchClicks(query string) map[string]searchClick {
	rows, err := e.db.QueryContext(
		"SELECT entity_mbid, click_count, last_clicked FROM search_clicks WHERE query = ?",
		query,
	)
	if err != nil {
		return nil
	}

	defer func() { _ = rows.Close() }()

	result := make(map[string]searchClick)

	for rows.Next() {
		var (
			mbid        string
			count       int
			lastClicked time.Time
		)

		if err := rows.Scan(&mbid, &count, &lastClicked); err == nil {
			result[mbid] = searchClick{count: count, lastClicked: lastClicked}
		}
	}

	return result
}

// RecordSearchClick records that the user clicked a search result.
// Called from the frontend when any search result is clicked.
func (e *Service) RecordSearchClick(query, mbid, entityType string) {
	if query == "" || mbid == "" {
		return
	}

	q := strings.ToLower(strings.TrimSpace(query))

	_, _ = e.db.ExecContext(`
		INSERT INTO search_clicks (query, entity_mbid, entity_type, click_count, last_clicked)
		VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)
		ON CONFLICT(query, entity_mbid) DO UPDATE SET
			click_count = click_count + 1,
			last_clicked = CURRENT_TIMESTAMP
	`, q, mbid, entityType)
}

// blendedScoreFull computes the weighted blend of relevance, popularity,
// and personalization.  personalization is 0.0–1.0.  Popularity is
// normalized against the result set's max (floored to 100K so a single
// modestly-popular result doesn't get an inflated score).
func blendedScoreFull(
	relevance float64,
	listenCount, maxListenCount int,
	personalization float64,
) float64 {
	effectiveMax := max(maxListenCount, 100_000) //nolint:mnd

	logPop := logNormalize(listenCount, effectiveMax)

	return relevanceWeight*relevance + popularityWeight*logPop + personalizationWeight*personalization
}

// logNormalize maps a count to [0,1] on a log10 scale relative to a
// reference maximum: log10(n+1) / log10(ref+1), clamped.  Shared by the
// blended rerank (dynamic per-result-set ref) and normLog (fixed ref).
func logNormalize(n, ref int) float64 {
	if n <= 0 || ref <= 0 {
		return 0
	}

	v := math.Log10(float64(n)+1) / math.Log10(float64(ref)+1)
	if v > 1.0 {
		return 1.0
	}

	return v
}

// ---------------------------------------------------------------------------
// Lucene query building
// ---------------------------------------------------------------------------
