package recommendation

import (
	"context"
	"fmt"
	"testing"
	"time"

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

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

// TestHomeData_YouMightLike_FallbackFillsFromLikedArtists: when the taste
// roll-up is empty/thin (no you_might_like_artists rows here), the section
// fills from the user's liked artists. Rediscover takes its share first; the
// You-might-like fallback fills the rest disjointly so no artist is shown
// twice. 20 liked artists, none played → Rediscover 10 + You-might-like 10.
func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-yml-fallback")
	liked := make(map[pgtype.UUID]bool, 20)
	for i := 0; i < 20; i++ {
		a := seedArtist(t, pool, fmt.Sprintf("YmlArtist%02d", i), "")
		likeArtist(t, pool, user.ID, a.ID)
		liked[a.ID] = true
	}

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
		t.Fatalf("you-might-like artists = %d, want %d (filled by fallback)",
			len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
	}
	inRediscover := map[pgtype.UUID]bool{}
	for _, r := range got.RediscoverArtists {
		inRediscover[r.Artist.ID] = true
	}
	for _, r := range got.YouMightLikeArtists {
		if !liked[r.Artist.ID] {
			t.Errorf("you-might-like artist %s not from the liked pool", r.Artist.Name)
		}
		if inRediscover[r.Artist.ID] {
			t.Errorf("artist %s shown in both Rediscover and You-might-like", r.Artist.Name)
		}
	}
}

// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
// artists, no artist-likes, no plays → the artist row fills from them.
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-yml-trackartists")
	fromLiked := make(map[pgtype.UUID]bool, 12)
	for i := 0; i < 12; i++ {
		a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
		al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
		tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
		if _, err := pool.Exec(context.Background(),
			`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
			t.Fatalf("like track: %v", err)
		}
		fromLiked[a.ID] = true
	}

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
		t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
			len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
	}
	for _, r := range got.YouMightLikeArtists {
		if !fromLiked[r.Artist.ID] {
			t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
		}
	}
}

func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-newuser")

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if got == nil {
		t.Fatal("HomeData returned nil payload")
	}
	if len(got.RecentlyAddedAlbums) != 0 {
		t.Errorf("RecentlyAddedAlbums len = %d, want 0", len(got.RecentlyAddedAlbums))
	}
	if len(got.RediscoverAlbums) != 0 {
		t.Errorf("RediscoverAlbums len = %d, want 0", len(got.RediscoverAlbums))
	}
	if len(got.RediscoverArtists) != 0 {
		t.Errorf("RediscoverArtists len = %d, want 0", len(got.RediscoverArtists))
	}
	if len(got.MostPlayedTracks) != 0 {
		t.Errorf("MostPlayedTracks len = %d, want 0", len(got.MostPlayedTracks))
	}
	if len(got.LastPlayedArtists) != 0 {
		t.Errorf("LastPlayedArtists len = %d, want 0", len(got.LastPlayedArtists))
	}
}

// Compile-time assert that pgtype.UUID is a valid map key — used by the
// service when de-duping rediscover fallback rows. If this changes, the
// service needs to switch to keying by string.
var _ map[pgtype.UUID]struct{}

func TestHomeData_RecentlyAdded_OrderedByCreatedAt(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-recents")
	a1 := seedArtist(t, pool, "ArtistA", "")
	// Three albums, oldest first — created_at increases naturally with each insert.
	al1 := seedAlbumForArtist(t, pool, a1.ID, "Older")
	al2 := seedAlbumForArtist(t, pool, a1.ID, "Middle")
	al3 := seedAlbumForArtist(t, pool, a1.ID, "Newest")

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.RecentlyAddedAlbums) != 3 {
		t.Fatalf("len = %d, want 3", len(got.RecentlyAddedAlbums))
	}
	if got.RecentlyAddedAlbums[0].Album.ID != al3.ID ||
		got.RecentlyAddedAlbums[1].Album.ID != al2.ID ||
		got.RecentlyAddedAlbums[2].Album.ID != al1.ID {
		t.Errorf("order = [%s, %s, %s], want newest→oldest",
			got.RecentlyAddedAlbums[0].Album.Title,
			got.RecentlyAddedAlbums[1].Album.Title,
			got.RecentlyAddedAlbums[2].Album.Title)
	}
}

func TestHomeData_MostPlayed_RanksByCount_HonorsQuarantine(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-mostplayed")
	artist := seedArtist(t, pool, "ArtistB", "")
	album := seedAlbumForArtist(t, pool, artist.ID, "AlbumB")
	tHigh := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "High")
	tLow := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Low")
	tQuar := seedTrackOnAlbum(t, pool, album.ID, artist.ID, "Quarantined")
	now := time.Now()
	for i := 0; i < 3; i++ {
		insertPlayEvent(t, pool, user.ID, tHigh.ID, now.Add(-time.Duration(i)*time.Minute))
	}
	insertPlayEvent(t, pool, user.ID, tLow.ID, now)
	for i := 0; i < 5; i++ {
		insertPlayEvent(t, pool, user.ID, tQuar.ID, now)
	}
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO lidarr_quarantine (user_id, track_id, reason) VALUES ($1, $2, 'bad_rip')`,
		user.ID, tQuar.ID,
	); err != nil {
		t.Fatalf("insert quarantine: %v", err)
	}

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.MostPlayedTracks) != 2 {
		t.Fatalf("len = %d, want 2 (quarantined excluded)", len(got.MostPlayedTracks))
	}
	if got.MostPlayedTracks[0].Track.ID != tHigh.ID || got.MostPlayedTracks[1].Track.ID != tLow.ID {
		t.Errorf("ranking wrong: got [%s, %s], want [High, Low]",
			got.MostPlayedTracks[0].Track.Title,
			got.MostPlayedTracks[1].Track.Title)
	}
}

