package subsonic

import (
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"testing"
	"time"

	"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"
	"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)

func testStarPool(t *testing.T) (*pgxpool.Pool, dbq.User, dbq.Track, dbq.Album, dbq.Artist) {
	t.Helper()
	if testing.Short() {
		t.Skip("skipping 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)
	dbtest.ResetDB(t, pool)
	q := dbq.New(pool)
	u, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + "tester", PasswordHash: "x", ApiToken: "x", IsAdmin: false,
	})
	a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "X", SortName: "X"})
	al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "X", SortTitle: "X", ArtistID: a.ID})
	tr, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "Y", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/star.flac", DurationMs: 100_000,
	})
	return pool, u, tr, al, a
}

func newStarHandlers(pool *pgxpool.Pool) *mediaHandlers {
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
	return newMediaHandlers(pool, w, logger)
}

func TestHandleStar_TrackIDInsertsRow(t *testing.T) {
	pool, user, track, _, _ := testStarPool(t)
	m := newStarHandlers(pool)

	q := url.Values{}
	q.Set("id", uuidToID(track.ID))
	req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
	ctx := context.WithValue(req.Context(), userCtxKey, user)
	w := httptest.NewRecorder()
	m.handleStar(w, req.WithContext(ctx))

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
	if count != 1 {
		t.Errorf("count = %d", count)
	}
}

func TestHandleStar_AllThreeIDsAtOnce(t *testing.T) {
	pool, user, track, album, artist := testStarPool(t)
	m := newStarHandlers(pool)

	q := url.Values{}
	q.Set("id", uuidToID(track.ID))
	q.Set("albumId", uuidToID(album.ID))
	q.Set("artistId", uuidToID(artist.ID))
	req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
	ctx := context.WithValue(req.Context(), userCtxKey, user)
	w := httptest.NewRecorder()
	m.handleStar(w, req.WithContext(ctx))

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	var tCount, alCount, arCount int
	_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&tCount)
	_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_albums WHERE user_id=$1", user.ID).Scan(&alCount)
	_ = pool.QueryRow(context.Background(), "SELECT count(*) FROM general_likes_artists WHERE user_id=$1", user.ID).Scan(&arCount)
	if tCount != 1 || alCount != 1 || arCount != 1 {
		t.Errorf("counts: tracks=%d albums=%d artists=%d", tCount, alCount, arCount)
	}
}

func TestHandleStar_UnknownTrackReturnsError70(t *testing.T) {
	pool, user, _, _, _ := testStarPool(t)
	m := newStarHandlers(pool)

	q := url.Values{}
	q.Set("id", "00000000-0000-0000-0000-000000000000")
	req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
	ctx := context.WithValue(req.Context(), userCtxKey, user)
	w := httptest.NewRecorder()
	m.handleStar(w, req.WithContext(ctx))

	body := w.Body.String()
	// Subsonic error envelope returns 200 OK with status="failed" inside the body.
	// The exact code is 70 (data not found).
	if w.Code != http.StatusOK || !contains(body, `"code":70`) && !contains(body, `code="70"`) {
		t.Errorf("expected error 70 envelope, got status=%d body=%s", w.Code, body)
	}
}

func TestHandleStar_PartialMissingAtomic(t *testing.T) {
	pool, user, track, _, _ := testStarPool(t)
	m := newStarHandlers(pool)

	q := url.Values{}
	q.Set("id", uuidToID(track.ID))
	q.Set("albumId", "00000000-0000-0000-0000-000000000000")
	req := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
	ctx := context.WithValue(req.Context(), userCtxKey, user)
	w := httptest.NewRecorder()
	m.handleStar(w, req.WithContext(ctx))

	// Album missing → atomic refusal: track NOT starred.
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
	if count != 0 {
		t.Errorf("track was starred despite album miss; count = %d, want 0", count)
	}
}

func TestHandleUnstar_RemovesAndIsIdempotent(t *testing.T) {
	pool, user, track, _, _ := testStarPool(t)
	m := newStarHandlers(pool)

	// First star.
	q := url.Values{}
	q.Set("id", uuidToID(track.ID))
	star := httptest.NewRequest(http.MethodGet, "/rest/star?"+q.Encode(), nil)
	starCtx := context.WithValue(star.Context(), userCtxKey, user)
	starResp := httptest.NewRecorder()
	m.handleStar(starResp, star.WithContext(starCtx))

	// Unstar.
	un := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
	unCtx := context.WithValue(un.Context(), userCtxKey, user)
	unResp := httptest.NewRecorder()
	m.handleUnstar(unResp, un.WithContext(unCtx))

	if unResp.Code != http.StatusOK {
		t.Fatalf("unstar status = %d", unResp.Code)
	}
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM general_likes WHERE user_id=$1", user.ID).Scan(&count)
	if count != 0 {
		t.Errorf("count after unstar = %d, want 0", count)
	}

	// Idempotent — unstar again.
	un2 := httptest.NewRequest(http.MethodGet, "/rest/unstar?"+q.Encode(), nil)
	un2 = un2.WithContext(context.WithValue(un2.Context(), userCtxKey, user))
	un2Resp := httptest.NewRecorder()
	m.handleUnstar(un2Resp, un2)
	if un2Resp.Code != http.StatusOK {
		t.Errorf("second unstar status = %d", un2Resp.Code)
	}
}

