package playlists_test

import (
	"context"
	"fmt"
	"path/filepath"
	"testing"
	"time"

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

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

// seedTrackForArtist creates another track under an existing artist +
// album pair. seedTrack's mbid-less upsert never dedupes artists, and
// Deep Cuts' affinity rule needs several tracks sharing one artist_id.
func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track {
	t.Helper()
	track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: title, AlbumID: albumID, ArtistID: artistID,
		DurationMs: 1000,
		FilePath:   filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)),
		FileSize:   100, FileFormat: "mp3",
	})
	if err != nil {
		t.Fatalf("seed track for artist: %v", err)
	}
	return track
}

// seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by
// the Rediscover cooldown, which keys off rediscover-sourced skips.
func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) {
	t.Helper()
	_, 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, source)
		SELECT $1, $2, s.id, $3, $4, $5 FROM s
	`, userID, trackID, startedAt, wasSkipped, source)
	if err != nil {
		t.Fatalf("seed sourced play_event: %v", err)
	}
}

// idSet collapses candidate rows into a lookup by track id.
func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
	out := make(map[pgtype.UUID]bool, len(rows))
	for _, r := range rows {
		out[id(r)] = true
	}
	return out
}

// seedPlayWithDuration inserts a completed play with an explicit
// duration_played_ms — the "attempted >= 30s" queries key off it.
func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) {
	t.Helper()
	_, 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, duration_played_ms)
		SELECT $1, $2, s.id, $3, $4 FROM s
	`, userID, trackID, startedAt, durationMs)
	if err != nil {
		t.Fatalf("seed play with duration: %v", err)
	}
}

// seedAlbumForArtist creates a fresh album under an existing artist —
// the tiered album queries need several albums sharing one artist_id.
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID {
	t.Helper()
	al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
		Title: title, SortTitle: title, ArtistID: artistID,
	})
	if err != nil {
		t.Fatalf("seed album for artist: %v", err)
	}
	return al.ID
}

func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) {
	t.Helper()
	if _, err := pool.Exec(context.Background(),
		`UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil {
		t.Fatalf("backdate album: %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)`,
		userID, artistID); err != nil {
		t.Fatalf("like artist: %v", err)
	}
}

func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
	// #1257: eligibility used to count only unskipped plays, so a track
	// skipped repeatedly (and never finished) read as "barely heard".
	pool := newPool(t)
	q := dbq.New(pool)
	u := seedUser(t, pool, "dc1")
	now := time.Now().UTC()

	// Affinity artist: 5 non-skip plays on the heavy track.
	heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist")
	for p := 0; p < 5; p++ {
		seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false)
	}
	// Actively rejected: two skips, zero completed plays.
	rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID)
	seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true)
	seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true)
	// Genuinely unexplored.
	unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID)
	// One accidental skip stays below the threshold.
	oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID)
	seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true)

	rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{
		UserID: u.ID, Column2: "2026-07-03",
	})
	if err != nil {
		t.Fatalf("ListDeepCutsTracks: %v", err)
	}
	got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID })
	if got[rejected.ID] {
		t.Error("twice-skipped track qualified as a deep cut; skips must disqualify")
	}
	if !got[unheard.ID] {
		t.Error("unexplored track missing from deep cuts")
	}
	if !got[oneSkip.ID] {
		t.Error("single accidental skip must not banish a track")
	}
	if got[heavy.ID] {
		t.Error("heavily-played track must not be a deep cut")
	}
}

func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
	// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
	// days apart. The build date parameter anchors "today" so the wrap
	// is testable at any run date.
	pool := newPool(t)
	q := dbq.New(pool)
	u := seedUser(t, pool, "otd1")

	// Played Dec 28, 2025 — 6 circular days from the Jan 3 build date.
	winter := seedTrack(t, pool, "otd1-winter", "otd1-artist")
	seedPlayEvent(t, pool, u.ID, winter.ID,
		time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false)
	// Played mid-March — far outside the ±10-day window.
	spring := seedTrack(t, pool, "otd1-spring", "otd1-artist")
	seedPlayEvent(t, pool, u.ID, spring.ID,
		time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false)

	rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{
		UserID: u.ID, Column2: "2026-01-03",
	})
	if err != nil {
		t.Fatalf("ListOnThisDayTracks: %v", err)
	}
	got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID })
	if !got[winter.ID] {
		t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)")
	}
	if got[spring.ID] {
		t.Error("play ~70 days away must not qualify")
	}
}

