🏡 index : quine-global/quintodrome.git

package persistence

import (
	"errors"
	"fmt"
	"maps"
	"reflect"
	"slices"
	"strconv"
	"strings"
	"time"

	"github.com/Masterminds/squirrel"
	"github.com/navidrome/navidrome/model"
	"github.com/navidrome/navidrome/model/criteria"
	"golang.org/x/text/unicode/norm"
)

type smartPlaylistJoinType int

const (
	smartPlaylistJoinNone            smartPlaylistJoinType = 0
	smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota
	smartPlaylistJoinArtistAnnotation
	smartPlaylistJoinAlbum
)

func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool {
	return j&other != 0
}

type smartPlaylistField struct {
	expr            string
	order           string
	joinType        smartPlaylistJoinType
	emptyValues     []string // additional values that encode "missing" for string columns (e.g. '[]' for lyrics)
	coalesceDefault any      // missing-row default for a nullable annotation column; nil = none. See annotationCond.
}

type smartPlaylistCriteria struct {
	criteria.Criteria
	owner model.User
}

func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria {
	cSQL := smartPlaylistCriteria{Criteria: c}
	for _, opt := range opts {
		opt(&cSQL)
	}
	return cSQL
}

func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) {
	return func(c *smartPlaylistCriteria) {
		c.owner = owner
	}
}

