package playlists_test

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"testing"

	"github.com/jackc/pgx/v5/pgtype"

	"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)

func TestCreate_HappyPath(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	dir := t.TempDir()

	svc := playlists.NewService(pool, nil, dir)
	row, err := svc.Create(context.Background(), user.ID, "Saturday morning", "Mellow", false)
	if err != nil {
		t.Fatalf("Create: %v", err)
	}
	if row.Name != "Saturday morning" {
		t.Errorf("name = %q, want Saturday morning", row.Name)
	}
	if row.IsPublic {
		t.Error("IsPublic = true, want false (default)")
	}
	if row.OwnerUsername != "test-alice" {
		t.Errorf("OwnerUsername = %q, want test-alice", row.OwnerUsername)
	}
	if row.TrackCount != 0 || row.DurationSec != 0 {
		t.Errorf("rollups should start at 0; got count=%d duration=%d", row.TrackCount, row.DurationSec)
	}
}

func TestCreate_EmptyNameRejected(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	_, err := svc.Create(context.Background(), user.ID, "", "", false)
	if !errors.Is(err, playlists.ErrInvalidInput) {
		t.Errorf("err = %v, want ErrInvalidInput", err)
	}
}

func TestGet_NotFound(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	_, err := svc.Get(context.Background(), user.ID, randomUUID())
	if !errors.Is(err, playlists.ErrNotFound) {
		t.Errorf("err = %v, want ErrNotFound", err)
	}
}

func TestGet_Forbidden_PrivatePlaylistFromOtherUser(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Private", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	if _, err := svc.Get(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) {
		t.Errorf("err = %v, want ErrForbidden", err)
	}
}

func TestGet_Public_VisibleToOtherUser(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Public mix", "", true)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	got, err := svc.Get(context.Background(), bob.ID, pl.ID)
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got.Name != "Public mix" {
		t.Errorf("name = %q", got.Name)
	}
	if !got.IsPublic {
		t.Error("IsPublic = false, want true")
	}
	if len(got.Tracks) != 0 {
		t.Errorf("Tracks len = %d, want 0 for an empty playlist", len(got.Tracks))
	}
}

func TestGet_Owner_SeesOwnPrivate(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}
	got, err := svc.Get(context.Background(), alice.ID, pl.ID)
	if err != nil {
		t.Fatalf("Get: %v", err)
	}
	if got.Name != "Mine" {
		t.Errorf("name = %q", got.Name)
	}
}

func TestList_OwnAndPublic(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	svc := playlists.NewService(pool, nil, t.TempDir())

	if _, err := svc.Create(context.Background(), alice.ID, "Alice private", "", false); err != nil {
		t.Fatalf("seed: %v", err)
	}
	if _, err := svc.Create(context.Background(), alice.ID, "Alice public", "", true); err != nil {
		t.Fatalf("seed: %v", err)
	}
	if _, err := svc.Create(context.Background(), bob.ID, "Bob private", "", false); err != nil {
		t.Fatalf("seed: %v", err)
	}
	if _, err := svc.Create(context.Background(), bob.ID, "Bob public", "", true); err != nil {
		t.Fatalf("seed: %v", err)
	}

	rows, err := svc.List(context.Background(), alice.ID)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	if len(rows) != 3 {
		t.Errorf("alice's list = %d rows, want 3 (her 2 + bob's public)", len(rows))
	}
	for _, r := range rows {
		if r.Name == "Bob private" {
			t.Errorf("alice's list leaked bob's private playlist")
		}
	}
}

func TestUpdate_OwnerOnly(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	rename := "Renamed"
	if _, err := svc.Update(context.Background(), bob.ID, pl.ID, playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrForbidden) {
		t.Errorf("err = %v, want ErrForbidden", err)
	}

	updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &rename})
	if err != nil {
		t.Fatalf("owner Update: %v", err)
	}
	if updated.Name != "Renamed" {
		t.Errorf("name = %q, want Renamed", updated.Name)
	}
}

func TestUpdate_PartialFieldsLeaveOthersAlone(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Original", "Long description", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	// Touch only IsPublic — name and description must survive.
	pub := true
	updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{IsPublic: &pub})
	if err != nil {
		t.Fatalf("Update: %v", err)
	}
	if updated.Name != "Original" {
		t.Errorf("name = %q, want unchanged Original", updated.Name)
	}
	if updated.Description != "Long description" {
		t.Errorf("description = %q, want unchanged", updated.Description)
	}
	if !updated.IsPublic {
		t.Error("IsPublic = false, want true after toggle")
	}
}

