package api

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// newPlaylistsRouter mounts the 9 playlist routes for an in-test chi
// instance. RequireUser is NOT applied — tests inject the user into
// context manually so we exercise the handler-owned auth defense
// directly. Mirrors the pattern from newAdminTracksRouter.
func newPlaylistsRouter(h *handlers) chi.Router {
	r := chi.NewRouter()
	r.Get("/api/playlists", h.handleListPlaylists)
	r.Post("/api/playlists", h.handleCreatePlaylist)
	r.Get("/api/playlists/{id}", h.handleGetPlaylist)
	r.Patch("/api/playlists/{id}", h.handleUpdatePlaylist)
	r.Delete("/api/playlists/{id}", h.handleDeletePlaylist)
	r.Post("/api/playlists/{id}/tracks", h.handleAppendTracks)
	r.Delete("/api/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
	r.Put("/api/playlists/{id}/tracks", h.handleReorderPlaylist)
	r.Get("/api/playlists/{id}/cover", h.handleGetPlaylistCover)
	return r
}

// doPlaylistsReq fires an HTTP request with the given user injected into
// the request context. Body may be nil.
func doPlaylistsReq(h *handlers, user dbq.User, method, path string, body []byte) *httptest.ResponseRecorder {
	var rdr *bytes.Reader
	if body != nil {
		rdr = bytes.NewReader(body)
	} else {
		rdr = bytes.NewReader(nil)
	}
	req := httptest.NewRequest(method, path, rdr)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	req = withUser(req, user)
	w := httptest.NewRecorder()
	newPlaylistsRouter(h).ServeHTTP(w, req)
	return w
}

// seedSimpleTrack inserts an artist + album + track triple under unique
// titles. Used by the append/reorder test that doesn't care about
// genre / file content. seedTrack from library_fixtures_test.go takes
// IDs the caller already has; we collapse the three calls here.
func seedSimpleTrack(t *testing.T, pool *pgxpool.Pool, title, artist string) dbq.Track {
	t.Helper()
	a := seedArtist(t, pool, artist)
	al := seedAlbum(t, pool, a.ID, artist+" - "+title+" Album", 0)
	return seedTrack(t, pool, al.ID, a.ID, title, 1, 1000)
}

// TestPlaylists_NoSession401 verifies the handler-owned defensive 401
// path on each route. Real traffic hits RequireUser upstream, but the
// handler also defends against routing misconfigurations.
func TestPlaylists_NoSession401(t *testing.T) {
	h, _ := testHandlers(t)
	r := newPlaylistsRouter(h)

	req := httptest.NewRequest(http.MethodGet, "/api/playlists", nil)
	w := httptest.NewRecorder()
	// Note: NO user in context.
	r.ServeHTTP(w, req)
	if w.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401; body = %s", w.Code, w.Body.String())
	}
	var env errorBody
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v; body = %s", err, w.Body.String())
	}
	if env.Error.Code != "unauthenticated" {
		t.Errorf("error.code = %q, want unauthenticated", env.Error.Code)
	}
}

// TestPlaylists_CreateThenGet exercises the happy-path end-to-end:
// POST creates a row, the response carries the id, GET round-trips
// the same fields.
func TestPlaylists_CreateThenGet(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	body := []byte(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
	w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
	if w.Code != http.StatusOK {
		t.Fatalf("create status = %d, body = %s", w.Code, w.Body.String())
	}
	var created playlistRowView
	if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil {
		t.Fatalf("decode create: %v; body = %s", err, w.Body.String())
	}
	if created.ID == "" {
		t.Fatal("created.id empty")
	}
	if created.Name != "Saturday morning" {
		t.Errorf("name = %q, want Saturday morning", created.Name)
	}
	if created.IsPublic {
		t.Error("is_public = true, want false")
	}
	if created.OwnerUsername != user.Username {
		t.Errorf("owner_username = %q, want %q", created.OwnerUsername, user.Username)
	}

	w2 := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+created.ID, nil)
	if w2.Code != http.StatusOK {
		t.Fatalf("get status = %d, body = %s", w2.Code, w2.Body.String())
	}
	var got playlistDetailView
	if err := json.Unmarshal(w2.Body.Bytes(), &got); err != nil {
		t.Fatalf("decode get: %v; body = %s", err, w2.Body.String())
	}
	if got.Name != "Saturday morning" {
		t.Errorf("get name = %q", got.Name)
	}
	if got.Tracks == nil {
		t.Error("tracks is nil; want empty slice")
	}
	if len(got.Tracks) != 0 {
		t.Errorf("tracks len = %d, want 0", len(got.Tracks))
	}
}

