package api

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"

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

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

func TestAudioContentType(t *testing.T) {
	cases := map[string]string{
		"mp3":     "audio/mpeg",
		"MP3":     "audio/mpeg",
		"flac":    "audio/flac",
		"ogg":     "audio/ogg",
		"opus":    "audio/ogg",
		"m4a":     "audio/mp4",
		"aac":     "audio/aac",
		"wav":     "audio/wav",
		"unknown": "application/octet-stream",
		"":        "application/octet-stream",
	}
	for in, want := range cases {
		if got := audioContentType(in); got != want {
			t.Errorf("audioContentType(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestImageContentType(t *testing.T) {
	cases := map[string]string{
		"/a/cover.jpg":  "image/jpeg",
		"/a/cover.JPG":  "image/jpeg",
		"/a/cover.jpeg": "image/jpeg",
		"/a/cover.png":  "image/png",
		"/a/cover.webp": "image/webp",
		"/a/cover.gif":  "image/gif",
		"/a/cover.bmp":  "application/octet-stream",
		"/a/cover":      "application/octet-stream",
	}
	for in, want := range cases {
		if got := imageContentType(in); got != want {
			t.Errorf("imageContentType(%q) = %q, want %q", in, got, want)
		}
	}
}

func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil {
		t.Fatal(err)
	}
	got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "any-track.flac")))
	if got != filepath.Join(dir, "cover.jpg") {
		t.Errorf("got %q, want cover.jpg path", got)
	}
}

func TestFindSidecarCover_FallsBackToFolder(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil {
		t.Fatal(err)
	}
	got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "t.flac")))
	if got != filepath.Join(dir, "folder.png") {
		t.Errorf("got %q, want folder.png path", got)
	}
}

func TestFindSidecarCover_NoneFound(t *testing.T) {
	dir := t.TempDir()
	if got := coverart.FindSidecar(filepath.Dir(filepath.Join(dir, "t.flac"))); got != "" {
		t.Errorf("got %q, want empty string", got)
	}
}

func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) {
	_, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)

	dir := t.TempDir()
	explicit := filepath.Join(dir, "explicit.jpg")
	if err := os.WriteFile(explicit, []byte("e"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
		t.Fatalf("UPDATE albums: %v", err)
	}
	got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
	if got != explicit {
		t.Errorf("got %q, want %q", got, explicit)
	}
}

func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
	_, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)

	_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
	sidecar := filepath.Join(dir, "cover.jpg")
	if err := os.WriteFile(sidecar, []byte("s"), 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
		filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
		t.Fatalf("UPDATE albums: %v", err)
	}

	got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID))
	if got != sidecar {
		t.Errorf("got %q, want %q", got, sidecar)
	}
}

func TestResolveAlbumCoverPath_NoArt(t *testing.T) {
	_, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000)
	got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album)
	if got != "" {
		t.Errorf("got %q, want empty string", got)
	}
}

// fetchAlbum reloads the album row so tests see updates made via raw SQL.
func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album {
	t.Helper()
	a, err := dbq.New(pool).GetAlbumByID(context.Background(), id)
	if err != nil {
		t.Fatalf("GetAlbumByID: %v", err)
	}
	return a
}

func TestHandleGetCover_SidecarHappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)

	_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
	coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
		t.Errorf("Content-Type = %q", ct)
	}
	if !bytes.Equal(w.Body.Bytes(), coverBytes) {
		t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes)
	}
}

func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)

	dir := t.TempDir()
	explicit := filepath.Join(dir, "art.png")
	pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic
	if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	if ct := w.Header().Get("Content-Type"); ct != "image/png" {
		t.Errorf("Content-Type = %q", ct)
	}
	if !bytes.Equal(w.Body.Bytes(), pngBytes) {
		t.Errorf("body mismatch")
	}
}

func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)

	_, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3")
	sidecarBytes := []byte("sidecar")
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil {
		t.Fatal(err)
	}
	if _, err := pool.Exec(context.Background(),
		`UPDATE albums SET cover_art_path = $1 WHERE id = $2`,
		filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	if !bytes.Equal(w.Body.Bytes(), sidecarBytes) {
		t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes)
	}
}

func TestHandleGetCover_NoArt(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar

	req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
	var body errorBody
	_ = json.Unmarshal(w.Body.Bytes(), &body)
	if body.Error.Code != "not_found" || body.Error.Message != "cover not found" {
		t.Errorf("error body = %+v", body)
	}
}

func TestHandleGetCover_BadUUID(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
}

