🏡 index : quine-global/quintodrome.git

package persistence

import (
	"cmp"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"os"
	"slices"
	"strings"
	"time"

	. "github.com/Masterminds/squirrel"
	"github.com/deluan/rest"
	"github.com/navidrome/navidrome/conf"
	"github.com/navidrome/navidrome/consts"
	"github.com/navidrome/navidrome/log"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/utils"
	"github.com/navidrome/navidrome/utils/slice"
	"github.com/navidrome/navidrome/utils/str"
	"github.com/pocketbase/dbx"
)

type artistRepository struct {
	sqlRepository
	indexGroups utils.IndexGroups
}

type dbArtist struct {
	*model.Artist    `structs:",flatten"`
	SimilarArtists   string `structs:"-" json:"-"`
	LibraryStatsJSON string `structs:"-" json:"-"`
}

type dbSimilarArtist struct {
	ID   string `json:"id,omitempty"`
	Name string `json:"name,omitempty"`
}

func (a *dbArtist) PostScan() error {
	a.Artist.Stats = make(map[model.Role]model.ArtistStats)

	if a.LibraryStatsJSON != "" {
		var rawLibStats map[string]map[string]map[string]int64
		if err := json.Unmarshal([]byte(a.LibraryStatsJSON), &rawLibStats); err != nil {
			return fmt.Errorf("parsing artist stats from db: %w", err)
		}

		for _, stats := range rawLibStats {
			// Sum all libraries roles stats
			for key, stat := range stats {
				// Aggregate stats into the main Artist.Stats map
				artistStats := model.ArtistStats{
					SongCount:  int(stat["m"]),
					AlbumCount: int(stat["a"]),
					Size:       stat["s"],
				}

				// Store total stats into the main attributes
				if key == "total" {
					a.Artist.Size += artistStats.Size
					a.Artist.SongCount += artistStats.SongCount
					a.Artist.AlbumCount += artistStats.AlbumCount
				}

				role := model.RoleFromString(key)
				if role == model.RoleInvalid {
					continue
				}

				current := a.Artist.Stats[role]
				current.Size += artistStats.Size
				current.SongCount += artistStats.SongCount
				current.AlbumCount += artistStats.AlbumCount
				a.Artist.Stats[role] = current
			}
		}
	}

	a.Artist.SimilarArtists = nil
	if a.SimilarArtists == "" {
		return nil
	}
	var sa []dbSimilarArtist
	if err := json.Unmarshal([]byte(a.SimilarArtists), &sa); err != nil {
		return fmt.Errorf("parsing similar artists from db: %w", err)
	}
	for _, s := range sa {
		a.Artist.SimilarArtists = append(a.Artist.SimilarArtists, model.Artist{
			ID:   s.ID,
			Name: s.Name,
		})
	}
	return nil
}

func (a *dbArtist) PostMapArgs(m map[string]any) error {
	sa := make([]dbSimilarArtist, 0)
	for _, s := range a.Artist.SimilarArtists {
		sa = append(sa, dbSimilarArtist{ID: s.ID, Name: s.Name})
	}
	similarArtists, _ := json.Marshal(sa)
	m["similar_artists"] = string(similarArtists)
	// When adding a derived column here, also add it to the scanner's artist Put column list
	// in phase_1_folders.go, or rescans will never update it (how search_normalized went stale).
	m["full_text"] = formatFullText(a.Name, a.SortArtistName)
	m["search_normalized"] = str.NormalizeForFTS(a.Name)

	// Do not override the sort_artist_name and mbz_artist_id fields if they are empty
	// TODO: Better way to handle this?
	if v, ok := m["sort_artist_name"]; !ok || v.(string) == "" {
		delete(m, "sort_artist_name")
	}
	if v, ok := m["mbz_artist_id"]; !ok || v.(string) == "" {
		delete(m, "mbz_artist_id")
	}
	return nil
}

type dbArtists []dbArtist

func (dba dbArtists) toModels() model.Artists {
	res := make(model.Artists, len(dba))
	for i := range dba {
		res[i] = *dba[i].Artist
	}
	return res
}