// TestPlaylists_CreateEmptyName400 verifies the service's
// ErrInvalidInput surfaces as bad_request on the wire.
func TestPlaylists_CreateEmptyName400(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	body := []byte(`{"name":"","description":""}`)
	w := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists", body)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body.String())
	}
	var env errorBody
	if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if env.Error.Code != "bad_request" {
		t.Errorf("error.code = %q, want bad_request", env.Error.Code)
	}
}

// TestPlaylists_PatchOwnerOnly verifies the ErrForbidden → 403
// not_authorized path. Bob creates nothing; Alice creates a playlist;
// Bob tries to PATCH it.
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	alice := seedUser(t, pool, "alice", "pw", false)
	bob := seedUser(t, pool, "bob", "pw", false)

	createBody := []byte(`{"name":"Mine","is_public":true}`)
	cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", createBody)
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
		t.Fatalf("decode: %v", err)
	}

	patchBody := []byte(`{"name":"Hijacked"}`)
	pw := doPlaylistsReq(h, bob, http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
	if pw.Code != http.StatusForbidden {
		t.Fatalf("non-owner patch status = %d, want 403; body = %s", pw.Code, pw.Body.String())
	}
	var env errorBody
	if err := json.Unmarshal(pw.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if env.Error.Code != "not_authorized" {
		t.Errorf("error.code = %q, want not_authorized", env.Error.Code)
	}
}

// TestPlaylists_GetNotFound verifies a well-formed UUID with no row
// surfaces as 404 not_found.
func TestPlaylists_GetNotFound(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	missing := "00000000-0000-0000-0000-000000000099"
	w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/"+missing, nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
	}
}

// TestPlaylists_ListSplitsOwnedAndPublic verifies the response carries
// owned vs. public buckets correctly. Alice owns one private + one
// public; Bob sees only Alice's public.
func TestPlaylists_ListSplitsOwnedAndPublic(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	alice := seedUser(t, pool, "alice", "pw", false)
	bob := seedUser(t, pool, "bob", "pw", false)

	for _, body := range [][]byte{
		[]byte(`{"name":"AlicePrivate","is_public":false}`),
		[]byte(`{"name":"AlicePublic","is_public":true}`),
	} {
		w := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists", body)
		if w.Code != http.StatusOK {
			t.Fatalf("create: %d %s", w.Code, w.Body.String())
		}
	}

	// Bob lists: should see exactly one public playlist owned by Alice.
	bw := doPlaylistsReq(h, bob, http.MethodGet, "/api/playlists", nil)
	if bw.Code != http.StatusOK {
		t.Fatalf("list: %d %s", bw.Code, bw.Body.String())
	}
	var bobResp listPlaylistsResponse
	if err := json.Unmarshal(bw.Body.Bytes(), &bobResp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(bobResp.Owned) != 0 {
		t.Errorf("bob.owned len = %d, want 0", len(bobResp.Owned))
	}
	if len(bobResp.Public) != 1 || bobResp.Public[0].Name != "AlicePublic" {
		t.Errorf("bob.public = %+v, want one [AlicePublic]", bobResp.Public)
	}

	// Alice lists: sees both as owned, public bucket empty.
	aw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists", nil)
	if aw.Code != http.StatusOK {
		t.Fatalf("list alice: %d %s", aw.Code, aw.Body.String())
	}
	var aliceResp listPlaylistsResponse
	if err := json.Unmarshal(aw.Body.Bytes(), &aliceResp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(aliceResp.Owned) != 2 {
		t.Errorf("alice.owned len = %d, want 2", len(aliceResp.Owned))
	}
	if len(aliceResp.Public) != 0 {
		t.Errorf("alice.public len = %d, want 0", len(aliceResp.Public))
	}
}

// TestPlaylists_AppendThenReorder appends three tracks and then reverses
// their order via the reorder endpoint. The post-mutation detail body
// must show Track 3 before Track 1.
func TestPlaylists_AppendThenReorder(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)
	t1 := seedSimpleTrack(t, pool, "Track 1", "Artist")
	t2 := seedSimpleTrack(t, pool, "Track 2", "Artist")
	t3 := seedSimpleTrack(t, pool, "Track 3", "Artist")

	cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
		[]byte(`{"name":"R"}`))
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	if err := json.Unmarshal(cw.Body.Bytes(), &created); err != nil {
		t.Fatalf("decode: %v", err)
	}

	appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
		uuidToString(t1.ID), uuidToString(t2.ID), uuidToString(t3.ID),
	}})
	aw := doPlaylistsReq(h, user, http.MethodPost,
		"/api/playlists/"+created.ID+"/tracks", appendPayload)
	if aw.Code != http.StatusOK {
		t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
	}
	var afterAppend playlistDetailView
	if err := json.Unmarshal(aw.Body.Bytes(), &afterAppend); err != nil {
		t.Fatalf("decode append: %v", err)
	}
	if len(afterAppend.Tracks) != 3 {
		t.Fatalf("after append, tracks len = %d, want 3", len(afterAppend.Tracks))
	}

	// Reverse: positions [2, 1, 0].
	rw := doPlaylistsReq(h, user, http.MethodPut,
		"/api/playlists/"+created.ID+"/tracks",
		[]byte(`{"ordered_positions":[2,1,0]}`))
	if rw.Code != http.StatusOK {
		t.Fatalf("reorder: %d %s", rw.Code, rw.Body.String())
	}

	gw := doPlaylistsReq(h, user, http.MethodGet,
		"/api/playlists/"+created.ID, nil)
	if gw.Code != http.StatusOK {
		t.Fatalf("get-after-reorder: %d %s", gw.Code, gw.Body.String())
	}
	body := gw.Body.String()
	pos3 := strings.Index(body, `"title":"Track 3"`)
	pos1 := strings.Index(body, `"title":"Track 1"`)
	if pos3 == -1 || pos1 == -1 {
		t.Fatalf("titles missing from body: %s", body)
	}
	if pos3 > pos1 {
		t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
	}
}