func TestHandleGetCover_UnknownAlbum(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	req := httptest.NewRequest(http.MethodGet,
		"/api/albums/00000000-0000-0000-0000-000000000001/cover", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
	var body errorBody
	_ = json.Unmarshal(w.Body.Bytes(), &body)
	if body.Error.Message != "album not found" {
		t.Errorf("message = %q, want \"album not found\"", body.Error.Message)
	}
}

// deterministicBytes returns n bytes whose values cycle 0..255. Using a
// fixed pattern (not crypto/rand) makes assertions easy and the test
// reproducible.
func deterministicBytes(n int) []byte {
	b := make([]byte, n)
	for i := range b {
		b[i] = byte(i % 256)
	}
	return b
}

func TestHandleGetStream_HappyPath(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	body := deterministicBytes(32 * 1024)
	track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")

	req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusOK {
		t.Fatalf("status = %d body = %s", w.Code, w.Body.String())
	}
	if ct := w.Header().Get("Content-Type"); ct != "audio/mpeg" {
		t.Errorf("Content-Type = %q", ct)
	}
	if ar := w.Header().Get("Accept-Ranges"); ar != "bytes" {
		t.Errorf("Accept-Ranges = %q", ar)
	}
	if cl := w.Header().Get("Content-Length"); cl != "32768" {
		t.Errorf("Content-Length = %q, want 32768", cl)
	}
	if !bytes.Equal(w.Body.Bytes(), body) {
		t.Errorf("body len = %d, want %d", len(w.Body.Bytes()), len(body))
	}
}

func TestHandleGetStream_RangeFromStart(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	body := deterministicBytes(32 * 1024)
	track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")

	req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
	req.Header.Set("Range", "bytes=0-99")
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusPartialContent {
		t.Fatalf("status = %d, want 206", w.Code)
	}
	if cr := w.Header().Get("Content-Range"); cr != "bytes 0-99/32768" {
		t.Errorf("Content-Range = %q", cr)
	}
	if got := w.Body.Bytes(); !bytes.Equal(got, body[:100]) {
		t.Errorf("body mismatch: len=%d, want 100", len(got))
	}
}

func TestHandleGetStream_RangeFromMiddleToEnd(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	body := deterministicBytes(32 * 1024)
	track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")

	req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
	req.Header.Set("Range", "bytes=32000-")
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusPartialContent {
		t.Fatalf("status = %d", w.Code)
	}
	if cr := w.Header().Get("Content-Range"); cr != "bytes 32000-32767/32768" {
		t.Errorf("Content-Range = %q", cr)
	}
	if got := w.Body.Bytes(); !bytes.Equal(got, body[32000:]) {
		t.Errorf("body mismatch: len=%d, want %d", len(got), len(body)-32000)
	}
}

func TestHandleGetStream_IfModifiedSinceReturns304(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	body := deterministicBytes(1024)
	track, _ := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", body, "mp3")

	info, err := os.Stat(track.FilePath)
	if err != nil {
		t.Fatal(err)
	}
	req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(track.ID)+"/stream", nil)
	req.Header.Set("If-Modified-Since", info.ModTime().UTC().Format(http.TimeFormat))
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusNotModified {
		t.Fatalf("status = %d, want 304", w.Code)
	}
	if got := w.Body.Len(); got != 0 {
		t.Errorf("body len = %d, want 0 for 304", got)
	}
}

func TestHandleGetStream_BadUUID(t *testing.T) {
	h, _ := testHandlers(t)
	req := httptest.NewRequest(http.MethodGet, "/api/tracks/not-a-uuid/stream", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusBadRequest {
		t.Errorf("status = %d, want 400", w.Code)
	}
}

func TestHandleGetStream_UnknownTrack(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	req := httptest.NewRequest(http.MethodGet,
		"/api/tracks/00000000-0000-0000-0000-000000000001/stream", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
	var body errorBody
	_ = json.Unmarshal(w.Body.Bytes(), &body)
	if body.Error.Message != "track not found" {
		t.Errorf("message = %q, want \"track not found\"", body.Error.Message)
	}
}

func TestHandleGetStream_VanishedFile(t *testing.T) {
	h, pool := testHandlers(t)
	truncateLibrary(t, pool)
	artist := seedArtist(t, pool, "A")
	album := seedAlbum(t, pool, artist.ID, "X", 0)
	dir := t.TempDir()
	// Insert a track whose file_path points somewhere we never create.
	gone := filepath.Join(dir, "does-not-exist.mp3")
	if _, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: "gone", AlbumID: album.ID, ArtistID: artist.ID,
		DurationMs: 1, FilePath: gone, FileSize: 1, FileFormat: "mp3",
	}); err != nil {
		t.Fatal(err)
	}
	// Look up the just-inserted track so we can address it by id.
	tr, err := dbq.New(pool).GetTrackByPath(context.Background(), gone)
	if err != nil {
		t.Fatal(err)
	}

	req := httptest.NewRequest(http.MethodGet, "/api/tracks/"+uuidToString(tr.ID)+"/stream", nil)
	w := httptest.NewRecorder()
	newLibraryRouter(h).ServeHTTP(w, req)

	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
	var body errorBody
	_ = json.Unmarshal(w.Body.Bytes(), &body)
	if body.Error.Message != "track file not found" {
		t.Errorf("message = %q, want \"track file not found\"", body.Error.Message)
	}
}
