package smartplaylist

import (
	"errors"
	"strings"
	"testing"

	"yellowjacket/backend/coverart"
	"yellowjacket/backend/database"
)

// ---------------------------------------------------------------------------
// Test helpers — seed data
// ---------------------------------------------------------------------------

// seedSmartPlaylistData inserts 8 tracks with the full FK chain
// required for smart playlist evaluation tests. Extends the
// seedSearchData pattern from search_test.go with a multi-genre
// track (ID 8) that has both "Rock" and "Alternative" genres.
//
// Track list:
//
//	ID 1: "Bohemian Rhapsody" by "Queen"   album "A Night at the Opera" (1975) genre=Rock
//	ID 2: "Halo"              by "Beyoncé" album "Lemonade"             (2008) genre=Pop
//	ID 3: "Back in Black"     by "AC/DC"   album "Back in Black"        (1980) genre=Hard Rock
//	ID 4: "Comfortably Numb"  by "Pink Floyd" album "The Dark Side"     (1979) genre=Progressive Rock
//	ID 5: "Another One"       by "Queen"   album "The Game"             (1980) genre=Funk Rock
//	ID 6: "Thunderstruck"     by "AC/DC"   album "The Razors Edge"      (1990) genre=Hard Rock
//	ID 7: "No One Knows"      by "QOTSA"   album "Rated R"             (2000) genre=Stoner Rock
//	ID 8: "Under the Bridge"  by "RHCP"    album "Blood Sugar"          (1991) genre=Rock+Alternative (multi-genre)
func seedSmartPlaylistData(t *testing.T, db *database.DB) {
	t.Helper()

	type track struct {
		id       int64
		filePath string
		title    string
		artist   string
		album    string
		trackNum *int64
		discNum  *int64
		year     int64
		genres   []string // supports multi-genre
		composer string
		lenMs    int64
		ftID     int64
		sr       int64
		bd       int64
		ch       int64
		br       int64
		fsize    int64
	}

	intPtr := func(v int64) *int64 { return &v }

	tracks := []track{
		{
			1, "/music/queen/bohemian_rhapsody.mp3",
			"Bohemian Rhapsody", "Queen",
			"A Night at the Opera", intPtr(11), intPtr(1),
			1975,
			[]string{"Rock"},
			"Freddie Mercury",
			354000, 0, 44100, 16, 2, 320000, 8500000,
		},
		{
			2, "/music/beyonce/halo.flac",
			"Halo", "Beyoncé", "Lemonade",
			intPtr(1), intPtr(1),
			2008,
			[]string{"Pop"},
			"Ryan Tedder",
			261000, 1, 96000, 24, 2, 1411000, 42000000,
		},
		{
			3, "/music/acdc/back_in_black.mp3",
			"Back in Black", "AC/DC", "Back in Black",
			intPtr(1), intPtr(1),
			1980,
			[]string{"Hard Rock"},
			"Angus Young",
			255000, 0, 44100, 16, 2, 320000, 6100000,
		},
		{
			4, "/music/pinkfloyd/comfortably_numb.flac",
			"Comfortably Numb", "Pink Floyd", "The Dark Side",
			intPtr(6), intPtr(1),
			1979,
			[]string{"Progressive Rock"},
			"David Gilmour",
			382000, 1, 96000, 24, 2, 1411000, 54000000,
		},
		{
			5, "/music/queen/another_one.mp3",
			"Another One Bites the Dust", "Queen", "The Game",
			intPtr(3), intPtr(1),
			1980,
			[]string{"Funk Rock"},
			"John Deacon",
			215000, 0, 44100, 16, 2, 320000, 5200000,
		},
		{
			6, "/music/acdc/thunderstruck.mp3",
			"Thunderstruck", "AC/DC", "The Razors Edge",
			intPtr(1), intPtr(1),
			1990,
			[]string{"Hard Rock"},
			"Angus Young",
			292000, 0, 44100, 16, 2, 320000, 7000000,
		},
		{
			7, "/music/qotsa/no_one_knows.mp3",
			"No One Knows", "QOTSA", "Rated R",
			intPtr(1), intPtr(1),
			2000,
			[]string{"Stoner Rock"},
			"Josh Homme",
			310000, 0, 44100, 16, 2, 320000, 7400000,
		},
		{
			8, "/music/rhcp/under_the_bridge.mp3",
			"Under the Bridge", "RHCP", "Blood Sugar",
			intPtr(2), intPtr(1),
			1991,
			[]string{"Rock", "Alternative"},
			"Anthony Kiedis",
			264000, 0, 44100, 16, 2, 320000, 6300000,
		},
	}

	for _, tr := range tracks {
		var trackNum, discNum int64
		if tr.trackNum != nil {
			trackNum = *tr.trackNum
		}

		if tr.discNum != nil {
			discNum = *tr.discNum
		}

		id := database.InsertTestTrack(t, db, database.TestTrack{
			FilePath:    tr.filePath,
			Title:       tr.title,
			Artist:      tr.artist,
			Album:       tr.album,
			Genres:      tr.genres,
			TrackNumber: trackNum,
			DiscNumber:  discNum,
			Year:        tr.year,
			LengthMs:    tr.lenMs,
		})

		if _, err := db.ExecContext(
			`UPDATE audio_files
			 SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
			     channels = ?, bitrate = ?, file_size = ?, composer = ?
			 WHERE id = ?`,
			tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
		); err != nil {
			t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
		}
	}
}

