package playlists_test

import (
	"context"
	"io"
	"log/slog"
	"sync"
	"testing"
	"time"

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

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

func discardLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

// seedPlayEvent inserts a play_events row with the given was_skipped flag,
// at startedAt, for the given user/track. Uses raw SQL because the sqlc
// `InsertPlayEvent` doesn't accept was_skipped (that's set by an UPDATE).
func seedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool) {
	t.Helper()
	// play_events.session_id has a FK to play_sessions; create a parent
	// session in the same statement (a random UUID violates the FK).
	_, err := pool.Exec(context.Background(), `
		WITH s AS (
			INSERT INTO play_sessions (user_id, started_at, last_event_at)
			VALUES ($1, $3, $3)
			RETURNING id
		)
		INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
		SELECT $1, $2, s.id, $3, $4 FROM s
	`, userID, trackID, startedAt, wasSkipped)
	if err != nil {
		t.Fatalf("seed play_event: %v", err)
	}
}

// seedQuarantine inserts a lidarr_quarantine row for (user, track).
func seedQuarantine(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
	t.Helper()
	_, err := pool.Exec(context.Background(), `
		INSERT INTO lidarr_quarantine (user_id, track_id, reason)
		VALUES ($1, $2, 'other')
	`, userID, trackID)
	if err != nil {
		t.Fatalf("seed quarantine: %v", err)
	}
}

// seedActiveLibrary creates a user with N played tracks across M artists,
// all within the last 7 days. Returns the user and the slice of tracks.
// Each track gets ≥3 unskipped plays so PickSeedArtists has a real signal.
func seedActiveLibrary(t *testing.T, pool *pgxpool.Pool, name string, numArtists, tracksPerArtist int) (dbq.User, []dbq.Track) {
	t.Helper()
	u := seedUser(t, pool, name)
	now := time.Now().UTC()
	var allTracks []dbq.Track
	for a := 0; a < numArtists; a++ {
		artistName := name + "-artist-" + string(rune('A'+a))
		for k := 0; k < tracksPerArtist; k++ {
			tk := seedTrack(t, pool, name+"-track-"+string(rune('A'+a))+string(rune('0'+k)), artistName)
			allTracks = append(allTracks, tk)
			// 3 unskipped plays per track to populate engagement stats.
			for p := 0; p < 3; p++ {
				seedPlayEvent(t, pool, u.ID, tk.ID, now.Add(-time.Duration(a*10+k+p)*time.Hour), false)
			}
		}
	}
	return u, allTracks
}

func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
	pool := newPool(t)
	logger := discardLogger()
	u, _ := seedActiveLibrary(t, pool, "act1", 4, 3)
	if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("build: %v", err)
	}

	rows, err := dbq.New(pool).ListPlaylistsByUserAndKind(context.Background(), dbq.ListPlaylistsByUserAndKindParams{
		UserID: u.ID, Column2: "system",
	})
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(rows) == 0 {
		t.Fatalf("expected at least one system playlist; got 0")
	}

	var hasForYou, hasSongsLike bool
	for _, r := range rows {
		if r.Kind != "system" {
			t.Errorf("row %s: kind=%q want 'system'", uuidString(r.ID), r.Kind)
		}
		if r.SystemVariant == nil {
			t.Errorf("row %s: nil system_variant", uuidString(r.ID))
			continue
		}
		switch *r.SystemVariant {
		case "for_you":
			hasForYou = true
			if r.SeedArtistID.Valid {
				t.Errorf("for_you row should have NULL seed_artist_id; got valid")
			}
		case "songs_like_artist":
			hasSongsLike = true
			if !r.SeedArtistID.Valid {
				t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
			}
		case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
			// Discover + the #411 discovery mixes are all seedless —
			// no seed_artist_id required.
		default:
			t.Errorf("unknown system_variant=%q", *r.SystemVariant)
		}
		if r.TrackCount == 0 {
			t.Errorf("row %s: empty track_count", uuidString(r.ID))
		}
		// For-You / Discover / the discovery mixes are sized to ~100
		// (#352/#411); songs_like_artist stays at systemMixLength (25).
		if r.TrackCount > 100 {
			t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount)
		}
	}
	if !hasForYou {
		t.Error("expected a for_you playlist")
	}
	if !hasSongsLike {
		t.Error("expected at least one songs_like_artist playlist")
	}
}