func NewArtistRepository(db dbx.Builder) model.ArtistRepository {
	r := &artistRepository{}
	r.db = db
	r.indexGroups = utils.ParseIndexGroups(conf.Server.IndexGroups)
	r.tableName = "artist" // To be used by the idFilter below
	r.registerModel(&model.Artist{}, map[string]filterFunc{
		"id":         idFilter(r.tableName),
		"name":       fullTextFilter(r.tableName, "mbz_artist_id"),
		"starred":    annotationBoolFilter("starred"),
		"has_rating": annotationBoolFilter("rating"),
		"role":       roleFilter,
		"missing":    booleanFilter,
		"library_id": artistLibraryIdFilter,
	})
	r.setSortMappings(map[string]string{ //nolint:gosec
		"name":        "order_artist_name",
		"starred_at":  "starred, starred_at",
		"rated_at":    "rating, rated_at",
		"song_count":  "stats->>'total'->>'m'",
		"album_count": "stats->>'total'->>'a'",
		"size":        "stats->>'total'->>'s'",

		// Stats by credits that are currently available
		"maincredit_song_count":  "sum(stats->>'maincredit'->>'m')",
		"maincredit_album_count": "sum(stats->>'maincredit'->>'a')",
		"maincredit_size":        "sum(stats->>'maincredit'->>'s')",
	})
	return r
}

func roleFilter(_ string, role any) Sqlizer {
	if role, ok := role.(string); ok {
		if safe, ok := sanitizeArtistStatsRole(role); ok && safe != "total" {
			return Expr("JSON_EXTRACT(library_artist.stats, '$." + safe + ".m') IS NOT NULL")
		}
	}
	return Eq{"1": 2}
}

// sanitizeArtistStatsRole allowlists values interpolated into JSON paths for artist
// stats (filter and sort). "total" is the aggregate key stored by the scanner.
// Unknown values must not reach SQL string concatenation.
func sanitizeArtistStatsRole(role string) (string, bool) {
	if role == "" || role == "total" {
		return "total", true
	}
	if _, ok := model.AllRoles[role]; ok {
		return role, true
	}
	return "", false
}

// artistLibraryIdFilter filters artists based on library access through the library_artist table
func artistLibraryIdFilter(_ string, value any) Sqlizer {
	return Eq{"library_artist.library_id": value}
}

// applyLibraryFilterToArtistQuery applies library filtering to artist queries through the library_artist junction table
func (r *artistRepository) applyLibraryFilterToArtistQuery(ctx context.Context, query SelectBuilder) SelectBuilder {
	user := loggedUser(ctx)
	// Join with library_artist first to ensure only artists with content in libraries are included
	// Exclude artists with empty stats (no actual content in the library)
	query = query.Join("library_artist on library_artist.artist_id = artist.id")
	//query = query.Join("library_artist on library_artist.artist_id = artist.id AND library_artist.stats != '{}'")

	// Admin users see all artists from all libraries, no additional filtering needed
	if user.ID != invalidUserId && !user.IsAdmin {
		// Apply library filtering only for non-admin users by joining with their accessible libraries
		query = query.Join("user_library on user_library.library_id = library_artist.library_id AND user_library.user_id = ?", user.ID)
	}

	return query
}

func (r *artistRepository) selectArtist(ctx context.Context, options ...model.QueryOptions) SelectBuilder {
	// Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}}
	query := r.newSelect(ctx, options...).Columns("artist.*",
		"JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json")

	query = r.applyLibraryFilterToArtistQuery(ctx, query)
	query = query.GroupBy("artist.id")
	return r.withAnnotation(ctx, query, "artist.id")
}

func (r *artistRepository) CountAll(ctx context.Context, options ...model.QueryOptions) (int64, error) {
	query := r.newSelect(ctx)
	query = r.applyLibraryFilterToArtistQuery(ctx, query)
	// Only the annotation join is gated; the library_artist join above (and its count(distinct))
	// must stay, since an artist can span multiple libraries.
	if filtersNeedAnnotation(r.applyFilters(query, options...)) {
		query = r.withAnnotation(ctx, query, "artist.id")
	}
	return r.count(ctx, query, options...)
}