// ---------------------------------------------------------------------------
// BuildWhereClause tests (pure — no DB needed)
// ---------------------------------------------------------------------------

func TestBuildWhereClause_TextIs(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "artist_name = ? COLLATE NOCASE" {
		t.Errorf("clause = %q, want %q", clause, "artist_name = ? COLLATE NOCASE")
	}

	if len(args) != 1 || args[0] != "Queen" {
		t.Errorf("args = %v, want [Queen]", args)
	}
}

func TestBuildWhereClause_TextIsNot(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "artist", Operator: "is_not", Value: "Queen"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "artist_name != ? COLLATE NOCASE" {
		t.Errorf("clause = %q, want %q",
			clause, "artist_name != ? COLLATE NOCASE")
	}

	if len(args) != 1 || args[0] != "Queen" {
		t.Errorf("args = %v, want [Queen]", args)
	}
}

func TestBuildWhereClause_TextContains(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "title", Operator: "contains", Value: "Black"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "title LIKE ?" {
		t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
	}

	if len(args) != 1 || args[0] != "%Black%" {
		t.Errorf("args = %v, want [%%Black%%]", args)
	}
}

func TestBuildWhereClause_TextDoesNotContain(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{
			Field: "title", Operator: "does_not_contain",
			Value: "Black",
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "title NOT LIKE ?" {
		t.Errorf("clause = %q, want %q",
			clause, "title NOT LIKE ?")
	}

	if len(args) != 1 || args[0] != "%Black%" {
		t.Errorf("args = %v, want [%%Black%%]", args)
	}
}

func TestBuildWhereClause_TextStartsWith(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "title", Operator: "starts_with", Value: "Back"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "title LIKE ?" {
		t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
	}

	if len(args) != 1 || args[0] != "Back%" {
		t.Errorf("args = %v, want [Back%%]", args)
	}
}

func TestBuildWhereClause_TextEndsWith(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "title", Operator: "ends_with", Value: "Black"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "title LIKE ?" {
		t.Errorf("clause = %q, want %q", clause, "title LIKE ?")
	}

	if len(args) != 1 || args[0] != "%Black" {
		t.Errorf("args = %v, want [%%Black]", args)
	}
}

func TestBuildWhereClause_TextIsAnyOf(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{
			Field: "artist", Operator: "is_any_of",
			Value: `["Queen","AC/DC"]`,
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)" {
		t.Errorf("clause = %q, want %q",
			clause, "artist_name IN (? COLLATE NOCASE, ? COLLATE NOCASE)")
	}

	if len(args) != 2 || args[0] != "Queen" || args[1] != "AC/DC" {
		t.Errorf("args = %v, want [Queen AC/DC]", args)
	}
}

func TestBuildWhereClause_NumericIs(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "year", Operator: "is", Value: "1980"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year = ?" {
		t.Errorf("clause = %q, want %q", clause, "year = ?")
	}

	if len(args) != 1 || args[0] != int64(1980) {
		t.Errorf("args = %v, want [1980]", args)
	}
}

func TestBuildWhereClause_NumericIsNot(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "year", Operator: "is_not", Value: "1980"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year != ?" {
		t.Errorf("clause = %q, want %q", clause, "year != ?")
	}

	if len(args) != 1 || args[0] != int64(1980) {
		t.Errorf("args = %v, want [1980]", args)
	}
}

func TestBuildWhereClause_NumericGreaterThan(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "year", Operator: "greater_than", Value: "2000"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year > ?" {
		t.Errorf("clause = %q, want %q", clause, "year > ?")
	}

	if len(args) != 1 || args[0] != int64(2000) {
		t.Errorf("args = %v, want [2000]", args)
	}
}

func TestBuildWhereClause_NumericLessThan(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "year", Operator: "less_than", Value: "1980"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year < ?" {
		t.Errorf("clause = %q, want %q", clause, "year < ?")
	}

	if len(args) != 1 || args[0] != int64(1980) {
		t.Errorf("args = %v, want [1980]", args)
	}
}

func TestBuildWhereClause_NumericBetween(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{
			Field: "year", Operator: "between",
			Value: "1975,1985",
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year BETWEEN ? AND ?" {
		t.Errorf("clause = %q, want %q",
			clause, "year BETWEEN ? AND ?")
	}

	if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) {
		t.Errorf("args = %v, want [1975 1985]", args)
	}
}