func TestHomeData_RediscoverAlbums_FallbackWhenSparse(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-rediscover")
	artist := seedArtist(t, pool, "ArtistC", "")
	// Like one album NOW (won't satisfy >30d eligibility) — should still
	// surface via fallback because primary returns 0 rows.
	al := seedAlbumForArtist(t, pool, artist.ID, "RecentLike")
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes_albums (user_id, album_id, liked_at) VALUES ($1, $2, now())`,
		user.ID, al.ID,
	); err != nil {
		t.Fatalf("insert like: %v", err)
	}
	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.RediscoverAlbums) != 1 {
		t.Fatalf("len = %d, want 1 (from fallback)", len(got.RediscoverAlbums))
	}
	if got.RediscoverAlbums[0].Album.ID != al.ID {
		t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title)
	}
}

// TestHomeData_Rediscover_AlbumFromTrackLikes verifies the track-derived
// path: 2+ liked tracks from an album (with the earliest like >30 days
// ago) is enough for the album to appear in Rediscover, even with no
// explicit general_likes_albums entry.
func TestHomeData_Rediscover_AlbumFromTrackLikes(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-redis-tracklike")
	artist := seedArtist(t, pool, "ArtistTL", "")
	al := seedAlbumForArtist(t, pool, artist.ID, "TrackLiked")
	t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T1")
	t2 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "T2")
	// Two track-likes, both >30 days old; no explicit album-like.
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
		   ($1, $2, now() - interval '60 days'),
		   ($1, $3, now() - interval '45 days')`,
		user.ID, t1.ID, t2.ID,
	); err != nil {
		t.Fatalf("insert track likes: %v", err)
	}
	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.RediscoverAlbums) != 1 {
		t.Fatalf("len = %d, want 1 (track-derived)", len(got.RediscoverAlbums))
	}
	if got.RediscoverAlbums[0].Album.ID != al.ID {
		t.Errorf("got %s, want %s", got.RediscoverAlbums[0].Album.Title, al.Title)
	}
}

