package similarity

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"sync/atomic"
	"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"
	"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
)

func testPool(t *testing.T) (*pgxpool.Pool, *dbq.Queries) {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping 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, dbq.New(pool)
}

type fixture struct {
	pool   *pgxpool.Pool
	q      *dbq.Queries
	user   pgtype.UUID
	artist dbq.Artist
	album  dbq.Album
}

func newFixture(t *testing.T) fixture {
	t.Helper()
	pool, q := testPool(t)
	ctx := context.Background()
	u, err := q.CreateUser(ctx, dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
	})
	if err != nil {
		t.Fatalf("user: %v", err)
	}
	artistMbid := "aaaaaaaa-1111-1111-1111-111111111111"
	a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{
		Name: "X", SortName: "X", Mbid: &artistMbid,
	})
	if err != nil {
		t.Fatalf("artist: %v", err)
	}
	al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{
		Title: "X", SortTitle: "X", ArtistID: a.ID,
	})
	if err != nil {
		t.Fatalf("album: %v", err)
	}
	return fixture{pool: pool, q: q, user: u.ID, artist: a, album: al}
}

func seedTrack(t *testing.T, f fixture, title string, mbid *string) dbq.Track {
	t.Helper()
	tr, err := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title:      title,
		AlbumID:    f.album.ID,
		ArtistID:   f.artist.ID,
		FilePath:   "/tmp/" + title + ".flac",
		DurationMs: 200_000,
		Mbid:       mbid,
	})
	if err != nil {
		t.Fatalf("track: %v", err)
	}
	return tr
}

func markPlayed(t *testing.T, f fixture, trackID pgtype.UUID) {
	t.Helper()
	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 '1 minute', now(), 250000, 0.83, false)`,
		f.user, trackID, sessionID); err != nil {
		t.Fatalf("play_event: %v", err)
	}
}

func newTestWorker(f fixture, lbBaseURL string) *Worker {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	return &Worker{
		pool: f.pool,
		// LabsBaseURL must point at the stub too: similarity calls hit
		// the Labs API (commit 4fca0e6), so an unset LabsBaseURL would
		// fall through to the real labs.api and the stub never runs.
		client: &listenbrainz.Client{BaseURL: lbBaseURL, LabsBaseURL: lbBaseURL, HTTP: http.DefaultClient},
		logger: logger,
		tick:   1 * time.Hour,
		batch:  5,
		topK:   20,
	}
}

// stubLB returns an httptest server that responds to similar-recordings and
// similar-artists with the given JSON payloads.
func stubLB(recordingsBody, artistsBody string, status int) *httptest.Server {
	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if status != 0 && status != http.StatusOK {
			w.WriteHeader(status)
			return
		}
		if strings.Contains(r.URL.Path, "/similar-recordings/") {
			_, _ = w.Write([]byte(recordingsBody))
			return
		}
		if strings.Contains(r.URL.Path, "/similar-artists/") {
			_, _ = w.Write([]byte(artistsBody))
			return
		}
		w.WriteHeader(http.StatusNotFound)
	}))
}

func countTrackSim(t *testing.T, f fixture, a pgtype.UUID) int {
	t.Helper()
	var n int
	if err := f.pool.QueryRow(context.Background(),
		`SELECT count(*) FROM track_similarity WHERE track_a_id = $1`, a).Scan(&n); err != nil {
		t.Fatalf("count: %v", err)
	}
	return n
}

func countArtistSim(t *testing.T, f fixture, a pgtype.UUID) int {
	t.Helper()
	var n int
	if err := f.pool.QueryRow(context.Background(),
		`SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1`, a).Scan(&n); err != nil {
		t.Fatalf("count: %v", err)
	}
	return n
}

func TestTickOnce_NoPlayedTracks_NoOp(t *testing.T) {
	f := newFixture(t)
	srv := stubLB(`[]`, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	var n int
	_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM track_similarity`).Scan(&n)
	if n != 0 {
		t.Errorf("track_similarity rows = %d, want 0", n)
	}
	_ = f.pool.QueryRow(context.Background(), `SELECT count(*) FROM artist_similarity`).Scan(&n)
	if n != 0 {
		t.Errorf("artist_similarity rows = %d, want 0", n)
	}
}