// TestPlaylists_RemoveTrack appends two tracks, removes position 0, and
// verifies the surviving track sits at position 0 with the renumbered
// list length 1.
func TestPlaylists_RemoveTrack(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)
	t1 := seedSimpleTrack(t, pool, "First", "Artist")
	t2 := seedSimpleTrack(t, pool, "Second", "Artist")

	cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
		[]byte(`{"name":"X"}`))
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	_ = json.Unmarshal(cw.Body.Bytes(), &created)

	appendPayload, _ := json.Marshal(map[string]any{"track_ids": []string{
		uuidToString(t1.ID), uuidToString(t2.ID),
	}})
	aw := doPlaylistsReq(h, user, http.MethodPost,
		"/api/playlists/"+created.ID+"/tracks", appendPayload)
	if aw.Code != http.StatusOK {
		t.Fatalf("append: %d %s", aw.Code, aw.Body.String())
	}

	rw := doPlaylistsReq(h, user, http.MethodDelete,
		"/api/playlists/"+created.ID+"/tracks/0", nil)
	if rw.Code != http.StatusOK {
		t.Fatalf("remove: %d %s", rw.Code, rw.Body.String())
	}
	var afterRemove playlistDetailView
	if err := json.Unmarshal(rw.Body.Bytes(), &afterRemove); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(afterRemove.Tracks) != 1 {
		t.Fatalf("after remove, tracks len = %d, want 1", len(afterRemove.Tracks))
	}
	if afterRemove.Tracks[0].Title != "Second" {
		t.Errorf("survivor title = %q, want Second", afterRemove.Tracks[0].Title)
	}
	if afterRemove.Tracks[0].Position != 0 {
		t.Errorf("survivor position = %d, want 0 (renumbered)", afterRemove.Tracks[0].Position)
	}
}