// Exists checks if an artist with the given ID exists in the database and is accessible by the current user.
func (r *artistRepository) Exists(ctx context.Context, id string) (bool, error) {
	// Create a query using the same library filtering logic as selectArtist()
	query := r.newSelect(ctx).Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id})
	query = r.applyLibraryFilterToArtistQuery(ctx, query)

	var res struct{ Exist int64 }
	err := r.queryOne(ctx, query, &res)
	return res.Exist > 0, err
}

func (r *artistRepository) Put(ctx context.Context, a *model.Artist, colsToUpdate ...string) error {
	dba := &dbArtist{Artist: a}
	dba.CreatedAt = new(time.Now())
	dba.UpdatedAt = dba.CreatedAt
	_, err := r.put(ctx, dba.ID, dba, colsToUpdate...)
	return err
}

func (r *artistRepository) UpdateExternalInfo(ctx context.Context, a *model.Artist) error {
	dba := &dbArtist{Artist: a}
	_, err := r.put(ctx, a.ID, dba,
		"biography", "small_image_url", "medium_image_url", "large_image_url",
		"similar_artists", "external_url", "external_info_updated_at")
	return err
}

func (r *artistRepository) Get(ctx context.Context, id string) (*model.Artist, error) {
	sel := r.selectArtist(ctx).Where(Eq{"artist.id": id})
	var dba dbArtists
	if err := r.queryAll(ctx, sel, &dba); err != nil {
		return nil, err
	}
	if len(dba) == 0 {
		return nil, model.ErrNotFound
	}
	res := dba.toModels()
	r.hydrateArtwork(ctx, res)
	return &res[0], nil
}

func (r *artistRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.Artists, error) {
	sel := r.selectArtist(ctx, options...)
	var dba dbArtists
	err := r.queryAll(ctx, sel, &dba)
	if err != nil {
		return nil, err
	}
	res := dba.toModels()
	r.hydrateArtwork(ctx, res)
	return res, err
}

// getAllIDs returns just the artist IDs for the same row set as GetAll, skipping the
// heavy stats columns and JSON post-processing.
func (r *artistRepository) getAllIDs(ctx context.Context, options ...model.QueryOptions) ([]string, error) {
	sq := r.applyLibraryFilterToArtistQuery(ctx, r.newSelect(ctx, options...).Columns("artist.id")).GroupBy("artist.id")
	if filtersNeedAnnotation(sq) {
		sq = r.withAnnotation(ctx, sq, "artist.id")
	}
	ids := []string{}
	err := r.queryAllSlice(ctx, sq, &ids)
	return ids, err
}

// hydrateArtwork fills each artist's ImageHash/ImageAbsent from one batched item_artwork lookup.
func (r *artistRepository) hydrateArtwork(ctx context.Context, artists model.Artists) {
	hydrateItems(ctx, r.db, model.KindArtistArtwork, artists,
		func(a *model.Artist) (string, *model.ItemImage) { return a.ID, &a.ItemImage })
}

func (r *artistRepository) GetCursor(ctx context.Context, options ...model.QueryOptions) (model.ArtistCursor, error) {
	ids, err := r.getAllIDs(ctx, options...)
	if err != nil {
		return nil, err
	}
	opts := chunkOptions(options, "artist.id")
	return model.ArtistCursor(streamByIDs(ids, func(chunk []string) (model.Artists, error) {
		return r.GetAll(ctx, opts(chunk))
	})), nil
}

func (r *artistRepository) getIndexKey(a model.Artist) string {
	source := a.OrderArtistName
	if conf.Server.PreferSortTags {
		source = cmp.Or(a.SortArtistName, a.OrderArtistName)
	}
	name := strings.ToLower(source)
	for k, v := range r.indexGroups {
		if strings.HasPrefix(name, strings.ToLower(k)) {
			return v
		}
	}
	return "#"
}

