package coverart

import (
	"context"
	"io"
	"log/slog"
	"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/db"
	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
	"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)

func newPool(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)
	dbtest.ResetDB(t, pool)
	return pool
}

func discardLogger() *slog.Logger {
	return slog.New(slog.NewTextHandler(io.Discard, nil))
}

// newTestEnricher constructs an Enricher backed by a real DB pool for
// integration tests. Callers should register any fakes they need in
// the registry before calling this; SettingsService.reconcile() will
// pick them up.
func newTestEnricher(t *testing.T, pool *pgxpool.Pool) *Enricher {
	t.Helper()
	// Do NOT reset the registry here: callers register their fakes
	// before calling this (see doc above) and resetting would wipe them
	// so reconcile() sees zero providers — every art source then stays
	// NULL. Registry lifecycle is the caller's (each test does
	// resetRegistryForTests + t.Cleanup before Register()).
	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	return NewEnricher(pool, discardLogger(), s)
}

// seedAlbumWithTrack creates an artist + album + track triple in a temp
// dir. Returns album ID and the album-dir path.
func seedAlbumWithTrack(t *testing.T, pool *pgxpool.Pool, title, artistName, mbid string) (pgtype.UUID, string) {
	t.Helper()
	q := dbq.New(pool)
	dir := t.TempDir()
	a, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{
		Name: artistName, SortName: artistName,
	})
	if err != nil {
		t.Fatalf("seed artist: %v", err)
	}
	var mbidParam *string
	if mbid != "" {
		mbidParam = &mbid
	}
	al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
		Title: title, SortTitle: title, ArtistID: a.ID, Mbid: mbidParam,
	})
	if err != nil {
		t.Fatalf("seed album: %v", err)
	}
	trackPath := filepath.Join(dir, title+".mp3")
	if err := os.WriteFile(trackPath, []byte("audio"), 0o644); err != nil {
		t.Fatalf("write track: %v", err)
	}
	if _, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
		Title: title, AlbumID: al.ID, ArtistID: a.ID,
		DurationMs: 1000, FilePath: trackPath, FileSize: 5, FileFormat: "mp3",
	}); err != nil {
		t.Fatalf("seed track: %v", err)
	}
	return al.ID, dir
}

func TestEnrichAlbum_SidecarFound(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "Sid", "Artist", "")
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("img"), 0o644); err != nil {
		t.Fatal(err)
	}

	resetRegistryForTests() // deterministic empty registry (sidecar-only path)
	t.Cleanup(resetRegistryForTests)
	e := newTestEnricher(t, pool)
	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}
	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if row.CoverArtSource == nil || *row.CoverArtSource != "sidecar" {
		t.Errorf("source = %v, want 'sidecar'", row.CoverArtSource)
	}
	if row.CoverArtPath == nil || *row.CoverArtPath != filepath.Join(dir, "cover.jpg") {
		t.Errorf("path = %v", row.CoverArtPath)
	}
}

func TestEnrichAlbum_NoSidecarNoMBID_SettlesNone(t *testing.T) {
	pool := newPool(t)
	id, _ := seedAlbumWithTrack(t, pool, "NoMbid", "Artist", "")
	resetRegistryForTests() // deterministic empty registry (no provider can supply)
	t.Cleanup(resetRegistryForTests)
	e := newTestEnricher(t, pool)
	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}
	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	// No sidecar, no MBID, no provider yields → the chain settles
	// 'none' (allWere404 stays true), version-stamped so it is only
	// re-tried when the registered provider set changes.
	if row.CoverArtSource == nil || *row.CoverArtSource != "none" {
		t.Errorf("source = %v, want 'none' (settled)", row.CoverArtSource)
	}
}

func TestEnrichAlbum_ProviderFetchSuccess(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "Prov", "Artist", "test-mbid-123")

	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "image/jpeg")
		_, _ = w.Write([]byte("MBCAA_BYTES"))
	}))
	defer upstream.Close()

	// Register the MBCAA provider pointing at the test server, then
	// build the Enricher so SettingsService picks it up.
	resetRegistryForTests()
	t.Cleanup(resetRegistryForTests)
	p := newMBCAAProvider(FetcherConfig{
		BaseURL:    upstream.URL,
		UserAgent:  "test",
		MinPeriod:  0,
		HTTPClient: upstream.Client(),
	})
	Register(p)
	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	e := NewEnricher(pool, discardLogger(), s)

	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}

	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if row.CoverArtSource == nil || *row.CoverArtSource != "mbcaa" {
		t.Errorf("source = %v, want 'mbcaa'", row.CoverArtSource)
	}
	expectedPath := filepath.Join(dir, "cover.jpg")
	if row.CoverArtPath == nil || *row.CoverArtPath != expectedPath {
		t.Errorf("path = %v, want %s", row.CoverArtPath, expectedPath)
	}
	body, err := os.ReadFile(expectedPath)
	if err != nil {
		t.Fatalf("read file: %v", err)
	}
	if string(body) != "MBCAA_BYTES" {
		t.Errorf("file body = %q", string(body))
	}
}

