package explore

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"golang.org/x/sync/singleflight"

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

// Index build parameters.
const (
	// indexMaxRGs is the number of release groups fetched per
	// artist by the API discography path (new library artists).
	indexMaxRGs = 50

	// indexMaxRecs is the number of recordings fetched per artist
	// by the API discography path (new library artists).
	indexMaxRecs = 200

	// indexMinPopularity is the minimum listen count for an entry
	// to be indexed.  Cuts noise from long-tail entries.
	indexMinPopularity = 50

	// championPopThreshold is the popularity floor for a row to join
	// the champion FTS.  Rows below it (unless owned) can never win a
	// generic short-prefix query, whose ranking is popularity-dominated,
	// so excluding them is lossless for those queries.  ~90k rows.
	championPopThreshold = 10000

	// genericMaxTokenLen classifies a query as "generic" when its
	// longest token is at most this many runes.  Such queries ("the",
	// "a", "u2") match a huge slice of the index with no selective term,
	// so they route to the champion tier; anything with a longer token
	// is selective enough that the full index is already fast.
	genericMaxTokenLen = 3

	// Typo-tolerant rescue (see fuzzyRescue).  When ordinary prefix-FTS
	// retrieval returns fewer than fuzzyRescueThinHits rows — usually a
	// misspelled query that shares no token prefix with any indexed name
	// — a bigram-similarity pass over the champion set kicks in.
	// fuzzyRescueMinLen guards against very short queries (too few
	// bigrams to discriminate); fuzzyRescueMinScore is the minimum bigram
	// Dice coefficient for a candidate to count as a plausible match.
	fuzzyRescueThinHits = 3
	fuzzyRescueMinLen   = 4
	fuzzyRescueMinScore = 0.34

	// prefixCacheTTL bounds how long a memoised generic-query result is
	// served before it is recomputed; prefixCacheMaxEntries caps the map.
	prefixCacheTTL        = 10 * time.Minute
	prefixCacheMaxEntries = 512

	// championBuiltKey marks in explore_index_meta that the champion FTS
	// has been populated at least once, so it is trusted across restarts.
	championBuiltKey = "champion_built"

	// localXrefReadyKey marks that PopulateLocalCrossReferences has synced
	// the current library into the index at least once.  The unchanged-
	// library launch path skips the (write-heavy) re-sync when it is set;
	// a scan re-runs the sync unconditionally, and an explore_index wipe
	// clears explore_index_meta with it.
	localXrefReadyKey = "local_xref_ready"

	// lyricsIndexReadyKey marks that the lyrics FTS has been rebuilt from
	// the library at least once.  It is kept in sync incrementally by the
	// LRCLIB backfill thereafter, so the unchanged-library launch path
	// skips the redundant full rebuild when it is set.
	lyricsIndexReadyKey = "lyrics_index_ready"

	// indexBatchSize is the number of rows per INSERT transaction.
	indexBatchSize = 100

	// indexerRate is the requests-per-second for the background
	// indexer's dedicated rate limiter (LB allows 30/10s).
	indexerRate = 3

	// discogBackfillMaxPerRun bounds how many owned artists a single
	// post-scan discography backfill run enriches before yielding.  The
	// remainder stay unenriched (discog_fetched = 0) and are picked up by
	// the next run, so a large first-scan library spreads its enrichment
	// across launches instead of running for the better part of an hour.
	discogBackfillMaxPerRun = 2000

	// discogBackfillWorkers is how many owned artists the backfill has
	// in flight at once.
	//
	// The pass was strictly serial, so an artist's ListenBrainz calls,
	// its MusicBrainz browse pages and every upstream's latency were
	// paid end to end before the next artist began — while the limiters
	// that actually keep us polite are per-host and were idle for most
	// of it.  Concurrency here does not raise the request rate against
	// any origin; it stops one artist's slowest upstream deciding how
	// fast the whole run goes.
	discogBackfillWorkers = 6

	// discogBackfillArtistTimeout bounds one artist's share of a run.
	//
	// The MusicBrainz client retries a 503 up to five times, honouring
	// the server's Retry-After (which it caps at a minute), so a single
	// throttled artist can otherwise hold a worker for longer than a
	// hundred healthy ones take.  A timed-out artist goes unmarked and
	// is retried next run, which is what every other failure here does.
	discogBackfillArtistTimeout = 90 * time.Second

	// labsBaseURL is the base URL for the ListenBrainz labs API.
	labsBaseURL = "https://labs.api.listenbrainz.org"

	// labsSimilarAlgorithm is the algorithm parameter for the
	// similar-artists endpoint.
	labsSimilarAlgorithm = "session_based_days_7500_session_300_contribution_5_threshold_10_limit_100_filter_True_skip_30"
)

// SearchIndexResult is a single hit from the local popularity index.
type SearchIndexResult struct {
	EntityType string `json:"entityType"`
	MBID       string `json:"mbid"`
	Title      string `json:"title"`
	ArtistName string `json:"artistName"`
	ArtistMBID string `json:"artistMbid"`
	Aliases    string `json:"aliases,omitempty"`

	// Popularity signals.
	Popularity    int `json:"popularity"`
	ListenerCount int `json:"listenerCount"`

	// Recording-specific fields.
	Duration       int    `json:"duration"` // milliseconds
	CAAReleaseMBID string `json:"caaReleaseMbid"`
	ReleaseName    string `json:"releaseName"`

	// Release-group-specific fields.
	PrimaryType    string `json:"primaryType"`
	SecondaryTypes string `json:"secondaryTypes"` // comma-separated
	ReleaseDate    string `json:"releaseDate"`

	// TotalTracks is the canonical release's track count, or 0 for
	// "the catalog does not say".  It is the denominator the album
	// page cannot get from the files when the library holds no tags
	// for the album, and it is deliberately only a denominator: the
	// tracklist itself is not shipped, because the per-artist track
	// budget truncates it and a truncated tracklist is a confident
	// lie about which tracks exist.
	TotalTracks int `json:"totalTracks"`

	// Artist-specific fields (from MB lookup).
	ArtistType     string `json:"artistType"`
	Country        string `json:"country"`
	Disambiguation string `json:"disambiguation"`
	SortName       string `json:"sortName"`

	// Personalization.
	InLibrary bool `json:"inLibrary"`
	IsSimilar bool `json:"isSimilar"`

	// DiscogFetched marks a row as having had its full MusicBrainz
	// metadata fetched.  For artist rows it means the discography
	// (release groups + recordings) was pulled by the indexer
	// pipeline, and indexedArtistMBIDs() uses it to skip already-
	// processed artists in tier 2/3.  For release_group rows it means
	// LookupReleaseGroup already did its one-time MB enrichment (e.g.
	// secondary_types), so the album page's background lookup fires at
	// most once per RG instead of on every visit.
	DiscogFetched bool `json:"-"`

	// Local library cross-reference (0 if not owned).
	LocalArtistID       int64 `json:"localArtistId,omitempty"`
	LocalReleaseGroupID int64 `json:"localReleaseGroupId,omitempty"`
	LocalRecordingID    int64 `json:"localRecordingId,omitempty"`
}

// lbSitewideArtist is the response shape from the LB sitewide
// top-artists endpoint.
type lbSitewideArtist struct {
	ArtistMBID  string `json:"artist_mbid"`
	ArtistName  string `json:"artist_name"`
	ListenCount int    `json:"listen_count"`
}

// SearchIndex maintains a local SQLite FTS5 index of popular
// albums and tracks.  The index is populated in the background from
// the MetaBrainz dumps (see dumpimport.go) and patched incrementally
// via the ListenBrainz API:
//
//   - Initial build: listens dump (popularity) + canonical dump (catalog)
//   - Post-scan: API discographies for new library artists
//   - Ongoing: organic growth from user browsing (AddFromCache)
type SearchIndex struct {
	db *database.DB
	lb *ListenBrainzClient
	// mb is wired after construction (SetMusicBrainz) because the MB
	// client is built alongside this one; it is only used by the
	// owned-artist backfill, which tolerates its absence.  Guarded by mu.
	mb         *MusicBrainzClient
	artistImg  *ArtistImageProvider
	logger     *slog.Logger
	runtimeCtx context.Context // Wails runtime context for event emission

	cancel context.CancelFunc
	done   chan struct{}

	// netPolicy decides whether this connection is one to spend ~0.6 GB
	// of catalog on. Its own lock: it is written once at startup and read
	// from the build goroutine (netpolicy.go).
	netPolicy networkPolicy

	mu    sync.RWMutex
	ready bool

	// championReady is set once the champion FTS (see
	// RebuildChampionIndex) has been populated; championBuilding guards
	// against overlapping rebuilds.  Both are protected by mu.
	championReady    bool
	championBuilding bool

	// prefixCache memoises the raw index hits for generic short-prefix
	// queries — the expensive ones — so repeats are served from memory.
	prefixCacheMu sync.Mutex
	prefixCache   map[string]prefixCacheEntry

	// discogSF dedupes concurrent lazy discography fetches for the same
	// artist — the detail page fires its top-tracks, top-releases, and
	// similar-artists requests in parallel, so without this the same
	// artist would be fetched several times at once on first view.
	discogSF singleflight.Group

	// Build status tracking — read by GetIndexStatus for the UI.
	buildStatus IndexStatus

	// lastEmitted is the status most recently pushed to the frontend, so
	// an unchanged one can be dropped rather than re-rendering the whole
	// settings page for nothing.  It has its own mutex: emitStatus is
	// called from paths that already hold mu for reading.
	emitMu      sync.Mutex
	lastEmitted *IndexStatus

	// jobs is the background job registry; buildPaused records that the
	// user paused the build, distinguishing a deliberate stop from a
	// build that merely finished.  Both are protected by mu.
	jobs        *jobs.Registry
	buildPaused bool
}

// prefixCacheEntry is one memoised generic-query result.
type prefixCacheEntry struct {
	hits    []SearchIndexResult
	created time.Time
}

// TierStatus represents the state of a single index tier.
type TierStatus struct {
	Name      string `json:"name"`
	State     string `json:"state"` // "pending", "running", "complete", "error", "skipped"
	Total     int    `json:"total"`
	Completed int    `json:"completed"`
	Error     string `json:"error,omitempty"`

	// Detail is a human-readable progress line for stages whose raw
	// completed/total numbers say little on their own — the listens
	// stream reports "42.3 / 205.1 GB · 18 MB/s · ~3h20m left" here.
	Detail string `json:"detail,omitempty"`
}

// IndexStatus is the full index build status, exposed to the frontend.
type IndexStatus struct {
	Building      bool         `json:"building"`
	Ready         bool         `json:"ready"`
	LastBuilt     string       `json:"lastBuilt,omitempty"` // RFC3339 timestamp of last complete build
	Tiers         []TierStatus `json:"tiers"`
	Artists       int          `json:"artists"`
	Recordings    int          `json:"recordings"`
	ReleaseGroups int          `json:"releaseGroups"`
	TotalRows     int          `json:"totalRows"`
}

// NewSearchIndex creates a search index backed by the given
// database.  Call StartBuild to kick off the background populate.
func NewSearchIndex(
	db *database.DB,
	lb *ListenBrainzClient,
	artistImg *ArtistImageProvider,
	logger *slog.Logger,
) *SearchIndex {
	return &SearchIndex{
		db:        db,
		lb:        lb,
		artistImg: artistImg,
		logger:    logger,
	}
}