func TestBuildWhereClause_NumericBetweenJSON(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{
			Field: "year", Operator: "between",
			Value: `["1975","1985"]`,
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "year BETWEEN ? AND ?" {
		t.Errorf("clause = %q, want %q",
			clause, "year BETWEEN ? AND ?")
	}

	if len(args) != 2 || args[0] != int64(1975) || args[1] != int64(1985) {
		t.Errorf("args = %v, want [1975 1985]", args)
	}
}

func TestBuildWhereClause_GenreIsProducesSubquery(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "genre", Operator: "is", Value: "Rock"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Must use subquery, NOT "genre = ?"
	if strings.Contains(clause, "genre =") {
		t.Errorf(
			"genre 'is' should use subquery, not direct column match: %q",
			clause,
		)
	}

	if !strings.Contains(clause, "file_genres") {
		t.Errorf("genre 'is' should reference file_genres: %q",
			clause)
	}

	if !strings.Contains(clause, "g.name = ? COLLATE NOCASE") {
		t.Errorf("genre 'is' should have g.name = ? COLLATE NOCASE: %q", clause)
	}

	if len(args) != 1 || args[0] != "Rock" {
		t.Errorf("args = %v, want [Rock]", args)
	}
}

func TestBuildWhereClause_GenreIsNotProducesSubquery(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "genre", Operator: "is_not", Value: "Rock"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if !strings.Contains(clause, "NOT IN") {
		t.Errorf("genre 'is_not' should use NOT IN: %q", clause)
	}

	if !strings.Contains(clause, "file_genres") {
		t.Errorf(
			"genre 'is_not' should reference file_genres: %q",
			clause,
		)
	}

	if len(args) != 1 || args[0] != "Rock" {
		t.Errorf("args = %v, want [Rock]", args)
	}
}

func TestBuildWhereClause_GenreIsAnyOfProducesSubquery(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{
			Field: "genre", Operator: "is_any_of",
			Value: `["Rock","Pop"]`,
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if !strings.Contains(clause, "file_genres") {
		t.Errorf(
			"genre 'is_any_of' should reference file_genres: %q",
			clause,
		)
	}

	if !strings.Contains(clause, "g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE)") {
		t.Errorf(
			"genre 'is_any_of' should have g.name IN (? COLLATE NOCASE, ? COLLATE NOCASE): %q",
			clause,
		)
	}

	if len(args) != 2 || args[0] != "Rock" || args[1] != "Pop" {
		t.Errorf("args = %v, want [Rock Pop]", args)
	}
}

func TestBuildWhereClause_GenreContainsUsesSubquery(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "genre", Operator: "contains", Value: "Rock"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Since the smart playlist query no longer projects a concatenated
	// genre column, "contains" filters genres via file_genres
	// with g.name LIKE applied to individual genre rows.
	if !strings.Contains(clause, "file_genres") {
		t.Errorf(
			"genre 'contains' should use file_genres subquery: %q",
			clause,
		)
	}

	if !strings.Contains(clause, "g.name LIKE ?") {
		t.Errorf(
			"genre 'contains' should filter with g.name LIKE ?: %q",
			clause,
		)
	}

	if len(args) != 1 || args[0] != "%Rock%" {
		t.Errorf("args = %v, want [%%Rock%%]", args)
	}
}

func TestBuildWhereClause_MultipleRulesAND(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
		{Field: "year", Operator: "greater_than", Value: "1975"},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "artist_name = ? COLLATE NOCASE AND year > ?" {
		t.Errorf("clause = %q, want %q",
			clause, "artist_name = ? COLLATE NOCASE AND year > ?")
	}

	if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
		t.Errorf("args = %v, want [Queen 1975]", args)
	}
}

func TestBuildWhereClause_MultipleRulesOR(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
		{Field: "year", Operator: "greater_than", Value: "1975"},
	}, MatchAny)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	want := "(artist_name = ? COLLATE NOCASE) OR (year > ?)"
	if clause != want {
		t.Errorf("clause = %q, want %q", clause, want)
	}

	if len(args) != 2 || args[0] != "Queen" || args[1] != int64(1975) {
		t.Errorf("args = %v, want [Queen 1975]", args)
	}
}

// An empty match is what every rule set saved before the field existed
// carries, and it has to keep meaning AND — a playlist silently
// widening to OR on upgrade is the whole risk of adding this field.
func TestBuildWhereClause_EmptyMatchIsAll(t *testing.T) {
	t.Parallel()

	rules := []Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
		{Field: "year", Operator: "greater_than", Value: "1975"},
	}

	empty, _, err := BuildWhereClause(rules, "")
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	all, _, err := BuildWhereClause(rules, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if empty != all {
		t.Errorf("empty match = %q, want the same as MatchAll %q",
			empty, all)
	}
}

// A condition carrying its own top-level AND is what makes the
// bracketing under OR load-bearing: `days_since_played less_than`
// is two predicates, and both belong to the same rule.
func TestBuildWhereClause_ORBracketsCompoundCondition(t *testing.T) {
	t.Parallel()

	clause, _, err := BuildWhereClause([]Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
		{
			Field:    "days_since_played",
			Operator: "less_than",
			Value:    "30",
		},
	}, MatchAny)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if !strings.Contains(clause, "(last_played IS NOT NULL AND") {
		t.Errorf(
			"compound condition is not bracketed under OR: %q",
			clause,
		)
	}
}