var smartPlaylistFields = map[string]smartPlaylistField{
	"title":                {expr: "media_file.title"},
	"album":                {expr: "media_file.album"},
	"hascoverart":          {expr: "media_file.has_cover_art"},
	"tracknumber":          {expr: "media_file.track_number"},
	"discnumber":           {expr: "media_file.disc_number"},
	"year":                 {expr: "media_file.year"},
	"date":                 {expr: "media_file.date"},
	"originalyear":         {expr: "media_file.original_year"},
	"originaldate":         {expr: "media_file.original_date"},
	"releaseyear":          {expr: "media_file.release_year"},
	"releasedate":          {expr: "media_file.release_date"},
	"size":                 {expr: "media_file.size"},
	"compilation":          {expr: "media_file.compilation"},
	"missing":              {expr: "media_file.missing"},
	"explicitstatus":       {expr: "media_file.explicit_status"},
	"dateadded":            {expr: "media_file.created_at"},
	"datemodified":         {expr: "media_file.updated_at"},
	"discsubtitle":         {expr: "media_file.disc_subtitle"},
	"comment":              {expr: "media_file.comment"},
	"lyrics":               {expr: "media_file.lyrics", emptyValues: []string{"[]"}},
	"sorttitle":            {expr: "media_file.sort_title"},
	"sortalbum":            {expr: "media_file.sort_album_name"},
	"sortartist":           {expr: "media_file.sort_artist_name"},
	"sortalbumartist":      {expr: "media_file.sort_album_artist_name"},
	"albumcomment":         {expr: "media_file.mbz_album_comment"},
	"catalognumber":        {expr: "media_file.catalog_num"},
	"filepath":             {expr: "media_file.path"},
	"filetype":             {expr: "media_file.suffix"},
	"codec":                {expr: "media_file.codec"},
	"duration":             {expr: "media_file.duration"},
	"bitrate":              {expr: "media_file.bit_rate"},
	"bitdepth":             {expr: "media_file.bit_depth"},
	"samplerate":           {expr: "media_file.sample_rate"},
	"bpm":                  {expr: "media_file.bpm"},
	"channels":             {expr: "media_file.channels"},
	"loved":                {expr: "annotation.starred", coalesceDefault: false},
	"dateloved":            {expr: "annotation.starred_at"},
	"lastplayed":           {expr: "annotation.play_date"},
	"daterated":            {expr: "annotation.rated_at"},
	"playcount":            {expr: "annotation.play_count", coalesceDefault: 0},
	"rating":               {expr: "annotation.rating", coalesceDefault: 0},
	"averagerating":        {expr: "media_file.average_rating"},
	"albumrating":          {expr: "album_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation},
	"albumloved":           {expr: "album_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinAlbumAnnotation},
	"albumplaycount":       {expr: "album_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinAlbumAnnotation},
	"albumlastplayed":      {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation},
	"albumdateloved":       {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation},
	"albumdaterated":       {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation},
	"albumdateadded":       {expr: "album.created_at", joinType: smartPlaylistJoinAlbum},
	"albumdatemodified":    {expr: "album.updated_at", joinType: smartPlaylistJoinAlbum},
	"albumduration":        {expr: "album.duration", joinType: smartPlaylistJoinAlbum},
	"albumsongcount":       {expr: "album.song_count", joinType: smartPlaylistJoinAlbum},
	"albumsize":            {expr: "album.size", joinType: smartPlaylistJoinAlbum},
	"artistrating":         {expr: "artist_annotation.rating", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation},
	"artistloved":          {expr: "artist_annotation.starred", coalesceDefault: false, joinType: smartPlaylistJoinArtistAnnotation},
	"artistplaycount":      {expr: "artist_annotation.play_count", coalesceDefault: 0, joinType: smartPlaylistJoinArtistAnnotation},
	"artistlastplayed":     {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation},
	"artistdateloved":      {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation},
	"artistdaterated":      {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation},
	"mbz_album_id":         {expr: "media_file.mbz_album_id"},
	"mbz_album_artist_id":  {expr: "media_file.mbz_album_artist_id"},
	"mbz_artist_id":        {expr: "media_file.mbz_artist_id"},
	"mbz_recording_id":     {expr: "media_file.mbz_recording_id"},
	"mbz_release_track_id": {expr: "media_file.mbz_release_track_id"},
	"mbz_release_group_id": {expr: "media_file.mbz_release_group_id"},
	"rgalbumgain":          {expr: "media_file.rg_album_gain"},
	"rgalbumpeak":          {expr: "media_file.rg_album_peak"},
	"rgtrackgain":          {expr: "media_file.rg_track_gain"},
	"rgtrackpeak":          {expr: "media_file.rg_track_peak"},
	"library_id":           {expr: "media_file.library_id"},
	"random":               {order: "random()"},
}

func (c smartPlaylistCriteria) where() (squirrel.Sqlizer, error) {
	if c.Criteria.Expression == nil {
		return squirrel.Expr("1 = 1"), nil
	}
	return c.exprSQL(c.Criteria.Expression)
}

func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) {
	switch e := expr.(type) {
	case criteria.All:
		and := squirrel.And{}
		for _, child := range e {
			cond, err := c.exprSQL(child)
			if err != nil {
				return nil, err
			}
			and = append(and, cond)
		}
		return mergeNegatedJsonConds(and), nil
	case criteria.Any:
		or := squirrel.Or{}
		for _, child := range e {
			cond, err := c.exprSQL(child)
			if err != nil {
				return nil, err
			}
			or = append(or, cond)
		}
		return mergeJsonConds(or), nil
	case criteria.Is:
		return comparisonExpr(e, cmpEq)
	case criteria.IsNot:
		return isNotExpr(e)
	case criteria.Gt:
		return comparisonExpr(e, cmpGt)
	case criteria.Lt:
		return comparisonExpr(e, cmpLt)
	case criteria.Before:
		return comparisonExpr(e, cmpLt)
	case criteria.After:
		return comparisonExpr(e, cmpGt)
	case criteria.Contains:
		return likeExpr(e, "%%%v%%", false)
	case criteria.NotContains:
		return likeExpr(e, "%%%v%%", true)
	case criteria.StartsWith:
		return likeExpr(e, "%v%%", false)
	case criteria.EndsWith:
		return likeExpr(e, "%%%v", false)
	case criteria.InTheRange:
		return rangeExpr(e)
	case criteria.InTheLast:
		return periodExpr(e, false)
	case criteria.NotInTheLast:
		return periodExpr(e, true)
	case criteria.InPlaylist:
		return c.inList(e, false)
	case criteria.NotInPlaylist:
		return c.inList(e, true)
	case criteria.IsMissing:
		return missingExpr(e, true)
	case criteria.IsPresent:
		return missingExpr(e, false)
	default:
		return nil, fmt.Errorf("unknown criteria expression type %T", expr)
	}
}

func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) {
	if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) {
		return jsonExpr(info, squirrel.Eq{"value": value}, true), nil
	}
	return comparisonExpr(values, cmpNe)
}

