package gc

import (
	"context"
	"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"
)

// testWorker constructs a Worker against MINSTREL_TEST_DATABASE_URL.
// Mirrors the api package's testHandlers pattern — skip when not in
// integration mode, migrate + reset, return the pool for the caller
// to seed.
func testWorker(t *testing.T) (*Worker, *pgxpool.Pool) {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping gc integration in -short mode")
	}
	dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("MINSTREL_TEST_DATABASE_URL not set")
	}
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	if err := db.Migrate(dsn, logger); 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 NewWorker(pool, logger), pool
}

// seedUser creates a minimal user row for tests that need play_events
// / play_sessions / scrobble_queue rows. Username is prefixed so
// dbtest.ResetDB cleans up between runs.
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
	t.Helper()
	u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
		Username:     dbtest.TestUserPrefix + name,
		PasswordHash: "test-hash",
		ApiToken:     "test-token-" + name,
		IsAdmin:      false,
	})
	if err != nil {
		t.Fatalf("CreateUser: %v", err)
	}
	return u.ID
}

func TestGcCloseStalePlayEvents_ClosesOnly24hOldRows(t *testing.T) {
	w, pool := testWorker(t)
	ctx := context.Background()
	userID := seedUser(t, pool, "alice")

	// Need a track + session to satisfy FKs on play_events.
	var trackID pgtype.UUID
	var artistID pgtype.UUID
	// `artists` has sort_name NOT NULL; mirror the title in sort_name
	// like every real insert site does (see api.search / library scan).
	if err := pool.QueryRow(ctx, `
		INSERT INTO artists (name, sort_name) VALUES ('A', 'A') RETURNING id
	`).Scan(&artistID); err != nil {
		t.Fatalf("seed artist row: %v", err)
	}
	var albumID pgtype.UUID
	if err := pool.QueryRow(ctx, `
		INSERT INTO albums (artist_id, title, sort_title) VALUES ($1, 'X', 'X') RETURNING id
	`, artistID).Scan(&albumID); err != nil {
		t.Fatalf("seed album: %v", err)
	}
	// `tracks` has file_size + file_format NOT NULL. Use plausible
	// stub values — the GC sweep doesn't read any of these columns,
	// it only joins on track_id.
	if err := pool.QueryRow(ctx, `
		INSERT INTO tracks (album_id, artist_id, title, file_path,
		                    duration_ms, file_size, file_format)
		VALUES ($1, $2, 'T', '/x.mp3', 180000, 4_000_000, 'mp3')
		RETURNING id
	`, albumID, artistID).Scan(&trackID); err != nil {
		t.Fatalf("seed track: %v", err)
	}
	var sessionID pgtype.UUID
	if err := pool.QueryRow(ctx, `
		INSERT INTO play_sessions (user_id, started_at, last_event_at)
		VALUES ($1, now() - interval '2 hours', now()) RETURNING id
	`, userID).Scan(&sessionID); err != nil {
		t.Fatalf("seed session: %v", err)
	}

	// Stale row (25h old, no ended_at) — should be closed.
	if _, err := pool.Exec(ctx, `
		INSERT INTO play_events (user_id, track_id, session_id, started_at)
		VALUES ($1, $2, $3, now() - interval '25 hours')
	`, userID, trackID, sessionID); err != nil {
		t.Fatalf("seed stale event: %v", err)
	}
	// Fresh row (1h old, no ended_at) — should be left alone.
	if _, err := pool.Exec(ctx, `
		INSERT INTO play_events (user_id, track_id, session_id, started_at)
		VALUES ($1, $2, $3, now() - interval '1 hour')
	`, userID, trackID, sessionID); err != nil {
		t.Fatalf("seed fresh event: %v", err)
	}

	w.tickOnce(ctx)

	var closedStale, openFresh bool
	if err := pool.QueryRow(ctx, `
		SELECT EXISTS(SELECT 1 FROM play_events
		              WHERE started_at < now() - interval '24 hours'
		                AND ended_at IS NOT NULL)
	`).Scan(&closedStale); err != nil {
		t.Fatalf("check stale: %v", err)
	}
	if err := pool.QueryRow(ctx, `
		SELECT EXISTS(SELECT 1 FROM play_events
		              WHERE started_at > now() - interval '2 hours'
		                AND ended_at IS NULL)
	`).Scan(&openFresh); err != nil {
		t.Fatalf("check fresh: %v", err)
	}
	if !closedStale {
		t.Errorf("stale play_events row not closed")
	}
	if !openFresh {
		t.Errorf("fresh play_events row was closed (should be left alone)")
	}
}

func TestGcClosePlaySessions_ClosesIdleAndEmptyStarvedSessions(t *testing.T) {
	w, pool := testWorker(t)
	ctx := context.Background()
	userID := seedUser(t, pool, "bob")

	// Idle session — has events, last_event_at 8h ago.
	if _, err := pool.Exec(ctx, `
		INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
		VALUES ($1, now() - interval '8 hours', now() - interval '8 hours', 5)
	`, userID); err != nil {
		t.Fatalf("seed idle session: %v", err)
	}
	// Empty-starved session — no events, started 2h ago.
	if _, err := pool.Exec(ctx, `
		INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
		VALUES ($1, now() - interval '2 hours', now() - interval '2 hours', 0)
	`, userID); err != nil {
		t.Fatalf("seed empty session: %v", err)
	}
	// Active session — recent last_event_at, has events.
	if _, err := pool.Exec(ctx, `
		INSERT INTO play_sessions (user_id, started_at, last_event_at, track_count)
		VALUES ($1, now() - interval '30 minutes', now() - interval '5 minutes', 3)
	`, userID); err != nil {
		t.Fatalf("seed active session: %v", err)
	}

	w.tickOnce(ctx)

	var closedCount, openCount int
	if err := pool.QueryRow(ctx, `
		SELECT count(*) FROM play_sessions
		 WHERE user_id = $1 AND ended_at IS NOT NULL
	`, userID).Scan(&closedCount); err != nil {
		t.Fatalf("count closed: %v", err)
	}
	if err := pool.QueryRow(ctx, `
		SELECT count(*) FROM play_sessions
		 WHERE user_id = $1 AND ended_at IS NULL
	`, userID).Scan(&openCount); err != nil {
		t.Fatalf("count open: %v", err)
	}
	if closedCount != 2 {
		t.Errorf("closed sessions = %d, want 2 (idle + empty-starved)", closedCount)
	}
	if openCount != 1 {
		t.Errorf("open sessions = %d, want 1 (active)", openCount)
	}
}