func TestEnrichAlbum_All404_RecordsNone(t *testing.T) {
	pool := newPool(t)
	id, _ := seedAlbumWithTrack(t, pool, "Missing", "Artist", "missing-mbid")

	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		http.Error(w, "not found", http.StatusNotFound)
	}))
	defer upstream.Close()

	resetRegistryForTests()
	t.Cleanup(resetRegistryForTests)
	p := newMBCAAProvider(FetcherConfig{
		BaseURL:    upstream.URL,
		UserAgent:  "test",
		MinPeriod:  0,
		HTTPClient: upstream.Client(),
	})
	Register(p)
	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	e := NewEnricher(pool, discardLogger(), s)

	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}

	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if row.CoverArtSource == nil || *row.CoverArtSource != "none" {
		t.Errorf("source = %v, want 'none'", row.CoverArtSource)
	}
}

func TestEnrichAlbum_AlreadySidecar_NoOp(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "Already", "Artist", "")
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("first"), 0o644); err != nil {
		t.Fatal(err)
	}

	resetRegistryForTests() // deterministic empty registry (sidecar-only path)
	t.Cleanup(resetRegistryForTests)
	e := newTestEnricher(t, pool)
	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("first enrich: %v", err)
	}
	// Second call should be a no-op even if we change the file content.
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("changed"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("second enrich: %v", err)
	}

	row, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if row.CoverArtSource == nil || *row.CoverArtSource != "sidecar" {
		t.Errorf("source = %v, want 'sidecar' (preserved)", row.CoverArtSource)
	}
}

func TestEnrichBatch_DrainsNullSourceOnly(t *testing.T) {
	pool := newPool(t)
	id1, dir1 := seedAlbumWithTrack(t, pool, "Drain1", "A", "")
	if err := os.WriteFile(filepath.Join(dir1, "cover.jpg"), []byte("c"), 0o644); err != nil {
		t.Fatal(err)
	}

	id2, _ := seedAlbumWithTrack(t, pool, "Drain2", "B", "")

	resetRegistryForTests() // deterministic empty registry
	t.Cleanup(resetRegistryForTests)
	e := newTestEnricher(t, pool)

	// Pre-mark id2 as 'none' AT the current sources version so
	// ListAlbumsMissingCover excludes it (it only returns NULL or
	// stale-version 'none'). SetAlbumCover does NOT stamp the version;
	// SetAlbumCoverWithVersion does — use the live current version, the
	// same value EnrichBatch compares against.
	curVer, err := dbq.New(pool).GetCurrentSourcesVersion(context.Background())
	if err != nil {
		t.Fatalf("current version: %v", err)
	}
	src := "none"
	if err := dbq.New(pool).SetAlbumCoverWithVersion(context.Background(), dbq.SetAlbumCoverWithVersionParams{
		ID: id2, CoverArtPath: nil, CoverArtSource: &src, CoverArtSourcesVersion: curVer,
	}); err != nil {
		t.Fatal(err)
	}

	processed, _, _, err := e.EnrichBatch(context.Background(), 100, nil)
	if err != nil {
		t.Fatalf("batch: %v", err)
	}
	if processed != 1 {
		t.Errorf("processed = %d, want 1 (only id1 NULL)", processed)
	}

	row1, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id1)
	if row1.CoverArtSource == nil || *row1.CoverArtSource != "sidecar" {
		t.Errorf("id1 source = %v", row1.CoverArtSource)
	}
	row2, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id2)
	if row2.CoverArtSource == nil || *row2.CoverArtSource != "none" {
		t.Errorf("id2 source = %v (should still be 'none')", row2.CoverArtSource)
	}
}