func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) {
	field, value, info, ok := singleField(values)
	if !ok {
		if len(values) != 1 {
			return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field")
		}
		return nil, fmt.Errorf("invalid field in criteria: %s", field)
	}
	b, ok := value.(bool)
	if !ok {
		return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value)
	}
	negate := checkAbsence == b

	switch {
	case info.IsTag || info.IsRole:
		return jsonExpr(info, nil, negate), nil
	case info.Nullable:
		// Nullable column fields are stored in dedicated columns, not in the tags JSON, so
		// "missing" maps to a column check rather than a json_tree lookup. Numeric/boolean
		// columns (e.g. ReplayGain, BPM) encode absence as NULL only; string columns (e.g.
		// mbz_* IDs, lyrics) additionally treat empty string — and any field-specific empty
		// encodings (e.g. '[]' for lyrics) — as missing. The unified flow below handles both:
		// numeric/boolean fields simply have no empties, so the loops are no-ops.
		f, ok := smartPlaylistFields[info.Name()]
		if !ok || f.expr == "" {
			return nil, fmt.Errorf("invalid field in criteria: %s", field)
		}
		col := f.expr
		var empties []string
		if !info.Numeric && !info.Boolean {
			empties = append([]string{""}, f.emptyValues...)
		}
		missing := squirrel.Or{squirrel.Eq{col: nil}}
		present := squirrel.And{squirrel.NotEq{col: nil}}
		for _, e := range empties {
			missing = append(missing, squirrel.Eq{col: e})
			present = append(present, squirrel.NotEq{col: e})
		}
		if negate {
			return missing, nil
		}
		return present, nil
	default:
		return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field)
	}
}

func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) {
	if _, value, info, ok := singleField(values); ok {
		if info.IsTag || info.IsRole {
			return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil
		}
		// LIKE can't use the column index, so annotation fields keep the COALESCE form: a NULL
		// column never matches LIKE, which would silently drop missing-annotation rows the original
		// COALESCE form included.
		if f, isAnnotation := annotationField(info); isAnnotation {
			return likeCond(f.coalesced(), fmt.Sprintf(pattern, value), negate), nil
		}
	}
	fields, err := sqlFields(values)
	if err != nil {
		return nil, err
	}
	if negate {
		lk := squirrel.NotLike{}
		for field, value := range fields {
			lk[field] = fmt.Sprintf(pattern, value)
		}
		return lk, nil
	}
	lk := squirrel.Like{}
	for field, value := range fields {
		lk[field] = fmt.Sprintf(pattern, value)
	}
	return lk, nil
}

func likeCond(col, pattern string, negate bool) squirrel.Sqlizer {
	if negate {
		return squirrel.NotLike{col: pattern}
	}
	return squirrel.Like{col: pattern}
}