func TestParseRuleSet_RejectsUnknownMatch(t *testing.T) {
	t.Parallel()

	_, err := ParseRuleSet(`{"rules":[],"match":"either"}`)
	if err == nil {
		t.Fatal("expected an error for an unknown match type")
	}

	if !errors.Is(err, errInvalidMatch) {
		t.Errorf("err = %v, want errInvalidMatch", err)
	}
}

func TestParseRuleSet_AcceptsAnyAndAll(t *testing.T) {
	t.Parallel()

	for _, want := range []MatchType{MatchAll, MatchAny} {
		rs, err := ParseRuleSet(
			`{"rules":[],"match":"` + string(want) + `"}`,
		)
		if err != nil {
			t.Fatalf("match %q: unexpected error: %v", want, err)
		}

		if rs.Match != want {
			t.Errorf("match = %q, want %q", rs.Match, want)
		}
	}
}

func TestBuildWhereClause_SameFieldMultipleTimes(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause([]Rule{
		{Field: "genre", Operator: "contains", Value: "Rock"},
		{
			Field: "genre", Operator: "does_not_contain",
			Value: "Punk",
		},
	}, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Genre text ops combine via AND across subqueries against
	// file_genres; the exact SQL shape is asserted elsewhere.
	if !strings.Contains(clause, " AND ") {
		t.Errorf("clause should combine rules with AND: %q", clause)
	}

	if !strings.Contains(clause, "af.id IN") {
		t.Errorf("clause should include positive IN subquery: %q", clause)
	}

	if !strings.Contains(clause, "af.id NOT IN") {
		t.Errorf("clause should include NOT IN subquery: %q", clause)
	}

	if len(args) != 2 ||
		args[0] != "%Rock%" || args[1] != "%Punk%" {
		t.Errorf("args = %v, want [%%Rock%% %%Punk%%]", args)
	}
}

func TestBuildWhereClause_EmptyRules(t *testing.T) {
	t.Parallel()

	clause, args, err := BuildWhereClause(nil, MatchAll)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if clause != "" {
		t.Errorf("clause = %q, want empty string", clause)
	}

	if args != nil {
		t.Errorf("args = %v, want nil", args)
	}
}

func TestBuildWhereClause_InvalidField(t *testing.T) {
	t.Parallel()

	_, _, err := BuildWhereClause([]Rule{
		{
			Field: "nonexistent", Operator: "is",
			Value: "anything",
		},
	}, MatchAll)
	if err == nil {
		t.Fatal("expected error for invalid field, got nil")
	}

	if !strings.Contains(err.Error(), "invalid field") {
		t.Errorf(
			"error should mention 'invalid field': %v", err,
		)
	}

	if !strings.Contains(err.Error(), "nonexistent") {
		t.Errorf(
			"error should include the field name: %v", err,
		)
	}
}

func TestBuildWhereClause_InvalidOperatorForNumeric(t *testing.T) {
	t.Parallel()

	_, _, err := BuildWhereClause([]Rule{
		{Field: "year", Operator: "contains", Value: "1980"},
	}, MatchAll)
	if err == nil {
		t.Fatal(
			"expected error for text operator on numeric field",
		)
	}

	if !strings.Contains(err.Error(), "invalid operator") {
		t.Errorf(
			"error should mention 'invalid operator': %v", err,
		)
	}
}

func TestBuildWhereClause_InvalidOperatorForText(t *testing.T) {
	t.Parallel()

	_, _, err := BuildWhereClause([]Rule{
		{
			Field: "artist", Operator: "greater_than",
			Value: "Queen",
		},
	}, MatchAll)
	if err == nil {
		t.Fatal(
			"expected error for numeric operator on text field",
		)
	}

	if !strings.Contains(err.Error(), "invalid operator") {
		t.Errorf(
			"error should mention 'invalid operator': %v", err,
		)
	}
}

// ---------------------------------------------------------------------------
// Evaluate tests (with DB)
// ---------------------------------------------------------------------------

func TestEvaluate_TextIs(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "artist", Operator: "is", Value: "Queen"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 2 {
		t.Fatalf("got %d tracks, want 2", len(tracks))
	}

	for _, tr := range tracks {
		if tr.ArtistName != "Queen" {
			t.Errorf(
				"track %q has artist %q, want Queen",
				tr.TrackName, tr.ArtistName,
			)
		}
	}
}

// Two rules that share no track at all: under AND this is empty, and
// under OR it is the union. Before Match existed only the first was
// expressible, so a playlist could only ever narrow — "jazz or blues"
// had no way to be said.
func TestEvaluate_MatchAnyUnionsWhereMatchAllIntersects(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Queen has two tracks; Beyoncé has one; no track is by both.
	rules := []Rule{
		{Field: "artist", Operator: "is", Value: "Queen"},
		{Field: "artist", Operator: "is", Value: "Beyoncé"},
	}

	all, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAll})
	if err != nil {
		t.Fatalf("Evaluate(all): %v", err)
	}

	if len(all) != 0 {
		t.Errorf("match=all returned %d tracks, want 0", len(all))
	}

	either, err := Evaluate(db, RuleSet{Rules: rules, Match: MatchAny})
	if err != nil {
		t.Fatalf("Evaluate(any): %v", err)
	}

	if len(either) != 3 {
		t.Fatalf("match=any returned %d tracks, want 3", len(either))
	}

	for _, tr := range either {
		if tr.ArtistName != "Queen" && tr.ArtistName != "Beyoncé" {
			t.Errorf(
				"track %q has artist %q, want Queen or Beyoncé",
				tr.TrackName, tr.ArtistName,
			)
		}
	}
}

