package api

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"
)

// parseYearFilter is pure, so this runs in the fast lane rather than waiting
// on the integration job.
func TestParseYearFilter(t *testing.T) {
	tests := []struct {
		name       string
		query      string
		wantActive bool
		wantFrom   int32
		wantTo     int32
		wantErr    error
	}{
		{name: "no params means no filtering", query: "", wantActive: false},
		{
			name: "both bounds", query: "year_from=1990&year_to=1999",
			wantActive: true, wantFrom: 1990, wantTo: 1999,
		},
		{
			// "everything from 2000 onward" shouldn't require the caller to
			// invent an upper bound.
			name: "from only leaves the upper edge open", query: "year_from=2000",
			wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear,
		},
		{
			name: "to only leaves the lower edge open", query: "year_to=1979",
			wantActive: true, wantFrom: minBrowseYear, wantTo: 1979,
		},
		{
			name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985",
			wantActive: true, wantFrom: 1985, wantTo: 1985,
		},
		{name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear},
		{name: "non-numeric to", query: "year_to=x", wantErr: errBadYear},
		{
			// Rejected, not silently swapped — reordering would answer a
			// question the caller didn't ask.
			name: "inverted range", query: "year_from=2000&year_to=1990",
			wantErr: errInvertedYearRange,
		},
		{
			name:  "whitespace-only values are treated as absent",
			query: "year_from=%20&year_to=%20", wantActive: false,
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			raw, err := url.ParseQuery(tc.query)
			if err != nil {
				t.Fatalf("ParseQuery: %v", err)
			}
			got, gotErr := parseYearFilter(raw)
			if tc.wantErr != nil {
				if gotErr != tc.wantErr {
					t.Fatalf("error = %v, want %v", gotErr, tc.wantErr)
				}
				return
			}
			if gotErr != nil {
				t.Fatalf("unexpected error: %v", gotErr)
			}
			if got.active != tc.wantActive {
				t.Errorf("active = %v, want %v", got.active, tc.wantActive)
			}
			if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) {
				t.Errorf("range = [%d,%d], want [%d,%d]",
					got.from, got.to, tc.wantFrom, tc.wantTo)
			}
		})
	}
}

// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
// genres. An exact-string match — which is what ListAlbumsByGenre did before
// this task — makes every multi-genre track invisible from either of its
// genres, so the index would list a genre whose page is empty.
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Genre Splitter")
	album := seedAlbum(t, pool, artist.ID, "Split Album", 1995)
	seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop")

	req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
	w := httptest.NewRecorder()
	h.handleListGenres(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", w.Code)
	}
	var got []genreCount
	if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	counts := map[string]int{}
	for _, g := range got {
		counts[g.Genre] = g.TrackCount
	}
	for _, want := range []string{"Rock", "Pop"} {
		if counts[want] < 1 {
			t.Errorf("genre %q missing from index (got %v)", want, counts)
		}
	}
	// The undivided string must NOT appear as its own genre.
	if _, ok := counts["Rock;Pop"]; ok {
		t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`)
	}
}

// Splitting produces leading spaces on every fragment after the first, and
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
// repair for our own splitting, not normalisation of the operator's tags.
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Spacey Tags")
	album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001)
	seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;")

	req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
	w := httptest.NewRecorder()
	h.handleListGenres(w, req)

	var got []genreCount
	if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	seen := map[string]bool{}
	for _, g := range got {
		seen[g.Genre] = true
		if g.Genre == "" {
			t.Error("empty genre in index — a trailing delimiter leaked through")
		}
	}
	for _, want := range []string{"Jazz", "Blues"} {
		if !seen[want] {
			t.Errorf("genre %q missing (got %v)", want, keysOf(seen))
		}
	}
	for _, unwanted := range []string{" Blues", "Blues ", " Blues "} {
		if seen[unwanted] {
			t.Errorf("untrimmed genre %q present", unwanted)
		}
	}
}

// Genre filtering must agree with the index: every genre the index lists has
// to lead to a non-empty page, which is exactly what the old exact-match
// query could not guarantee.
func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Reachable")
	album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998)
	seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop")

	for _, genre := range []string{"Rock", "Pop"} {
		t.Run(genre, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet,
				"/api/library/albums?genre="+url.QueryEscape(genre), nil)
			w := httptest.NewRecorder()
			h.handleListLibraryAlbums(w, req)

			if w.Code != http.StatusOK {
				t.Fatalf("status = %d, want 200", w.Code)
			}
			var page Page[AlbumRef]
			if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
				t.Fatalf("decode: %v", err)
			}
			if page.Total < 1 {
				t.Fatalf("total = %d, want >=1 — genre %q led to an empty page",
					page.Total, genre)
			}
			found := false
			for _, a := range page.Items {
				if a.Title == "Reachable Album" {
					found = true
				}
			}
			if !found {
				t.Errorf("seeded album absent from genre %q results", genre)
			}
		})
	}
}

// A genre containing a slash is why filtering is a query parameter rather
// than a path segment — "Rock/Pop" cannot survive a path.
func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Slashed")
	album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003)
	seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop")

	req := httptest.NewRequest(http.MethodGet,
		"/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil)
	w := httptest.NewRecorder()
	h.handleListLibraryAlbums(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", w.Code)
	}
	var page Page[AlbumRef]
	if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if page.Total < 1 {
		t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total)
	}
}

func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Chronology")
	seedAlbum(t, pool, artist.ID, "Old Record", 1972)
	seedAlbum(t, pool, artist.ID, "Middle Record", 1995)
	seedAlbum(t, pool, artist.ID, "New Record", 2020)
	// An undated album must not appear in ANY year range.
	seedAlbum(t, pool, artist.ID, "Undated Record", 0)

	titles := func(query string) map[string]bool {
		t.Helper()
		req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil)
		w := httptest.NewRecorder()
		h.handleListLibraryAlbums(w, req)
		if w.Code != http.StatusOK {
			t.Fatalf("status = %d for %q, want 200", w.Code, query)
		}
		var page Page[AlbumRef]
		if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
			t.Fatalf("decode: %v", err)
		}
		out := map[string]bool{}
		for _, a := range page.Items {
			out[a.Title] = true
		}
		return out
	}

	got := titles("year_from=1990&year_to=2000&limit=200")
	if !got["Middle Record"] {
		t.Error("Middle Record (1995) missing from 1990-2000")
	}
	for _, absent := range []string{"Old Record", "New Record", "Undated Record"} {
		if got[absent] {
			t.Errorf("%s present in 1990-2000 range", absent)
		}
	}

	// Open upper edge.
	got = titles("year_from=1990&limit=200")
	if !got["Middle Record"] || !got["New Record"] {
		t.Error("open-ended year_from should include 1995 and 2020")
	}
	if got["Old Record"] {
		t.Error("Old Record (1972) present in year_from=1990")
	}
	if got["Undated Record"] {
		t.Error("undated album present in an open-ended range")
	}
}

func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet,
		"/api/library/albums?genre=Rock&year_from=1990", nil)
	w := httptest.NewRecorder()
	h.handleListLibraryAlbums(w, req)

	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400 for combined filters", w.Code)
	}
}

func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) {
	h, pool := testHandlers(t)
	artist := seedArtist(t, pool, "Years Only")
	seedAlbum(t, pool, artist.ID, "Dated One", 1984)
	seedAlbum(t, pool, artist.ID, "No Date", 0)

	req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil)
	w := httptest.NewRecorder()
	h.handleListAlbumYears(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", w.Code)
	}
	var got []yearCount
	if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
		t.Fatalf("decode: %v", err)
	}
	found1984 := false
	for _, y := range got {
		if y.Year == 1984 {
			found1984 = true
		}
		if y.Year == 0 {
			t.Error("year 0 present — undated albums leaked into the index")
		}
	}
	if !found1984 {
		t.Error("1984 missing from the year index")
	}
	// Newest-first ordering, so a picker reads chronologically without the
	// client re-sorting.
	for i := 1; i < len(got); i++ {
		if got[i-1].Year < got[i].Year {
			t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year)
		}
	}
}

func keysOf(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	return out
}