// GetIndex returns a list of artists grouped by the first letter of their name, or by the index group if configured.
// It can filter by roles and libraries, and optionally include artists that are missing (i.e., have no albums).
// TODO Cache the index (recalculate at scan time)
func (r *artistRepository) GetIndex(ctx context.Context, includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) {
	// Validate library IDs. If no library IDs are provided, return an empty index.
	if len(libraryIds) == 0 {
		return nil, nil
	}

	options := model.QueryOptions{Sort: "name"}
	if len(roles) > 0 {
		roleFilters := slice.Map(roles, func(r model.Role) Sqlizer {
			return roleFilter("role", r.String())
		})
		options.Filters = Or(roleFilters)
	}
	if !includeMissing {
		if options.Filters == nil {
			options.Filters = Eq{"artist.missing": false}
		} else {
			options.Filters = And{options.Filters, Eq{"artist.missing": false}}
		}
	}

	libFilter := artistLibraryIdFilter("library_id", libraryIds)
	if options.Filters == nil {
		options.Filters = libFilter
	} else {
		options.Filters = And{options.Filters, libFilter}
	}

	artists, err := r.GetAll(ctx, options)
	if err != nil {
		return nil, err
	}

	var result model.ArtistIndexes
	for k, v := range slice.Group(artists, r.getIndexKey) {
		result = append(result, model.ArtistIndex{ID: k, Artists: v})
	}
	slices.SortFunc(result, func(a, b model.ArtistIndex) int {
		return cmp.Compare(a.ID, b.ID)
	})
	return result, nil
}

func (r *artistRepository) purgeEmpty(ctx context.Context) error {
	orphanFilter := "id not in (select artist_id from album_artists)"

	// Collect uploaded image filenames before deleting
	sel := Select("uploaded_image").From(r.tableName).
		Where(orphanFilter).
		Where("uploaded_image != ''")
	var imageFiles []string
	if err := r.queryAllSlice(ctx, sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) {
		return fmt.Errorf("collecting artist images for cleanup: %w", err)
	}

	// Delete orphan artists
	del := Delete(r.tableName).Where(orphanFilter)
	c, err := r.executeSQL(ctx, del)
	if err != nil {
		return fmt.Errorf("purging empty artists: %w", err)
	}
	if c > 0 {
		log.Debug(ctx, "Purged empty artists", "totalDeleted", c)
	}

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

	// Best-effort cleanup of uploaded image files
	log.Debug(ctx, "Cleaning up artist images", "totalImages", len(imageFiles))
	for _, filename := range imageFiles {
		path := model.UploadedImagePath(consts.EntityArtist, filename)
		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
			log.Warn(ctx, "Failed to remove artist image during GC", "path", path, err)
		}
	}
	return nil
}

// markOrphansMissing flags as missing any non-missing artist with no library_artist row, keeping the
// search fast-path's `missing = false` filter correct (see searchCfg). Called wherever such a row can
// be dropped: RefreshStats cleanup and library deletion cascade.
func (r *artistRepository) markOrphansMissing(ctx context.Context) error {
	_, err := r.executeSQL(ctx, Expr(
		"update artist set missing = true where missing = false "+
			"and not exists (select 1 from library_artist where library_artist.artist_id = artist.id)"))
	if err != nil {
		return fmt.Errorf("marking orphaned artists missing: %w", err)
	}
	return nil
}

// markMissing marks artists as missing if all their albums are missing.
func (r *artistRepository) markMissing(ctx context.Context) error {
	q := Expr(`
with artists_with_non_missing_albums as (
    select distinct aa.artist_id
    from album_artists aa
    join album a on aa.album_id = a.id
    where a.missing = false
)
update artist
set missing = (artist.id not in (select artist_id from artists_with_non_missing_albums));
        `)
	_, err := r.executeSQL(ctx, q)
	if err != nil {
		return fmt.Errorf("marking missing artists: %w", err)
	}
	return nil
}

// RefreshPlayCounts updates the play count and last play date annotations for all artists, based
// on the media files associated with them.
func (r *artistRepository) RefreshPlayCounts(ctx context.Context) (int64, error) {
	query := Expr(`
with play_counts as (
    select user_id, atom as artist_id, sum(play_count) as total_play_count, max(play_date) as last_play_date
    from media_file
    join annotation on item_id = media_file.id
    left join json_tree(participants, '$.artist') as jt
    where atom is not null and key = 'id'
    group by user_id, atom
)
insert into annotation (user_id, item_id, item_type, play_count, play_date)
select user_id, artist_id, 'artist', total_play_count, last_play_date
from play_counts
where total_play_count > 0
on conflict (user_id, item_id, item_type) do update
    set play_count = excluded.play_count,
        play_date  = excluded.play_date;
`)
	return r.executeSQL(ctx, query)
}