func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) {
	field, value, info, ok := singleField(values)
	if !ok {
		return nil, fmt.Errorf("invalid field in criteria: %s", field)
	}
	if info.IsTag || info.IsRole {
		// Tags/roles are multi-valued JSON, so splitting a range into two independent EXISTS
		// subqueries would let different values satisfy each bound. Ranges are unsupported there.
		return nil, fmt.Errorf("range operator not supported for tag/role field: %s", field)
	}
	s := reflect.ValueOf(value)
	if s.Kind() != reflect.Slice || s.Len() != 2 {
		return nil, fmt.Errorf("range criteria for %q must be a [min, max] pair, got: %v", field, value)
	}
	low, err := comparisonExpr(map[string]any{field: s.Index(0).Interface()}, cmpGe)
	if err != nil {
		return nil, err
	}
	high, err := comparisonExpr(map[string]any{field: s.Index(1).Interface()}, cmpLe)
	if err != nil {
		return nil, err
	}
	return squirrel.And{low, high}, nil
}

func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
	fields, err := sqlFields(values)
	if err != nil {
		return nil, err
	}
	var field string
	var value any
	for f, v := range fields {
		field, value = f, v
		break
	}
	days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64)
	if err != nil {
		return nil, err
	}
	firstDate := startOfPeriod(days, time.Now())
	if negate {
		return squirrel.Or{
			squirrel.Lt{field: firstDate},
			squirrel.Eq{field: nil},
		}, nil
	}
	return squirrel.Gt{field: firstDate}, nil
}

func startOfPeriod(numDays int64, from time.Time) string {
	return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02")
}

func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) {
	var condition squirrel.Sqlizer
	if playlistId, ok := values["id"].(string); ok && playlistId != "" {
		condition = squirrel.Eq{"pl.playlist_id": playlistId}
	} else if playlistPath, ok := values["path"].(string); ok && playlistPath != "" {
		condition = squirrel.Eq{"playlist.path": pathVariants(playlistPath)}
	} else {
		return nil, errors.New("playlist id or path not given")
	}
	filters := squirrel.And{condition}
	if !c.owner.IsAdmin {
		if c.owner.ID == "" {
			filters = append(filters, squirrel.Eq{"playlist.public": 1})
		} else {
			filters = append(filters, squirrel.Or{
				squirrel.Eq{"playlist.public": 1},
				squirrel.Eq{"playlist.owner_id": c.owner.ID},
			})
		}
	}
	subQuery := squirrel.Select("media_file_id").
		From("playlist_tracks pl").
		LeftJoin("playlist on pl.playlist_id = playlist.id").
		Where(filters)
	subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql()
	if err != nil {
		return nil, err
	}
	if negate {
		return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil
	}
	return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil
}

// Filesystems disagree on the Unicode form of a name, so match the path in NFC and NFD.
func pathVariants(path string) []string {
	variants := []string{path}
	if alt := norm.NFC.String(path); alt != path {
		variants = append(variants, alt)
	}
	if alt := norm.NFD.String(path); alt != path {
		variants = append(variants, alt)
	}
	return variants
}

func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer {
	if info.IsRole {
		return roleCond{role: info.Name(), cond: cond, not: negate}
	}
	return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate}
}

type tagCond struct {
	tag     string
	numeric bool
	cond    squirrel.Sqlizer
	not     bool
}

func (e tagCond) ToSql() (string, []any, error) {
	var cond string
	var args []any
	var err error
	if e.cond != nil {
		cond, args, err = e.cond.ToSql()
		if e.numeric {
			cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
		}
		cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond)
	} else {
		cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag)
	}
	if e.not {
		cond = "not " + cond
	}
	return cond, args, err
}

type roleCond struct {
	role string
	cond squirrel.Sqlizer
	not  bool
}

func (e roleCond) ToSql() (string, []any, error) {
	var cond string
	var args []any
	if e.cond != nil {
		innerSQL, innerArgs, err := roleCondSQL(e.cond)
		if err != nil {
			return "", nil, err
		}
		cond = roleExistsSQL(innerSQL)
		args = append([]any{e.role}, innerArgs...)
	} else {
		cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)"
		args = []any{e.role}
	}
	if e.not {
		cond = "not " + cond
	}
	return cond, args, nil
}

// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name.
func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) {
	sql, args, err := cond.ToSql()
	if err != nil {
		return "", nil, err
	}
	return strings.ReplaceAll(sql, "value", "artist.name"), args, nil
}

// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery.
func roleExistsSQL(innerCond string) string {
	return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+
		"where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond)
}

// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery
// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper
// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum.
const jsonCondBatchSize = 350

// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same
// field within an OR group into batched EXISTS subqueries with the conditions ORed inside.
// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically
// improving performance for smart playlists with many patterns.
func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer {
	if merged, ok := mergeSameFieldConds(or, false); ok {
		return squirrel.Or(merged)
	}
	return or
}

// mergeNegatedJsonConds is the AND-group counterpart to mergeJsonConds, merging negated
// conditions. By De Morgan, "NOT EXISTS(X) AND NOT EXISTS(Y)" == "NOT EXISTS(X OR Y)".
func mergeNegatedJsonConds(and squirrel.And) squirrel.Sqlizer {
	if merged, ok := mergeSameFieldConds(and, true); ok {
		return squirrel.And(merged)
	}
	return and
}

// mergeSameFieldConds groups roleCond/tagCond entries that share a field and the requested
// polarity, replacing each group of 2+ with batched roleCondGroup/tagCondGroup subqueries.
// Returns the rewritten conditions and whether any merge happened.
func mergeSameFieldConds(conds []squirrel.Sqlizer, negated bool) ([]squirrel.Sqlizer, bool) {
	type condEntry struct {
		index int
		cond  squirrel.Sqlizer
	}
	type group struct {
		entries []condEntry
		isRole  bool
		numeric bool
		tag     string
	}
	groups := make(map[string]*group)
	for i, s := range conds {
		switch c := s.(type) {
		case roleCond:
			if c.not != negated || c.cond == nil {
				continue
			}
			g, exists := groups["role:"+c.role]
			if !exists {
				g = &group{isRole: true}
				groups["role:"+c.role] = g
			}
			g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
		case tagCond:
			if c.not != negated || c.cond == nil {
				continue
			}
			g, exists := groups["tag:"+c.tag]
			if !exists {
				g = &group{tag: c.tag, numeric: c.numeric}
				groups["tag:"+c.tag] = g
			}
			g.entries = append(g.entries, condEntry{index: i, cond: c.cond})
		}
	}

	remove := make(map[int]bool)
	var additions []squirrel.Sqlizer
	for _, key := range slices.Sorted(maps.Keys(groups)) {
		g := groups[key]
		if len(g.entries) < 2 {
			continue
		}
		batchConds := make([]squirrel.Sqlizer, len(g.entries))
		for i, e := range g.entries {
			remove[e.index] = true
			batchConds[i] = e.cond
		}
		if g.isRole {
			role := key[len("role:"):]
			for batch := range slices.Chunk(batchConds, jsonCondBatchSize) {
				additions = append(additions, roleCondGroup{role: role, conds: batch, not: negated})
			}
		} else {
			for batch := range slices.Chunk(batchConds, jsonCondBatchSize) {
				additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch, not: negated})
			}
		}
	}

	if len(remove) == 0 {
		return conds, false
	}

	result := make([]squirrel.Sqlizer, 0, len(conds)-len(remove)+len(additions))
	for i, s := range conds {
		if !remove[i] {
			result = append(result, s)
		}
	}
	return append(result, additions...), true
}

// roleCondGroup represents multiple role conditions for the same role, merged into a single
// (optionally negated) EXISTS subquery for performance.
type roleCondGroup struct {
	role  string
	conds []squirrel.Sqlizer
	not   bool
}

func (g roleCondGroup) ToSql() (string, []any, error) {
	innerParts := make([]string, 0, len(g.conds))
	allArgs := []any{g.role}
	for _, c := range g.conds {
		part, args, err := roleCondSQL(c)
		if err != nil {
			return "", nil, err
		}
		innerParts = append(innerParts, part)
		allArgs = append(allArgs, args...)
	}
	cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")")
	if g.not {
		cond = "not " + cond
	}
	return cond, allArgs, nil
}

