package playevents

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"
)

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)
	if _, err := pool.Exec(context.Background(),
		"TRUNCATE play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
		t.Fatalf("truncate: %v", err)
	}
	return pool
}

type fixture struct {
	pool  *pgxpool.Pool
	q     *dbq.Queries
	w     *Writer
	user  pgtype.UUID
	track pgtype.UUID
}

func newFixture(t *testing.T, durationMs int32) fixture {
	t.Helper()
	pool := testPool(t)
	q := dbq.New(pool)
	u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
		Username: "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
	})
	if err != nil {
		t.Fatalf("user: %v", err)
	}
	a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Artist", SortName: "Artist"})
	if err != nil {
		t.Fatalf("artist: %v", err)
	}
	al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Album", SortTitle: "Album", ArtistID: a.ID})
	if err != nil {
		t.Fatalf("album: %v", err)
	}
	tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "Track", AlbumID: al.ID, ArtistID: a.ID,
		FilePath:   "/tmp/track-fixture.flac",
		DurationMs: durationMs,
	})
	if err != nil {
		t.Fatalf("track: %v", err)
	}
	return fixture{
		pool:  pool,
		q:     q,
		w:     NewWriter(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000),
		user:  u.ID,
		track: tr.ID,
	}
}

func TestRecordPlayStarted_InsertsRow(t *testing.T) {
	f := newFixture(t, 200_000)
	now := time.Now().UTC()
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	if !res.PlayEventID.Valid {
		t.Fatalf("play_event_id invalid")
	}
	got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.EndedAt.Valid {
		t.Errorf("ended_at should be NULL right after start")
	}
	if got.WasSkipped {
		t.Errorf("was_skipped should default false")
	}
}

func TestRecordPlayStarted_AutoClosesPriorOpenRow(t *testing.T) {
	f := newFixture(t, 200_000)
	t1 := time.Now().UTC()
	first, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	if err != nil {
		t.Fatalf("first: %v", err)
	}
	t2 := t1.Add(45 * time.Second)
	_, err = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
	if err != nil {
		t.Fatalf("second: %v", err)
	}
	prior, err := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if !prior.EndedAt.Valid {
		t.Errorf("prior should be closed")
	}
	if !prior.WasSkipped {
		t.Errorf("prior should be marked was_skipped=true (auto-close convention)")
	}
	if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
		t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
	}
}

func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
	f := newFixture(t, 60_000) // 60s track
	t1 := time.Now().UTC()
	first, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	// Start a second play 5 minutes later — elapsed > track duration.
	t2 := t1.Add(5 * time.Minute)
	_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2)
	prior, _ := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
	if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 60_000 {
		t.Errorf("duration_played_ms = %v, want capped at 60000", prior.DurationPlayedMs)
	}
}

func TestRecordPlayEnded_AppliesSkipRule_BothFail_NotSkip(t *testing.T) {
	f := newFixture(t, 200_000)
	t1 := time.Now().UTC()
	res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	// 15% completion (30_000ms), 30s duration -> 30s satisfies "duration >= 30s"
	// so the rule's AND fails -> NOT a skip.
	t2 := t1.Add(30 * time.Second)
	if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 30_000, t2); err != nil {
		t.Fatalf("RecordPlayEnded: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.WasSkipped {
		t.Errorf("was_skipped = true, want false (duration_played_ms == 30000 fails the AND)")
	}
}

func TestRecordPlayEnded_AppliesSkipRule_BothPass_IsSkip(t *testing.T) {
	f := newFixture(t, 200_000)
	t1 := time.Now().UTC()
	res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	// 5% completion (10_000ms), 10s duration -> both conditions hold -> SKIP.
	t2 := t1.Add(10 * time.Second)
	if err := f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 10_000, t2); err != nil {
		t.Fatalf("RecordPlayEnded: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if !got.WasSkipped {
		t.Errorf("expected was_skipped=true")
	}
	rows, err := f.pool.Query(context.Background(),
		"SELECT id FROM skip_events WHERE user_id=$1 AND track_id=$2", f.user, f.track)
	if err != nil {
		t.Fatalf("query skips: %v", err)
	}
	defer rows.Close()
	count := 0
	for rows.Next() {
		count++
	}
	if count != 1 {
		t.Errorf("skip_events count = %d, want 1", count)
	}
}