func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
	// #1255: the seed-artist query's old hard 7-day window emptied the
	// pool after a quiet week, and the atomic-replace build then deleted
	// every "Songs like X" mix. With the tiered fallback, plays that are
	// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
	// and the built tracks carry the tier2 stamp so metrics can compare
	// stale-seeded mixes against fresh ones.
	pool := newPool(t)
	logger := discardLogger()
	u := seedUser(t, pool, "stale1")
	old := time.Now().UTC().Add(-20 * 24 * time.Hour)
	for a := 0; a < 4; a++ {
		for k := 0; k < 3; k++ {
			tk := seedTrack(t, pool,
				"stale1-track-"+string(rune('A'+a))+string(rune('0'+k)),
				"stale1-artist-"+string(rune('A'+a)))
			for p := 0; p < 3; p++ {
				seedPlayEvent(t, pool, u.ID, tk.ID,
					old.Add(-time.Duration(a*10+k+p)*time.Hour), false)
			}
		}
	}

	if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("build: %v", err)
	}

	rows, err := pool.Query(context.Background(), `
		SELECT DISTINCT COALESCE(pt.pick_kind, '<null>')
		  FROM playlist_tracks pt
		  JOIN playlists p ON p.id = pt.playlist_id
		 WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
	`, u.ID)
	if err != nil {
		t.Fatalf("query pick_kinds: %v", err)
	}
	defer rows.Close()
	var kinds []string
	for rows.Next() {
		var k string
		if err := rows.Scan(&k); err != nil {
			t.Fatalf("scan: %v", err)
		}
		kinds = append(kinds, k)
	}
	if len(kinds) == 0 {
		t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)")
	}
	if len(kinds) != 1 || kinds[0] != "tier2" {
		t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds)
	}
}

func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
	pool := newPool(t)
	logger := discardLogger()
	u, tracks := seedActiveLibrary(t, pool, "qact", 3, 3)

	quarantined := tracks[0]
	seedQuarantine(t, pool, u.ID, quarantined.ID)

	if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("build: %v", err)
	}

	// Inspect playlist_tracks for the user's system playlists; quarantined
	// track must not appear.
	rows, err := pool.Query(context.Background(), `
		SELECT pt.track_id
		  FROM playlist_tracks pt
		  JOIN playlists p ON p.id = pt.playlist_id
		 WHERE p.user_id = $1 AND p.kind = 'system'
	`, u.ID)
	if err != nil {
		t.Fatalf("query playlist_tracks: %v", err)
	}
	defer rows.Close()
	for rows.Next() {
		var tid pgtype.UUID
		if err := rows.Scan(&tid); err != nil {
			t.Fatalf("scan: %v", err)
		}
		if tid.Bytes == quarantined.ID.Bytes {
			t.Fatalf("quarantined track %s appeared in a system playlist", uuidString(quarantined.ID))
		}
	}
}

func TestBuildSystemPlaylists_AtomicReplace(t *testing.T) {
	pool := newPool(t)
	logger := discardLogger()
	u, _ := seedActiveLibrary(t, pool, "atomic", 3, 3)
	ctx := context.Background()

	// First build.
	if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("first build: %v", err)
	}
	var ids1 []pgtype.UUID
	rows1, _ := dbq.New(pool).ListPlaylistsByUserAndKind(ctx, dbq.ListPlaylistsByUserAndKindParams{
		UserID: u.ID, Column2: "system",
	})
	for _, r := range rows1 {
		ids1 = append(ids1, r.ID)
	}
	if len(ids1) == 0 {
		t.Skip("no system playlists from first build; can't verify replace")
	}

	// Second build — old rows must be gone, replaced with fresh ones.
	if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("second build: %v", err)
	}
	rows2, _ := dbq.New(pool).ListPlaylistsByUserAndKind(ctx, dbq.ListPlaylistsByUserAndKindParams{
		UserID: u.ID, Column2: "system",
	})
	for _, r := range rows2 {
		for _, oldID := range ids1 {
			if r.ID.Bytes == oldID.Bytes {
				t.Errorf("playlist id %s persisted across rebuilds — atomic replace failed", uuidString(oldID))
			}
		}
	}
}

func TestBuildSystemPlaylists_Concurrency(t *testing.T) {
	pool := newPool(t)
	logger := discardLogger()
	u, _ := seedActiveLibrary(t, pool, "conc", 3, 3)
	ctx := context.Background()
	now := time.Now().UTC()

	var wg sync.WaitGroup
	wg.Add(2)
	dataDir := t.TempDir()
	go func() { defer wg.Done(); _ = playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, dataDir) }()
	go func() { defer wg.Done(); _ = playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, now, dataDir) }()
	wg.Wait()

	// Exactly one run must have completed; in_flight must be false either way.
	run, err := dbq.New(pool).GetSystemPlaylistRun(ctx, u.ID)
	if err != nil {
		t.Fatalf("get run: %v", err)
	}
	if run.InFlight {
		t.Errorf("in_flight should be false after both calls return")
	}
	if !run.LastRunAt.Valid {
		t.Errorf("last_run_at should be set after at least one successful build")
	}
}

func TestBuildSystemPlaylists_DailyNonceDeterminism(t *testing.T) {
	pool := newPool(t)
	logger := discardLogger()
	u, _ := seedActiveLibrary(t, pool, "nonce", 4, 5)
	ctx := context.Background()
	day1 := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)

	dataDir := t.TempDir()
	if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, day1, dataDir); err != nil {
		t.Fatalf("build day1 first: %v", err)
	}
	snap1 := snapshotSystemTracks(t, pool, u.ID)

	if err := playlists.BuildSystemPlaylists(ctx, pool, logger, u.ID, day1, dataDir); err != nil {
		t.Fatalf("build day1 second: %v", err)
	}
	snap2 := snapshotSystemTracks(t, pool, u.ID)

	if !equalSnapshots(snap1, snap2) {
		t.Errorf("same-day rebuild produced different track lists")
	}
}