func TestRetryAlbum_DeletesWrittenFileAndRefetches(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "Retry", "Artist", "retry-mbid")

	hits := 0
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		hits++
		_, _ = w.Write([]byte("data"))
	}))
	defer upstream.Close()

	resetRegistryForTests()
	t.Cleanup(resetRegistryForTests)
	p := newMBCAAProvider(FetcherConfig{
		BaseURL:    upstream.URL,
		UserAgent:  "test",
		MinPeriod:  0,
		HTTPClient: upstream.Client(),
	})
	Register(p)
	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	e := NewEnricher(pool, discardLogger(), s)

	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("first enrich: %v", err)
	}
	written := filepath.Join(dir, "cover.jpg")
	if _, err := os.Stat(written); err != nil {
		t.Fatalf("expected file at %s: %v", written, err)
	}

	if err := e.RetryAlbum(context.Background(), id); err != nil {
		t.Fatalf("retry: %v", err)
	}
	if hits < 2 {
		t.Errorf("expected ≥2 upstream hits (initial + retry), got %d", hits)
	}
	row, _ := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if row.CoverArtSource == nil || *row.CoverArtSource != "mbcaa" {
		t.Errorf("source after retry = %v, want 'mbcaa'", row.CoverArtSource)
	}
}

func TestEnrichRetryMissing_DrainsNullAndNone(t *testing.T) {
	pool := newPool(t)
	// Three albums:
	//   id1: NULL source, has sidecar → drains, becomes 'sidecar'.
	//   id2: 'none' source, has sidecar → cleared then drained, becomes 'sidecar'.
	//   id3: 'sidecar' source already → NOT touched.
	id1, dir1 := seedAlbumWithTrack(t, pool, "RM1", "A", "")
	if err := os.WriteFile(filepath.Join(dir1, "cover.jpg"), []byte("c1"), 0o644); err != nil {
		t.Fatal(err)
	}

	id2, dir2 := seedAlbumWithTrack(t, pool, "RM2", "B", "")
	if err := os.WriteFile(filepath.Join(dir2, "cover.jpg"), []byte("c2"), 0o644); err != nil {
		t.Fatal(err)
	}
	none := "none"
	if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{
		ID: id2, Column2: "", CoverArtSource: &none,
	}); err != nil {
		t.Fatal(err)
	}

	id3, dir3 := seedAlbumWithTrack(t, pool, "RM3", "C", "")
	if err := os.WriteFile(filepath.Join(dir3, "cover.jpg"), []byte("c3"), 0o644); err != nil {
		t.Fatal(err)
	}
	sidecar := "sidecar"
	preservedPath := filepath.Join(dir3, "cover.jpg")
	if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{
		ID: id3, Column2: preservedPath, CoverArtSource: &sidecar,
	}); err != nil {
		t.Fatal(err)
	}

	e := newTestEnricher(t, pool)
	processed, err := e.EnrichRetryMissing(context.Background(), 100)
	if err != nil {
		t.Fatalf("retry missing: %v", err)
	}
	if processed != 2 {
		t.Errorf("processed = %d, want 2 (id1 NULL + id2 'none'; id3 already 'sidecar' not touched)", processed)
	}

	q := dbq.New(pool)
	for _, c := range []struct {
		id   pgtype.UUID
		want string
	}{
		{id1, "sidecar"},
		{id2, "sidecar"},
		{id3, "sidecar"},
	} {
		row, _ := q.GetAlbumWithFirstTrackPath(context.Background(), c.id)
		if row.CoverArtSource == nil || *row.CoverArtSource != c.want {
			t.Errorf("album %v source = %v, want %q", c.id, row.CoverArtSource, c.want)
		}
	}
}

// TestEnrichAlbum_MultiProviderChain verifies that when provider A
// returns ErrNotFound, the enricher continues to provider B.
func TestEnrichAlbum_MultiProviderChain(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "Multi", "Artist", "multi-mbid")

	// Provider A: always 404.
	srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		http.Error(w, "not found", http.StatusNotFound)
	}))
	defer srvA.Close()

	// Provider B: always succeeds.
	srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "image/jpeg")
		_, _ = w.Write([]byte("PROVIDER_B_BYTES"))
	}))
	defer srvB.Close()

	resetRegistryForTests()
	t.Cleanup(resetRegistryForTests)
	pA := newMBCAAProvider(FetcherConfig{
		BaseURL:    srvA.URL,
		UserAgent:  "test",
		MinPeriod:  0,
		HTTPClient: srvA.Client(),
	})
	Register(pA)

	// fakeAlbumProvider from provider_test.go doesn't exist here yet;
	// use a second mbcaa-shaped provider by registering a distinct one.
	// We need a second AlbumCoverProvider with a different ID.
	// Use a simple inline struct that wraps the second test server.
	Register(&testAlbumProvider{
		id:      "test-provider-b",
		baseURL: srvB.URL,
		client:  srvB.Client(),
	})

	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	e := NewEnricher(pool, discardLogger(), s)

	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}

	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if row.CoverArtSource == nil || *row.CoverArtSource != "test-provider-b" {
		t.Errorf("source = %v, want 'test-provider-b' (provider B should win)", row.CoverArtSource)
	}
	if body, err := os.ReadFile(filepath.Join(dir, "cover.jpg")); err != nil || string(body) != "PROVIDER_B_BYTES" {
		t.Errorf("cover.jpg body = %q, err = %v", body, err)
	}
}