func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
	// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
	// affinity albums; tier 2 = affinity albums from the wider 90-day
	// window; tier 3 = fresh additions without affinity; any attempted
	// (>=30s) track consumes the album out of the mix entirely.
	pool := newPool(t)
	q := dbq.New(pool)
	u := seedUser(t, pool, "nfy1")
	now := time.Now().UTC()

	// Affinity artist via a like; anchor track establishes the artist.
	anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX")
	likeArtist(t, pool, u.ID, anchor.ArtistID)
	backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window

	// Fresh affinity album → tier 1.
	freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID)
	freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID)
	// Affinity album added 60 days ago → tier 2.
	olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID)
	olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID)
	backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour))
	// Fresh album by an unrelated artist → tier 3.
	otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY")
	// Fresh affinity album with one attempted track → consumed, absent.
	consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID)
	consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID)
	seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000)
	// Affinity album added 120 days ago → outside every window, absent.
	ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID)
	ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID)
	backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour))

	rows, err := q.ListNewForYouTracks(context.Background(), u.ID)
	if err != nil {
		t.Fatalf("ListNewForYouTracks: %v", err)
	}
	tiers := map[pgtype.UUID]int32{}
	for _, r := range rows {
		tiers[r.ID] = r.Tier
	}
	if got := tiers[freshTrack.ID]; got != 1 {
		t.Errorf("fresh affinity album tier = %d, want 1", got)
	}
	if got := tiers[olderTrack.ID]; got != 2 {
		t.Errorf("60-day affinity album tier = %d, want 2", got)
	}
	if got := tiers[otherTrack.ID]; got != 3 {
		t.Errorf("fresh non-affinity album tier = %d, want 3", got)
	}
	if _, in := tiers[consumedTrack.ID]; in {
		t.Error("album with an attempted track must be consumed out of the mix")
	}
	if _, in := tiers[ancientTrack.ID]; in {
		t.Error("album added 120 days ago must be outside every window")
	}
}

func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
	// #1268: "attempted" is track-level at >=30s. A 2-second brush no
	// longer disqualifies an album; barely-attempted albums step back
	// to tier 2 with the attempted tracks themselves excluded.
	pool := newPool(t)
	q := dbq.New(pool)
	u := seedUser(t, pool, "fl1")
	now := time.Now().UTC()

	// Never touched → tier 1.
	untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA")
	// One 2-second accidental brush → still tier 1 (the headline bug).
	brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB")
	seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000)
	// 4-track album with one attempted (25%) → tier 2, attempted track
	// excluded, siblings present.
	barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC")
	barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID)
	barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID)
	barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID)
	seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000)
	// 2-track album with one attempted (50%) → past the brush
	// threshold, absent entirely.
	explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD")
	explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID)
	seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000)

	rows, err := q.ListFirstListensTracks(context.Background(), u.ID)
	if err != nil {
		t.Fatalf("ListFirstListensTracks: %v", err)
	}
	tiers := map[pgtype.UUID]int32{}
	for _, r := range rows {
		tiers[r.ID] = r.Tier
	}
	if got := tiers[untouched.ID]; got != 1 {
		t.Errorf("untouched album tier = %d, want 1", got)
	}
	if got := tiers[brushed.ID]; got != 1 {
		t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got)
	}
	if _, in := tiers[barely1.ID]; in {
		t.Error("the attempted track itself is not a first listen")
	}
	for _, tr := range []dbq.Track{barely2, barely3, barely4} {
		if got := tiers[tr.ID]; got != 2 {
			t.Errorf("sibling of attempted track tier = %d, want 2", got)
		}
	}
	if _, in := tiers[explored1.ID]; in {
		t.Error("half-attempted album must be excluded (attempted track)")
	}
	if _, in := tiers[explored2.ID]; in {
		t.Error("half-attempted album must be excluded (sibling)")
	}
}

func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
	// #1258: a skip on a rediscover-sourced play is a declined
	// invitation — the track sits out ~90 days instead of re-qualifying
	// the next day. Skips from other surfaces don't cool down, and the
	// cooldown expires.
	pool := newPool(t)
	q := dbq.New(pool)
	u := seedUser(t, pool, "rd1")
	now := time.Now().UTC()

	eligible := func(name string, playsAge time.Duration) dbq.Track {
		tk := seedTrack(t, pool, name, name+"-artist")
		for p := 0; p < 3; p++ {
			seedPlayEvent(t, pool, u.ID, tk.ID,
				now.Add(-playsAge-time.Duration(p)*time.Hour), false)
		}
		return tk
	}

	// Declined 5 days ago from Rediscover itself → cooling down.
	declined := eligible("rd1-declined", 60*24*time.Hour)
	seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover")
	// Skipped 5 days ago, but from a different surface → still eligible.
	otherSkip := eligible("rd1-otherskip", 60*24*time.Hour)
	seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio")
	// Declined 100 days ago → cooldown expired, eligible again.
	lapsed := eligible("rd1-lapsed", 120*24*time.Hour)
	seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover")

	rows, err := q.ListRediscoverTracks(context.Background(), u.ID)
	if err != nil {
		t.Fatalf("ListRediscoverTracks: %v", err)
	}
	got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID })
	if got[declined.ID] {
		t.Error("track declined from rediscover 5 days ago must be cooling down")
	}
	if !got[otherSkip.ID] {
		t.Error("skip from a non-rediscover surface must not cool the track down")
	}
	if !got[lapsed.ID] {
		t.Error("cooldown must expire after ~90 days")
	}
}