// SetContext injects the Wails runtime context for event emission.
func (si *SearchIndex) SetContext(ctx context.Context) {
	si.runtimeCtx = ctx

	// Initialize buildStatus with empty (non-nil) tiers so the
	// frontend always receives a valid array, not JSON null.
	si.mu.Lock()
	if si.buildStatus.Tiers == nil {
		si.buildStatus.Tiers = []TierStatus{}
	}
	si.mu.Unlock()

	// Load current row counts + last-built timestamp from DB.
	//
	// There is deliberately no ticker here.  This used to emit the status
	// every 3 seconds for the life of the process, with a byte-identical
	// payload once the index was ready — which re-rendered the whole of
	// `config-page` on every tick, since it is a cached view that never
	// unmounts.  Every path that mutates the status already calls
	// emitStatus, that call now suppresses an unchanged payload, and the
	// frontend seeds itself with GetIndexStatus() on connect rather than
	// waiting for the next tick.
	si.refreshStatusCounts()
}

// EnsureArtistDiscography lazily fetches an artist's top release groups
// and recordings from ListenBrainz and persists them into the index —
// the first time the artist is actually needed (e.g. their detail page
// opens).  It is a no-op once the artist is marked discog_fetched, so
// each artist costs at most one set of API calls, ever; every later
// view is served from the index with no network.  This replaces the old
// post-scan sweep that eagerly fetched every library artist on startup.
//
// Concurrent calls for the same artist collapse into one via discogSF,
// since the detail page fires several requests for the same artist at
// once.  Safe to call synchronously from a request handler: bounded work
// on its own rate-limited client.
func (si *SearchIndex) EnsureArtistDiscography(ctx context.Context, artistMBID string) {
	if si.lb == nil || artistMBID == "" {
		return
	}

	if si.artistDiscogFetched(artistMBID) {
		return
	}

	_, _, _ = si.discogSF.Do(artistMBID, func() (any, error) {
		// Re-check under the singleflight: a sibling call may have just
		// finished fetching this artist while we were queued.
		if si.artistDiscogFetched(artistMBID) {
			return nil, nil
		}

		indexLB := NewListenBrainzClient(
			NewRateLimiterN(indexerRate), si.lb.cache, si.logger.WithGroup("indexer"),
		)

		si.indexOneArtist(ctx, indexLB, lbSitewideArtist{
			ArtistMBID: artistMBID,
			ArtistName: si.artistDisplayName(artistMBID),
		})

		return nil, nil
	})
}

// artistDiscogFetched reports whether the artist row already has its
// discography fetched, so EnsureArtistDiscography can skip the network.
func (si *SearchIndex) artistDiscogFetched(mbid string) bool {
	rows, err := si.db.QueryContext(
		"SELECT 1 FROM explore_index WHERE entity_type = 1 /* artist */ "+
			"AND mbid = ? AND discog_fetched = 1 LIMIT 1",
		dbMBID(mbid),
	)
	if err != nil {
		return false
	}

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

	return rows.Next()
}

// unenrichedLibraryArtistMBIDs returns MBIDs for owned artists whose
// discography has not yet been fetched — either they have no index row
// or their row is still discog_fetched = 0, or the full MusicBrainz
// browse has not run.  The LEFT JOINs key off
// persistent marks, so an artist enriched on a prior run (interactively
// or by an earlier backfill) never reappears, giving "new artists only"
// for free.  Ordered by owned-track count so the artists the user has
// most of are enriched first.  The limit bounds a single run (see
// discogBackfillMaxPerRun).
//
// The conditions are OR'd because they are different fetches: an
// artist covered by the downloaded catalog artifact arrives with
// discog_fetched = 1 and has still never been browsed, and the
// artifact's own per-artist coverage is graded — so "the artifact knows
// this artist" is not "we have their discography".
//
// similar_at is deliberately not one of them.  The backfill no longer
// fetches similar artists, so testing the mark it does not set would
// make every owned artist a candidate on every run, forever.
func (si *SearchIndex) unenrichedLibraryArtistMBIDs(limit int) []string {
	rows, err := si.db.QueryContext(`
		SELECT a.mbid
		FROM artists a
		LEFT JOIN explore_index ei
		  ON ei.entity_type = 1 /* artist */ AND ei.mbid = unhex(replace(a.mbid, '-', ''))
		LEFT JOIN artist_enrichment ae ON ae.artist_mbid = a.mbid
		LEFT JOIN audio_files af ON af.artist_id = a.id
		WHERE a.mbid IS NOT NULL AND a.mbid != ''
		  AND (ei.id IS NULL OR ei.discog_fetched = 0
		       OR ae.browsed_at IS NULL)
		GROUP BY a.mbid
		ORDER BY COUNT(DISTINCT af.id) DESC
		LIMIT ?
	`, limit)
	if err != nil {
		si.logger.Warn("discography backfill: query failed", "error", err)

		return nil
	}

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

	var mbids []string

	for rows.Next() {
		var mbid string
		if err := rows.Scan(&mbid); err == nil {
			mbids = append(mbids, mbid)
		}
	}

	return mbids
}

// BackfillLibraryDiscographies makes an owned artist's page renderable
// offline, so their catalogue is there right after a scan instead of
// only on first view.  Two fetches per artist, each skipped by its own
// persistent mark:
//
//   - the ListenBrainz top release groups and recordings (marked by
//     explore_index.discog_fetched), which is what popularity ordering
//     and the top-tracks section need;
//   - the full MusicBrainz browse (artist_enrichment.browsed_at), which
//     is what makes the discography complete and typed — see
//     browseFullDiscography.
//
// Similar artists are deliberately *not* fetched here.  Nothing shows
// them until someone opens an artist page, and that page already
// resolves them on demand through Service.SimilarArtists →
// ensureSimilarArtistsAsync, which stamps the same similar_at mark.
// Fetching them for every owned artist bought a third of the run's
// requests for a section most of those artists will never have shown.
//
// It is bounded (discogBackfillMaxPerRun) and resumable: a cancelled or
// capped run continues on the next call, and each artist's marks are
// set as they complete rather than at the end.  Each artist runs
// through discogSF so it never double-fetches one a concurrent
// interactive EnsureArtistDiscography is already handling, and under
// its own deadline so no single artist can stall the run.
func (si *SearchIndex) BackfillLibraryDiscographies(ctx context.Context) {
	if si.lb == nil {
		return
	}

	mbids := si.unenrichedLibraryArtistMBIDs(discogBackfillMaxPerRun)
	if len(mbids) == 0 {
		return
	}

	// Everything below is work nobody is waiting on, so it yields the
	// shared MusicBrainz limiters to whatever the user is looking at.
	ctx = WithBackgroundPriority(ctx)

	job, ctx := startBackfillJob(
		ctx, si.jobRegistry(), discogBackfillJobID,
		"Filling in artist details",
		"Discographies for artists in your library",
		len(mbids),
	)

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

	// One shared rate limiter paces the whole run, unlike the per-call
	// client EnsureArtistDiscography builds for interactive fetches.
	indexLB := NewListenBrainzClient(
		NewRateLimiterN(indexerRate), si.lb.cache, si.logger.WithGroup("indexer"),
	)

	var (
		wg   sync.WaitGroup
		done atomic.Int64
		work = make(chan string)
	)

	wg.Add(discogBackfillWorkers)

	for range discogBackfillWorkers {
		go func() {
			defer wg.Done()

			for mbid := range work {
				si.backfillOneArtist(ctx, indexLB, mbid)

				job.progress(int(done.Add(1)), len(mbids))
			}
		}()
	}

	for _, mbid := range mbids {
		if ctx.Err() != nil {
			break
		}

		work <- mbid
	}

	close(work)
	wg.Wait()

	total := int(done.Load())

	if ctx.Err() != nil {
		si.logger.Info("discography backfill stopped",
			"artists", total, "of", len(mbids),
		)

		return
	}

	job.logf(jobs.LevelInfo, "Filled in "+strconv.Itoa(total)+" artists")

	si.logger.Info("discography backfill complete", "artists", total)
}

// backfillOneArtist runs one owned artist's fetches, under the
// singleflight that keeps it from racing an interactive fetch and under
// a deadline of its own (see discogBackfillArtistTimeout).
func (si *SearchIndex) backfillOneArtist(
	ctx context.Context, lb *ListenBrainzClient, mbid string,
) {
	if ctx.Err() != nil {
		return
	}

	ctx, cancel := context.WithTimeout(ctx, discogBackfillArtistTimeout)
	defer cancel()

	_, _, _ = si.discogSF.Do(mbid, func() (any, error) {
		// Re-checked under the singleflight: an interactive fetch may
		// have done either of these since the query above.
		if !si.artistDiscogFetched(mbid) {
			si.indexOneArtist(ctx, lb, lbSitewideArtist{
				ArtistMBID: mbid,
				ArtistName: si.artistDisplayName(mbid),
			})
		}

		if !si.enrichmentFor(mbid).Browsed {
			si.browseFullDiscography(ctx, mbid)
		}

		return nil, nil
	})
}

// artistDisplayName resolves a human-readable name for an artist MBID,
// preferring the index title, then the local library, then the MBID
// itself.  Used to seed the discography fetch's artist entry.
func (si *SearchIndex) artistDisplayName(mbid string) string {
	// The two tables spell an MBID differently: the catalog stores raw
	// bytes, the library stores text.  Each query brings its own form.
	for _, q := range []struct {
		sql string
		arg any
	}{
		{"SELECT title FROM explore_index WHERE entity_type = 1 /* artist */ " +
			"AND mbid = ? AND title != '' LIMIT 1", dbMBID(mbid)},
		{"SELECT name FROM artists WHERE mbid = ? AND name != '' LIMIT 1", mbid},
	} {
		rows, err := si.db.QueryContext(q.sql, q.arg)
		if err != nil {
			continue
		}

		if rows.Next() {
			var name string
			if err := rows.Scan(&name); err == nil && name != "" {
				_ = rows.Close()

				return name
			}
		}

		_ = rows.Close()
	}

	return mbid
}

// PersistSimilarArtists stores ListenBrainz similar-artist results into
// the similar_artist_map so future lookups are served locally instead of
// re-hitting the labs API on every artist-page view.
func (si *SearchIndex) PersistSimilarArtists(sourceMBID string, similar []LBSimilarArtist) {
	if len(similar) == 0 {
		return
	}

	wire := make([]lbSimilarArtistWire, len(similar))
	for i, s := range similar {
		wire[i] = lbSimilarArtistWire{
			ArtistMBID: s.ArtistMBID,
			Name:       s.Name,
			Score:      int(s.Score),
		}
	}

	si.storeSimilarArtists(sourceMBID, wire)
}