// TestHomeData_Rediscover_AlbumTrackLikeThreshold verifies that ONE
// liked track from an album is not enough — threshold is 2 for the
// track-derived path. The fallback should also miss this since no
// album-like exists.
func TestHomeData_Rediscover_AlbumTrackLikeThreshold(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-redis-threshold")
	artist := seedArtist(t, pool, "ArtistTh", "")
	al := seedAlbumForArtist(t, pool, artist.ID, "OneTrackLiked")
	t1 := seedTrackOnAlbum(t, pool, al.ID, artist.ID, "Solo")
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
		   ($1, $2, now() - interval '60 days')`,
		user.ID, t1.ID,
	); err != nil {
		t.Fatalf("insert track like: %v", err)
	}
	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.RediscoverAlbums) != 0 {
		t.Errorf("len = %d, want 0 (below 2-track threshold)", len(got.RediscoverAlbums))
	}
}

// TestHomeData_Rediscover_DiversityCap verifies that no more than 2
// albums from the same artist appear in Rediscover even when more are
// eligible.
func TestHomeData_Rediscover_DiversityCap(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-redis-cap")
	artist := seedArtist(t, pool, "ArtistDom", "")
	albums := []dbq.Album{
		seedAlbumForArtist(t, pool, artist.ID, "A1"),
		seedAlbumForArtist(t, pool, artist.ID, "A2"),
		seedAlbumForArtist(t, pool, artist.ID, "A3"),
		seedAlbumForArtist(t, pool, artist.ID, "A4"),
	}
	// Explicit album-likes >30 days old on all four.
	for _, al := range albums {
		if _, err := pool.Exec(context.Background(),
			`INSERT INTO general_likes_albums (user_id, album_id, liked_at)
			 VALUES ($1, $2, now() - interval '40 days')`,
			user.ID, al.ID,
		); err != nil {
			t.Fatalf("insert like: %v", err)
		}
	}
	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	if len(got.RediscoverAlbums) != maxAlbumsPerArtistInRediscover {
		t.Errorf("len = %d, want %d (diversity cap)",
			len(got.RediscoverAlbums), maxAlbumsPerArtistInRediscover)
	}
}

// TestHomeData_Rediscover_DedupVsMostPlayed verifies that an otherwise-
// eligible album whose tracks appear in the Most Played section is
// dropped from Rediscover (the "you forgot about this" contract is
// broken if the user is actively spinning it).
func TestHomeData_Rediscover_DedupVsMostPlayed(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "home-redis-dedup")
	artist := seedArtist(t, pool, "ArtistD", "")
	// "Forgotten" album: liked >30d ago, no recent plays — would be
	// eligible on its own.
	forgotten := seedAlbumForArtist(t, pool, artist.ID, "Forgotten")
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes_albums (user_id, album_id, liked_at)
		 VALUES ($1, $2, now() - interval '40 days')`,
		user.ID, forgotten.ID,
	); err != nil {
		t.Fatalf("insert forgotten like: %v", err)
	}
	// "Active" album: liked >30d ago BUT not played in last 14 days at
	// the album level (uses one >30-day-old track-like, no recent plays
	// — qualifies for Rediscover). Its track ALSO appears in MostPlayed
	// via many old plays >14 days ago... wait, we need the track to be
	// in MostPlayed, which means it needs play_events that aren't
	// skipped. MostPlayed has no recency filter; any plays count.
	active := seedAlbumForArtist(t, pool, artist.ID, "Active")
	activeT1 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT1")
	activeT2 := seedTrackOnAlbum(t, pool, active.ID, artist.ID, "AT2")
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes (user_id, track_id, liked_at) VALUES
		   ($1, $2, now() - interval '60 days'),
		   ($1, $3, now() - interval '60 days')`,
		user.ID, activeT1.ID, activeT2.ID,
	); err != nil {
		t.Fatalf("insert active track likes: %v", err)
	}
	// Many plays >14 days ago (so still Rediscover-eligible by the
	// "not played in last 14 days" rule) but enough to put it in
	// MostPlayed.
	for i := 0; i < 5; i++ {
		insertPlayEvent(t, pool, user.ID, activeT1.ID,
			time.Now().Add(-30*24*time.Hour))
	}

	got, err := HomeData(context.Background(), pool, user.ID)
	if err != nil {
		t.Fatalf("HomeData: %v", err)
	}
	// MostPlayed must include the active track.
	foundInMP := false
	for _, mp := range got.MostPlayedTracks {
		if mp.Track.ID == activeT1.ID {
			foundInMP = true
		}
	}
	if !foundInMP {
		t.Fatalf("active track not in MostPlayed; test fixture broken")
	}
	// Rediscover must contain forgotten but NOT active.
	if len(got.RediscoverAlbums) != 1 {
		t.Fatalf("RediscoverAlbums len = %d, want 1 (active deduped)",
			len(got.RediscoverAlbums))
	}
	if got.RediscoverAlbums[0].Album.ID != forgotten.ID {
		t.Errorf("got %s, want %s (active should have been deduped)",
			got.RediscoverAlbums[0].Album.Title, forgotten.Title)
	}
}