// tagCondGroup represents multiple tag conditions for the same tag, merged into a single
// (optionally negated) EXISTS subquery for performance.
type tagCondGroup struct {
	tag     string
	numeric bool
	conds   []squirrel.Sqlizer
	not     bool
}

func (g tagCondGroup) ToSql() (string, []any, error) {
	innerParts := make([]string, 0, len(g.conds))
	var allArgs []any
	for _, c := range g.conds {
		part, args, err := c.ToSql()
		if err != nil {
			return "", nil, err
		}
		if g.numeric {
			part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)")
		}
		innerParts = append(innerParts, part)
		allArgs = append(allArgs, args...)
	}
	cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))",
		g.tag, strings.Join(innerParts, " OR "))
	if g.not {
		cond = "not " + cond
	}
	return cond, allArgs, nil
}

func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) {
	if len(values) != 1 {
		return "", nil, criteria.FieldInfo{}, false
	}
	for field, value := range values {
		info, ok := criteria.LookupField(field)
		return field, value, info, ok
	}
	return "", nil, criteria.FieldInfo{}, false
}

func sqlFields(values map[string]any) (map[string]any, error) {
	fields := make(map[string]any, len(values))
	for field, value := range values {
		info, ok := criteria.LookupField(field)
		if !ok {
			return nil, fmt.Errorf("invalid field in criteria: %s", field)
		}
		if info.IsTag || info.IsRole {
			return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field)
		}
		sqlField, ok := fieldExpr(info.Name())
		if !ok || sqlField == "" {
			return nil, fmt.Errorf("invalid field in criteria: %s", field)
		}
		fields[sqlField] = value
	}
	return fields, nil
}

func fieldExpr(name string) (string, bool) {
	field, ok := smartPlaylistFields[strings.ToLower(name)]
	return field.expr, ok
}

// comparator is a scalar SQL comparison operator used by smart playlist criteria.
type comparator struct {
	build   func(map[string]any) squirrel.Sqlizer
	satisfy func(a, b float64) bool // the operator as a predicate, to reason about a column's COALESCE default
	// ordering is false only for = and <>, the only operators with a clean bare form over bool columns.
	ordering bool
}

var (
	cmpEq = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Eq(f) }, satisfy: func(a, b float64) bool { return a == b }}
	cmpNe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.NotEq(f) }, satisfy: func(a, b float64) bool { return a != b }}
	cmpGt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Gt(f) }, satisfy: func(a, b float64) bool { return a > b }, ordering: true}
	cmpGe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.GtOrEq(f) }, satisfy: func(a, b float64) bool { return a >= b }, ordering: true}
	cmpLt = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.Lt(f) }, satisfy: func(a, b float64) bool { return a < b }, ordering: true}
	cmpLe = comparator{build: func(f map[string]any) squirrel.Sqlizer { return squirrel.LtOrEq(f) }, satisfy: func(a, b float64) bool { return a <= b }, ordering: true}
)

// annotationField returns the field definition only for nullable annotation columns that have a
// COALESCE default (playcount, rating, loved). Date annotation columns (lastplayed, dateloved, ...)
// have no default and return ok=false.
func annotationField(info criteria.FieldInfo) (smartPlaylistField, bool) {
	f, ok := smartPlaylistFields[info.Name()]
	if !ok || f.coalesceDefault == nil {
		return smartPlaylistField{}, false
	}
	return f, true
}

// coalesced wraps the field in COALESCE(col, default) (bare expression if it has no default). Used
// where index-friendliness does not apply (ORDER BY, list comparisons) and the missing-row-as-default
// semantics must be kept.
func (f smartPlaylistField) coalesced() string {
	if f.coalesceDefault == nil {
		return f.expr
	}
	return fmt.Sprintf("COALESCE(%s, %s)", f.expr, sqlLiteral(f.coalesceDefault))
}