// StartBuild launches the background index build goroutine.
// Returns immediately.
func (si *SearchIndex) StartBuild(ctx context.Context) {
	// A build the user paused stays paused until they resume it —
	// including across restarts, where the marker is read back from
	// explore_index_meta.  ResumeBuild clears it before calling here.
	if si.buildPausedByUser() {
		si.logger.Info("search index: build is paused, not starting")

		return
	}

	si.mu.Lock()
	// Don't start if already running.
	if si.cancel != nil {
		si.mu.Unlock()

		return
	}

	si.done = make(chan struct{})
	si.mu.Unlock()

	buildCtx, cancel := context.WithCancel(ctx)

	si.mu.Lock()
	si.cancel = cancel
	si.mu.Unlock()

	go func() {
		defer func() {
			si.mu.Lock()
			si.cancel = nil
			si.mu.Unlock()

			// `Building` is derived from si.cancel, so clearing it is a
			// status change and has to say so.  This is what resolves the
			// job in the registry — syncIndexJob only finishes a job on a
			// sync that reports Building false, and without this line the
			// header badge reads "Building search index" over an index the
			// settings page calls ready.
			si.emitStatus()

			close(si.done)

			// The index rows (and their popularities) may have changed, so
			// refresh the champion tier once the build settles.
			si.scheduleChampionRebuild()
		}()

		si.runDumpBuild(buildCtx)
	}()
}

// StopBuild cancels an in-flight build and waits for it to finish.
// Safe to call even if no build is running.
func (si *SearchIndex) StopBuild() {
	si.mu.RLock()
	cancel := si.cancel
	done := si.done
	si.mu.RUnlock()

	if cancel != nil {
		cancel()
	}

	if done != nil {
		<-done
	}
}

// WaitForIdle blocks until no build or indexing goroutine is running.
// Unlike StopBuild, this does NOT cancel a running build.
func (si *SearchIndex) WaitForIdle() {
	si.mu.RLock()
	done := si.done
	si.mu.RUnlock()

	if done != nil {
		<-done
	}
}

// IsReady returns true once the index has been built at least once.
func (si *SearchIndex) IsReady() bool {
	si.mu.RLock()
	defer si.mu.RUnlock()

	return si.ready
}

// GetIndexStatus returns the current index build status for the UI.
// Entirely in-memory — no DB queries — to avoid blocking the Wails
// UI thread when the index build holds a write lock.
func (si *SearchIndex) GetIndexStatus() IndexStatus {
	si.mu.RLock()
	status := si.buildStatus
	status.Ready = si.ready
	status.Building = si.cancel != nil
	si.mu.RUnlock()

	return status
}

// refreshStatusCounts updates the row counts and last-built timestamp
// in buildStatus from the DB.  Called between tiers when the DB is idle.
func (si *SearchIndex) refreshStatusCounts() {
	var artists, recordings, rgs int

	rows, err := si.db.QueryContext(`
		SELECT entity_type, COUNT(*) FROM explore_index GROUP BY entity_type
	`)
	if err == nil {
		defer func() { _ = rows.Close() }()

		for rows.Next() {
			var (
				et    dbEntityType
				count int
			)

			if err := rows.Scan(&et, &count); err == nil {
				switch string(et) {
				case EntityArtist:
					artists = count
				case EntityRecording:
					recordings = count
				case EntityReleaseGroup:
					rgs = count
				}
			}
		}
	}

	var lastBuilt string

	metaRow, err := si.db.QueryContext(
		"SELECT value FROM explore_index_meta WHERE key = 'dump_import_done'",
	)
	if err == nil {
		defer func() { _ = metaRow.Close() }()

		if metaRow.Next() {
			_ = metaRow.Scan(&lastBuilt)
		}
	}

	si.mu.Lock()
	si.buildStatus.Artists = artists
	si.buildStatus.Recordings = recordings
	si.buildStatus.ReleaseGroups = rgs
	si.buildStatus.TotalRows = artists + recordings + rgs
	si.buildStatus.LastBuilt = lastBuilt
	si.mu.Unlock()

	si.emitStatus()
}

// setTierStatus updates the build status for a named tier.
func (si *SearchIndex) setTierStatus(name, state string, total, completed int) {
	si.setTierDetail(name, state, total, completed, "")
}

// setTierDetail is setTierStatus plus a human-readable progress line.
func (si *SearchIndex) setTierDetail(name, state string, total, completed int, detail string) {
	si.mu.Lock()

	for i := range si.buildStatus.Tiers {
		if si.buildStatus.Tiers[i].Name == name {
			transitioned := si.buildStatus.Tiers[i].State != state
			si.buildStatus.Tiers[i].State = state
			si.buildStatus.Tiers[i].Total = total
			si.buildStatus.Tiers[i].Completed = completed
			si.buildStatus.Tiers[i].Detail = detail
			si.mu.Unlock()

			if transitioned {
				si.logIndexJob(jobs.LevelInfo, "Stage "+state+": "+name)
			}

			si.emitStatus()

			return
		}
	}

	si.buildStatus.Tiers = append(si.buildStatus.Tiers, TierStatus{
		Name:      name,
		State:     state,
		Total:     total,
		Completed: completed,
		Detail:    detail,
	})

	si.mu.Unlock()
	si.emitStatus()
}

// sameStatusAs reports whether two statuses would render identically.
// IndexStatus holds a slice, so it is not comparable with ==.
func (s IndexStatus) sameStatusAs(o IndexStatus) bool {
	if s.Building != o.Building ||
		s.Ready != o.Ready ||
		s.LastBuilt != o.LastBuilt ||
		s.Artists != o.Artists ||
		s.Recordings != o.Recordings ||
		s.ReleaseGroups != o.ReleaseGroups ||
		s.TotalRows != o.TotalRows ||
		len(s.Tiers) != len(o.Tiers) {
		return false
	}

	for i := range s.Tiers {
		if s.Tiers[i] != o.Tiers[i] {
			return false
		}
	}

	return true
}

// emitStatus pushes the current index status to the frontend via Wails
// event — but only when it differs from the last one pushed.
//
// The status is emitted from every path that touches it, several of
// which report progress in a tight loop, and the frontend's handler
// assigns to a @state field: an identical payload is therefore a full
// re-render of a 2 000-line template saying nothing.  Deduplicating
// here rather than at the call sites means no future emitter has to
// remember (`perf.M6` / `H-14`).
func (si *SearchIndex) emitStatus() {
	if si.runtimeCtx == nil {
		return
	}

	si.mu.RLock()
	status := si.buildStatus
	status.Ready = si.ready
	status.Building = si.cancel != nil
	si.mu.RUnlock()

	si.emitMu.Lock()
	unchanged := si.lastEmitted != nil &&
		si.lastEmitted.sameStatusAs(status)

	if !unchanged {
		snapshot := status
		snapshot.Tiers = append(
			[]TierStatus(nil), status.Tiers...,
		)
		si.lastEmitted = &snapshot
	}
	si.emitMu.Unlock()

	if unchanged {
		return
	}

	events.Emit(si.runtimeCtx, events.IndexStatusChanged, status)

	// Mirror into the shared job registry.  Every status mutation goes
	// through emitStatus, so hooking here covers all update paths.
	si.syncIndexJob(status)
}

// GetPopularity returns the cached popularity (listen count) for
// the given MBID from the local index.  Returns 0 if not found.
func (si *SearchIndex) GetPopularity(mbid string) int {
	if mbid == "" {
		return 0
	}

	rows, err := si.db.QueryContext(
		"SELECT popularity FROM explore_index WHERE mbid = ? LIMIT 1",
		dbMBID(mbid),
	)
	if err != nil {
		return 0
	}

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

	if rows.Next() {
		var pop int
		if err := rows.Scan(&pop); err == nil {
			return pop
		}
	}

	return 0
}

// PopularityBatchResult contains popularity, listener count, library
// status, and similarity scores for a batch of MBIDs.
type PopularityBatchResult struct {
	Popularity       map[string]int
	ListenerCount    map[string]int
	InLibrary        map[string]bool
	SimilarityScores map[string]int // max similarity score (0 = not similar)
}

// GetPopularityBatch returns popularity (listen count) and library
// status for multiple MBIDs in a single query.
func (si *SearchIndex) GetPopularityBatch(mbids []string) *PopularityBatchResult {
	if len(mbids) == 0 {
		return nil
	}

	placeholders := make([]string, len(mbids))
	args := make([]any, len(mbids))

	for i, m := range mbids {
		placeholders[i] = "?"
		args[i] = dbMBID(m)
	}

	query := "SELECT mbid, popularity, listener_count, in_library FROM explore_index WHERE mbid IN (" +
		strings.Join(
			placeholders,
			",",
		) + ")"

	rows, err := si.db.QueryContext(query, args...)
	if err != nil {
		return nil
	}

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

	result := &PopularityBatchResult{
		Popularity:       make(map[string]int, len(mbids)),
		ListenerCount:    make(map[string]int),
		InLibrary:        make(map[string]bool),
		SimilarityScores: make(map[string]int),
	}

	for rows.Next() {
		var (
			id        dbMBID
			pop       int
			listeners int
			inLib     int
		)

		if err := rows.Scan(&id, &pop, &listeners, &inLib); err == nil {
			mbid := string(id)

			existing, ok := result.Popularity[mbid]
			if !ok || pop > existing {
				result.Popularity[mbid] = pop
			}

			if listeners > 0 {
				result.ListenerCount[mbid] = listeners
			}

			if inLib == 1 {
				result.InLibrary[mbid] = true
			}
		}
	}

	// Fetch similarity scores from the map table.
	result.SimilarityScores = si.GetSimilarityScores(mbids)

	return result
}

// IsInLibrary returns whether the given MBID is marked as in the
// user's local library in the search index.
func (si *SearchIndex) IsInLibrary(mbid string) bool {
	if mbid == "" {
		return false
	}

	rows, err := si.db.QueryContext(
		"SELECT in_library FROM explore_index WHERE mbid = ? AND in_library = 1 LIMIT 1",
		dbMBID(mbid),
	)
	if err != nil {
		return false
	}

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

	return rows.Next()
}

// LookupArtistByMBID reads a single artist row from the index, including
// all metadata fields (type, country, disambiguation, sort_name).
// Returns nil if the artist isn't indexed.
func (si *SearchIndex) LookupArtistByMBID(mbid string) *SearchIndexResult {
	rows, err := si.db.QueryContext(
		`SELECT title, artist_name, artist_mbid, popularity, listener_count,
		        artist_type, country, disambiguation, sort_name, aliases,
		        in_library, is_similar, COALESCE(local_artist_id, 0)
		 FROM explore_index
		 WHERE mbid = ? AND entity_type = 1 /* artist */ LIMIT 1`,
		dbMBID(mbid),
	)
	if err != nil {
		return nil
	}

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

	if !rows.Next() {
		return nil
	}

	r := SearchIndexResult{
		EntityType: EntityArtist,
		MBID:       mbid,
	}

	var artist dbMBID

	if err := rows.Scan(
		&r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount,
		&r.ArtistType, &r.Country, &r.Disambiguation, &r.SortName, &r.Aliases,
		&r.InLibrary, &r.IsSimilar, &r.LocalArtistID,
	); err != nil {
		return nil
	}

	r.ArtistMBID = string(artist)

	return &r
}