func TestTickOnce_MapsLBResponseToLocalLibrary(t *testing.T) {
	f := newFixture(t)
	mbidA := "11111111-1111-1111-1111-111111111111"
	mbidB := "22222222-2222-2222-2222-222222222222"
	mbidC := "99999999-9999-9999-9999-999999999999" // NOT in library
	trackA := seedTrack(t, f, "A", &mbidA)
	_ = seedTrack(t, f, "B", &mbidB)
	markPlayed(t, f, trackA.ID)
	body := `[
		{"recording_mbid": "` + mbidB + `", "score": 0.9},
		{"recording_mbid": "` + mbidC + `", "score": 0.7}
	]`
	srv := stubLB(body, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	if got := countTrackSim(t, f, trackA.ID); got != 1 {
		t.Errorf("track_similarity rows = %d, want 1 (only mbidB is in-library)", got)
	}
}

func TestTickOnce_TopKEnforced(t *testing.T) {
	f := newFixture(t)
	mbidSeed := "11111111-1111-1111-1111-111111111111"
	seed := seedTrack(t, f, "Seed", &mbidSeed)
	markPlayed(t, f, seed.ID)
	type lbRow struct {
		MBID  string  `json:"recording_mbid"`
		Score float64 `json:"score"`
	}
	rows := make([]lbRow, 0, 25)
	for i := 0; i < 25; i++ {
		mbid := fmt.Sprintf("20000000-0000-0000-0000-%012d", i+1)
		_ = seedTrack(t, f, fmt.Sprintf("T%02d", i+1), &mbid)
		rows = append(rows, lbRow{MBID: mbid, Score: 1.0 - 0.01*float64(i)})
	}
	body, _ := json.Marshal(rows)
	srv := stubLB(string(body), `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	if got := countTrackSim(t, f, seed.ID); got != 20 {
		t.Errorf("got %d rows, want exactly 20 (all 25 in-library; top 20 by score)", got)
	}
}

func TestTickOnce_RespectsSevenDayCap(t *testing.T) {
	f := newFixture(t)
	mbid := "11111111-1111-1111-1111-111111111111"
	trackA := seedTrack(t, f, "A", &mbid)
	markPlayed(t, f, trackA.ID)
	otherMbid := "55555555-5555-5555-5555-555555555555"
	other := seedTrack(t, f, "Other", &otherMbid)
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
		 VALUES ($1, $2, 0.5, 'listenbrainz', now())`, trackA.ID, other.ID); err != nil {
		t.Fatalf("seed sim: %v", err)
	}
	beforeCount := countTrackSim(t, f, trackA.ID)
	srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.99}]`, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	_ = w.tickOnce(context.Background())
	if got := countTrackSim(t, f, trackA.ID); got != beforeCount {
		t.Errorf("worker re-queried fresh track: before=%d after=%d", beforeCount, got)
	}
}

func TestTickOnce_RefreshesStaleRow(t *testing.T) {
	f := newFixture(t)
	mbid := "11111111-1111-1111-1111-111111111111"
	trackA := seedTrack(t, f, "A", &mbid)
	markPlayed(t, f, trackA.ID)
	otherMbid := "55555555-5555-5555-5555-555555555555"
	other := seedTrack(t, f, "Other", &otherMbid)
	if _, err := f.pool.Exec(context.Background(),
		`INSERT INTO track_similarity (track_a_id, track_b_id, score, source, fetched_at)
		 VALUES ($1, $2, 0.5, 'listenbrainz', now() - interval '8 days')`, trackA.ID, other.ID); err != nil {
		t.Fatalf("seed sim: %v", err)
	}
	srv := stubLB(`[{"recording_mbid":"`+otherMbid+`","score":0.95}]`, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	var score float64
	var fetchedAt time.Time
	_ = f.pool.QueryRow(context.Background(),
		`SELECT score, fetched_at FROM track_similarity WHERE track_a_id = $1 AND track_b_id = $2 AND source = 'listenbrainz'`,
		trackA.ID, other.ID).Scan(&score, &fetchedAt)
	if score != 0.95 {
		t.Errorf("score = %v, want 0.95 (refreshed)", score)
	}
	if time.Since(fetchedAt) > time.Minute {
		t.Errorf("fetched_at not bumped: %v ago", time.Since(fetchedAt))
	}
}

func TestTickOnce_429AbortsTick(t *testing.T) {
	f := newFixture(t)
	mbid := "11111111-1111-1111-1111-111111111111"
	trackA := seedTrack(t, f, "A", &mbid)
	markPlayed(t, f, trackA.ID)
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Retry-After", "60")
		w.WriteHeader(http.StatusTooManyRequests)
	}))
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	_ = w.tickOnce(context.Background())
	if got := countTrackSim(t, f, trackA.ID); got != 0 {
		t.Errorf("rows on 429 = %d, want 0 (tick aborted before any inserts)", got)
	}
}

func TestTickOnce_TransientErrorSkipsTrack(t *testing.T) {
	f := newFixture(t)
	mbidA := "11111111-1111-1111-1111-111111111111"
	mbidB := "22222222-2222-2222-2222-222222222222"
	otherMbid := "55555555-5555-5555-5555-555555555555"
	trackA := seedTrack(t, f, "A", &mbidA)
	trackB := seedTrack(t, f, "B", &mbidB)
	_ = seedTrack(t, f, "Other", &otherMbid)
	markPlayed(t, f, trackA.ID)
	markPlayed(t, f, trackB.ID)
	var seen atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !strings.Contains(r.URL.Path, "/similar-recordings/") {
			_, _ = w.Write([]byte(`[]`))
			return
		}
		n := seen.Add(1)
		if n == 1 {
			w.WriteHeader(http.StatusServiceUnavailable)
			return
		}
		_, _ = w.Write([]byte(`[{"recording_mbid":"` + otherMbid + `","score":0.7}]`))
	}))
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	_ = w.tickOnce(context.Background())
	a := countTrackSim(t, f, trackA.ID)
	b := countTrackSim(t, f, trackB.ID)
	if a+b != 1 {
		t.Errorf("expected exactly one track to succeed: a=%d b=%d", a, b)
	}
}

func TestTickOnce_FiltersInLibrary(t *testing.T) {
	f := newFixture(t)
	mbid := "11111111-1111-1111-1111-111111111111"
	trackA := seedTrack(t, f, "A", &mbid)
	markPlayed(t, f, trackA.ID)
	body := `[
		{"recording_mbid":"99999999-9999-9999-9999-999999999991","score":0.9},
		{"recording_mbid":"99999999-9999-9999-9999-999999999992","score":0.8},
		{"recording_mbid":"99999999-9999-9999-9999-999999999993","score":0.7}
	]`
	srv := stubLB(body, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	if got := countTrackSim(t, f, trackA.ID); got != 0 {
		t.Errorf("filtered out-of-library: got %d, want 0", got)
	}
}

func TestTickOnce_ArtistPassMirrors(t *testing.T) {
	f := newFixture(t)
	mbid := "11111111-1111-1111-1111-111111111111"
	trackA := seedTrack(t, f, "A", &mbid)
	markPlayed(t, f, trackA.ID)
	bMbid := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
	_, err := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
		Name: "Other", SortName: "Other", Mbid: &bMbid,
	})
	if err != nil {
		t.Fatalf("artist: %v", err)
	}
	body := `[{"artist_mbid":"` + bMbid + `","score":0.85}]`
	srv := stubLB(`[]`, body, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	if got := countArtistSim(t, f, f.artist.ID); got != 1 {
		t.Errorf("artist_similarity rows = %d, want 1", got)
	}
}

func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) {
	f := newFixture(t)
	trackNoMbid := seedTrack(t, f, "NoMbid", nil)
	markPlayed(t, f, trackNoMbid.ID)
	srv := stubLB(`[{"recording_mbid":"11111111-1111-1111-1111-111111111111","score":0.9}]`, `[]`, http.StatusOK)
	defer srv.Close()
	w := newTestWorker(f, srv.URL)
	if err := w.tickOnce(context.Background()); err != nil {
		t.Fatalf("tickOnce: %v", err)
	}
	if got := countTrackSim(t, f, trackNoMbid.ID); got != 0 {
		t.Errorf("no-MBID track produced rows: %d", got)
	}
}

// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls
// upsertArtistSimilar directly with a mix of in-library and out-of-library
// similar-artist payloads; verifies the matched path lands in
// artist_similarity (1 row), the unmatched path lands in
// artist_similarity_unmatched (3 rows), and rows with empty Name are
// skipped (we can't render a suggestion without a name).
func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) {
	f := newFixture(t)
	ctx := context.Background()

	// Seed one additional in-library artist that will be the in-library match.
	inLibMBID := "in-lib-mbid-123"
	inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{
		Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID,
	})
	if err != nil {
		t.Fatalf("seed in-lib artist: %v", err)
	}

	w := newTestWorker(f, "")

	similars := []listenbrainz.SimilarArtist{
		{MBID: inLibMBID, Name: "InLib Artist", Score: 0.95},
		{MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85},
		{MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80},
		{MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70},
		{MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped
	}
	w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars)

	// Matched path: 1 row in artist_similarity for the in-library candidate.
	var matchedCount int
	if err := f.pool.QueryRow(ctx,
		"SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1",
		f.artist.ID,
	).Scan(&matchedCount); err != nil {
		t.Fatalf("count matched: %v", err)
	}
	if matchedCount != 1 {
		t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount)
	}

	// Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped.
	var unmatchedCount int
	if err := f.pool.QueryRow(ctx,
		"SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1",
		f.artist.ID,
	).Scan(&unmatchedCount); err != nil {
		t.Fatalf("count unmatched: %v", err)
	}
	if unmatchedCount != 3 {
		t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount)
	}

	// Verify a specific row's name + score round-tripped correctly.
	var name string
	var score float64
	if err := f.pool.QueryRow(ctx,
		"SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2",
		f.artist.ID, "out-mbid-1",
	).Scan(&name, &score); err != nil {
		t.Fatalf("fetch out-mbid-1: %v", err)
	}
	if name != "Outsider One" || score != 0.85 {
		t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score)
	}

	_ = inLibArtist
}
