package testfixtures_test

import (
	"crypto/sha256"
	"encoding/hex"
	"math"
	"path/filepath"
	"testing"

	"yellowjacket/backend/metadata"
	"yellowjacket/internal/testfixtures"
)

// durationToleranceMS is the slack allowed between the nominal length
// in the spec and what a decoder reports.  Lossy encoders pad to a
// frame boundary, so exact equality is not achievable.
const durationToleranceMS = 250

// TestFixturesMatchManifest reads every generated fixture back with the
// application's own metadata extractor and asserts it says what the
// manifest claims.
//
// This is the check that keeps the generator honest: fixtures are
// tagged by backend/tagwriter and read by backend/metadata, so if those
// two ever disagree — a new format, a changed frame ID — it surfaces
// here rather than as a mystery in the UI.
func TestFixturesMatchManifest(t *testing.T) {
	t.Parallel()

	m := testfixtures.Load(t)

	for _, want := range m.Tracks {
		// WAV tags are write-only today; see
		// TestWAVTagsAreNotReadableYet.
		if want.Format == "wav" {
			continue
		}

		t.Run(want.Path, func(t *testing.T) {
			t.Parallel()

			path := m.Abs(want.Path)

			got, err := metadata.ExtractTags(path)
			if err != nil {
				t.Fatalf("extract tags: %v", err)
			}

			assertTag(t, "title", want, got.Title)
			assertTag(t, "artist", want, got.Artist)
			assertTag(t, "album", want, got.Album)
			assertTag(t, "album_artist", want, got.AlbumArtist)
			assertTag(t, "genre", want, got.Genre)
			assertIntTag(t, "year", want, got.Year)
			assertIntTag(t, "track_number", want, got.TrackNumber)
			assertIntTag(t, "disc_number", want, got.DiscNumber)

			assertCover(t, want, got)
		})
	}
}

// TestFixtureDurationsMatchManifest decodes each fixture and checks its
// length, which is what makes seek, progress and queue-advance
// assertions meaningful elsewhere.
func TestFixtureDurationsMatchManifest(t *testing.T) {
	t.Parallel()

	m := testfixtures.Load(t)

	for _, want := range m.Tracks {
		t.Run(want.Path, func(t *testing.T) {
			t.Parallel()

			got, err := metadata.GetTrackLengthMillis(m.Abs(want.Path))
			if err != nil {
				t.Fatalf("decode duration: %v", err)
			}

			if delta := math.Abs(float64(got - want.DurationMS)); delta > durationToleranceMS {
				t.Errorf(
					"duration: got %dms, want %dms (±%dms)",
					got, want.DurationMS, durationToleranceMS,
				)
			}
		})
	}
}

// TestCoverDedupFixturesShareOneImage guards the premise of the
// cover-dedup case: every track in that album must carry byte-identical
// artwork, or the dedup path is not actually under test.
func TestCoverDedupFixturesShareOneImage(t *testing.T) {
	t.Parallel()

	m := testfixtures.Load(t)

	var first string

	for _, path := range m.Case(t, testfixtures.CaseCoverDedup) {
		tags, err := metadata.ExtractTags(path)
		if err != nil {
			t.Fatalf("extract tags from %s: %v", path, err)
		}

		if tags.Picture == nil {
			t.Fatalf("%s: no embedded cover", filepath.Base(path))
		}

		sum := sha256.Sum256(tags.Picture.Data)
		digest := hex.EncodeToString(sum[:])

		if first == "" {
			first = digest

			continue
		}

		if digest != first {
			t.Errorf(
				"%s: cover differs from the album's first track",
				filepath.Base(path),
			)
		}
	}
}

// TestDuplicateFixturesAreIndistinguishable guards the premise of the
// duplicates case: the pair must agree on everything the duplicate
// detector compares, across two different formats.
func TestDuplicateFixturesAreIndistinguishable(t *testing.T) {
	t.Parallel()

	m := testfixtures.Load(t)

	paths := m.Case(t, testfixtures.CaseDuplicates)
	if len(paths) < 2 {
		t.Fatalf("expected at least two duplicate fixtures, got %d", len(paths))
	}

	ref, err := metadata.ExtractTags(paths[0])
	if err != nil {
		t.Fatalf("extract reference tags: %v", err)
	}

	for _, path := range paths[1:] {
		got, err := metadata.ExtractTags(path)
		if err != nil {
			t.Fatalf("extract tags from %s: %v", path, err)
		}

		if got.Title != ref.Title || got.Artist != ref.Artist ||
			got.Album != ref.Album {
			t.Errorf(
				"%s: (%q, %q, %q) differs from reference (%q, %q, %q)",
				filepath.Base(path),
				got.Title, got.Artist, got.Album,
				ref.Title, ref.Artist, ref.Album,
			)
		}
	}
}

// TestWAVTagsAreNotReadableYet pins a known gap rather than hiding it.
//
// backend/tagwriter writes WAV tags into a RIFF "id3 " chunk, but
// backend/metadata reads through dhowden/tag, which recognises MP3,
// FLAC, OGG, MP4 and DSF and has no RIFF parser at all.  So every tag
// the app writes to a WAV is invisible to the app that wrote it, and
// WAV tracks always scan in as untitled.
//
// The fixtures are tagged correctly on disk, so when the reader learns
// to unwrap the RIFF chunk this test starts failing — which is the
// point.  Delete it then and drop the "wav" skip in
// TestFixturesMatchManifest.
func TestWAVTagsAreNotReadableYet(t *testing.T) {
	t.Parallel()

	m := testfixtures.Load(t)

	for _, path := range m.Case(t, testfixtures.CaseWAVTracks) {
		got, err := metadata.ExtractTags(path)
		if err != nil {
			t.Fatalf("extract tags from %s: %v", path, err)
		}

		if got.Title != "" {
			t.Errorf(
				"%s: WAV tags are now readable (%q) — good news; "+
					"see this test's comment for what to update",
				filepath.Base(path), got.Title,
			)
		}
	}
}

func assertTag(t *testing.T, field string, want testfixtures.Track, got string) {
	t.Helper()

	expected, _ := want.Tags[field].(string)

	if got != expected {
		t.Errorf("%s: got %q, want %q", field, got, expected)
	}
}

func assertIntTag(t *testing.T, field string, want testfixtures.Track, got int) {
	t.Helper()

	// JSON numbers decode as float64.
	expected, _ := want.Tags[field].(float64)

	if got != int(expected) {
		t.Errorf("%s: got %d, want %d", field, got, int(expected))
	}
}

func assertCover(
	t *testing.T,
	want testfixtures.Track,
	got *metadata.TrackMetadata,
) {
	t.Helper()

	if want.CoverSHA == "" {
		if got.Picture != nil {
			t.Errorf("cover: got embedded artwork, want none")
		}

		return
	}

	if got.Picture == nil {
		t.Fatalf("cover: no embedded artwork, want %s", want.CoverSHA[:12])
	}

	sum := sha256.Sum256(got.Picture.Data)

	if digest := hex.EncodeToString(sum[:]); digest != want.CoverSHA {
		t.Errorf("cover: got sha %s, want %s", digest[:12], want.CoverSHA[:12])
	}
}