// ReleaseGroupMBIDsForCAAReleaseMBIDs takes a list of release MBIDs
// (from recording.caa_release_mbid) and returns a map from release
// MBID → release group MBID, by joining against the release_group
// rows whose caa_release_mbid matches.  Used to find parent release
// groups for tracks so we can fetch cover art via the existing
// release-group endpoint instead of the per-release endpoint.
func (si *SearchIndex) ReleaseGroupMBIDsForCAAReleaseMBIDs(
	caaReleaseMBIDs []string,
) map[string]string {
	if len(caaReleaseMBIDs) == 0 {
		return nil
	}

	// Filter out empty strings — an empty input MBID would match
	// every release_group row that also has an empty caa_release_mbid,
	// producing false positives that resolve to release groups with
	// no actual cover art (e.g. bootlegs, demos).
	filtered := make([]string, 0, len(caaReleaseMBIDs))
	seen := make(map[string]struct{}, len(caaReleaseMBIDs))

	for _, m := range caaReleaseMBIDs {
		if m == "" {
			continue
		}

		if _, dup := seen[m]; dup {
			continue
		}

		seen[m] = struct{}{}
		filtered = append(filtered, m)
	}

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

	placeholders := make([]string, len(filtered))
	args := make([]any, len(filtered))

	for i, m := range filtered {
		placeholders[i] = "?"
		args[i] = dbMBID(m)
	}

	query := `SELECT caa_release_mbid, mbid
	          FROM explore_index
	          WHERE entity_type = 2 /* release_group */
	            AND caa_release_mbid != x''
	            AND caa_release_mbid IN (` + strings.Join(placeholders, ",") + `)`

	rows, err := si.db.QueryContext(query, args...)
	if err != nil {
		return nil
	}

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

	out := make(map[string]string, len(filtered))

	for rows.Next() {
		var caaMBID, rgMBID dbMBID
		if err := rows.Scan(&caaMBID, &rgMBID); err == nil {
			out[string(caaMBID)] = string(rgMBID)
		}
	}

	return out
}

// LookupReleaseGroupByMBID reads a single release group row from the index.
func (si *SearchIndex) LookupReleaseGroupByMBID(mbid string) *SearchIndexResult {
	rows, err := si.db.QueryContext(
		`SELECT title, artist_name, artist_mbid, popularity, listener_count,
		        primary_type, secondary_types, release_date, total_tracks,
		        in_library, COALESCE(local_release_group_id, 0), discog_fetched
		 FROM explore_index
		 WHERE mbid = ? AND entity_type = 2 /* release_group */ LIMIT 1`,
		dbMBID(mbid),
	)
	if err != nil {
		return nil
	}

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

	if !rows.Next() {
		return nil
	}

	r := SearchIndexResult{
		EntityType: EntityReleaseGroup,
		MBID:       mbid,
	}

	var artist dbMBID

	if err := rows.Scan(
		&r.Title, &r.ArtistName, &artist, &r.Popularity, &r.ListenerCount,
		&r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks,
		&r.InLibrary, &r.LocalReleaseGroupID, &r.DiscogFetched,
	); err != nil {
		return nil
	}

	return &r
}

// PersistReleaseGroupLookup writes the MB-enriched metadata for a
// single release group back into the index and marks it DiscogFetched
// so LookupReleaseGroup's background enrichment runs at most once per
// RG.  Popularity is left at 0 here — upsertBatch keeps the higher of
// old/new, so the dump-provided popularity is never clobbered.
func (si *SearchIndex) PersistReleaseGroupLookup(rg *MBReleaseGroup) {
	if rg == nil || rg.MBID == "" {
		return
	}

	si.upsertBatch([]SearchIndexResult{{
		EntityType:     "release_group",
		MBID:           rg.MBID,
		Title:          rg.Title,
		ArtistName:     rg.ArtistCredit,
		PrimaryType:    rg.PrimaryType,
		SecondaryTypes: strings.Join(rg.SecondaryTypes, ","),
		ReleaseDate:    rg.FirstReleaseDate,
		DiscogFetched:  true,
	}})
}

// TopRecordingsByArtist returns the most popular recordings for an
// artist MBID from the index, ordered by popularity descending.
// Falls back to returning entries without popularity data if there
// aren't enough popular ones.
func (si *SearchIndex) TopRecordingsByArtist(artistMBID string, limit int) []SearchIndexResult {
	rows, err := si.db.QueryContext(
		`SELECT mbid, title, artist_name, popularity, listener_count,
		        duration, caa_release_mbid, release_name,
		        in_library, COALESCE(local_recording_id, 0)
		 FROM explore_index
		 WHERE artist_mbid = ? AND entity_type = 3 /* recording */
		 ORDER BY popularity DESC
		 LIMIT ?`,
		dbMBID(artistMBID), limit,
	)
	if err != nil {
		return nil
	}

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

	var results []SearchIndexResult

	for rows.Next() {
		var (
			r       SearchIndexResult
			id, caa dbMBID
		)

		if err := rows.Scan(
			&id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount,
			&r.Duration, &caa, &r.ReleaseName,
			&r.InLibrary, &r.LocalRecordingID,
		); err == nil {
			r.MBID = string(id)
			r.CAAReleaseMBID = string(caa)
			r.EntityType = EntityRecording
			r.ArtistMBID = artistMBID
			results = append(results, r)
		}
	}

	return results
}

// TopReleaseGroupsByArtist returns the most popular release groups
// for an artist MBID from the index, ordered by popularity descending.
// Falls back to returning entries without popularity data if there
// aren't enough popular ones.
func (si *SearchIndex) TopReleaseGroupsByArtist(artistMBID string, limit int) []SearchIndexResult {
	rows, err := si.db.QueryContext(
		`SELECT mbid, title, artist_name, popularity, listener_count,
		        primary_type, secondary_types, release_date,
		        in_library, COALESCE(local_release_group_id, 0)
		 FROM explore_index
		 WHERE artist_mbid = ? AND entity_type = 2 /* release_group */
		 ORDER BY popularity DESC
		 LIMIT ?`,
		dbMBID(artistMBID), limit,
	)
	if err != nil {
		return nil
	}

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

	var results []SearchIndexResult

	for rows.Next() {
		var (
			r  SearchIndexResult
			id dbMBID
		)

		if err := rows.Scan(
			&id, &r.Title, &r.ArtistName, &r.Popularity, &r.ListenerCount,
			&r.PrimaryType, &r.SecondaryTypes, &r.ReleaseDate,
			&r.InLibrary, &r.LocalReleaseGroupID,
		); err == nil {
			r.MBID = string(id)
			r.EntityType = EntityReleaseGroup
			r.ArtistMBID = artistMBID
			results = append(results, r)
		}
	}

	return results
}

// AddFromCache inserts entries from a cached discography browse into
// the search index.  Called when a user views an artist page and the
// discography is fetched — organic growth beyond the shipped catalog.
func (si *SearchIndex) AddFromCache(artistName, artistMBID string, rgs []MBReleaseGroup) {
	if len(rgs) == 0 {
		return
	}

	// resolveArtistName falls back to the MBID when it cannot find a
	// name, which is fine for a one-off render but must never be
	// persisted: an MBID stored as a title is unsearchable and shows up
	// as a UUID in the UI.  Writing nothing lets the upsert's
	// "non-empty wins" rule keep whatever real name arrives later.
	if artistName == artistMBID {
		artistName = ""
	}

	entries := make([]SearchIndexResult, 0, len(rgs)+1)

	// Add the artist itself, unless there is no name to add.
	if artistName != "" {
		entries = append(entries, SearchIndexResult{
			EntityType: "artist",
			MBID:       artistMBID,
			Title:      artistName,
			ArtistName: artistName,
			ArtistMBID: artistMBID,
			Popularity: 0, // Unknown from this path.
		})
	}

	for _, rg := range rgs {
		entries = append(entries, SearchIndexResult{
			EntityType:     "release_group",
			MBID:           rg.MBID,
			Title:          rg.Title,
			ArtistName:     artistName,
			ArtistMBID:     artistMBID,
			Popularity:     0,
			PrimaryType:    rg.PrimaryType,
			SecondaryTypes: strings.Join(rg.SecondaryTypes, ","),
			ReleaseDate:    rg.FirstReleaseDate,
		})
	}

	si.upsertBatch(entries)

	si.logger.Debug("search index: organic add",
		"artist", artistName,
		"releaseGroups", len(rgs),
	)
}

// ExactMatches returns index rows whose normalized title (or artist
// name) exactly equals the given query.  Used by the top-results
// intent pipeline as a dedicated retrieval source — exact matches
// against high-popularity entities are almost always the right
// answer and should bypass the noise of MB text search.
//
// Returns up to `perCategory` matches per entity type, ordered by
// popularity descending.  Case-insensitive; trims whitespace.
func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndexResult {
	if !si.IsReady() {
		return nil
	}

	q := strings.ToLower(strings.TrimSpace(query))
	if q == "" {
		return nil
	}

	if perCategory <= 0 {
		perCategory = 3
	}

	// UNION of two equality lookups rather than `LOWER(title) = ? OR
	// LOWER(artist_name) = ?`.  The OR form forces SQLite to scan all
	// ~240k rows; splitting into two equalities lets each branch seek
	// its partial expression index (idx_explore_title_lower /
	// idx_explore_artist_lower).  Ordering is done in Go below since
	// an ORDER BY here would also defeat the index seek.
	//
	// The popularity clause **must match those indexes' predicate** or
	// the seek becomes a scan of two million rows.  It is the champion
	// set: what the user owns, plus what is popular enough to be worth
	// an exact-match boost.  Anything below the floor is still found by
	// the FTS tiers; it just does not jump the queue.
	rows, err := si.db.QueryContext(`
		SELECT `+indexRowColumns+`
		FROM explore_index
		WHERE LOWER(title) = ? AND (popularity >= ? OR in_library = 1)
		UNION
		SELECT `+indexRowColumns+`
		FROM explore_index
		WHERE LOWER(artist_name) = ? AND (popularity >= ? OR in_library = 1)
	`, q, championPopThreshold, q, championPopThreshold)
	if err != nil {
		return nil
	}

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

	var matches []SearchIndexResult

	for rows.Next() {
		var r SearchIndexResult

		if err := scanIndexRow(rows, &r); err != nil {
			continue
		}

		// For artists, only match on title (name).  For recordings
		// and release groups, match on either title or artist name
		// — that way "miley cyrus" surfaces both the artist and
		// her recordings.
		titleMatch := strings.ToLower(r.Title) == q
		artistMatch := strings.ToLower(r.ArtistName) == q

		if r.EntityType == "artist" && !titleMatch {
			continue
		}

		if r.EntityType != "artist" && !titleMatch && !artistMatch {
			continue
		}

		matches = append(matches, r)
	}

	// Sort by popularity desc so the per-category cap keeps the most
	// popular entries (the old SQL ORDER BY guaranteed this; UNION
	// does not preserve order).
	sort.SliceStable(matches, func(i, j int) bool {
		return matches[i].Popularity > matches[j].Popularity
	})

	// Group by entity type, capping at perCategory each.
	buckets := map[string][]SearchIndexResult{
		"artist":        nil,
		"release_group": nil,
		"recording":     nil,
	}

	for _, r := range matches {
		bucket := buckets[r.EntityType]
		if len(bucket) >= perCategory {
			continue
		}

		buckets[r.EntityType] = append(bucket, r)
	}

	var out []SearchIndexResult

	out = append(out, buckets["artist"]...)
	out = append(out, buckets["release_group"]...)
	out = append(out, buckets["recording"]...)

	return out
}