func comparisonExpr(values map[string]any, cmp comparator) (squirrel.Sqlizer, error) {
	if _, value, info, ok := singleField(values); ok {
		if info.IsTag || info.IsRole {
			return jsonExpr(info, cmp.build(map[string]any{"value": value}), false), nil
		}
		if f, isAnnotation := annotationField(info); isAnnotation {
			return annotationCond(f, cmp, value), nil
		}
	}
	fields, err := sqlFields(values)
	if err != nil {
		return nil, err
	}
	return cmp.build(fields), nil
}

// annotationCond builds a comparison against a nullable annotation column (see smartPlaylistField
// for why). When bareNullInclusion can reason about the comparison exactly it emits the
// index-friendly bare `col <cmp> ?`, adding `OR col IS NULL` only when the default would match;
// otherwise it falls back to the COALESCE form, which can't use the index but is always equivalent.
func annotationCond(f smartPlaylistField, cmp comparator, value any) squirrel.Sqlizer {
	wrapNull, ok := bareNullInclusion(f.coalesceDefault, cmp, value)
	if !ok {
		return cmp.build(map[string]any{f.coalesced(): value})
	}
	base := cmp.build(map[string]any{f.expr: value})
	if !wrapNull {
		return base
	}
	// The default satisfies the predicate, so missing rows (NULL) must be included. All comparators
	// (including <>) evaluate to false against NULL, so an explicit IS NULL restores them.
	return squirrel.Or{base, squirrel.Eq{f.expr: nil}}
}

// bareNullInclusion decides whether the index-friendly bare form is safe for this comparison and,
// if so, whether missing (NULL) rows must be re-included via `OR col IS NULL`. It returns ok=false
// when the bare form can't be proven equivalent to COALESCE(col, default) <cmp> ? — i.e. the value
// isn't a scalar the comparator can order exactly (lists, bool ordering, unparseable values) — in
// which case the caller keeps the COALESCE form. When ok=true, wrapNull is true iff the default
// value itself satisfies the predicate (so missing rows would match and must be preserved).
func bareNullInclusion(defaultVal any, cmp comparator, value any) (wrapNull, ok bool) {
	if b, isBool := defaultVal.(bool); isBool {
		// Bool columns have an exact bare form only for equality; ordering operators fall back to
		// COALESCE. Mapping both bools to 0/1 lets cmp.satisfy reuse the numeric eq/ne predicate.
		if cmp.ordering {
			return false, false
		}
		v, okV := criteria.ToBool(value)
		if !okV {
			return false, false
		}
		return cmp.satisfy(boolToFloat(b), boolToFloat(v)), true
	}
	d, okD := toFloat(defaultVal)
	v, okV := toFloat(value)
	if !okD || !okV {
		// Non-scalar or unparseable value: no exact bare form, keep COALESCE.
		return false, false
	}
	return cmp.satisfy(d, v), true
}

func boolToFloat(b bool) float64 {
	if b {
		return 1
	}
	return 0
}

// toFloat coerces a scalar criteria value to float64. Criteria values come from JSON (float64,
// string) or are built in Go (int, float64), so only those types are handled; anything else —
// including a slice or an unparseable string — reports ok=false so the caller keeps the COALESCE
// form instead of an index-friendly bare comparison.
func toFloat(v any) (float64, bool) {
	switch n := v.(type) {
	case float64:
		return n, true
	case int:
		return float64(n), true
	case int64:
		return float64(n), true
	case string:
		f, err := strconv.ParseFloat(n, 64)
		return f, err == nil
	default:
		return 0, false
	}
}

// sqlLiteral renders an annotation field's COALESCE default (0 or false) as a SQL literal for ORDER
// BY. %v renders both bool and numeric defaults correctly (false/true, 0).
func sqlLiteral(v any) string {
	return fmt.Sprintf("%v", v)
}