func TestGcResetStuckSystemPlaylistRuns(t *testing.T) {
	w, pool := testWorker(t)
	ctx := context.Background()
	stuckUser := seedUser(t, pool, "stuck")
	activeUser := seedUser(t, pool, "active")

	// Stuck row: in_flight, last_run_at 30 min ago.
	if _, err := pool.Exec(ctx, `
		INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
		VALUES ($1, now() - interval '30 minutes', true)
	`, stuckUser); err != nil {
		t.Fatalf("seed stuck run: %v", err)
	}
	// Active row: in_flight, last_run_at 2 min ago — still legitimate.
	if _, err := pool.Exec(ctx, `
		INSERT INTO system_playlist_runs (user_id, last_run_at, in_flight)
		VALUES ($1, now() - interval '2 minutes', true)
	`, activeUser); err != nil {
		t.Fatalf("seed active run: %v", err)
	}

	w.tickOnce(ctx)

	var stuckFlight, activeFlight bool
	if err := pool.QueryRow(ctx, `
		SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
	`, stuckUser).Scan(&stuckFlight); err != nil {
		t.Fatalf("read stuck row: %v", err)
	}
	if err := pool.QueryRow(ctx, `
		SELECT in_flight FROM system_playlist_runs WHERE user_id = $1
	`, activeUser).Scan(&activeFlight); err != nil {
		t.Fatalf("read active row: %v", err)
	}
	if stuckFlight {
		t.Errorf("stuck row still in_flight after sweep")
	}
	if !activeFlight {
		t.Errorf("active row was reset (should be left alone — only 2 min old)")
	}
}

func TestGcDeleteExpiredPasswordResets(t *testing.T) {
	w, pool := testWorker(t)
	ctx := context.Background()
	userID := seedUser(t, pool, "pwd")

	// Expired-unused (> 1h past expires_at) — should delete.
	if _, err := pool.Exec(ctx, `
		INSERT INTO password_resets (token, user_id, expires_at)
		VALUES ('expired-old', $1, now() - interval '2 hours')
	`, userID); err != nil {
		t.Fatalf("seed expired: %v", err)
	}
	// Used (> 7 days past used_at) — should delete.
	if _, err := pool.Exec(ctx, `
		INSERT INTO password_resets (token, user_id, expires_at, used_at)
		VALUES ('used-old', $1, now() - interval '8 days', now() - interval '8 days')
	`, userID); err != nil {
		t.Fatalf("seed used-old: %v", err)
	}
	// Active (expires in future) — should survive.
	if _, err := pool.Exec(ctx, `
		INSERT INTO password_resets (token, user_id, expires_at)
		VALUES ('active', $1, now() + interval '1 hour')
	`, userID); err != nil {
		t.Fatalf("seed active: %v", err)
	}
	// Recently used (< 7 days) — should survive for audit.
	if _, err := pool.Exec(ctx, `
		INSERT INTO password_resets (token, user_id, expires_at, used_at)
		VALUES ('used-recent', $1, now() - interval '1 hour', now() - interval '1 day')
	`, userID); err != nil {
		t.Fatalf("seed used-recent: %v", err)
	}

	w.tickOnce(ctx)

	var remaining []string
	rows, err := pool.Query(ctx, `SELECT token FROM password_resets WHERE user_id = $1`, userID)
	if err != nil {
		t.Fatalf("list remaining: %v", err)
	}
	defer rows.Close()
	for rows.Next() {
		var tok string
		if err := rows.Scan(&tok); err != nil {
			t.Fatalf("scan: %v", err)
		}
		remaining = append(remaining, tok)
	}
	wantSet := map[string]bool{"active": true, "used-recent": true}
	if len(remaining) != len(wantSet) {
		t.Errorf("remaining tokens = %v, want %v", remaining, []string{"active", "used-recent"})
	}
	for _, tok := range remaining {
		if !wantSet[tok] {
			t.Errorf("unexpected surviving token %q", tok)
		}
	}
}

// Verifies tickOnce doesn't blow up when the tables are completely
// empty — sweeps just no-op. Regression guard against an EXEC vs
// QUERY-row-count mismatch failing on zero rows.
func TestGcTickOnce_NoOpOnEmptyTables(t *testing.T) {
	w, _ := testWorker(t)
	w.tickOnce(context.Background())
}

// Sanity check on the Run() loop's cancel behaviour — we don't want
// to leave a goroutine spinning at test-runner exit. 10ms tick with
// an immediate cancel should return promptly.
func TestGcRun_HonoursContextCancel(t *testing.T) {
	w, _ := testWorker(t)
	w.tick = 10 * time.Millisecond
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		w.Run(ctx)
		close(done)
	}()
	cancel()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("Run did not return after cancel")
	}
}