// Search queries the local FTS5 index and returns matches ordered
// by relevance (popularity-blended).  Returns nil when the index
// hasn't finished its initial build.
func (si *SearchIndex) Search(ctx context.Context, query string, limit int) []SearchIndexResult {
	// IsReady is latched once at startup, so it is right in the app and
	// wrong for anything that fills the table afterwards — including the
	// e2e suite staging a catalog, which is how search gets tested in
	// CI, where the artifact URL points at a dead address on purpose.
	// shelves.go learned this already; the search path did not, and the
	// symptom was three specs that passed only when an earlier one
	// happened to flip the flag first.
	//
	// The latch stays as the fast path — once it is true nothing can
	// make it false — and the probe is one indexed `SELECT 1 … LIMIT 1`
	// on the only path that could otherwise answer "no catalog" wrongly.
	if !si.IsReady() && !si.hasCatalogRows(ctx) {
		return nil
	}

	if limit <= 0 {
		limit = 20
	}

	ftsQuery := buildFTSQuery(query)
	if ftsQuery == "" {
		return nil
	}

	generic := isGenericQuery(query)

	// Generic short-prefix queries are the slow ones; serve repeats from
	// the in-memory cache and route the rest through the champion tier.
	if generic {
		if hits, ok := si.prefixCacheGet(ftsQuery, limit); ok {
			return hits
		}
	}

	hits := si.runSearch(ctx, ftsQuery, limit, generic)

	// Typo-tolerant fallback: a thin result usually means the query is
	// misspelled and matched no token prefix.  Rescue with bigram
	// similarity over the champion set before giving up.  Skipped once a
	// search is superseded (ctx cancelled) — the result is discarded
	// anyway.
	if len(hits) < fuzzyRescueThinHits && ctx.Err() == nil {
		hits = si.appendFuzzyRescue(ctx, query, limit, hits)
	}

	// Cache only fully-formed generic results — never a partial list from
	// a superseded (cancelled) query.
	if generic && ctx.Err() == nil {
		si.prefixCachePut(ftsQuery, limit, hits)
	}

	return hits
}

// appendFuzzyRescue runs the bigram rescue pass and merges its hits into
// the existing (thin) result, de-duplicated by MBID and preserving the
// original hits first.  Returns the original slice unchanged when rescue
// finds nothing new.
func (si *SearchIndex) appendFuzzyRescue(
	ctx context.Context,
	query string,
	limit int,
	hits []SearchIndexResult,
) []SearchIndexResult {
	rescued := si.fuzzyRescue(ctx, query, limit)
	if len(rescued) == 0 {
		return hits
	}

	seen := make(map[string]struct{}, len(hits)+len(rescued))
	for _, h := range hits {
		seen[h.MBID] = struct{}{}
	}

	for _, r := range rescued {
		if _, dup := seen[r.MBID]; dup {
			continue
		}

		seen[r.MBID] = struct{}{}
		hits = append(hits, r)

		if len(hits) >= limit {
			break
		}
	}

	return hits
}

// fuzzyRescue is the typo-tolerant fallback for Search.  It scans the
// champion set (high-popularity + owned rows) and scores each candidate
// by character-bigram overlap (Dice) against its title and artist name,
// so a misspelled query still surfaces its intended entity even though it
// shares no token prefix with any indexed name.  Returns up to `limit`
// hits above fuzzyRescueMinScore, ordered by fuzzy score with popularity
// as a tie-break.  Bounded work that fires only on the rare thin query,
// so it never touches the fast common path.
func (si *SearchIndex) fuzzyRescue(
	ctx context.Context,
	query string,
	limit int,
) []SearchIndexResult {
	qSet := fuzzyBigrams(query)
	if len([]rune(fuzzyNormalize(query))) < fuzzyRescueMinLen || len(qSet) == 0 {
		return nil
	}

	// Champion membership predicate, mirrored from rebuildChampionIndex:
	// a row below the popularity floor that isn't owned can't be a
	// meaningful popular match, so scoring it would only add noise.
	rows, err := si.db.QueryContextWith(ctx, `
		SELECT id, title, artist_name, popularity
		FROM explore_index
		WHERE popularity >= ? OR in_library = 1
	`, championPopThreshold)
	if err != nil {
		if !errors.Is(err, context.Canceled) {
			si.logger.Warn("fuzzy rescue scan error", "error", err)
		}

		return nil
	}

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

	type scored struct {
		id    int64
		score float64
		pop   int
	}

	var candidates []scored

	scanned := 0

	for rows.Next() {
		// Periodically honour cancellation: a superseded search shouldn't
		// keep scoring tens of thousands of rows.
		scanned++
		if scanned%4096 == 0 && ctx.Err() != nil {
			return nil
		}

		var (
			id     int64
			title  string
			artist string
			pop    int
		)

		if err := rows.Scan(&id, &title, &artist, &pop); err != nil {
			continue
		}

		score := diceCoefficient(qSet, fuzzyBigrams(title))
		if s := diceCoefficient(qSet, fuzzyBigrams(artist)); s > score {
			score = s
		}

		if score >= fuzzyRescueMinScore {
			candidates = append(candidates, scored{id: id, score: score, pop: pop})
		}
	}

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

	// Best bigram match first; popularity breaks near-ties so the more
	// prominent entity wins when two names are equally close.
	sort.SliceStable(candidates, func(i, j int) bool {
		if candidates[i].score != candidates[j].score {
			return candidates[i].score > candidates[j].score
		}

		return candidates[i].pop > candidates[j].pop
	})

	if len(candidates) > limit {
		candidates = candidates[:limit]
	}

	ids := make([]int64, len(candidates))
	for i, c := range candidates {
		ids[i] = c.id
	}

	return si.rowsByIDs(ctx, ids)
}

// rowsByIDs loads full index rows for the given explore_index ids,
// returned in the same order as `ids` (rows are keyed by id in a map,
// then re-emitted in input order).  Used by fuzzyRescue to hydrate the
// lightweight candidate ids it scored into full search results.
func (si *SearchIndex) rowsByIDs(ctx context.Context, ids []int64) []SearchIndexResult {
	if len(ids) == 0 {
		return nil
	}

	placeholders := make([]string, len(ids))
	args := make([]any, len(ids))

	for i, id := range ids {
		placeholders[i] = "?"
		args[i] = id
	}

	query := `
		SELECT id, ` + indexRowColumns + `
		FROM explore_index
		WHERE id IN (` + strings.Join(placeholders, ",") + `)`

	rows, err := si.db.QueryContextWith(ctx, query, args...)
	if err != nil {
		if !errors.Is(err, context.Canceled) {
			si.logger.Warn("fuzzy rescue hydrate error", "error", err)
		}

		return nil
	}

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

	byID := make(map[int64]SearchIndexResult, len(ids))

	for rows.Next() {
		var (
			id int64
			r  SearchIndexResult
		)

		if err := scanIndexRow(rows, &r, &id); err != nil {
			continue
		}

		byID[id] = r
	}

	out := make([]SearchIndexResult, 0, len(ids))

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

	return out
}

// runSearch dispatches an FTS query to the champion tier when the query
// is generic and the champion index is ready, falling back to the full
// index when the champion result is thin (few high-popularity matches)
// or the champion index isn't built yet.
func (si *SearchIndex) runSearch(
	ctx context.Context,
	ftsQuery string,
	limit int,
	generic bool,
) []SearchIndexResult {
	if generic {
		if si.championIsReady() {
			cStart := time.Now()
			hits := si.queryFTS(ctx, "explore_champion_fts", ftsQuery, limit)

			if len(hits) >= limit {
				si.logger.Info("search path: champion",
					"fts", ftsQuery,
					"rows", len(hits),
					"elapsed", time.Since(cStart).Round(time.Millisecond),
				)

				return hits
			}

			// Thin champion result: the full index is authoritative, and a
			// query the champion couldn't fill matches few rows there too.
			si.logger.Info("search path: champion thin -> full",
				"fts", ftsQuery,
				"champion_rows", len(hits),
				"champion_elapsed", time.Since(cStart).Round(time.Millisecond),
			)
		} else {
			// First generic query before the champion exists — build it in
			// the background so the next one is fast.
			si.logger.Info("search path: champion not ready -> full", "fts", ftsQuery)
			si.scheduleChampionRebuild()
		}
	}

	fStart := time.Now()
	hits := si.queryFTS(ctx, "explore_index_fts", ftsQuery, limit)

	if generic {
		si.logger.Info("search path: full",
			"fts", ftsQuery,
			"rows", len(hits),
			"elapsed", time.Since(fStart).Round(time.Millisecond),
		)
	}

	return hits
}

// queryFTS runs the popularity-blended ranking query against the named
// FTS table (the full index or the champion tier — identical schema, so
// the only difference is how many rows MATCH).
func (si *SearchIndex) queryFTS(
	ctx context.Context,
	ftsTable, ftsQuery string,
	limit int,
) []SearchIndexResult {
	// ftsTable is a trusted in-package constant, never user input.
	sqlText := fmt.Sprintf(`
		SELECT `+indexRowColumnsFor("i")+`
		FROM explore_index i
		JOIN %[1]s f ON f.rowid = i.id
		WHERE %[1]s MATCH ?
		ORDER BY bm25(%[1]s, 3.0, 1.0, 0.5)
		         - (ln(i.popularity + 1) * 1.5)
		         - (i.in_library * 3.0)
		         - (i.is_similar * 1.5)
		LIMIT ?
	`, ftsTable)

	rows, err := si.db.QueryContextWith(ctx, sqlText, ftsQuery, limit)
	if err != nil {
		// A superseded search has its context cancelled on purpose; that
		// is expected shutdown, not a query fault, so don't warn on it.
		if !errors.Is(err, context.Canceled) {
			si.logger.Warn("search index query error",
				"table", ftsTable,
				"ftsQuery", ftsQuery,
				"error", err,
			)
		}

		return nil
	}

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

	var results []SearchIndexResult

	for rows.Next() {
		var r SearchIndexResult

		if err := scanIndexRow(rows, &r); err != nil {
			si.logger.Warn("search index scan error", "error", err)

			continue
		}

		results = append(results, r)
	}

	return results
}

// isGenericQuery reports whether a query has no selective (long) token
// and therefore matches a broad slice of the index — the case the
// champion tier and prefix cache exist to accelerate.
func isGenericQuery(query string) bool {
	words := splitWords(query)
	if len(words) == 0 {
		return false
	}

	for _, w := range words {
		if len([]rune(w)) > genericMaxTokenLen {
			return false
		}
	}

	return true
}

// ---------------------------------------------------------------------------
// Champion tier
// ---------------------------------------------------------------------------

// championIsReady reports whether the champion FTS is populated.
func (si *SearchIndex) championIsReady() bool {
	si.mu.RLock()
	defer si.mu.RUnlock()

	return si.championReady
}

// scheduleChampionRebuild rebuilds the champion FTS in the background,
// unless one is already running or the main index isn't ready yet.  The
// rebuild runs in a single write transaction, so concurrent searches
// keep reading the previous champion snapshot (WAL) until it commits.
func (si *SearchIndex) scheduleChampionRebuild() {
	si.mu.Lock()
	if si.championBuilding || !si.ready {
		si.mu.Unlock()

		return
	}

	si.championBuilding = true
	si.mu.Unlock()

	go func() {
		// Use a stable context, not any per-search one, so a superseded
		// search can't cancel the rebuild midway.
		ctx := si.runtimeCtx
		if ctx == nil {
			ctx = context.Background()
		}

		start := time.Now()
		err := si.rebuildChampionIndex(ctx)

		si.mu.Lock()
		si.championBuilding = false
		// Keep a previously-good champion usable if a refresh failed; mark
		// ready on success.
		si.championReady = si.championReady || err == nil
		si.mu.Unlock()

		// The champion set changed, so any memoised generic results are
		// stale.
		si.clearPrefixCache()

		if err != nil {
			si.logger.Warn("champion index rebuild failed", "error", err)

			return
		}

		si.setMeta(championBuiltKey, time.Now().UTC().Format(time.RFC3339))
		si.logger.Info("champion index rebuilt",
			"elapsed", time.Since(start).Round(time.Millisecond),
		)
	}()
}