// An empty match is what every playlist saved before the field existed
// carries, and it has to keep meaning AND all the way through Evaluate
// — a stored playlist silently widening on upgrade is the only real
// risk in adding this.
func TestEvaluate_EmptyMatchStillIntersects(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "artist", Operator: "is", Value: "Queen"},
			{Field: "year", Operator: "greater_than", Value: "1979"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Only "Another One Bites the Dust" (Queen, 1980) satisfies both.
	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	if want := "Another One Bites the Dust"; tracks[0].TrackName != want {
		t.Errorf("got %q, want %q", tracks[0].TrackName, want)
	}
}

// TestEvaluate_ArtworkEnrichment verifies the presentation-only
// cover-art and MusicBrainz-ID fields are attached to matched tracks
// by the batched fetchArtwork pass (they are no longer part of the
// lean filter query).
func TestEvaluate_ArtworkEnrichment(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)

	// One file, fully identified: cover art on its album, MBIDs on the
	// album, the artist and the file itself.
	database.InsertTestTrack(t, db, database.TestTrack{
		FilePath:      "/music/bohemian.mp3",
		Title:         "Bohemian Rhapsody",
		Artist:        "Queen",
		ArtistMBID:    "artist-mbid-1",
		Album:         "A Night at the Opera",
		AlbumMBID:     "rg-mbid-1",
		RecordingMBID: "rec-mbid-1",
		LengthMs:      354000,
	})

	if _, err := db.ExecContext(
		"INSERT INTO cover_art (id, file_path, mime_type) " +
			"VALUES (1, '/covers/abc123.jpg', 'image/jpeg')",
	); err != nil {
		t.Fatalf("seed cover art: %v", err)
	}

	if _, err := db.ExecContext(
		"UPDATE albums SET cover_art_id = 1 WHERE name = 'A Night at the Opera'",
	); err != nil {
		t.Fatalf("attach cover art: %v", err)
	}

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "artist", Operator: "is", Value: "Queen"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	got := tracks[0]

	if got.CoverArtPath != coverart.ResolveURLs("/covers/abc123.jpg").Original {
		t.Errorf("cover art = %q, want the resolved original", got.CoverArtPath)
	}

	if got.ArtistMBID != "artist-mbid-1" {
		t.Errorf("artist mbid = %q, want artist-mbid-1", got.ArtistMBID)
	}

	if got.ReleaseGroupMBID != "rg-mbid-1" {
		t.Errorf("release group mbid = %q, want rg-mbid-1", got.ReleaseGroupMBID)
	}

	if got.RecordingMBID != "rec-mbid-1" {
		t.Errorf("recording mbid = %q, want rec-mbid-1", got.RecordingMBID)
	}
}

func TestEvaluate_TextContains(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "contains",
				Value: "Black",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	if tracks[0].TrackName != "Back in Black" {
		t.Errorf("got %q, want %q",
			tracks[0].TrackName, "Back in Black")
	}
}

func TestEvaluate_TextStartsWith(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "starts_with",
				Value: "Back",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	if tracks[0].TrackName != "Back in Black" {
		t.Errorf("got %q, want %q",
			tracks[0].TrackName, "Back in Black")
	}
}

func TestEvaluate_TextEndsWith(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "ends_with",
				Value: "Numb",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	if tracks[0].TrackName != "Comfortably Numb" {
		t.Errorf("got %q, want %q",
			tracks[0].TrackName, "Comfortably Numb")
	}
}

func TestEvaluate_TextDoesNotContain(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "does_not_contain",
				Value: "the",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// "the" appears in: "Another One Bites the Dust",
	// "Under the Bridge". The rest should be returned.
	for _, tr := range tracks {
		if strings.Contains(
			strings.ToLower(tr.TrackName), "the") {
			t.Errorf(
				"track %q should not contain 'the'",
				tr.TrackName,
			)
		}
	}
}

func TestEvaluate_TextIsAnyOf(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "artist", Operator: "is_any_of",
				Value: `["Queen","AC/DC"]`,
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 4 {
		t.Fatalf("got %d tracks, want 4 (2 Queen + 2 AC/DC)",
			len(tracks))
	}

	for _, tr := range tracks {
		if tr.ArtistName != "Queen" &&
			tr.ArtistName != "AC/DC" {
			t.Errorf(
				"unexpected artist %q", tr.ArtistName,
			)
		}
	}
}

func TestEvaluate_NumericGreaterThan(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "year", Operator: "greater_than",
				Value: "2000",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Only "Halo" (2008) has year > 2000.
	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	if tracks[0].TrackName != "Halo" {
		t.Errorf("got %q, want Halo", tracks[0].TrackName)
	}
}