func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
	f := newFixture(t, 200_000) // 200s track
	t1 := time.Now().UTC()
	res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	// 50% completion (100_000ms), 5s duration. Completion fails the "< 0.5"
	// check — so AND fails, NOT a skip.
	t2 := t1.Add(5 * time.Second)
	_ = f.w.RecordPlayEnded(context.Background(), res.PlayEventID, 100_000, t2)
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.WasSkipped {
		t.Errorf("expected was_skipped=false at completion >= 0.5")
	}
}

func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
	f := newFixture(t, 200_000)
	t1 := time.Now().UTC()
	res, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	// User-initiated skip at 90% — overrides the rule, always treated as skip.
	t2 := t1.Add(180 * time.Second)
	if err := f.w.RecordPlaySkipped(context.Background(), res.PlayEventID, 180_000, t2); err != nil {
		t.Fatalf("RecordPlaySkipped: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if !got.WasSkipped {
		t.Errorf("RecordPlaySkipped must always set was_skipped=true")
	}
}

func TestRecordSyntheticCompletedPlay_WritesBothRows(t *testing.T) {
	f := newFixture(t, 200_000)
	now := time.Now().UTC()
	if err := f.w.RecordSyntheticCompletedPlay(context.Background(), f.user, f.track, "feishin", now); err != nil {
		t.Fatalf("synthetic: %v", err)
	}
	rows, _ := f.pool.Query(context.Background(),
		"SELECT id FROM play_events WHERE user_id=$1 AND ended_at IS NOT NULL", f.user)
	defer rows.Close()
	count := 0
	for rows.Next() {
		count++
	}
	if count != 1 {
		t.Errorf("closed play_events count = %d, want 1", count)
	}
}

func TestRecordPlayStarted_PersistsSessionVector_Seed(t *testing.T) {
	f := newFixture(t, 200_000)
	now := time.Now().UTC()
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if got.SessionVectorAtPlay == nil {
		t.Fatalf("session_vector_at_play is NULL")
	}
	var vec map[string]any
	if err := json.Unmarshal(got.SessionVectorAtPlay, &vec); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if seed, _ := vec["seed"].(bool); !seed {
		t.Errorf("seed = %v, want true (first play in session)", vec["seed"])
	}
	if artists, _ := vec["artists"].([]any); len(artists) != 0 {
		t.Errorf("artists not empty: %v", artists)
	}
}

func TestRecordPlayStarted_PersistsSessionVector_Populated(t *testing.T) {
	f := newFixture(t, 200_000)
	// Seed 4 prior plays with the same track (reusing the fixture's single
	// track is fine — vector dedup means we'll see 1 artist regardless).
	t0 := time.Now().UTC().Add(-1 * time.Hour)
	for i := 0; i < 4; i++ {
		at := t0.Add(time.Duration(i) * time.Minute)
		_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	}
	// 5th play — should see vector with 4 prior tracks.
	at := t0.Add(10 * time.Minute)
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", at)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.SessionVectorAtPlay == nil {
		t.Fatal("session_vector_at_play is NULL")
	}
	var vec map[string]any
	_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
	if seed, _ := vec["seed"].(bool); seed {
		t.Errorf("seed = true after 4 prior tracks, want false")
	}
	recentIDs, _ := vec["recent_track_ids"].([]any)
	if len(recentIDs) != 4 {
		t.Errorf("recent_track_ids len = %d, want 4", len(recentIDs))
	}
}

func TestRecordPlayStarted_VectorScopedToSession(t *testing.T) {
	f := newFixture(t, 200_000)
	// Play 1: at t=0.
	t0 := time.Now().UTC().Add(-2 * time.Hour)
	_, _ = f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t0)
	// Play 2: t = t0 + 31 minutes (exceeds 30-min session timeout).
	t1 := t0.Add(31 * time.Minute)
	res, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
	if err != nil {
		t.Fatalf("RecordPlayStarted: %v", err)
	}
	got, _ := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
	if got.SessionVectorAtPlay == nil {
		t.Fatal("vector NULL")
	}
	var vec map[string]any
	_ = json.Unmarshal(got.SessionVectorAtPlay, &vec)
	if seed, _ := vec["seed"].(bool); !seed {
		t.Errorf("seed = false in fresh session, want true")
	}
	recentIDs, _ := vec["recent_track_ids"].([]any)
	if len(recentIDs) != 0 {
		t.Errorf("recent_track_ids has %d entries from prior session, want 0", len(recentIDs))
	}
}
