package recommendation

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"os"
	"testing"
	"time"

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

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

func testPool(t *testing.T) *pgxpool.Pool {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping integration test in -short mode")
	}
	dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("MINSTREL_TEST_DATABASE_URL not set")
	}
	if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
		t.Fatalf("migrate: %v", err)
	}
	pool, err := pgxpool.New(context.Background(), dsn)
	if err != nil {
		t.Fatalf("pool: %v", err)
	}
	t.Cleanup(pool.Close)
	dbtest.ResetDB(t, pool)
	return pool
}

type fixture struct {
	pool   *pgxpool.Pool
	q      *dbq.Queries
	user   pgtype.UUID
	tracks []dbq.Track // tracks[0] is the canonical seed
}

func newFixture(t *testing.T, n int) fixture {
	t.Helper()
	pool := testPool(t)
	q := dbq.New(pool)
	u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
	})
	if err != nil {
		t.Fatalf("user: %v", err)
	}
	a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "A", SortName: "A"})
	al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "B", SortTitle: "B", ArtistID: a.ID})
	tracks := make([]dbq.Track, 0, n)
	for i := 0; i < n; i++ {
		tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
			Title:      string(rune('A' + i)),
			AlbumID:    al.ID,
			ArtistID:   a.ID,
			FilePath:   "/tmp/track-" + string(rune('A'+i)) + ".flac",
			DurationMs: 120_000,
		})
		if err != nil {
			t.Fatalf("track[%d]: %v", i, err)
		}
		tracks = append(tracks, tr)
	}
	return fixture{pool: pool, q: q, user: u.ID, tracks: tracks}
}

func TestLoadCandidates_ExcludesSeed(t *testing.T) {
	f := newFixture(t, 5)
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
	if err != nil {
		t.Fatalf("LoadCandidates: %v", err)
	}
	if len(got) != 4 {
		t.Fatalf("len = %d, want 4 (5 minus seed)", len(got))
	}
	for _, c := range got {
		if c.Track.ID == f.tracks[0].ID {
			t.Errorf("seed appeared in candidate set")
		}
	}
}

func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
	f := newFixture(t, 3)
	// Seed = tracks[0]. Other tracks are tracks[1], tracks[2].
	// Insert a play_session and a play_event for tracks[1] 30 minutes ago.
	now := time.Now().UTC()
	thirtyMinAgo := now.Add(-30 * time.Minute)
	sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
		UserID:    f.user,
		StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
		ClientID:  nil,
	})
	_, _ = f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
		UserID:    f.user,
		TrackID:   f.tracks[1].ID,
		SessionID: sess.ID,
		StartedAt: pgtype.Timestamptz{Time: thirtyMinAgo, Valid: true},
		ClientID:  nil,
	})

	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
	if err != nil {
		t.Fatalf("LoadCandidates: %v", err)
	}
	// Expect tracks[2] only (seed excluded, tracks[1] excluded by recently-played).
	if len(got) != 1 {
		t.Fatalf("len = %d, want 1; got=%v", len(got), trackTitles(got))
	}
	if got[0].Track.ID != f.tracks[2].ID {
		t.Errorf("expected tracks[2], got %v", got[0].Track.Title)
	}
}

func TestLoadCandidates_StatJoin(t *testing.T) {
	f := newFixture(t, 2)
	// Seed = tracks[0]. Like tracks[1]; play it twice (one skip, one full play) 2 hours ago.
	if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}); err != nil {
		t.Fatalf("like: %v", err)
	}
	twoH := time.Now().UTC().Add(-2 * time.Hour)
	sess, _ := f.q.InsertPlaySession(context.Background(), dbq.InsertPlaySessionParams{
		UserID: f.user, StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
	})
	// First play: completed (was_skipped=false).
	pe1, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
		UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
		StartedAt: pgtype.Timestamptz{Time: twoH, Valid: true},
	})
	dur := int32(120_000)
	ratio := 1.0
	_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
		ID: pe1.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(2 * time.Minute), Valid: true},
		DurationPlayedMs: &dur, CompletionRatio: &ratio, WasSkipped: false,
	})
	// Second play: skipped (was_skipped=true).
	pe2, _ := f.q.InsertPlayEvent(context.Background(), dbq.InsertPlayEventParams{
		UserID: f.user, TrackID: f.tracks[1].ID, SessionID: sess.ID,
		StartedAt: pgtype.Timestamptz{Time: twoH.Add(5 * time.Minute), Valid: true},
	})
	dur2 := int32(5_000)
	ratio2 := 0.04
	_, _ = f.q.UpdatePlayEventEnded(context.Background(), dbq.UpdatePlayEventEndedParams{
		ID: pe2.ID, EndedAt: pgtype.Timestamptz{Time: twoH.Add(5*time.Minute + 5*time.Second), Valid: true},
		DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
	})

	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
	if err != nil {
		t.Fatalf("LoadCandidates: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("len = %d, want 1", len(got))
	}
	c := got[0]
	if !c.Inputs.IsGeneralLiked {
		t.Errorf("IsGeneralLiked = false, want true")
	}
	if c.Inputs.PlayCount != 2 {
		t.Errorf("PlayCount = %d, want 2", c.Inputs.PlayCount)
	}
	if c.Inputs.SkipCount != 1 {
		t.Errorf("SkipCount = %d, want 1", c.Inputs.SkipCount)
	}
	if c.Inputs.LastPlayedAt == nil {
		t.Errorf("LastPlayedAt = nil, want non-nil")
	}
}

