package api

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

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

func callRadio(h *handlers, user dbq.User, query string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodGet, "/api/radio?"+query, nil)
	req = withUser(req, user)
	w := httptest.NewRecorder()
	h.handleRadio(w, req)
	return w
}

func TestHandleRadio_ColdStart_OnlySeedReturned(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)

	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	if len(resp.Tracks) != 1 {
		t.Fatalf("len = %d, want 1", len(resp.Tracks))
	}
	if resp.Tracks[0].Title != "Seed" {
		t.Errorf("seed not first: %v", resp.Tracks[0].Title)
	}
}

func TestHandleRadio_Typical_SeedFirstPlusPicks(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	for i := 2; i <= 6; i++ {
		seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
	}

	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	if len(resp.Tracks) != 6 {
		t.Fatalf("len = %d, want 6 (seed + 5 picks)", len(resp.Tracks))
	}
	if resp.Tracks[0].Title != "Seed" {
		t.Errorf("seed not at index 0: %v", resp.Tracks[0].Title)
	}
}

func TestHandleRadio_UnknownSeedIs404(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "x", false)
	w := callRadio(h, user, "seed_track=00000000-0000-0000-0000-000000000000")
	if w.Code != http.StatusNotFound {
		t.Errorf("status = %d", w.Code)
	}
}

func TestHandleRadio_BadSeedIs400(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "x", false)
	w := callRadio(h, user, "seed_track=not-a-uuid")
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d", w.Code)
	}
}

func TestHandleRadio_MissingSeedIs400(t *testing.T) {
	h, pool := testHandlers(t)
	user := seedUser(t, pool, "alice", "x", false)
	w := callRadio(h, user, "")
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d", w.Code)
	}
}

func TestHandleRadio_BadLimitIs400(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=0")
	if w.Code != http.StatusBadRequest {
		t.Errorf("limit=0 status = %d", w.Code)
	}
	w = callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=-1")
	if w.Code != http.StatusBadRequest {
		t.Errorf("limit=-1 status = %d", w.Code)
	}
}

func TestHandleRadio_LimitClampedToMax(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	for i := 2; i <= 6; i++ {
		seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
	}
	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=99999")
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	// We only have 6 tracks; clamped limit (max 200) returns all 6.
	if len(resp.Tracks) != 6 {
		t.Errorf("len = %d, want 6", len(resp.Tracks))
	}
}

func TestHandleRadio_WithSimilarityPool_RanksLBSimilarHigher(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	target := seedTrack(t, pool, album.ID, artist.ID, "Target", 2, 100_000)
	control := seedTrack(t, pool, album.ID, artist.ID, "Control", 3, 100_000)
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, 0.95, 'listenbrainz')`,
		seed.ID, target.ID); err != nil {
		t.Fatalf("insert sim: %v", err)
	}
	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)+"&limit=3")
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	targetIdx, controlIdx := -1, -1
	for i, tr := range resp.Tracks {
		if tr.ID == uuidToString(target.ID) {
			targetIdx = i
		}
		if tr.ID == uuidToString(control.ID) {
			controlIdx = i
		}
	}
	if targetIdx < 0 || controlIdx < 0 {
		t.Fatalf("target=%d control=%d (one not present)", targetIdx, controlIdx)
	}
	if targetIdx >= controlIdx {
		t.Errorf("target ranked at %d, control at %d — LB-similar should rank higher", targetIdx, controlIdx)
	}
}

func TestHandleRadio_ExcludeParamFiltersOut(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	excluded := seedTrack(t, pool, album.ID, artist.ID, "Excluded", 2, 100_000)
	for i := 3; i <= 6; i++ {
		seedTrack(t, pool, album.ID, artist.ID, "T"+string(rune('0'+i)), i, 100_000)
	}
	q := "seed_track=" + uuidToString(seed.ID) + "&limit=10&exclude=" + uuidToString(excluded.ID)
	w := callRadio(h, user, q)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	for _, tr := range resp.Tracks {
		if tr.ID == uuidToString(excluded.ID) {
			t.Error("excluded track present in response")
		}
	}
}

func TestHandleRadio_ExcludeParamMalformedSkipped(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	other := seedTrack(t, pool, album.ID, artist.ID, "Other", 2, 100_000)
	q := "seed_track=" + uuidToString(seed.ID) + "&limit=5&exclude=not-a-uuid," + uuidToString(other.ID)
	w := callRadio(h, user, q)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d (malformed UUID should be silently dropped)", w.Code)
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	for _, tr := range resp.Tracks {
		if tr.ID == uuidToString(other.ID) {
			t.Error("other track should still be excluded after dropping malformed entry")
		}
	}
}

func TestHandleRadio_SeedAlwaysAtIndex0(t *testing.T) {
	// Defensive: even when the similarity pool returns no candidates,
	// the seed track must still be the first track in the response.
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)
	artist := seedArtist(t, pool, "X")
	album := seedAlbum(t, pool, artist.ID, "X", 1990)
	seed := seedTrack(t, pool, album.ID, artist.ID, "Seed", 1, 100_000)
	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var resp RadioResponse
	_ = json.Unmarshal(w.Body.Bytes(), &resp)
	if len(resp.Tracks) == 0 || resp.Tracks[0].ID != uuidToString(seed.ID) {
		t.Errorf("seed not at index 0: %v", resp.Tracks)
	}
}

func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	user := seedUser(t, pool, "alice", "x", false)

	// Two artists in distinct genres, so we can construct a "rock vibe" session
	// and a contextual match.
	rockArtist := seedArtist(t, pool, "RockArtist")
	rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020)
	jazzArtist := seedArtist(t, pool, "JazzArtist")
	jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020)

	// Seed track is unrelated to both (don't want it to dominate scoring).
	popArtist := seedArtist(t, pool, "PopArtist")
	popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020)
	seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000)

	// Target: a rock track. Control: a jazz track. Both will be scored.
	target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock")
	control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz")
	_ = control // present in DB; we look it up by title in the response

	// Build the user's "rock vibe" current context: insert an open play_session
	// with a play_event whose session_vector_at_play matches the rock vibe.
	rockVec := recommendation.SessionVector{
		Artists: []string{rockArtist.ID.String()},
		Tags:    map[string]int{"rock": 3},
	}
	rockVecJSON, err := json.Marshal(rockVec)
	if err != nil {
		t.Fatalf("marshal rockVec: %v", err)
	}
	insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON)

	// Insert a contextual_like on the target track whose stored vector matches
	// the rock vibe. Direct DB insert — we want full control over the vector
	// for this test.
	if _, err := pool.Exec(context.Background(),
		`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
		user.ID, target.ID, rockVecJSON); err != nil {
		t.Fatalf("insert contextual_like: %v", err)
	}

	// Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0)
	// means rankings are reproducible for this test.
	w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
	}
	var resp RadioResponse
	if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v", err)
	}

	targetIdx, controlIdx := -1, -1
	for i, tr := range resp.Tracks {
		if tr.Title == "Target" {
			targetIdx = i
		}
		if tr.Title == "Control" {
			controlIdx = i
		}
	}
	if targetIdx == -1 || controlIdx == -1 {
		t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks)
	}
	if targetIdx >= controlIdx {
		t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx)
	}
}