func TestEvaluate_NumericLessThan(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "year", Operator: "less_than",
				Value: "1980",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Bohemian Rhapsody (1975) and Comfortably Numb (1979)
	if len(tracks) != 2 {
		t.Fatalf("got %d tracks, want 2", len(tracks))
	}

	for _, tr := range tracks {
		if tr.Year >= 1980 {
			t.Errorf("track %q year=%d should be < 1980",
				tr.TrackName, tr.Year)
		}
	}
}

func TestEvaluate_NumericBetween(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "year", Operator: "between",
				Value: "1975,1985",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// 1975: Bohemian Rhapsody, 1979: Comfortably Numb,
	// 1980: Back in Black, 1980: Another One Bites the Dust
	if len(tracks) != 4 {
		t.Fatalf("got %d tracks, want 4", len(tracks))
	}

	for _, tr := range tracks {
		if tr.Year < 1975 || tr.Year > 1985 {
			t.Errorf(
				"track %q year=%d outside 1975-1985",
				tr.TrackName, tr.Year,
			)
		}
	}
}

func TestEvaluate_GenreIs_MultiGenreTrack(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Genre "is Rock" must match track 8 (Rock+Alternative) and
	// track 1 (Rock). This proves the subquery works correctly
	// with multi-genre tracks.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "genre", Operator: "is", Value: "Rock"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 2 {
		names := make([]string, len(tracks))
		for i, tr := range tracks {
			names[i] = tr.TrackName
		}

		t.Fatalf(
			"genre 'is Rock' got %d tracks %v, want 2 "+
				"(Bohemian Rhapsody + Under the Bridge)",
			len(tracks), names,
		)
	}

	foundBR := false
	foundUTB := false

	for _, tr := range tracks {
		if tr.TrackName == "Bohemian Rhapsody" {
			foundBR = true
		}

		if tr.TrackName == "Under the Bridge" {
			foundUTB = true
		}
	}

	if !foundBR {
		t.Error("genre 'is Rock' missing Bohemian Rhapsody")
	}

	if !foundUTB {
		t.Error(
			"genre 'is Rock' missing Under the Bridge " +
				"(multi-genre track)",
		)
	}
}

func TestEvaluate_GenreIsNot_MultiGenreTrack(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Genre "is_not Rock" must NOT return tracks 1 or 8.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "genre", Operator: "is_not",
				Value: "Rock",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	for _, tr := range tracks {
		if tr.TrackName == "Bohemian Rhapsody" ||
			tr.TrackName == "Under the Bridge" {
			t.Errorf(
				"genre 'is_not Rock' should not return %q",
				tr.TrackName,
			)
		}
	}

	// Should return the other 6 tracks.
	if len(tracks) != 6 {
		t.Errorf("got %d tracks, want 6", len(tracks))
	}
}

func TestEvaluate_GenreContains(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// "contains Rock" on the concatenated genre column should match
	// any track that has "Rock" anywhere in its genre string.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "genre", Operator: "contains",
				Value: "Rock",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Rock (1,8), Hard Rock (3,6), Progressive Rock (4),
	// Funk Rock (5), Stoner Rock (7) = 7 tracks
	if len(tracks) != 7 {
		names := make([]string, len(tracks))
		for i, tr := range tracks {
			names[i] = tr.TrackName
		}

		t.Fatalf(
			"genre 'contains Rock' got %d tracks %v, want 7",
			len(tracks), names,
		)
	}
}

func TestEvaluate_MultipleRulesAND(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// genre contains "Rock" AND year > 1970 AND year < 1981
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "genre", Operator: "contains",
				Value: "Rock",
			},
			{
				Field: "year", Operator: "greater_than",
				Value: "1970",
			},
			{
				Field: "year", Operator: "less_than",
				Value: "1981",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	// Bohemian Rhapsody (1975, Rock), Comfortably Numb (1979, Progressive Rock),
	// Back in Black (1980, Hard Rock), Another One (1980, Funk Rock)
	if len(tracks) != 4 {
		names := make([]string, len(tracks))
		for i, tr := range tracks {
			names[i] = tr.TrackName
		}

		t.Fatalf("got %d tracks %v, want 4", len(tracks), names)
	}
}

func TestEvaluate_EmptyRulesReturnsAll(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 8 {
		t.Fatalf("got %d tracks, want 8 (all seeded)",
			len(tracks))
	}
}

func TestEvaluate_Limit(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{Limit: 3})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 3 {
		t.Fatalf("got %d tracks, want 3 (limited)",
			len(tracks))
	}
}

func TestEvaluate_SortByYearASC(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		SortField: "year",
		SortDir:   "ASC",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) < 2 {
		t.Fatalf("got %d tracks, want >= 2", len(tracks))
	}

	for i := 1; i < len(tracks); i++ {
		if tracks[i].Year < tracks[i-1].Year {
			t.Errorf(
				"sort ASC violated: track[%d].Year=%d < "+
					"track[%d].Year=%d",
				i, tracks[i].Year, i-1, tracks[i-1].Year,
			)
		}
	}
}

