package recommendation

import (
	"context"
	"testing"

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

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

// helperLBSimilarity inserts a track_similarity row.
func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
	t.Helper()
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
		a, b, score); err != nil {
		t.Fatalf("insert track_similarity: %v", err)
	}
}

// helperArtistSimilarity inserts an artist_similarity row.
func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) {
	t.Helper()
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`,
		a, b, score); err != nil {
		t.Fatalf("insert artist_similarity: %v", err)
	}
}

// helperSetTrackGenre updates a track's genre column. Used to retrofit
// genres onto the fixture's auto-created tracks (fixture creates tracks
// with NULL genre).
func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) {
	t.Helper()
	if _, err := f.pool.Exec(context.Background(),
		`UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil {
		t.Fatalf("set genre: %v", err)
	}
}

func defaultLimits() CandidateSourceLimits {
	return DefaultCandidateSourceLimits()
}

func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
	f := newFixture(t, 5)
	seed := f.tracks[0]
	target := f.tracks[1]
	helperLBSimilarity(t, f, seed.ID, target.ID, 0.85)
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	var found *Candidate
	for i := range got {
		if got[i].Track.ID == target.ID {
			found = &got[i]
			break
		}
	}
	if found == nil {
		t.Fatal("LB-similar target missing from candidates")
	}
	if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 {
		t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore)
	}
}

func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) {
	f := newFixture(t, 1) // creates 1 artist + 1 album + 1 track (the seed)
	seed := f.tracks[0]
	// Add a SECOND artist + track in that artist; relate the two artists via artist_similarity.
	otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"})
	otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID})
	otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID,
		FilePath: "/tmp/other.flac", DurationMs: 180_000,
	})
	helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8)
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == otherTrack.ID {
			// 0.8 × 0.5 = 0.4
			if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 {
				t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore)
			}
			return
		}
	}
	t.Error("similar-artist track missing from candidates")
}

func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) {
	f := newFixture(t, 2)
	seed := f.tracks[0]
	target := f.tracks[1]
	helperSetTrackGenre(t, f, seed.ID, "Rock; Pop")
	helperSetTrackGenre(t, f, target.ID, "Rock")
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == target.ID {
			// Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5.
			if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 {
				t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore)
			}
			return
		}
	}
	t.Error("tag-overlap target missing from candidates")
}

func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) {
	f := newFixture(t, 2)
	seed := f.tracks[0]
	liked := f.tracks[1]
	helperSetTrackGenre(t, f, seed.ID, "Rock")
	helperSetTrackGenre(t, f, liked.ID, "Rock")
	if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil {
		t.Fatalf("like: %v", err)
	}
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == liked.ID {
			// Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap.
			// likes-overlap = 0.6. Max wins = 1.0.
			if c.Inputs.SimilarityScore < 0.59 {
				t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore)
			}
			return
		}
	}
	t.Error("liked track with shared tag missing from candidates")
}

func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) {
	f := newFixture(t, 10) // 10 tracks; no similarity data
	seed := f.tracks[0]
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	if len(got) == 0 {
		t.Error("random fill returned 0 candidates; expected at least some")
	}
	for _, c := range got {
		if c.Inputs.SimilarityScore != 0 {
			t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore)
		}
	}
}

func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) {
	f := newFixture(t, 5)
	seed := f.tracks[0]
	excluded := f.tracks[1].ID
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true},
		[]pgtype.UUID{excluded}, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == excluded {
			t.Error("excluded track appeared in candidates")
		}
	}
}

func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) {
	f := newFixture(t, 5)
	seed := f.tracks[0]
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == seed.ID {
			t.Error("seed track appeared in candidates")
		}
	}
}

func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) {
	f := newFixture(t, 5)
	seed := f.tracks[0]
	recent := f.tracks[1].ID
	var sessionID pgtype.UUID
	if err := f.pool.QueryRow(context.Background(),
		`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
         VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
		f.user).Scan(&sessionID); err != nil {
		t.Fatalf("session: %v", err)
	}
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped)
         VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`,
		f.user, recent, sessionID); err != nil {
		t.Fatalf("play_event: %v", err)
	}
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == recent {
			t.Error("recently-played track appeared in candidates")
		}
	}
}

func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
	f := newFixture(t, 2)
	seed := f.tracks[0]
	target := f.tracks[1]
	helperSetTrackGenre(t, f, seed.ID, "Rock")
	helperSetTrackGenre(t, f, target.ID, "Rock")      // jaccard 1/1 = 1.0 from tag-overlap
	helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	count := 0
	for _, c := range got {
		if c.Track.ID == target.ID {
			count++
			// tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99
			if c.Inputs.SimilarityScore < 0.99 {
				t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore)
			}
		}
	}
	if count != 1 {
		t.Errorf("target appeared %d times, want 1 (dedup failed)", count)
	}
}

// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
// a positively-weighted taste-profile artist enters the pool via taste_overlap
// even with every other arm disabled; a negatively-weighted artist's track does
// not (the WHERE weight > 0 filter).
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
	f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
	seed := f.tracks[0]
	target := f.tracks[1]
	ctx := context.Background()

	// Fixture artist gets a positive taste weight.
	if _, err := f.pool.Exec(ctx,
		`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
		f.user, seed.ArtistID); err != nil {
		t.Fatalf("insert taste (positive): %v", err)
	}

	// A second artist with a NEGATIVE weight — its track must be excluded.
	negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
	negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
	negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
		Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
		FilePath: "/tmp/neg.flac", DurationMs: 180_000,
	})
	if _, err := f.pool.Exec(ctx,
		`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
		f.user, negArtist.ID); err != nil {
		t.Fatalf("insert taste (negative): %v", err)
	}

	// Only the taste_overlap arm is enabled.
	limits := CandidateSourceLimits{TasteOverlap: 10}
	got, err := LoadCandidatesFromSimilarity(
		ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	var sawTarget, sawNeg bool
	for _, c := range got {
		switch c.Track.ID {
		case target.ID:
			sawTarget = true
		case negTrack.ID:
			sawNeg = true
		}
	}
	if !sawTarget {
		t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
	}
	if sawNeg {
		t.Error("negative-taste-artist track present (weight > 0 filter failed)")
	}
}

func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
	f := newFixture(t, 1) // just the seed
	seed := f.tracks[0]
	got, err := LoadCandidatesFromSimilarity(
		context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(),
	)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	// Only the seed exists; it's excluded → 0 candidates.
	if len(got) != 0 {
		t.Errorf("got %d candidates from seed-only library, want 0", len(got))
	}
}