func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
	f := newFixture(t, 2)
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
	if err != nil {
		t.Fatalf("LoadCandidates: %v", err)
	}
	if len(got) != 1 {
		t.Fatalf("len = %d, want 1", len(got))
	}
	if got[0].Inputs.LastPlayedAt != nil {
		t.Errorf("never-played track has non-nil LastPlayedAt: %v", got[0].Inputs.LastPlayedAt)
	}
	if got[0].Inputs.PlayCount != 0 || got[0].Inputs.SkipCount != 0 {
		t.Errorf("never-played track has non-zero counts: %+v", got[0].Inputs)
	}
}

func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
	f := newFixture(t, 2)
	bob, _ := f.q.CreateUser(context.Background(), dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
	})
	// Alice likes tracks[1]; Bob shouldn't see it.
	_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})

	got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1, SessionVector{Seed: true})
	if err != nil {
		t.Fatalf("LoadCandidates: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == f.tracks[1].ID && c.Inputs.IsGeneralLiked {
			t.Errorf("bob sees alice's like on track %v", c.Track.Title)
		}
	}
}

func trackTitles(cs []Candidate) []string {
	out := make([]string, 0, len(cs))
	for _, c := range cs {
		out = append(out, c.Track.Title)
	}
	return out
}

// helperInsertContextualLike inserts a contextual_like row with the given
// session_vector marshaled to JSON. Bypasses playevents.CaptureContextualLikeIfPlaying
// because we want full control over the stored vector for these unit tests.
func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) {
	t.Helper()
	raw, err := json.Marshal(vec)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
		f.user, trackID, raw); err != nil {
		t.Fatalf("insert: %v", err)
	}
}

func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) {
	f := newFixture(t, 5)
	current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Inputs.ContextualMatchScore != 0 {
			t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore)
		}
	}
}

func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) {
	f := newFixture(t, 3)
	target := f.tracks[1]
	likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	helperInsertContextualLike(t, f, target.ID, likeVec)

	current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	var found bool
	for _, c := range got {
		if c.Track.ID == target.ID {
			found = true
			if c.Inputs.ContextualMatchScore < 0.99 {
				t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore)
			}
		} else {
			if c.Inputs.ContextualMatchScore != 0 {
				t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore)
			}
		}
	}
	if !found {
		t.Error("target track missing from candidate list")
	}
}

func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) {
	f := newFixture(t, 3)
	target := f.tracks[1]
	helperInsertContextualLike(t, f, target.ID, SessionVector{
		Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1},
	})
	helperInsertContextualLike(t, f, target.ID, SessionVector{
		Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
	})
	helperInsertContextualLike(t, f, target.ID, SessionVector{
		Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1},
	})

	current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 {
			t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore)
		}
	}
}

func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) {
	f := newFixture(t, 3)
	target := f.tracks[1]
	likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	helperInsertContextualLike(t, f, target.ID, likeVec)
	if _, err := f.pool.Exec(context.Background(),
		`UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil {
		t.Fatalf("soft-delete: %v", err)
	}

	current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Inputs.ContextualMatchScore != 0 {
			t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
		}
	}
}

func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) {
	f := newFixture(t, 3)
	target := f.tracks[1]
	helperInsertContextualLike(t, f, target.ID, SessionVector{
		Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
	})

	current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Inputs.ContextualMatchScore != 0 {
			t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
		}
	}
}

func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) {
	f := newFixture(t, 3)
	target := f.tracks[1]
	likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
	helperInsertContextualLike(t, f, target.ID, likeVec)

	currentSeed := SessionVector{Seed: true}
	got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed)
	if err != nil {
		t.Fatalf("load: %v", err)
	}
	for _, c := range got {
		if c.Inputs.ContextualMatchScore != 0 {
			t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
		}
	}
}