func TestEvaluate_SortByYearDESC(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		SortField: "year",
		SortDir:   "DESC",
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) < 2 {
		t.Fatalf("got %d tracks, want >= 2", len(tracks))
	}

	for i := 1; i < len(tracks); i++ {
		if tracks[i].Year > tracks[i-1].Year {
			t.Errorf(
				"sort DESC violated: track[%d].Year=%d > "+
					"track[%d].Year=%d",
				i, tracks[i].Year, i-1, tracks[i-1].Year,
			)
		}
	}
}

func TestEvaluate_SortByRandom(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Just verify it doesn't error.
	tracks, err := Evaluate(db, RuleSet{
		SortField: "random",
	})
	if err != nil {
		t.Fatalf("Evaluate with random sort: %v", err)
	}

	if len(tracks) != 8 {
		t.Fatalf("got %d tracks, want 8", len(tracks))
	}
}

func TestEvaluate_InvalidField(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	_, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "bogus", Operator: "is", Value: "x"},
		},
	})
	if err == nil {
		t.Fatal("expected error for invalid field, got nil")
	}

	if !strings.Contains(err.Error(), "invalid field") {
		t.Errorf("error should mention 'invalid field': %v",
			err)
	}
}

// ---------------------------------------------------------------------------
// SQL injection tests
// ---------------------------------------------------------------------------

func TestSQLInjection_FieldName(t *testing.T) {
	t.Parallel()

	_, _, err := BuildWhereClause([]Rule{
		{
			Field:    "title; DROP TABLE playlists",
			Operator: "is", Value: "x",
		},
	}, MatchAll)
	if err == nil {
		t.Fatal(
			"expected error for injected field name, got nil",
		)
	}

	if !strings.Contains(err.Error(), "invalid field") {
		t.Errorf("error should mention 'invalid field': %v",
			err)
	}
}

func TestSQLInjection_Value(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Value with SQL injection — should produce no error (safe
	// parameterization) and return 0 results.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "is",
				Value: "'; DROP TABLE playlists; --",
			},
		},
	})
	if err != nil {
		t.Fatalf("unexpected error with injection value: %v",
			err)
	}

	if len(tracks) != 0 {
		t.Errorf("got %d tracks, want 0", len(tracks))
	}
}

// ---------------------------------------------------------------------------
// ParseRuleSet tests
// ---------------------------------------------------------------------------

func TestParseRuleSet_Valid(t *testing.T) {
	t.Parallel()

	input := `{
		"rules": [
			{"field": "artist", "operator": "is", "value": "Queen"}
		],
		"limit": 50,
		"sort_field": "year",
		"sort_dir": "DESC"
	}`

	rs, err := ParseRuleSet(input)
	if err != nil {
		t.Fatalf("ParseRuleSet: %v", err)
	}

	if len(rs.Rules) != 1 {
		t.Fatalf("got %d rules, want 1", len(rs.Rules))
	}

	if rs.Rules[0].Field != "artist" {
		t.Errorf("Field = %q, want artist",
			rs.Rules[0].Field)
	}

	if rs.Limit != 50 {
		t.Errorf("Limit = %d, want 50", rs.Limit)
	}

	if rs.SortField != "year" {
		t.Errorf("SortField = %q, want year", rs.SortField)
	}

	if rs.SortDir != "DESC" {
		t.Errorf("SortDir = %q, want DESC", rs.SortDir)
	}
}

func TestParseRuleSet_Invalid(t *testing.T) {
	t.Parallel()

	_, err := ParseRuleSet("not json at all")
	if err == nil {
		t.Fatal("expected error for invalid JSON, got nil")
	}

	if !strings.Contains(err.Error(), "invalid smart playlist") {
		t.Errorf(
			"error should mention 'invalid smart playlist': %v",
			err,
		)
	}
}

func TestParseRuleSet_EmptyRules(t *testing.T) {
	t.Parallel()

	rs, err := ParseRuleSet(`{"rules":[]}`)
	if err != nil {
		t.Fatalf("ParseRuleSet: %v", err)
	}

	if len(rs.Rules) != 0 {
		t.Errorf("got %d rules, want 0", len(rs.Rules))
	}
}

// ---------------------------------------------------------------------------
// Track field mapping test
// ---------------------------------------------------------------------------