// snapshotSystemTracks returns a per-playlist-name → []track_id mapping
// for the user's system playlists, in playlist_tracks position order.
func snapshotSystemTracks(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) map[string][]string {
	t.Helper()
	rows, err := pool.Query(context.Background(), `
		SELECT p.name, pt.track_id
		  FROM playlists p
		  JOIN playlist_tracks pt ON pt.playlist_id = p.id
		 WHERE p.user_id = $1 AND p.kind = 'system'
		 ORDER BY p.name, pt.position
	`, userID)
	if err != nil {
		t.Fatalf("snapshot query: %v", err)
	}
	defer rows.Close()
	out := map[string][]string{}
	for rows.Next() {
		var name string
		var tid pgtype.UUID
		if err := rows.Scan(&name, &tid); err != nil {
			t.Fatalf("snapshot scan: %v", err)
		}
		out[name] = append(out[name], uuidString(tid))
	}
	return out
}

func equalSnapshots(a, b map[string][]string) bool {
	if len(a) != len(b) {
		return false
	}
	for k, va := range a {
		vb, ok := b[k]
		if !ok || len(va) != len(vb) {
			return false
		}
		for i := range va {
			if va[i] != vb[i] {
				return false
			}
		}
	}
	return true
}

func TestListActiveUsersForSystemPlaylists(t *testing.T) {
	pool := newPool(t)
	ctx := context.Background()
	activeU, tracks := seedActiveLibrary(t, pool, "active", 1, 1)
	inactiveU := seedUser(t, pool, "inactive")
	// inactive user has plays older than 7 days.
	seedPlayEvent(t, pool, inactiveU.ID, tracks[0].ID, time.Now().UTC().AddDate(0, 0, -30), false)

	ids, err := dbq.New(pool).ListActiveUsersForSystemPlaylists(ctx)
	if err != nil {
		t.Fatalf("list: %v", err)
	}

	var sawActive, sawInactive bool
	for _, id := range ids {
		if id.Bytes == activeU.ID.Bytes {
			sawActive = true
		}
		if id.Bytes == inactiveU.ID.Bytes {
			sawInactive = true
		}
	}
	if !sawActive {
		t.Error("active user not returned")
	}
	if sawInactive {
		t.Error("inactive user (plays > 7d old) was returned")
	}
}

func TestBuildSystemPlaylists_DiscoverColdStart(t *testing.T) {
	// User has no plays / likes → Discover's dormant + cross-user
	// buckets are empty (or minimal), all slots come from random unheard.
	pool := newPool(t)
	logger := discardLogger()
	u, _ := seedActiveLibrary(t, pool, "discover_cold", 4, 5)
	// seedActiveLibrary seeds 3 plays per track; delete them so dormant
	// bucket (< 10 plays per artist) and random bucket both operate on
	// a true cold-start user with no prior play history.
	if _, err := pool.Exec(context.Background(),
		"DELETE FROM play_events WHERE user_id = $1", u.ID); err != nil {
		t.Fatalf("clear plays: %v", err)
	}

	if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
		t.Fatalf("build: %v", err)
	}

	rows, err := dbq.New(pool).ListPlaylistsByUserAndKind(context.Background(), dbq.ListPlaylistsByUserAndKindParams{
		UserID: u.ID, Column2: "system",
	})
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	var foundDiscover bool
	for _, r := range rows {
		if r.SystemVariant != nil && *r.SystemVariant == "discover" {
			foundDiscover = true
			if r.TrackCount == 0 {
				t.Errorf("Discover playlist has zero tracks on cold start (random bucket should fill)")
			}
			if r.TrackCount > 100 {
				t.Errorf("Discover track_count = %d, want <= 100", r.TrackCount)
			}
		}
	}
	if !foundDiscover {
		t.Errorf("expected a 'discover' system playlist; got variants: %v", systemVariantsOf(rows))
	}
}

func systemVariantsOf(rows []dbq.ListPlaylistsByUserAndKindRow) []string {
	out := make([]string, 0, len(rows))
	for _, r := range rows {
		if r.SystemVariant != nil {
			out = append(out, *r.SystemVariant)
		}
	}
	return out
}

func TestStartupRecoveryClearsStaleInFlight(t *testing.T) {
	pool := newPool(t)
	ctx := context.Background()
	u := seedUser(t, pool, "stale")
	// Seed a stale in_flight=true row.
	if _, err := pool.Exec(ctx, `INSERT INTO system_playlist_runs (user_id, in_flight) VALUES ($1, true)`, u.ID); err != nil {
		t.Fatalf("seed runs: %v", err)
	}

	if err := dbq.New(pool).ClearStaleSystemPlaylistInFlight(ctx); err != nil {
		t.Fatalf("clear: %v", err)
	}

	run, err := dbq.New(pool).GetSystemPlaylistRun(ctx, u.ID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if run.InFlight {
		t.Errorf("in_flight should be false after ClearStale; got true")
	}
}