// RefreshStats updates the stats field for artists whose associated media files were updated after the oldest recorded library scan time.
// When allArtists is true, it refreshes stats for all artists. It processes artists in batches to handle potentially large updates.
// This method now calculates per-library statistics and stores them in the library_artist junction table.
func (r *artistRepository) RefreshStats(ctx context.Context, allArtists bool) (int64, error) {
	var allTouchedArtistIDs []string
	if allArtists {
		// Refresh stats for all artists
		allArtistsQuerySQL := `SELECT DISTINCT id FROM artist WHERE id <> ''`
		if err := r.db.NewQuery(allArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
			return 0, fmt.Errorf("fetching all artist IDs: %w", err)
		}
		log.Debug(ctx, "RefreshStats: Refreshing all artists.", "count", len(allTouchedArtistIDs))
	} else {
		// Only refresh artists with updated timestamps
		touchedArtistsQuerySQL := `
        SELECT DISTINCT id
        FROM artist
        WHERE updated_at > (SELECT last_scan_at FROM library ORDER BY last_scan_at ASC LIMIT 1)
        `
		if err := r.db.NewQuery(touchedArtistsQuerySQL).Column(&allTouchedArtistIDs); err != nil {
			return 0, fmt.Errorf("fetching touched artist IDs: %w", err)
		}
		log.Debug(ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs))
	}

	if len(allTouchedArtistIDs) == 0 {
		log.Debug(ctx, "RefreshStats: No artists to update.")
		return 0, nil
	}

	// Template for the batch update with placeholder markers that we'll replace
	// This now calculates per-library statistics and stores them in library_artist.stats
	batchUpdateStatsSQL := `
    WITH artist_role_counters AS (
        SELECT mfa.artist_id,
               mf.library_id,
               mfa.role,
               count(DISTINCT mf.album_id) AS album_count,
               count(DISTINCT mf.id) AS count,
               sum(mf.size) AS size
        FROM media_file_artists mfa
        JOIN media_file mf ON mfa.media_file_id = mf.id
        WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
        GROUP BY mfa.artist_id, mf.library_id, mfa.role
    ),
    artist_total_counters AS (
        SELECT mfa.artist_id,
               mf.library_id,
               'total' AS role,
               count(DISTINCT mf.album_id) AS album_count,
               count(DISTINCT mf.id) AS count,
               sum(mf.size) AS size
        FROM media_file_artists mfa
        JOIN media_file mf ON mfa.media_file_id = mf.id
        WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
        GROUP BY mfa.artist_id, mf.library_id
    ),
    artist_participant_counter AS (
        SELECT mfa.artist_id,
               mf.library_id,
               'maincredit' AS role,
               count(DISTINCT mf.album_id) AS album_count,
               count(DISTINCT mf.id) AS count,
               sum(mf.size) AS size
        FROM media_file_artists mfa
        JOIN media_file mf ON mfa.media_file_id = mf.id
        WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
        AND mfa.role IN ('albumartist', 'artist')
        GROUP BY mfa.artist_id, mf.library_id
    ),
    combined_counters AS (
        SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
        UNION ALL
        SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
        UNION ALL
        SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
    ),
    library_artist_counters AS (
        SELECT artist_id,
               library_id,
               json_group_object(
                       role,
                       json_object('a', album_count, 'm', count, 's', size)
               ) AS counters
        FROM combined_counters
        GROUP BY artist_id, library_id
    )
    UPDATE library_artist
    SET stats = coalesce((SELECT counters FROM library_artist_counters lac
                         WHERE lac.artist_id = library_artist.artist_id
                         AND lac.library_id = library_artist.library_id), '{}')
    WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders

	var totalRowsAffected int64 = 0
	const batchSize = 1000

	batchCounter := 0
	for artistIDBatch := range slice.CollectChunks(slices.Values(allTouchedArtistIDs), batchSize) {
		batchCounter++
		log.Trace(ctx, "RefreshStats: Processing batch", "batchNum", batchCounter, "batchSize", len(artistIDBatch))

		// Create placeholders for each ID in the IN clauses
		placeholders := make([]string, len(artistIDBatch))
		for i := range artistIDBatch {
			placeholders[i] = "?"
		}
		// Don't add extra parentheses, the IN clause already expects them in SQL syntax
		inClause := strings.Join(placeholders, ",")

		// Replace the placeholder markers with actual SQL placeholders
		batchSQL := strings.Replace(batchUpdateStatsSQL, "ROLE_IDS_PLACEHOLDER", inClause, 4)

		// Create a single parameter array with all IDs (repeated 4 times for each IN clause)
		// We need to repeat each ID 4 times (once for each IN clause)
		args := make([]any, 4*len(artistIDBatch))
		for idx, id := range artistIDBatch {
			for i := range 4 {
				startIdx := i * len(artistIDBatch)
				args[startIdx+idx] = id
			}
		}

		// Now use Expr with the expanded SQL and all parameters
		sqlizer := Expr(batchSQL, args...)

		rowsAffected, err := r.executeSQL(ctx, sqlizer)
		if err != nil {
			return totalRowsAffected, fmt.Errorf("executing batch update for artist stats (batch %d): %w", batchCounter, err)
		}
		totalRowsAffected += rowsAffected
	}

	// Remove library_artist entries for artists that no longer have any content in a library.
	cleanupSQL := Delete("library_artist").Where("stats = '{}'")
	cleanupRows, err := r.executeSQL(ctx, cleanupSQL)
	if err != nil {
		log.Warn(ctx, "Failed to cleanup empty library_artist entries", err)
	} else {
		if cleanupRows > 0 {
			log.Debug(ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows)
		}
		// Reconcile orphans whenever the cleanup removed rows, and on a full refresh so a full scan
		// also heals any left by older versions.
		if cleanupRows > 0 || allArtists {
			if err := r.markOrphansMissing(ctx); err != nil {
				log.Warn(ctx, "Failed to mark orphaned artists missing after library_artist cleanup", err)
			}
		}
	}

	log.Debug(ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected)
	return totalRowsAffected, nil
}

// searchCfg builds the per-search config. scope is the set of library IDs the rowid Phase 1 must
// restrict artists to, or nil to skip the filter (fast-path). See [artistRepository.searchScope].
func (r *artistRepository) searchCfg(scope []int) searchConfig {
	return searchConfig{
		// Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist
		NaturalOrder: "artist.id",
		OrderBy:      []string{"sum(json_extract(stats, '$.total.m')) desc", "name"},
		MBIDFields:   []string{"mbz_artist_id"},
		// scope==nil is the fast-path: no filter (and orphans must not exist — see markOrphansMissing).
		// Otherwise the join-free [artistLibraryFilter].
		LibraryFilter: func(query SelectBuilder) SelectBuilder {
			if scope == nil {
				return query
			}
			return query.Where(artistLibraryFilter(scope))
		},
	}
}

// artistLibraryFilter restricts artists to the given libraries via a correlated EXISTS over the
// library_artist junction, staying join-free so it can scope the join-free search Phase 1 (a JOIN
// would fan out rowids and corrupt offset pagination). The inner LIMIT 1 is load-bearing: it stops
// SQLite from flattening the EXISTS back into a fan-out join, while still using the
// (library_id, artist_id) UNIQUE autoindex.
func artistLibraryFilter(libraryIDs []int) Sqlizer {
	if len(libraryIDs) == 0 {
		return Eq{"1": 2} // match nothing, without a degenerate `IN ()` subquery
	}
	sub, args, _ := Select("1").From("library_artist").
		Where(And{
			Expr("library_artist.artist_id = artist.id"),
			Eq{"library_artist.library_id": libraryIDs},
		}).Limit(1).ToSql()
	return Expr("EXISTS ("+sub+")", args...)
}

func (r *artistRepository) Search(ctx context.Context, q string, options ...model.QueryOptions) (model.Artists, error) {
	var opts model.QueryOptions
	if len(options) > 0 {
		opts = options[0]
	}
	// Artists have no library_id column, so the library_id filter callers pass (same as albums/songs)
	// can't be applied directly: consume it and realize it as a join-free Phase-1 scope (searchCfg).
	scope := r.searchScope(ctx, opts.Filters)
	if isLibraryIDFilter(opts.Filters) {
		opts.Filters = nil
	}
	var res dbArtists
	err := r.doSearch(ctx, r.selectArtist(ctx, opts), q, &res, r.searchCfg(scope), opts)
	if err != nil {
		return nil, fmt.Errorf("searching artist %q: %w", q, err)
	}
	artists := res.toModels()
	r.hydrateArtwork(ctx, artists)
	return artists, nil
}

// searchScope returns the library IDs the search must be restricted to, or nil to skip the filter
// entirely (the fast-path: the user sees everything the search could return, so a filter would be
// pure O(offset) overhead). It intersects the requested libraries with what the user can see.
func (r *artistRepository) searchScope(ctx context.Context, filter Sqlizer) []int {
	visible, err := r.visibleLibraryIDs(ctx)
	if err != nil {
		return r.requestedLibraryIDs(filter) // fail safe: narrow to the request rather than widen
	}
	requested := r.requestedLibraryIDs(filter)
	if requested == nil {
		// No explicit request: scope to the visible set, unless the user sees everything.
		if r.userSeesAllLibraries(ctx, visible) {
			return nil
		}
		return visible
	}
	// Narrow unless the request already covers everything the user can see. Compare by membership,
	// not length: the requested IDs may contain duplicates.
	requestedSet := slice.ToSet(requested)
	if slices.ContainsFunc(visible, func(id int) bool { _, ok := requestedSet[id]; return !ok }) {
		return requested
	}
	return nil
}

// requestedLibraryIDs extracts the []int from an Eq{"library_id": ids} filter, or nil if filter is
// not that shape.
func (r *artistRepository) requestedLibraryIDs(filter Sqlizer) []int {
	eq, ok := filter.(Eq)
	if !ok {
		return nil
	}
	ids, _ := eq["library_id"].([]int)
	return ids
}

// isLibraryIDFilter reports whether the filter is an Eq carrying a library_id key, so Search can
// consume it before it reaches the bare artist table (which has no library_id column).
func isLibraryIDFilter(filter Sqlizer) bool {
	eq, ok := filter.(Eq)
	if !ok {
		return false
	}
	_, ok = eq["library_id"]
	return ok
}

func (r *artistRepository) Count(ctx context.Context, options ...rest.QueryOptions) (int64, error) {
	return r.CountAll(ctx, r.parseRestOptions(ctx, options...))
}

func (r *artistRepository) Read(ctx context.Context, id string) (*model.Artist, error) {
	return r.Get(ctx, id)
}

// sortMappingsForRole copies the shared mappings so a role-specific sort never leaks into them.
// Anything sanitizeArtistStatsRole rejects falls back to the "total" aggregate.
func (r *artistRepository) sortMappingsForRole(options ...rest.QueryOptions) map[string]string {
	role := "total"
	if len(options) > 0 {
		if v, ok := options[0].Filters["role"].(string); ok {
			if safe, ok := sanitizeArtistStatsRole(v); ok {
				role = safe
			}
		}
	}
	mappings := maps.Clone(r.sortMappings)
	mappings["song_count"] = "sum(stats->>'" + role + "'->>'m')"
	mappings["album_count"] = "sum(stats->>'" + role + "'->>'a')"
	mappings["size"] = "sum(stats->>'" + role + "'->>'s')"
	return mappings
}

func (r *artistRepository) ReadAll(ctx context.Context, options ...rest.QueryOptions) ([]model.Artist, error) {
	scoped := *r
	scoped.sortMappings = r.sortMappingsForRole(options...)
	return scoped.GetAll(ctx, scoped.parseRestOptions(ctx, options...))
}

var _ model.ArtistRepository = (*artistRepository)(nil)
var _ rest.Repository[model.Artist] = (*artistRepository)(nil)