// rebuildChampionIndex repopulates the champion FTS from the high-
// popularity and owned rows of explore_index.  A row below the
// popularity floor can never place in a generic query's top results
// (ranking there is popularity-dominated), so dropping it is lossless
// for those queries; owned rows are always kept for their in-library
// boost.
func (si *SearchIndex) rebuildChampionIndex(ctx context.Context) error {
	tx, err := si.db.BeginTx()
	if err != nil {
		return fmt.Errorf("begin champion rebuild: %w", err)
	}

	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO explore_champion_fts(explore_champion_fts) VALUES('delete-all')`,
	); err != nil {
		return fmt.Errorf("clear champion fts: %w", err)
	}

	if _, err := tx.ExecContext(ctx, `
		INSERT INTO explore_champion_fts(rowid, title, artist_name, aliases)
		SELECT id, title, artist_name, aliases
		FROM explore_index
		WHERE popularity >= ? OR in_library = 1
	`, championPopThreshold); err != nil {
		return fmt.Errorf("populate champion fts: %w", err)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit champion rebuild: %w", err)
	}

	return nil
}

// ---------------------------------------------------------------------------
// Prefix result cache
// ---------------------------------------------------------------------------

func prefixCacheKey(ftsQuery string, limit int) string {
	return strconv.Itoa(limit) + "\x00" + ftsQuery
}

func (si *SearchIndex) prefixCacheGet(ftsQuery string, limit int) ([]SearchIndexResult, bool) {
	si.prefixCacheMu.Lock()
	defer si.prefixCacheMu.Unlock()

	key := prefixCacheKey(ftsQuery, limit)

	entry, ok := si.prefixCache[key]
	if !ok {
		return nil, false
	}

	if time.Since(entry.created) > prefixCacheTTL {
		delete(si.prefixCache, key)

		return nil, false
	}

	return entry.hits, true
}

func (si *SearchIndex) prefixCachePut(ftsQuery string, limit int, hits []SearchIndexResult) {
	si.prefixCacheMu.Lock()
	defer si.prefixCacheMu.Unlock()

	if si.prefixCache == nil {
		si.prefixCache = make(map[string]prefixCacheEntry)
	}

	// Generic keys are few and cheap to recompute; when the cap is hit,
	// drop everything rather than track per-entry LRU state.
	if len(si.prefixCache) >= prefixCacheMaxEntries {
		si.prefixCache = make(map[string]prefixCacheEntry)
	}

	si.prefixCache[prefixCacheKey(ftsQuery, limit)] = prefixCacheEntry{
		hits:    hits,
		created: time.Now(),
	}
}

func (si *SearchIndex) clearPrefixCache() {
	si.prefixCacheMu.Lock()
	si.prefixCache = nil
	si.prefixCacheMu.Unlock()
}

// ---------------------------------------------------------------------------
// FTS query building
// ---------------------------------------------------------------------------

func buildFTSQuery(query string) string {
	words := splitWords(query)
	if len(words) == 0 {
		return ""
	}

	var b strings.Builder

	for i, w := range words {
		if i > 0 {
			b.WriteByte(' ')
		}

		b.WriteString(w)
		b.WriteByte('*')
	}

	return b.String()
}

func splitWords(s string) []string {
	var words []string

	current := ""

	for _, r := range s {
		if isWordChar(r) {
			current += string(r)
		} else if current != "" {
			words = append(words, current)
			current = ""
		}
	}

	if current != "" {
		words = append(words, current)
	}

	return words
}

func isWordChar(r rune) bool {
	return (r >= 'a' && r <= 'z') ||
		(r >= 'A' && r <= 'Z') ||
		(r >= '0' && r <= '9') ||
		r >= 0x80
}

type lbSimilarArtistWire struct {
	ArtistMBID    string `json:"artist_mbid"`
	Name          string `json:"name"`
	Score         int    `json:"score"`
	ReferenceMBID string `json:"reference_mbid"` // which seed artist this result belongs to
}

// ---------------------------------------------------------------------------
// Shared: index artist discographies
// ---------------------------------------------------------------------------

func (si *SearchIndex) indexOneArtist(
	ctx context.Context,
	lb *ListenBrainzClient,
	artist lbSitewideArtist,
) {
	if ctx.Err() != nil {
		return
	}

	rgLimit, recLimit := indexMaxRGs, indexMaxRecs

	// Run LB discography fetches and MB artist image resolution
	// concurrently — they use different rate limiters so they
	// don't block each other.
	var (
		rgs    []SearchIndexResult
		recs   []SearchIndexResult
		rgErr  error
		recErr error
		wg     sync.WaitGroup
	)

	// LB pipeline: top release groups + top recordings.
	wg.Add(1)

	go func() {
		defer wg.Done()

		rgs, rgErr = si.fetchTopReleaseGroups(ctx, lb, artist, rgLimit)
		recs, recErr = si.fetchTopRecordings(ctx, lb, artist, recLimit)
	}()

	// MB pipeline: cache the artist lookup, which is what the details
	// below are read from (uses MB rate limiter).
	//
	// Deliberately *not* GetArtistImage.  This function wants the MB
	// artist response; that entry point additionally queried fanart.tv,
	// TheAudioDB, Wikidata and Wikipedia and downloaded up to ten
	// full-size portraits per artist, none of which any caller here
	// reads.  A portrait is resolved when a view asks for one.
	wg.Add(1)

	go func() {
		defer wg.Done()

		if si.artistImg != nil {
			// Carries the caller's priority: interactive from
			// EnsureArtistDiscography, background from the backfill.
			si.artistImg.EnsureArtistRels(ctx, artist.ArtistMBID)
		}
	}()

	wg.Wait()

	// Write the artist entry into the index so indexedArtistMBIDs()
	// recognises this artist as processed on subsequent builds.
	// Also stores aliases and detail fields from the now-cached MB rels
	// (populated by the image resolution above) for FTS search.
	//
	// DiscogFetched records that both LB endpoints were *asked*, not
	// that they had anything to say.  A transient failure still leaves
	// the artist unmarked so the next run retries it — but an artist LB
	// has no popularity data for (or none above indexMinPopularity,
	// which is most of a long-tail library) answers empty every single
	// time, and keying the mark on emptiness made those artists
	// permanent candidates: the owned-artist backfill re-ran for them
	// on every launch, forever, which is the bug this replaces.
	if si.artistImg != nil {
		gotData := rgErr == nil && recErr == nil
		artistEntry := SearchIndexResult{
			EntityType:    "artist",
			MBID:          artist.ArtistMBID,
			Title:         artist.ArtistName,
			ArtistName:    artist.ArtistName,
			ArtistMBID:    artist.ArtistMBID,
			Popularity:    artist.ListenCount,
			DiscogFetched: gotData,
		}

		if details := si.artistImg.GetArtistDetails(artist.ArtistMBID); details != nil {
			artistEntry.ArtistType = details.Type
			artistEntry.Country = details.Country
			artistEntry.Disambiguation = details.Disambiguation
			artistEntry.SortName = details.SortName
			artistEntry.Aliases = details.Aliases
		}

		si.upsertBatch([]SearchIndexResult{artistEntry})
	}

	// Batch write discography results.
	all := make([]SearchIndexResult, 0, len(rgs)+len(recs))
	all = append(all, rgs...)
	all = append(all, recs...)

	for i := 0; i < len(all); i += indexBatchSize {
		end := i + indexBatchSize
		if end > len(all) {
			end = len(all)
		}

		si.upsertBatch(all[i:end])
	}
}

func (si *SearchIndex) fetchTopReleaseGroups(
	ctx context.Context,
	lb *ListenBrainzClient,
	artist lbSitewideArtist,
	maxCount int,
) ([]SearchIndexResult, error) {
	url := fmt.Sprintf(
		"%s/1/popularity/top-release-groups-for-artist/%s",
		lb.baseURL, artist.ArtistMBID,
	)

	body, err := lb.doGet(ctx, url)
	if err != nil {
		si.logger.Debug("search index: top RGs failed",
			"artist", artist.ArtistName,
			"error", err,
		)

		return nil, err
	}

	var raw []struct {
		ReleaseGroupMBID string `json:"release_group_mbid"`
		TotalListenCount int    `json:"total_listen_count"`
		ReleaseGroup     struct {
			Name           string `json:"name"`
			Type           string `json:"type"`
			Date           string `json:"date"`
			CAAReleaseMBID string `json:"caa_release_mbid"`
		} `json:"release_group"`
		Artist struct {
			Artists []struct {
				ArtistMBID string `json:"artist_mbid"`
				Name       string `json:"name"`
			} `json:"artists"`
		} `json:"artist"`
	}

	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, err
	}

	limit := maxCount
	if limit > len(raw) {
		limit = len(raw)
	}

	results := make([]SearchIndexResult, 0, limit)

	for _, r := range raw[:limit] {
		if r.TotalListenCount < indexMinPopularity {
			continue
		}

		artistName := artist.ArtistName
		artistMBID := artist.ArtistMBID

		if len(r.Artist.Artists) > 0 {
			artistName = r.Artist.Artists[0].Name
			artistMBID = r.Artist.Artists[0].ArtistMBID
		}

		results = append(results, SearchIndexResult{
			EntityType:     "release_group",
			MBID:           r.ReleaseGroupMBID,
			Title:          r.ReleaseGroup.Name,
			ArtistName:     artistName,
			ArtistMBID:     artistMBID,
			Popularity:     r.TotalListenCount,
			PrimaryType:    r.ReleaseGroup.Type,
			ReleaseDate:    r.ReleaseGroup.Date,
			CAAReleaseMBID: r.ReleaseGroup.CAAReleaseMBID,
		})
	}

	return results, nil
}

func (si *SearchIndex) fetchTopRecordings(
	ctx context.Context,
	lb *ListenBrainzClient,
	artist lbSitewideArtist,
	maxCount int,
) ([]SearchIndexResult, error) {
	url := fmt.Sprintf(
		"%s/1/popularity/top-recordings-for-artist/%s",
		lb.baseURL, artist.ArtistMBID,
	)

	body, err := lb.doGet(ctx, url)
	if err != nil {
		return nil, err
	}

	var raw []lbTopRecordingWire
	if err := json.Unmarshal(body, &raw); err != nil {
		return nil, err
	}

	limit := maxCount
	if limit > len(raw) {
		limit = len(raw)
	}

	results := make([]SearchIndexResult, 0, limit)

	for _, r := range raw[:limit] {
		if r.TotalListenCount < indexMinPopularity {
			continue
		}

		results = append(results, SearchIndexResult{
			EntityType:     "recording",
			MBID:           r.RecordingMBID,
			Title:          r.RecordingName,
			ArtistName:     r.ArtistName,
			ArtistMBID:     artist.ArtistMBID,
			Popularity:     r.TotalListenCount,
			Duration:       r.Length,
			CAAReleaseMBID: r.CAAReleaseMBID,
			ReleaseName:    r.ReleaseName,
		})
	}

	return results, nil
}

// ---------------------------------------------------------------------------
// Database writes
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------
// Unified write API
// ---------------------------------------------------------------------------
//
// All writes to explore_index go through upsertBatch.  There are no
// side-channel write paths — if data needs to land in the index, it
// flows through a SearchIndexResult struct that carries every field.
// The merge semantics are: non-empty incoming values replace existing
// empty values, and numeric fields use "highest wins" for popularity/
// listener_count/duration so older richer data survives refreshes.

// indexRowColumns is the full explore_index projection, and
// scanIndexRow is the only thing that reads it.
//
// There were four copies of this column list and four matching Scan
// calls, which is what makes the storage encoding dangerous: a blob
// column scanned into a string yields sixteen bytes of garbage rather
// than an error, and it would have had to be got right four times.
// dbMBID and dbEntityType do the decoding, and they refuse anything
// that is not what they expect.
var indexRowFields = []string{
	"entity_type", "mbid", "title", "artist_name", "artist_mbid",
	"popularity", "listener_count", "duration", "primary_type",
	"secondary_types", "release_date", "total_tracks", "caa_release_mbid",
	"release_name", "artist_type", "country", "disambiguation",
	"sort_name", "in_library", "is_similar",
	"local_artist_id", "local_release_group_id", "local_recording_id",
}

// nullableIndexRowFields are the ones a caller wants zero rather than
// NULL for.
var nullableIndexRowFields = map[string]bool{
	"local_artist_id":        true,
	"local_release_group_id": true,
	"local_recording_id":     true,
}

// indexRowColumns is the projection unqualified; indexRowColumnsFor
// qualifies it with a table alias, for the joins where the other side
// also has a `title`.
var indexRowColumns = indexRowColumnsFor("")

func indexRowColumnsFor(alias string) string {
	if alias != "" {
		alias += "."
	}

	cols := make([]string, 0, len(indexRowFields))

	for _, f := range indexRowFields {
		if nullableIndexRowFields[f] {
			cols = append(cols, "COALESCE("+alias+f+", 0)")

			continue
		}

		cols = append(cols, alias+f)
	}

	return strings.Join(cols, ", ")
}

// scanIndexRow reads one indexRowColumns row into a result.
func scanIndexRow(rows *sql.Rows, r *SearchIndexResult, before ...any) error {
	var (
		entity dbEntityType
		id     dbMBID
		artist dbMBID
		caa    dbMBID
	)

	dest := make([]any, 0, len(before)+len(indexRowFields))
	dest = append(dest, before...)
	dest = append(dest,
		&entity, &id, &r.Title, &r.ArtistName, &artist,
		&r.Popularity, &r.ListenerCount, &r.Duration, &r.PrimaryType,
		&r.SecondaryTypes, &r.ReleaseDate, &r.TotalTracks, &caa,
		&r.ReleaseName, &r.ArtistType, &r.Country, &r.Disambiguation,
		&r.SortName, &r.InLibrary, &r.IsSimilar,
		&r.LocalArtistID, &r.LocalReleaseGroupID, &r.LocalRecordingID,
	)

	if err := rows.Scan(dest...); err != nil {
		return fmt.Errorf("scan index row: %w", err)
	}

	r.EntityType = string(entity)
	r.MBID = string(id)
	r.ArtistMBID = string(artist)
	r.CAAReleaseMBID = string(caa)

	return nil
}

// upsertIndexSQL is the single index write statement.  It is kept as
// a const so assembly can prepare it once per transaction instead of
// re-parsing this large upsert for every row.
const upsertIndexSQL = `
	INSERT INTO explore_index (
		entity_type, mbid, title, artist_name, artist_mbid, aliases,
		popularity, listener_count,
		duration, caa_release_mbid, release_name,
		primary_type, secondary_types, release_date, total_tracks,
		artist_type, country, disambiguation, sort_name,
		in_library, is_similar,
		local_artist_id, local_release_group_id, local_recording_id,
		discog_fetched
	) VALUES (
		?, ?, ?, ?, ?, ?,
		?, ?,
		?, ?, ?,
		?, ?, ?, ?,
		?, ?, ?, ?,
		?, ?,
		NULLIF(?, 0), NULLIF(?, 0), NULLIF(?, 0),
		?
	)` + upsertIndexConflictSQL

// upsertIndexConflictSQL is the merge half of every index write, split
// out so the bulk artifact import (which inserts by SELECT rather than
// by parameter list) resolves conflicts identically instead of carrying
// a second, drifting copy of these rules.
const upsertIndexConflictSQL = `
	ON CONFLICT(mbid) DO UPDATE SET
		-- Title and artist info: never clobber a good value with an
		-- empty one.  Writers are responsible for not offering an MBID
		-- as a name; AddFromCache is the path that used to.
		title       = CASE WHEN excluded.title != '' THEN excluded.title ELSE title END,
		artist_name = CASE WHEN excluded.artist_name != '' THEN excluded.artist_name ELSE artist_name END,
		artist_mbid = CASE WHEN excluded.artist_mbid != x'' THEN excluded.artist_mbid ELSE artist_mbid END,
		aliases     = CASE WHEN excluded.aliases != '' THEN excluded.aliases ELSE aliases END,

		-- Highest wins for popularity + listener_count (refreshes can go up).
		popularity     = CASE WHEN excluded.popularity > popularity THEN excluded.popularity ELSE popularity END,
		listener_count = CASE WHEN excluded.listener_count > listener_count THEN excluded.listener_count ELSE listener_count END,

		-- Non-empty wins for all other optional fields (never clobber with empty).
		duration         = CASE WHEN excluded.duration > 0 THEN excluded.duration ELSE duration END,
		caa_release_mbid = CASE WHEN excluded.caa_release_mbid != x'' THEN excluded.caa_release_mbid ELSE caa_release_mbid END,
		release_name     = CASE WHEN excluded.release_name != '' THEN excluded.release_name ELSE release_name END,
		primary_type     = CASE WHEN excluded.primary_type != '' THEN excluded.primary_type ELSE primary_type END,
		secondary_types  = CASE WHEN excluded.secondary_types != '' THEN excluded.secondary_types ELSE secondary_types END,
		release_date     = CASE WHEN excluded.release_date != '' THEN excluded.release_date ELSE release_date END,
		total_tracks     = CASE WHEN excluded.total_tracks > 0 THEN excluded.total_tracks ELSE total_tracks END,
		artist_type      = CASE WHEN excluded.artist_type != '' THEN excluded.artist_type ELSE artist_type END,
		country          = CASE WHEN excluded.country != '' THEN excluded.country ELSE country END,
		disambiguation   = CASE WHEN excluded.disambiguation != '' THEN excluded.disambiguation ELSE disambiguation END,
		sort_name        = CASE WHEN excluded.sort_name != '' THEN excluded.sort_name ELSE sort_name END,

		-- Flags and cross-references: non-null wins.
		in_library             = MAX(in_library, excluded.in_library),
		is_similar             = MAX(is_similar, excluded.is_similar),
		discog_fetched         = MAX(discog_fetched, excluded.discog_fetched),
		local_artist_id        = COALESCE(excluded.local_artist_id, local_artist_id),
		local_release_group_id = COALESCE(excluded.local_release_group_id, local_release_group_id),
		local_recording_id     = COALESCE(excluded.local_recording_id, local_recording_id)
`

// upsertBatch writes a batch of SearchIndexResult entries to the index
// inside a single transaction.  This is the ONE function that all
// writes go through.  All fields are handled — callers don't need to
// know which columns exist for which entity types.
func (si *SearchIndex) upsertBatch(entries []SearchIndexResult) {
	if len(entries) == 0 {
		return
	}

	tx, err := si.db.BeginTx()
	if err != nil {
		si.logger.Warn("search index: begin tx error", "error", err)

		return
	}

	stmt, err := tx.Prepare(upsertIndexSQL)
	if err != nil {
		si.logger.Warn("search index: prepare upsert error", "error", err)

		_ = tx.Rollback()

		return
	}

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

	for _, e := range entries {
		if e.MBID == "" {
			continue // skip entries without MBIDs — can't be looked up
		}

		inLib := 0
		if e.InLibrary {
			inLib = 1
		}

		isSim := 0
		if e.IsSimilar {
			isSim = 1
		}

		discogFetched := 0
		if e.DiscogFetched {
			discogFetched = 1
		}

		if _, err := stmt.Exec(
			dbEntityType(e.EntityType), dbMBID(e.MBID),
			e.Title, e.ArtistName, dbMBID(e.ArtistMBID), e.Aliases,
			e.Popularity, e.ListenerCount,
			e.Duration, dbMBID(e.CAAReleaseMBID), e.ReleaseName,
			e.PrimaryType, e.SecondaryTypes, e.ReleaseDate, e.TotalTracks,
			e.ArtistType, e.Country, e.Disambiguation, e.SortName,
			inLib, isSim,
			e.LocalArtistID, e.LocalReleaseGroupID, e.LocalRecordingID,
			discogFetched,
		); err != nil {
			si.logger.Warn("search index: upsert error",
				"mbid", e.MBID,
				"error", err,
			)
		}
	}

	if err := tx.Commit(); err != nil {
		si.logger.Warn("search index: commit error", "error", err)
	}
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// hasMeta reports whether a key exists in explore_index_meta.
func (si *SearchIndex) hasMeta(key string) bool {
	rows, err := si.db.QueryContext(
		"SELECT 1 FROM explore_index_meta WHERE key = ?", key,
	)
	if err != nil {
		return false
	}

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

	return rows.Next()
}

// PopulateLocalCrossReferences syncs the local library into the search
// index: every MB-verified library artist, release group, and recording
// is upserted into explore_index, flagged in_library with its local id.
// Call after a library scan completes.
//
// This is the local half of the index — it makes owned content
// searchable regardless of the dump's popularity floor, using only data
// already in the library tables (no API calls).  upsertBatch's conflict
// rules mean dump-seeded rows are merely tagged in_library while
// below-floor owned entities are inserted fresh (popularity 0, to be
// filled later by incremental dumps or a lazy discography fetch).
// MBID-less local content is intentionally excluded — the explore index
// is MBID-keyed, and unmatched files are served by the library search.
func (si *SearchIndex) PopulateLocalCrossReferences() {
	// Clear cross-references for entities no longer owned before adding
	// current ones — the upsert below only ever adds/refreshes rows for
	// what's currently in the library, so removals need their own pass.
	si.pruneStaleLocalCrossReferences()

	entries := si.collectLibraryEntities()
	if len(entries) == 0 {
		si.logger.Info("library sync: no MB-verified library entities to index")
		si.setMeta(localXrefReadyKey, "1")

		return
	}

	const batchSize = 1000
	for i := 0; i < len(entries); i += batchSize {
		end := i + batchSize
		if end > len(entries) {
			end = len(entries)
		}

		si.upsertBatch(entries[i:end])
	}

	si.setMeta(localXrefReadyKey, "1")
	si.logger.Info("library sync: upserted library entities into index", "count", len(entries))
}

// pruneStaleLocalCrossReferences clears in_library/local_*_id on
// explore_index rows whose local row no longer exists — e.g. an artist
// whose owned files were swapped out and removed by a rescan.  The
// index upsert (upsertIndexConflictSQL) is a one-way ratchet that only
// ever sets these columns, never clears them, so this is the only place
// a removal from the library is ever reflected back into the index.
// The row itself is left in place (it may still be part of the shipped
// catalog, just no longer owned) — only the "this is mine" bookkeeping
// is cleared.
//
// It is gated on the flag *or* the id, not on the id alone.  Gated on
// the id, `in_library = 1 AND local_*_id IS NULL` is a fixed point: the
// upsert can only ever raise the flag and this pass skipped such a row
// by construction, so nothing in the app could clear it — a row claiming
// to be owned, permanently, with no local row to check the claim
// against.  Nothing in the tree writes that shape today
// (collectLibraryEntities sets both together), which is exactly why it
// is worth closing now: the exposure is a database written by an older
// version, and the next writer that sets the flag without an id, which
// nothing structurally prevents.  A NULL id fails the existence test on
// its own, so the wider gate needs no second clause to say what "not
// owned" means.
func (si *SearchIndex) pruneStaleLocalCrossReferences() {
	type prune struct {
		entityType string
		column     string
		// exists is the test for "this local id still refers to
		// something the user owns".  It is a file test in every case -
		// the version that tested the metadata table left 129 rows in
		// a real catalog claiming to be owned by files that were gone.
		exists string
	}

	for _, p := range []prune{
		{"artist", "local_artist_id", `
			SELECT 1 FROM artists a WHERE a.id = explore_index.local_artist_id
			AND (
				EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
				OR EXISTS (
					SELECT 1 FROM albums al
					JOIN audio_files af2 ON af2.album_id = al.id
					WHERE al.artist_id = a.id
				)
			)`},
		{"release_group", "local_release_group_id", `
			SELECT 1 FROM audio_files af
			WHERE af.album_id = explore_index.local_release_group_id`},
		{"recording", "local_recording_id", `
			SELECT 1 FROM audio_files af
			WHERE af.id = explore_index.local_recording_id`},
	} {
		result, err := si.db.ExecContext(
			`UPDATE explore_index
			 SET in_library = 0, `+p.column+` = NULL
			 WHERE entity_type = ?
			   AND (`+p.column+` IS NOT NULL OR in_library = 1)
			   AND NOT EXISTS (`+p.exists+`)`,
			dbEntityType(p.entityType),
		)
		if err != nil {
			si.logger.Warn("library sync: prune stale cross-references failed",
				"entityType", p.entityType, "error", err)

			continue
		}

		if n, _ := result.RowsAffected(); n > 0 {
			si.logger.Info("library sync: cleared stale cross-references",
				"entityType", p.entityType, "count", n)
		}
	}
}

// collectLibraryEntities builds index entries for every library artist,
// release group, and recording that carries a MusicBrainz ID.  Artist
// credit strings and the primary artist MBID are resolved from the local
// artist_credit tables so no network lookup is needed.
func (si *SearchIndex) collectLibraryEntities() []SearchIndexResult {
	var entries []SearchIndexResult

	// Every one of these is gated on a file existing.  They used to
	// select straight from the metadata tables, so an artist, album or
	// recording whose files were gone stayed flagged "in library" in
	// the catalog until something noticed - and nothing did.
	type entityQuery struct {
		kind  string
		query string
	}

	queries := []entityQuery{
		{"artist", `
			SELECT DISTINCT a.id, a.name, a.mbid, a.name, a.mbid
			FROM artists a
			WHERE a.mbid IS NOT NULL AND a.mbid != ''
			  AND (
				EXISTS (SELECT 1 FROM audio_files af WHERE af.artist_id = a.id)
				OR EXISTS (
					SELECT 1 FROM albums al
					JOIN audio_files af2 ON af2.album_id = al.id
					WHERE al.artist_id = a.id
				)
			  )`},
		{"release_group", `
			SELECT DISTINCT al.id, al.name, al.mbid, al.artist_credit,
			       COALESCE(ar.mbid, '')
			FROM albums al
			JOIN audio_files af ON af.album_id = al.id
			LEFT JOIN artists ar ON ar.id = al.artist_id
			WHERE al.mbid IS NOT NULL AND al.mbid != ''`},
		{"recording", `
			SELECT af.id, af.title, af.recording_mbid, af.artist_credit,
			       COALESCE(ar.mbid, '')
			FROM audio_files af
			LEFT JOIN artists ar ON ar.id = af.artist_id
			WHERE af.recording_mbid IS NOT NULL AND af.recording_mbid != ''`},
	}

	for _, eq := range queries {
		rows, err := si.db.QueryContext(eq.query)
		if err != nil {
			si.logger.Warn("library sync: query failed", "kind", eq.kind, "error", err)

			continue
		}

		for rows.Next() {
			var (
				id                           int64
				name, mbid, credit, artistMB string
			)

			if err := rows.Scan(&id, &name, &mbid, &credit, &artistMB); err != nil {
				continue
			}

			entry := SearchIndexResult{
				EntityType: eq.kind,
				MBID:       mbid,
				Title:      name,
				ArtistName: credit,
				ArtistMBID: artistMB,
				InLibrary:  true,
			}

			switch eq.kind {
			case "artist":
				entry.LocalArtistID = id
			case "release_group":
				entry.LocalReleaseGroupID = id
			case "recording":
				// The local id of a "recording" is the file's, which is
				// what every caller wants: it is the thing that plays.
				entry.LocalRecordingID = id
			}

			entries = append(entries, entry)
		}

		_ = rows.Close()
	}

	return entries
}

// storeSimilarArtists persists the similar artist relationships
// for a source artist into the similar_artist_map table.
func (si *SearchIndex) storeSimilarArtists(sourceMBID string, similar []lbSimilarArtistWire) {
	if len(similar) == 0 {
		return
	}

	tx, err := si.db.BeginTx()
	if err != nil {
		return
	}

	defer func() { _ = tx.Rollback() }()

	// Clear existing entries for this source to avoid stale data.
	_, _ = tx.Exec(
		"DELETE FROM similar_artist_map WHERE source_artist_mbid = ?",
		sourceMBID,
	)

	for _, s := range similar {
		_, _ = tx.Exec(`
			INSERT OR IGNORE INTO similar_artist_map
				(source_artist_mbid, similar_artist_mbid, similar_artist_name, score)
			VALUES (?, ?, ?, ?)
		`, sourceMBID, s.ArtistMBID, s.Name, s.Score)
	}

	_ = tx.Commit()
}

// PopularityData holds both listen count and listener count for a
// single entity.  Used by BackfillPopularity and the popularity
// pipeline to pass both metrics together.
type PopularityData struct {
	ListenCount   int
	ListenerCount int
}

// BackfillPopularity writes LB popularity values back to the index
// for MBIDs that already exist.  Called after LB API responses so
// subsequent searches use the index instead of re-fetching from LB.
func (si *SearchIndex) BackfillPopularity(updates map[string]PopularityData) {
	if len(updates) == 0 {
		return
	}

	tx, err := si.db.BeginTx()
	if err != nil {
		return
	}

	defer func() { _ = tx.Rollback() }()

	for mbid, data := range updates {
		if data.ListenCount <= 0 {
			continue
		}

		_, _ = tx.Exec(
			`UPDATE explore_index
			 SET popularity = CASE WHEN ? > popularity THEN ? ELSE popularity END,
			     listener_count = CASE WHEN ? > listener_count THEN ? ELSE listener_count END
			 WHERE mbid = ?`,
			data.ListenCount, data.ListenCount,
			data.ListenerCount, data.ListenerCount,
			dbMBID(mbid),
		)
	}

	_ = tx.Commit()
}

// BackfillPopularitySimple is a convenience wrapper for callers that
// only have listen counts (no listener count).
func (si *SearchIndex) BackfillPopularitySimple(updates map[string]int) {
	if len(updates) == 0 {
		return
	}

	full := make(map[string]PopularityData, len(updates))
	for mbid, pop := range updates {
		full[mbid] = PopularityData{ListenCount: pop}
	}

	si.BackfillPopularity(full)
}

// GetSimilarityScores returns the highest similarity score for each
// MBID that appears in similar_artist_map as a similar artist.
// Returns a map of mbid → max similarity score.
func (si *SearchIndex) GetSimilarityScores(mbids []string) map[string]int {
	if len(mbids) == 0 {
		return nil
	}

	placeholders := make([]string, len(mbids))
	args := make([]any, len(mbids))

	for i, m := range mbids {
		placeholders[i] = "?"
		args[i] = m
	}

	query := "SELECT similar_artist_mbid, MAX(score) FROM similar_artist_map WHERE similar_artist_mbid IN (" +
		strings.Join(
			placeholders,
			",",
		) + ") GROUP BY similar_artist_mbid"

	rows, err := si.db.QueryContext(query, args...)
	if err != nil {
		return nil
	}

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

	result := make(map[string]int, len(mbids))

	for rows.Next() {
		var (
			mbid  string
			score int
		)

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

	return result
}

// InvalidateDiscographies clears the dump-import completion marker so
// the next StartBuild re-runs the full dump import.
func (si *SearchIndex) InvalidateDiscographies() {
	_, _ = si.db.ExecContext(
		"DELETE FROM explore_index_meta WHERE key = 'dump_import_done'",
	)
}

func (si *SearchIndex) setMeta(key, value string) {
	if _, err := si.db.ExecContext(
		"INSERT OR REPLACE INTO explore_index_meta (key, value) VALUES (?, ?)",
		key, value,
	); err != nil {
		si.logger.Warn("search index: set meta error", "key", key, "error", err)
	}
}

// deleteMeta removes a key from explore_index_meta, e.g. to invalidate a
// "ready" marker so the next launch re-runs a gated build step.
func (si *SearchIndex) deleteMeta(key string) {
	if _, err := si.db.ExecContext(
		"DELETE FROM explore_index_meta WHERE key = ?", key,
	); err != nil {
		si.logger.Warn("search index: delete meta error", "key", key, "error", err)
	}
}

// MarkReadyIfPopulated sets the index as ready for querying if it
// already contains data from a previous build.  Called eagerly at
// service creation so the index is queryable before StartBuild runs.
func (si *SearchIndex) MarkReadyIfPopulated() {
	rows, err := si.db.QueryContext("SELECT COUNT(*) FROM explore_index")
	if err != nil {
		return
	}

	var count int
	if rows.Next() {
		_ = rows.Scan(&count)
	}
	// Release the connection before any further query: under a
	// single-connection pool (tests) a nested query while these rows are
	// open would deadlock.
	_ = rows.Close()

	if count == 0 {
		return
	}

	// Trust a champion index built in a previous run; otherwise build it
	// below now that the main index is known-ready.
	championBuilt := si.hasMeta(championBuiltKey)

	si.mu.Lock()
	si.ready = true
	si.championReady = championBuilt
	si.mu.Unlock()

	si.logger.Info("search index: using existing index", "entries", count)

	// Becoming ready is a change the UI has to see, and this was the one
	// path that mutated the status without saying so — the 3 s ticker
	// carried it, invisibly, which is why removing the ticker without
	// this line would have left the settings page reading "not ready"
	// over a fully built index.
	si.emitStatus()

	if !championBuilt {
		si.scheduleChampionRebuild()
	}
}