func TestEvaluate_TrackFieldMapping(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	// Fetch Bohemian Rhapsody and verify all library.Track fields
	// are correctly populated.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "is",
				Value: "Bohemian Rhapsody",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	tr := tracks[0]

	if tr.TrackName != "Bohemian Rhapsody" {
		t.Errorf("TrackName = %q", tr.TrackName)
	}

	if tr.ArtistName != "Queen" {
		t.Errorf("ArtistName = %q", tr.ArtistName)
	}

	if tr.TrackLength != "354000" {
		t.Errorf("TrackLength = %q, want 354000",
			tr.TrackLength)
	}

	if tr.FilePath != "/music/queen/bohemian_rhapsody.mp3" {
		t.Errorf("FilePath = %q", tr.FilePath)
	}

	if tr.TrackNumber != 11 {
		t.Errorf("TrackNumber = %d, want 11", tr.TrackNumber)
	}

	if tr.DiscNumber != 1 {
		t.Errorf("DiscNumber = %d, want 1", tr.DiscNumber)
	}

	if tr.Album != "A Night at the Opera" {
		t.Errorf("Album = %q", tr.Album)
	}

	if len(tr.Genre) != 1 || tr.Genre[0] != "Rock" {
		t.Errorf("Genre = %v, want [Rock]", tr.Genre)
	}

	if tr.Year != 1975 {
		t.Errorf("Year = %d, want 1975", tr.Year)
	}

	if tr.Composer != "Freddie Mercury" {
		t.Errorf("Composer = %q", tr.Composer)
	}

	if tr.FileType != ".mp3" {
		t.Errorf("FileType = %q, want .mp3", tr.FileType)
	}

	if tr.SampleRate != 44100 {
		t.Errorf("SampleRate = %d", tr.SampleRate)
	}

	if tr.BitDepth != 16 {
		t.Errorf("BitDepth = %d", tr.BitDepth)
	}

	if tr.Channels != 2 {
		t.Errorf("Channels = %d", tr.Channels)
	}

	if tr.Bitrate != 320000 {
		t.Errorf("Bitrate = %d", tr.Bitrate)
	}

	if tr.FileSize != 8500000 {
		t.Errorf("FileSize = %d", tr.FileSize)
	}
}

// TestEvaluate_MultiGenreTrackGenreField verifies that a track with
// multiple genres has them split correctly into the []string field.
func TestEvaluate_MultiGenreTrackGenreField(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)
	seedSmartPlaylistData(t, db)

	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{
				Field: "title", Operator: "is",
				Value: "Under the Bridge",
			},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf("got %d tracks, want 1", len(tracks))
	}

	tr := tracks[0]

	if len(tr.Genre) != 2 {
		t.Fatalf("Genre = %v, want 2 genres", tr.Genre)
	}

	hasRock := false
	hasAlt := false

	for _, g := range tr.Genre {
		if g == "Rock" {
			hasRock = true
		}

		if g == "Alternative" {
			hasAlt = true
		}
	}

	if !hasRock || !hasAlt {
		t.Errorf(
			"Genre = %v, want [Rock, Alternative]", tr.Genre,
		)
	}
}

// TestEvaluate_YearUsesOriginalReleaseYear is a regression test for a
// bug where the smart-playlist year filter tested recordings.year (the
// file's ID3/reissue tag year) instead of the release group's original
// first-release year, the way the canonical track_metadata view and the
// UI do. That made a 1977 album owned as a 2010s reissue leak into a
// "2010s" year filter even though it displays as 1977.
func TestEvaluate_YearUsesOriginalReleaseYear(t *testing.T) {
	t.Parallel()

	db := database.NewTestDB(t)

	// One track: a 1977 album the user owns as a 2013 reissue.  The
	// file's own year is 2013; the album's original-release year is
	// 1977, and that is what a year filter means.
	database.InsertTestTrack(t, db, database.TestTrack{
		FilePath: "/music/b52s/rock_lobster.mp3",
		Title:    "Rock Lobster",
		Artist:   "The B-52's",
		Album:    "Reissue Compilation",
		Year:     2013,
		LengthMs: 300000,
	})

	if _, err := db.ExecContext(
		"UPDATE albums SET year = 2013, original_year = 1977",
	); err != nil {
		t.Fatalf("set album years: %v", err)
	}

	// A "2010s" filter must NOT match — the album is originally from 1977.
	tracks, err := Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "year", Operator: "between", Value: "2010,2019"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate 2010s: %v", err)
	}

	if len(tracks) != 0 {
		t.Errorf(
			"2010s filter matched %d tracks, want 0 "+
				"(reissue year leaked in)", len(tracks),
		)
	}

	// A "1970s" filter must match — original_year is 1977.
	tracks, err = Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "year", Operator: "between", Value: "1970,1979"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate 1970s: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf(
			"1970s filter matched %d tracks, want 1", len(tracks),
		)
	}

	if tracks[0].Year != 1977 {
		t.Errorf("Year = %d, want 1977", tracks[0].Year)
	}

	// The release_year field, by contrast, tracks the specific release
	// owned (the 2013 reissue), so a 2010s filter on it MUST match.
	tracks, err = Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "release_year", Operator: "between", Value: "2010,2019"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate release_year 2010s: %v", err)
	}

	if len(tracks) != 1 {
		t.Fatalf(
			"release_year 2010s filter matched %d tracks, want 1",
			len(tracks),
		)
	}

	// And a 1970s release_year filter must NOT match — the owned
	// release is from 2013.
	tracks, err = Evaluate(db, RuleSet{
		Rules: []Rule{
			{Field: "release_year", Operator: "between", Value: "1970,1979"},
		},
	})
	if err != nil {
		t.Fatalf("Evaluate release_year 1970s: %v", err)
	}

	if len(tracks) != 0 {
		t.Errorf(
			"release_year 1970s filter matched %d tracks, want 0",
			len(tracks),
		)
	}
}