// TestEnrichAlbum_TransientLeavesNull verifies that a transient error
// from the provider leaves the row NULL for next-pass retry.
func TestEnrichAlbum_TransientLeavesNull(t *testing.T) {
	pool := newPool(t)
	id, _ := seedAlbumWithTrack(t, pool, "Transient", "Artist", "transient-mbid")

	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		http.Error(w, "boom", http.StatusInternalServerError)
	}))
	defer upstream.Close()

	resetRegistryForTests()
	t.Cleanup(resetRegistryForTests)
	p := newMBCAAProvider(FetcherConfig{
		BaseURL:    upstream.URL,
		UserAgent:  "test",
		MinPeriod:  0,
		HTTPClient: upstream.Client(),
	})
	Register(p)
	s, err := NewSettingsService(context.Background(), pool, discardLogger())
	if err != nil {
		t.Fatalf("NewSettingsService: %v", err)
	}
	e := NewEnricher(pool, discardLogger(), s)

	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}

	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	// Transient failure: source must remain NULL (eligible for retry).
	if row.CoverArtSource != nil {
		t.Errorf("source = %v, want nil (NULL — transient should not settle)", row.CoverArtSource)
	}
}

// TestEnrichAlbum_StaleNoneFlipsAndRetries verifies that a row with
// cover_art_source='none' and a stale version gets cleared and
// re-enriched (sidecar wins because the album dir now has a cover file).
func TestEnrichAlbum_StaleNoneFlipsAndRetries(t *testing.T) {
	pool := newPool(t)
	id, dir := seedAlbumWithTrack(t, pool, "StaleNone", "Artist", "")

	// Mark the album as 'none' (simulating a previous pass).
	src := "none"
	if err := dbq.New(pool).SetAlbumCover(context.Background(), dbq.SetAlbumCoverParams{
		ID: id, Column2: "", CoverArtSource: &src,
	}); err != nil {
		t.Fatal(err)
	}
	// Now add a sidecar — EnrichAlbum should flip 'none' → NULL and pick it up.
	if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("new_art"), 0o644); err != nil {
		t.Fatal(err)
	}

	e := newTestEnricher(t, pool)
	if err := e.EnrichAlbum(context.Background(), id); err != nil {
		t.Fatalf("enrich: %v", err)
	}

	row, err := dbq.New(pool).GetAlbumWithFirstTrackPath(context.Background(), id)
	if err != nil {
		t.Fatalf("get: %v", err)
	}
	if row.CoverArtSource == nil || *row.CoverArtSource != "sidecar" {
		t.Errorf("source = %v, want 'sidecar' (stale none flipped and sidecar found)", row.CoverArtSource)
	}
}

// testAlbumProvider is a minimal AlbumCoverProvider for multi-chain tests.
type testAlbumProvider struct {
	id      string
	baseURL string
	client  *http.Client
}

func (p *testAlbumProvider) ID() string                       { return p.id }
func (p *testAlbumProvider) DisplayName() string              { return p.id }
func (p *testAlbumProvider) RequiresAPIKey() bool             { return false }
func (p *testAlbumProvider) DefaultEnabled() bool             { return true }
func (p *testAlbumProvider) Configure(ProviderSettings) error { return nil }
func (p *testAlbumProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/"+ref.MBID, nil)
	if err != nil {
		return nil, err
	}
	resp, err := p.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer func() { _ = resp.Body.Close() }()
	if resp.StatusCode == http.StatusNotFound {
		return nil, ErrNotFound
	}
	if resp.StatusCode != http.StatusOK {
		return nil, ErrTransient
	}
	body := make([]byte, 0, 512)
	buf := make([]byte, 512)
	for {
		n, rerr := resp.Body.Read(buf)
		body = append(body, buf[:n]...)
		if rerr != nil {
			break
		}
	}
	return body, nil
}