// TestPlaylists_DeleteOwnerOnly verifies non-owner DELETE is 403 and
// owner DELETE is 204.
func TestPlaylists_DeleteOwnerOnly(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	alice := seedUser(t, pool, "alice", "pw", false)
	bob := seedUser(t, pool, "bob", "pw", false)

	cw := doPlaylistsReq(h, alice, http.MethodPost, "/api/playlists",
		[]byte(`{"name":"D","is_public":true}`))
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	_ = json.Unmarshal(cw.Body.Bytes(), &created)

	bw := doPlaylistsReq(h, bob, http.MethodDelete, "/api/playlists/"+created.ID, nil)
	if bw.Code != http.StatusForbidden {
		t.Errorf("bob delete status = %d, want 403", bw.Code)
	}

	aw := doPlaylistsReq(h, alice, http.MethodDelete, "/api/playlists/"+created.ID, nil)
	if aw.Code != http.StatusNoContent {
		t.Errorf("alice delete status = %d, want 204; body = %s", aw.Code, aw.Body.String())
	}

	// Subsequent GET must 404.
	gw := doPlaylistsReq(h, alice, http.MethodGet, "/api/playlists/"+created.ID, nil)
	if gw.Code != http.StatusNotFound {
		t.Errorf("get-after-delete status = %d, want 404", gw.Code)
	}
}

// TestPlaylists_BadUUID400 verifies a malformed playlist id returns
// bad_request rather than reaching the service layer.
func TestPlaylists_BadUUID400(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	w := doPlaylistsReq(h, user, http.MethodGet, "/api/playlists/not-a-uuid", nil)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
}

// TestPlaylists_AppendInvalidTrackUUID400 verifies a malformed track id
// in the append body returns bad_request before reaching the DB.
func TestPlaylists_AppendInvalidTrackUUID400(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
		[]byte(`{"name":"A"}`))
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	_ = json.Unmarshal(cw.Body.Bytes(), &created)

	body := []byte(`{"track_ids":["not-a-uuid"]}`)
	w := doPlaylistsReq(h, user, http.MethodPost,
		"/api/playlists/"+created.ID+"/tracks", body)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400; body = %s", w.Code, w.Body.String())
	}
}

// TestPlaylists_CoverNoneReturns404 verifies that a freshly-created
// (empty) playlist has no cached cover and the /cover endpoint returns
// 404 not_found rather than serving a missing file.
func TestPlaylists_CoverNoneReturns404(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "pw", false)

	cw := doPlaylistsReq(h, user, http.MethodPost, "/api/playlists",
		[]byte(`{"name":"NoCover"}`))
	if cw.Code != http.StatusOK {
		t.Fatalf("create: %d %s", cw.Code, cw.Body.String())
	}
	var created playlistRowView
	_ = json.Unmarshal(cw.Body.Bytes(), &created)

	w := doPlaylistsReq(h, user, http.MethodGet,
		"/api/playlists/"+created.ID+"/cover", nil)
	if w.Code != http.StatusNotFound {
		t.Errorf("status = %d, want 404; body = %s", w.Code, w.Body.String())
	}
}

// TestListPlaylists_KindFilterDefaultsToUser verifies that an absent ?kind=
// parameter defaults to "user" and excludes system-kind playlists.
func TestListPlaylists_KindFilterDefaultsToUser(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "klistdefault", "pw", false)

	// Create one user-kind playlist via the service.
	userPL, err := h.playlists.Create(context.Background(), u.ID, "My PL", "", false)
	if err != nil {
		t.Fatalf("create user playlist: %v", err)
	}
	// Insert a system-kind playlist directly via SQL.
	if _, err := pool.Exec(context.Background(), `
		INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
		VALUES ($1, 'For You', '', false, 'system', 'for_you')
	`, u.ID); err != nil {
		t.Fatalf("seed system playlist: %v", err)
	}

	w := doPlaylistsReq(h, u, "GET", "/api/playlists", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
	}
	var resp listPlaylistsResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(resp.Owned) != 1 {
		t.Fatalf("default should return 1 user-kind playlist; got %d", len(resp.Owned))
	}
	if resp.Owned[0].Kind != "user" {
		t.Errorf("expected kind=user; got %q", resp.Owned[0].Kind)
	}
	if resp.Owned[0].ID != uuidToString(userPL.ID) {
		t.Errorf("returned wrong playlist id")
	}
}