func contains(s, substr string) bool {
	for i := 0; i+len(substr) <= len(s); i++ {
		if s[i:i+len(substr)] == substr {
			return true
		}
	}
	return false
}

func TestHandleGetStarred2_ReturnsAllThreeArrays(t *testing.T) {
	pool, user, track, album, artist := testStarPool(t)
	m := newStarHandlers(pool)
	q := dbq.New(pool)

	// Star one of each.
	_, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: user.ID, TrackID: track.ID})
	_ = q.LikeAlbum(context.Background(), dbq.LikeAlbumParams{UserID: user.ID, AlbumID: album.ID})
	_ = q.LikeArtist(context.Background(), dbq.LikeArtistParams{UserID: user.ID, ArtistID: artist.ID})

	req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
	ctx := context.WithValue(req.Context(), userCtxKey, user)
	w := httptest.NewRecorder()
	m.handleGetStarred2(w, req.WithContext(ctx))

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
	}
	// Subsonic JSON envelope: {"subsonic-response":{...,"starred2":{...}}}
	var raw map[string]any
	_ = json.Unmarshal(w.Body.Bytes(), &raw)
	resp, _ := raw["subsonic-response"].(map[string]any)
	starred, _ := resp["starred2"].(map[string]any)
	if starred == nil {
		t.Fatalf("missing starred2 envelope: %v", raw)
	}
	songs, _ := starred["song"].([]any)
	albums, _ := starred["album"].([]any)
	artists, _ := starred["artist"].([]any)
	if len(songs) != 1 || len(albums) != 1 || len(artists) != 1 {
		t.Errorf("counts: songs=%d albums=%d artists=%d", len(songs), len(albums), len(artists))
	}
}

func TestHandleGetStarred2_CrossUserIsolation(t *testing.T) {
	pool, alice, track, _, _ := testStarPool(t)
	q := dbq.New(pool)
	bob, _ := q.CreateUser(context.Background(), dbq.CreateUserParams{
		Username: dbtest.TestUserPrefix + "bob", PasswordHash: "x", ApiToken: "y", IsAdmin: false,
	})
	_, _ = q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: alice.ID, TrackID: track.ID})

	m := newStarHandlers(pool)
	req := httptest.NewRequest(http.MethodGet, "/rest/getStarred2?f=json", nil)
	ctx := context.WithValue(req.Context(), userCtxKey, bob)
	w := httptest.NewRecorder()
	m.handleGetStarred2(w, req.WithContext(ctx))

	var raw map[string]any
	_ = json.Unmarshal(w.Body.Bytes(), &raw)
	resp, _ := raw["subsonic-response"].(map[string]any)
	starred, _ := resp["starred2"].(map[string]any)
	songs, _ := starred["song"].([]any)
	if len(songs) != 0 {
		t.Errorf("bob's starred songs should be empty, got %d", len(songs))
	}
}

func TestHandleStar_DuringNowPlaying_WritesContextualLike(t *testing.T) {
	pool, user, track1, _, _ := testStarPool(t)
	// Add a second track so star refers to a different track than the one playing.
	q := dbq.New(pool)
	a, _ := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "Y", SortName: "Y"})
	al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "Y", SortTitle: "Y", ArtistID: a.ID})
	track2, _ := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "Star Me", AlbumID: al.ID, ArtistID: a.ID, FilePath: "/tmp/t2.flac", DurationMs: 100_000,
	})

	m := newStarHandlers(pool)
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	m.logger = logger

	// Start now-playing for track1 (creates open play_event with vector).
	q1 := url.Values{}
	q1.Set("id", uuidToID(track1.ID))
	q1.Set("submission", "false")
	scrobReq := httptest.NewRequest(http.MethodGet, "/rest/scrobble?"+q1.Encode(), nil)
	scrobCtx := context.WithValue(scrobReq.Context(), userCtxKey, user)
	scrobResp := httptest.NewRecorder()
	m.handleScrobble(scrobResp, scrobReq.WithContext(scrobCtx))
	if scrobResp.Code != http.StatusOK {
		t.Fatalf("scrobble: %d", scrobResp.Code)
	}

	// Star track2.
	q2 := url.Values{}
	q2.Set("id", uuidToID(track2.ID))
	starReq := httptest.NewRequest(http.MethodGet, "/rest/star?"+q2.Encode(), nil)
	starCtx := context.WithValue(starReq.Context(), userCtxKey, user)
	starResp := httptest.NewRecorder()
	m.handleStar(starResp, starReq.WithContext(starCtx))
	if starResp.Code != http.StatusOK {
		t.Fatalf("star: %d", starResp.Code)
	}

	// Verify contextual_likes row.
	var count int
	_ = pool.QueryRow(context.Background(),
		"SELECT count(*) FROM contextual_likes WHERE user_id=$1 AND track_id=$2", user.ID, track2.ID).Scan(&count)
	if count != 1 {
		t.Errorf("contextual_likes count = %d, want 1", count)
	}
}