func fieldJoinType(name string) smartPlaylistJoinType {
	info, ok := criteria.LookupField(name)
	if !ok {
		return smartPlaylistJoinNone
	}
	field, ok := smartPlaylistFields[info.Name()]
	if !ok {
		return smartPlaylistJoinNone
	}
	return field.joinType
}

func (c smartPlaylistCriteria) expressionJoins() smartPlaylistJoinType {
	var joins smartPlaylistJoinType
	_ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error {
		for field := range criteria.Fields(expr) {
			joins |= fieldJoinType(field)
		}
		return nil
	})
	return joins
}

func (c smartPlaylistCriteria) requiredJoins() smartPlaylistJoinType {
	joins := c.expressionJoins()
	for _, name := range c.Criteria.SortFieldNames() {
		joins |= fieldJoinType(name)
	}
	return joins
}

// applyExpressionJoins adds every join the criteria's WHERE clause resolves against.
func (c smartPlaylistCriteria) applyExpressionJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
	return c.applyJoins(sq, c.expressionJoins(), userID)
}

// applyRequiredJoins adds the WHERE joins plus any the ORDER BY resolves against.
func (c smartPlaylistCriteria) applyRequiredJoins(sq squirrel.SelectBuilder, userID string) squirrel.SelectBuilder {
	return c.applyJoins(sq, c.requiredJoins(), userID)
}

// applyJoins joins the media_file annotation unconditionally — annotation fields
// COALESCE a missing row to a default, so the row has to be reachable to be absent.
func (c smartPlaylistCriteria) applyJoins(sq squirrel.SelectBuilder, joins smartPlaylistJoinType, userID string) squirrel.SelectBuilder {
	sq = sq.LeftJoin("annotation on ("+
		"annotation.item_id = media_file.id"+
		" AND annotation.item_type = 'media_file'"+
		" AND annotation.user_id = ?)", userID)
	if joins.has(smartPlaylistJoinAlbumAnnotation) {
		sq = sq.LeftJoin("annotation AS album_annotation ON ("+
			"album_annotation.item_id = media_file.album_id"+
			" AND album_annotation.item_type = 'album'"+
			" AND album_annotation.user_id = ?)", userID)
	}
	if joins.has(smartPlaylistJoinArtistAnnotation) {
		sq = sq.LeftJoin("annotation AS artist_annotation ON ("+
			"artist_annotation.item_id = media_file.artist_id"+
			" AND artist_annotation.item_type = 'artist'"+
			" AND artist_annotation.user_id = ?)", userID)
	}
	if joins.has(smartPlaylistJoinAlbum) {
		sq = sq.LeftJoin("album ON album.id = media_file.album_id")
	}
	return sq
}

func (c smartPlaylistCriteria) orderBy() string {
	sortFields := c.Criteria.OrderByFields()
	parts := make([]string, 0, len(sortFields))
	for _, sf := range sortFields {
		mapped, ok := sortExpr(sf.Field)
		if !ok {
			continue
		}
		dir := "asc"
		if sf.Desc {
			dir = "desc"
		}
		parts = append(parts, mapped+" "+dir)
	}
	return strings.Join(parts, ", ")
}

func sortExpr(sortField string) (string, bool) {
	info, ok := criteria.LookupField(sortField)
	if !ok {
		return "", false
	}
	if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" {
		return field.order, true
	}
	var mapped string
	switch {
	case info.IsTag:
		mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')"
	case info.IsRole:
		mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')"
	default:
		field, ok := smartPlaylistFields[info.Name()]
		if !ok || field.expr == "" {
			return "", false
		}
		// Sorting keeps the COALESCE default so missing-annotation rows sort as that default
		// (filtering drops COALESCE for index use, but ORDER BY has no index to preserve here).
		mapped = field.coalesced()
	}
	if info.Numeric {
		mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped)
	}
	return mapped, true
}