func TestUpdate_EmptyNameRejected(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Original", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	empty := ""
	if _, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &empty}); !errors.Is(err, playlists.ErrInvalidInput) {
		t.Errorf("err = %v, want ErrInvalidInput", err)
	}
}

func TestUpdate_NotFound(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	rename := "Renamed"
	if _, err := svc.Update(context.Background(), alice.ID, randomUUID(), playlists.UpdateInput{Name: &rename}); !errors.Is(err, playlists.ErrNotFound) {
		t.Errorf("err = %v, want ErrNotFound", err)
	}
}

func TestDelete_OwnerOnly(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, err := svc.Create(context.Background(), alice.ID, "Mine", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	if err := svc.Delete(context.Background(), bob.ID, pl.ID); !errors.Is(err, playlists.ErrForbidden) {
		t.Errorf("non-owner err = %v, want ErrForbidden", err)
	}
	if err := svc.Delete(context.Background(), alice.ID, pl.ID); err != nil {
		t.Fatalf("owner Delete: %v", err)
	}
	// Idempotency: a second delete reports NotFound rather than silently succeeding.
	if err := svc.Delete(context.Background(), alice.ID, pl.ID); !errors.Is(err, playlists.ErrNotFound) {
		t.Errorf("repeat Delete err = %v, want ErrNotFound", err)
	}
}

func TestDelete_NotFound(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	if err := svc.Delete(context.Background(), alice.ID, randomUUID()); !errors.Is(err, playlists.ErrNotFound) {
		t.Errorf("err = %v, want ErrNotFound", err)
	}
}

func TestDelete_RemovesCoverFile(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	dir := t.TempDir()
	svc := playlists.NewService(pool, nil, dir)

	pl, err := svc.Create(context.Background(), user.ID, "Will be deleted", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}

	// Simulate the collage generator having stashed a file at the
	// path stored on the row. The Delete path should clean it up.
	coverDir := filepath.Join(dir, "playlist_covers")
	if err := os.MkdirAll(coverDir, 0o755); err != nil {
		t.Fatalf("mkdir cover dir: %v", err)
	}
	relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg")
	full := filepath.Join(dir, relPath)
	if err := os.WriteFile(full, []byte("jpeg-bytes"), 0o644); err != nil {
		t.Fatalf("seed cover file: %v", err)
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil {
		t.Fatalf("set cover_path: %v", err)
	}

	if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil {
		t.Fatalf("Delete: %v", err)
	}
	if _, statErr := os.Stat(full); !errors.Is(statErr, os.ErrNotExist) {
		t.Errorf("cover file still on disk after delete: stat err=%v", statErr)
	}
}

func TestDelete_MissingCoverFileTolerated(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	dir := t.TempDir()
	svc := playlists.NewService(pool, nil, dir)

	pl, err := svc.Create(context.Background(), user.ID, "No cover on disk", "", false)
	if err != nil {
		t.Fatalf("seed Create: %v", err)
	}
	// Set cover_path to a file that doesn't exist — Delete must not
	// error on a missing cached file; it's just cleanup.
	relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg")
	if _, err := pool.Exec(context.Background(),
		`UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil {
		t.Fatalf("set cover_path: %v", err)
	}

	if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil {
		t.Fatalf("Delete with missing cover file: %v", err)
	}
}

func TestAppendTracks_Snapshot(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	track := seedTrack(t, pool, "Roygbiv", "Boards of Canada")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{track.ID}); err != nil {
		t.Fatalf("AppendTracks: %v", err)
	}

	got, _ := svc.Get(context.Background(), user.ID, pl.ID)
	if len(got.Tracks) != 1 {
		t.Fatalf("Tracks length = %d, want 1", len(got.Tracks))
	}
	if got.Tracks[0].Title != "Roygbiv" {
		t.Errorf("Title snapshot = %q, want Roygbiv", got.Tracks[0].Title)
	}
	if got.Tracks[0].ArtistName != "Boards of Canada" {
		t.Errorf("ArtistName snapshot = %q, want Boards of Canada", got.Tracks[0].ArtistName)
	}
	if got.TrackCount != 1 {
		t.Errorf("rollup TrackCount = %d, want 1", got.TrackCount)
	}
}

func TestAppendTracks_OwnerOnly(t *testing.T) {
	pool := newPool(t)
	alice := seedUser(t, pool, "alice")
	bob := seedUser(t, pool, "bob")
	track := seedTrack(t, pool, "T", "A")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", true)
	err := svc.AppendTracks(context.Background(), bob.ID, pl.ID, []pgtype.UUID{track.ID})
	if !errors.Is(err, playlists.ErrForbidden) {
		t.Errorf("err = %v, want ErrForbidden", err)
	}
}

func TestAppendTracks_NonExistentTrack(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{randomUUID()})
	if !errors.Is(err, playlists.ErrTrackNotFound) {
		t.Errorf("err = %v, want ErrTrackNotFound", err)
	}
}

func TestRemoveTrack_RenumbersPositions(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	t1 := seedTrack(t, pool, "Track 1", "Artist")
	t2 := seedTrack(t, pool, "Track 2", "Artist")
	t3 := seedTrack(t, pool, "Track 3", "Artist")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})

	// Remove the middle one (position 1).
	if err := svc.RemoveTrack(context.Background(), user.ID, pl.ID, 1); err != nil {
		t.Fatalf("RemoveTrack: %v", err)
	}

	got, _ := svc.Get(context.Background(), user.ID, pl.ID)
	if len(got.Tracks) != 2 {
		t.Fatalf("Tracks length = %d, want 2", len(got.Tracks))
	}
	if got.Tracks[0].Title != "Track 1" {
		t.Errorf("position 0 = %q, want Track 1", got.Tracks[0].Title)
	}
	if got.Tracks[1].Title != "Track 3" {
		t.Errorf("position 1 = %q, want Track 3 (renumbered from 2)", got.Tracks[1].Title)
	}
}

func TestReorder_Permutation(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	t1 := seedTrack(t, pool, "A", "Artist")
	t2 := seedTrack(t, pool, "B", "Artist")
	t3 := seedTrack(t, pool, "C", "Artist")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})

	// Reverse order: send [2, 1, 0].
	if err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{2, 1, 0}); err != nil {
		t.Fatalf("Reorder: %v", err)
	}

	got, _ := svc.Get(context.Background(), user.ID, pl.ID)
	wantOrder := []string{"C", "B", "A"}
	for i, pt := range got.Tracks {
		if pt.Title != wantOrder[i] {
			t.Errorf("position %d = %q, want %q", i, pt.Title, wantOrder[i])
		}
	}
}

func TestReorder_RejectsNonPermutation(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	tk := seedTrack(t, pool, "T", "A")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})

	// 2 positions for a 1-track playlist — invalid.
	err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{0, 1})
	if !errors.Is(err, playlists.ErrInvalidInput) {
		t.Errorf("err = %v, want ErrInvalidInput (length mismatch)", err)
	}

	pl2, _ := svc.Create(context.Background(), user.ID, "Two", "", false)
	t2 := seedTrack(t, pool, "T2", "A")
	_ = svc.AppendTracks(context.Background(), user.ID, pl2.ID, []pgtype.UUID{tk.ID, t2.ID})
	err = svc.Reorder(context.Background(), user.ID, pl2.ID, []int32{0, 0})
	if !errors.Is(err, playlists.ErrInvalidInput) {
		t.Errorf("err = %v, want ErrInvalidInput (duplicate)", err)
	}
}

func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	tk := seedTrack(t, pool, "Doomed track", "Artist")
	svc := playlists.NewService(pool, nil, t.TempDir())

	pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
	_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})

	// Delete the track row directly (simulating Lidarr re-import / file removal).
	if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil {
		t.Fatalf("delete track: %v", err)
	}

	got, _ := svc.Get(context.Background(), user.ID, pl.ID)
	if len(got.Tracks) != 1 {
		t.Fatalf("Tracks length = %d, want 1 (soft-mark not silent-cascade)", len(got.Tracks))
	}
	row := got.Tracks[0]
	if row.TrackID != nil {
		t.Error("TrackID should be nil after upstream delete")
	}
	if row.Title != "Doomed track" {
		t.Errorf("Snapshot Title = %q, want Doomed track", row.Title)
	}
}