// TestListPlaylists_KindSystem verifies ?kind=system returns only
// system-generated playlists and excludes user-created ones.
func TestListPlaylists_KindSystem(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "klistsys", "pw", false)

	if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
		t.Fatalf("create user playlist: %v", err)
	}
	if _, err := pool.Exec(context.Background(), `
		INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
		VALUES ($1, 'For You', '', false, 'system', 'for_you')
	`, u.ID); err != nil {
		t.Fatalf("seed system playlist: %v", err)
	}

	w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=system", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
	}
	var resp listPlaylistsResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(resp.Owned) != 1 {
		t.Fatalf("kind=system should return 1 system playlist; got %d", len(resp.Owned))
	}
	if resp.Owned[0].Kind != "system" {
		t.Errorf("expected kind=system; got %q", resp.Owned[0].Kind)
	}
}

// TestListPlaylists_KindAll verifies ?kind=all returns all playlists
// regardless of kind.
func TestListPlaylists_KindAll(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	u := seedUser(t, pool, "klistall", "pw", false)

	if _, err := h.playlists.Create(context.Background(), u.ID, "User PL", "", false); err != nil {
		t.Fatalf("create user playlist: %v", err)
	}
	if _, err := pool.Exec(context.Background(), `
		INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
		VALUES ($1, 'For You', '', false, 'system', 'for_you')
	`, u.ID); err != nil {
		t.Fatalf("seed system playlist: %v", err)
	}

	w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=all", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("status: got %d, want 200; body=%s", w.Code, w.Body.String())
	}
	var resp listPlaylistsResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(resp.Owned) != 2 {
		t.Fatalf("kind=all should return both playlists; got %d", len(resp.Owned))
	}
}

// TestListPlaylists_BadKindReturns400 verifies an unrecognised kind value
// is rejected with 400 bad_kind.
func TestListPlaylists_BadKindReturns400(t *testing.T) {
	h, pool := testHandlers(t)
	u := seedUser(t, pool, "klistbad", "pw", false)
	_ = pool

	w := doPlaylistsReq(h, u, "GET", "/api/playlists?kind=garbage", nil)
	if w.Code != http.StatusBadRequest {
		t.Fatalf("expected 400; got %d, body=%s", w.Code, w.Body.String())
	}
}

// TestEditEndpoints_403OnSystemPlaylist verifies that all five edit verbs
// return 403 system_playlist_readonly when the target playlist has kind='system'.
func TestEditEndpoints_403OnSystemPlaylist(t *testing.T) {
	h, pool := testHandlers(t)
	u := seedUser(t, pool, "sysedit", "pw", false)

	// Seed a system playlist directly via SQL (BuildSystemPlaylists requires
	// play_events seeding which we already exercise in playlists package tests).
	var sid string
	if err := pool.QueryRow(context.Background(), `
		INSERT INTO playlists (user_id, name, description, is_public, kind, system_variant)
		VALUES ($1, 'For You', '', false, 'system', 'for_you')
		RETURNING id::text
	`, u.ID).Scan(&sid); err != nil {
		t.Fatalf("seed system playlist: %v", err)
	}

	cases := []struct {
		name   string
		method string
		path   string
		body   []byte
	}{
		{"PATCH", "PATCH", "/api/playlists/" + sid, []byte(`{"name":"new name"}`)},
		{"DELETE_playlist", "DELETE", "/api/playlists/" + sid, nil},
		{"POST_tracks", "POST", "/api/playlists/" + sid + "/tracks", []byte(`{"track_ids":["00000000-0000-0000-0000-000000000000"]}`)},
		{"PUT_tracks_reorder", "PUT", "/api/playlists/" + sid + "/tracks", []byte(`{"ordered_positions":[]}`)},
		{"DELETE_track_at_position", "DELETE", "/api/playlists/" + sid + "/tracks/0", nil},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			w := doPlaylistsReq(h, u, c.method, c.path, c.body)
			if w.Code != http.StatusForbidden {
				t.Fatalf("status: got %d want 403; body=%s", w.Code, w.Body.String())
			}
			var env errorBody
			if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
				t.Fatalf("decode envelope: %v", err)
			}
			if env.Error.Code != "system_playlist_readonly" {
				t.Errorf("code: got %q want system_playlist_readonly; full body=%s", env.Error.Code, w.Body.String())
			}
		})
	}
}
