package recommendation

import (
	"context"
	"fmt"
	"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 newPool(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
}

func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
	t.Helper()
	u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
		ApiToken: name + "-token", IsAdmin: false,
	})
	if err != nil {
		t.Fatalf("seed user: %v", err)
	}
	return u
}

func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist {
	t.Helper()
	var mbidPtr *string
	if mbid != "" {
		mbidPtr = &mbid
	}
	a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
		Name: name, SortName: name, Mbid: mbidPtr,
	})
	if err != nil {
		t.Fatalf("seed artist: %v", err)
	}
	return a
}

func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album {
	t.Helper()
	a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
		Title: title, SortTitle: title, ArtistID: artistID,
	})
	if err != nil {
		t.Fatalf("seed album: %v", err)
	}
	return a
}

func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track {
	t.Helper()
	tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: title, AlbumID: albumID, ArtistID: artistID,
		DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3",
		FileSize: 1, FileFormat: "mp3",
	})
	if err != nil {
		t.Fatalf("seed track: %v", err)
	}
	return tr
}

func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
	t.Helper()
	ctx := context.Background()
	var sessionID pgtype.UUID
	if err := pool.QueryRow(ctx,
		`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
		 VALUES ($1, $2, $2, 'm5c-test') RETURNING id`,
		userID, startedAt,
	).Scan(&sessionID); err != nil {
		t.Fatalf("insert play_session: %v", err)
	}
	if _, err := pool.Exec(ctx,
		`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
		 VALUES ($1, $2, $3, $4, false)`,
		userID, trackID, sessionID, startedAt,
	); err != nil {
		t.Fatalf("insert play_event: %v", err)
	}
}

func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
	t.Helper()
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
		userID, artistID,
	); err != nil {
		t.Fatalf("like artist: %v", err)
	}
}

func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) {
	t.Helper()
	if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{
		SeedArtistID:  seedID,
		CandidateMbid: candMBID,
		CandidateName: candName,
		Score:         score,
		Source:        "listenbrainz",
	}); err != nil {
		t.Fatalf("seed unmatched: %v", err)
	}
}

func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seedA := seedArtist(t, pool, "Seed Liked", "")
	seedB := seedArtist(t, pool, "Seed Played", "")

	likeArtist(t, pool, user.ID, seedA.ID)

	seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B")
	seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B")
	insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour))

	seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
	seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 1 {
		t.Fatalf("len = %d, want 1", len(out))
	}
	s := out[0]
	if s.MBID != "out-mbid" || s.Name != "Outsider" {
		t.Errorf("got = %+v", s)
	}
	if s.Score <= 0 {
		t.Errorf("score = %v, want > 0", s.Score)
	}
	if len(s.Attribution) != 2 {
		t.Errorf("attribution len = %d, want 2", len(s.Attribution))
	}
}

func TestSuggestArtists_Top12Cap(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, user.ID, seed.ID)
	for i := 0; i < 30; i++ {
		seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
	}
	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 12 {
		t.Errorf("len = %d, want 12", len(out))
	}
	if out[0].MBID != "mbid-00" {
		t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID)
	}
}

func TestSuggestArtists_AttributionTopThree(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seeds := make([]dbq.Artist, 5)
	for i := 0; i < 5; i++ {
		seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "")
		likeArtist(t, pool, user.ID, seeds[i].ID)
		seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
	}
	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 1 {
		t.Fatalf("len = %d, want 1 (shared candidate)", len(out))
	}
	if got := len(out[0].Attribution); got != 3 {
		t.Errorf("attribution len = %d, want 3", got)
	}
	if out[0].Attribution[0].Name != "Seed 0" {
		t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name)
	}
}

func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	recentSeed := seedArtist(t, pool, "Recent", "")
	oldSeed := seedArtist(t, pool, "Old", "")

	rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album")
	rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track")
	insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour))

	oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album")
	oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track")
	insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour))

	seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
	seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 1 {
		t.Fatalf("len = %d, want 1", len(out))
	}
	if len(out[0].Attribution) != 2 {
		t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution))
	}
	if out[0].Attribution[0].Name != "Recent" {
		t.Errorf("top attribution = %q, want Recent", out[0].Attribution[0].Name)
	}
	if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution {
		t.Errorf("recent contribution (%v) should exceed old (%v)",
			out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution)
	}
}

func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, user.ID, seed.ID)
	inLibMBID := "in-lib-mbid"
	seedArtist(t, pool, "InLib", inLibMBID)
	seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 0 {
		t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out))
	}
}

func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, user.ID, seed.ID)
	seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9)
	if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
		UserID:           user.ID,
		Kind:             dbq.LidarrRequestKindArtist,
		LidarrArtistMbid: "req-mbid",
		ArtistName:       "Pending Request",
	}); err != nil {
		t.Fatalf("CreateLidarrRequest: %v", err)
	}

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 0 {
		t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out))
	}
}

func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "alice")
	seed := seedArtist(t, pool, "Seed", "")
	likeArtist(t, pool, user.ID, seed.ID)
	seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9)
	req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
		UserID:           user.ID,
		Kind:             dbq.LidarrRequestKindArtist,
		LidarrArtistMbid: "rej-mbid",
		ArtistName:       "Rejected Once",
	})
	if err != nil {
		t.Fatalf("CreateLidarrRequest: %v", err)
	}
	rejNotes := "wrong artist"
	if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{
		ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID,
	}); err != nil {
		t.Fatalf("RejectLidarrRequest: %v", err)
	}

	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 1 {
		t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out))
	}
}

func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
	pool := newPool(t)
	user := seedUser(t, pool, "newbie")
	out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
	if err != nil {
		t.Fatalf("SuggestArtists: %v", err)
	}
	if len(out) != 0 {
		t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
	}
}
