package library

import (
	"context"
	"errors"
	"testing"

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

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

type fakeAdopter struct {
	byMbid        []dbq.FindMissingTrackByMbidRow
	byFingerprint []dbq.FindMissingTrackByFingerprintRow

	mbidErr        error
	fingerprintErr error
	adoptErr       error
	adoptRows      int64

	mbidQueried        []string
	fingerprintQueried []dbq.FindMissingTrackByFingerprintParams
	adopted            []dbq.AdoptTrackPathParams
}

func (f *fakeAdopter) FindMissingTrackByMbid(_ context.Context, mbid string) ([]dbq.FindMissingTrackByMbidRow, error) {
	f.mbidQueried = append(f.mbidQueried, mbid)
	return f.byMbid, f.mbidErr
}

func (f *fakeAdopter) FindMissingTrackByFingerprint(
	_ context.Context, arg dbq.FindMissingTrackByFingerprintParams,
) ([]dbq.FindMissingTrackByFingerprintRow, error) {
	f.fingerprintQueried = append(f.fingerprintQueried, arg)
	return f.byFingerprint, f.fingerprintErr
}

func (f *fakeAdopter) AdoptTrackPath(_ context.Context, arg dbq.AdoptTrackPathParams) (int64, error) {
	f.adopted = append(f.adopted, arg)
	if f.adoptErr != nil {
		return 0, f.adoptErr
	}
	return f.adoptRows, nil
}

// The narrowed interface must not drift from the real queries.
var _ trackAdopter = (*dbq.Queries)(nil)

func mbidRow(n byte, path string) dbq.FindMissingTrackByMbidRow {
	return dbq.FindMissingTrackByMbidRow{ID: testUUID(n), FilePath: path}
}

func fpRow(n byte, path string) dbq.FindMissingTrackByFingerprintRow {
	return dbq.FindMissingTrackByFingerprintRow{ID: testUUID(n), FilePath: path}
}

const (
	oldPath = "/music/Linkin Park/Minutes to Midnight/02 - Bleed It Out.mp3"
	newPath = "/music/Linkin Park/Minutes to Midnight/04 - Bleed It Out.mp3"
)

func TestAdoptMovedTrack_MatchesByMbid(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{mbidRow(7, oldPath)}, adoptRows: 1}

	if !s.adoptMovedTrack(context.Background(), q, newPath, 5_000_000, 200_000, "rec-mbid") {
		t.Fatal("expected the moved track to be adopted")
	}
	if len(q.adopted) != 1 {
		t.Fatalf("adopted %d rows, want 1", len(q.adopted))
	}
	if q.adopted[0].ID != testUUID(7) {
		t.Errorf("adopted the wrong row: %v", q.adopted[0].ID)
	}
	if q.adopted[0].FilePath != newPath {
		t.Errorf("adopted FilePath = %q, want %q", q.adopted[0].FilePath, newPath)
	}
	// MBID matched, so the weaker signal should not have been consulted.
	if len(q.fingerprintQueried) != 0 {
		t.Errorf("queried the fingerprint despite an MBID match")
	}
}

func TestAdoptMovedTrack_FallsBackToFingerprint(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(3, oldPath)}, adoptRows: 1}

	// No MBID: an untagged file, which is exactly what the fallback is for.
	if !s.adoptMovedTrack(context.Background(), q, newPath, 4_200_000, 187_000, "") {
		t.Fatal("expected adoption via fingerprint")
	}
	if len(q.mbidQueried) != 0 {
		t.Errorf("queried by MBID with no MBID available")
	}
	if len(q.fingerprintQueried) != 1 {
		t.Fatalf("fingerprint queried %d times, want 1", len(q.fingerprintQueried))
	}
	got := q.fingerprintQueried[0]
	if got.FileSize != 4_200_000 || got.DurationMs != 187_000 {
		t.Errorf("fingerprint = %+v, want size 4200000 duration 187000", got)
	}
	if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(3) {
		t.Errorf("adopted = %+v, want row 3", q.adopted)
	}
}

// Two missing rows carrying the same recording MBID means real duplicates.
// Adopting one arbitrarily would attach this file's future history to a coin
// flip, so it must insert fresh instead.
func TestAdoptMovedTrack_RefusesAmbiguousMbidMatch(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{byMbid: []dbq.FindMissingTrackByMbidRow{
		mbidRow(1, "/music/a.mp3"),
		mbidRow(2, "/music/b.mp3"),
	}, adoptRows: 1}

	if s.adoptMovedTrack(context.Background(), q, newPath, 0, 0, "rec-mbid") {
		t.Fatal("expected refusal on an ambiguous MBID match")
	}
	if len(q.adopted) != 0 {
		t.Errorf("adopted despite ambiguity: %+v", q.adopted)
	}
}

// An ambiguous MBID may still be resolvable by the fingerprint, which is a
// narrower signal — so falling through is allowed to succeed.
func TestAdoptMovedTrack_AmbiguousMbidFallsThroughToFingerprint(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{
		byMbid: []dbq.FindMissingTrackByMbidRow{
			mbidRow(1, "/music/a.mp3"),
			mbidRow(2, "/music/b.mp3"),
		},
		byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(2, "/music/b.mp3")},
		adoptRows:     1,
	}

	if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
		t.Fatal("expected the fingerprint to disambiguate")
	}
	if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(2) {
		t.Errorf("adopted = %+v, want row 2", q.adopted)
	}
}

func TestAdoptMovedTrack_RefusesAmbiguousFingerprintMatch(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{byFingerprint: []dbq.FindMissingTrackByFingerprintRow{
		fpRow(1, "/music/a.mp3"),
		fpRow(2, "/music/b.mp3"),
	}, adoptRows: 1}

	if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "") {
		t.Fatal("expected refusal on an ambiguous fingerprint match")
	}
	if len(q.adopted) != 0 {
		t.Errorf("adopted despite ambiguity: %+v", q.adopted)
	}
}

// duration_ms is 0 when ffprobe failed. Matching 0 against 0 would pair up
// unrelated broken files, so the fingerprint must not be attempted.
func TestAdoptMovedTrack_SkipsFingerprintWithoutRealValues(t *testing.T) {
	tests := []struct {
		name     string
		size     int64
		duration int32
	}{
		{"no duration", 1000, 0},
		{"no size", 0, 2000},
		{"neither", 0, 0},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			s := testScanner(t)
			q := &fakeAdopter{
				byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(1, oldPath)},
				adoptRows:     1,
			}
			if s.adoptMovedTrack(context.Background(), q, newPath, tc.size, tc.duration, "") {
				t.Error("adopted on an unusable fingerprint")
			}
			if len(q.fingerprintQueried) != 0 {
				t.Error("queried the fingerprint with unusable values")
			}
		})
	}
}

func TestAdoptMovedTrack_NoCandidates(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{adoptRows: 1}

	if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
		t.Fatal("expected no adoption when nothing matches")
	}
	if len(q.adopted) != 0 {
		t.Errorf("adopted with no candidates: %+v", q.adopted)
	}
}

// The row's mark was cleared between lookup and update — another file adopted it
// first. AdoptTrackPath's `missing_since IS NOT NULL` predicate reports 0 rows.
func TestAdoptMovedTrack_LostRaceReportsNotAdopted(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{
		byMbid:    []dbq.FindMissingTrackByMbidRow{mbidRow(5, oldPath)},
		adoptRows: 0,
	}

	if s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
		t.Fatal("expected not-adopted when the update matched no rows")
	}
}

// Failing to detect a move must never fail the file: the caller falls back to
// inserting a fresh row, which is the pre-#2528 behaviour.
func TestAdoptMovedTrack_ToleratesQueryErrors(t *testing.T) {
	sentinel := errors.New("db down")
	tests := []struct {
		name string
		q    *fakeAdopter
	}{
		{"mbid lookup fails", &fakeAdopter{mbidErr: sentinel}},
		{"fingerprint lookup fails", &fakeAdopter{fingerprintErr: sentinel}},
		{"adopt fails", &fakeAdopter{
			byMbid:   []dbq.FindMissingTrackByMbidRow{mbidRow(1, oldPath)},
			adoptErr: sentinel,
		}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			s := testScanner(t)
			if s.adoptMovedTrack(context.Background(), tc.q, newPath, 1000, 2000, "rec-mbid") {
				t.Error("reported adoption despite a query error")
			}
		})
	}
}

// A failed MBID lookup must not stop the fingerprint from being tried.
func TestAdoptMovedTrack_MbidErrorStillTriesFingerprint(t *testing.T) {
	s := testScanner(t)
	q := &fakeAdopter{
		mbidErr:       errors.New("db hiccup"),
		byFingerprint: []dbq.FindMissingTrackByFingerprintRow{fpRow(9, oldPath)},
		adoptRows:     1,
	}

	if !s.adoptMovedTrack(context.Background(), q, newPath, 1000, 2000, "rec-mbid") {
		t.Fatal("expected the fingerprint to be tried after an MBID lookup error")
	}
	if len(q.adopted) != 1 || q.adopted[0].ID != testUUID(9) {
		t.Errorf("adopted = %+v, want row 9", q.adopted)
	}
}

func TestUniqueMatch(t *testing.T) {
	s := testScanner(t)
	if _, ok := s.uniqueMatch(nil, newPath, "mbid"); ok {
		t.Error("empty candidate set matched")
	}
	c, ok := s.uniqueMatch([]candidate{{id: testUUID(4), filePath: oldPath}}, newPath, "mbid")
	if !ok {
		t.Fatal("single candidate did not match")
	}
	if c.id != testUUID(4) || c.filePath != oldPath {
		t.Errorf("candidate = %+v, want id 4 at %q", c, oldPath)
	}
	if _, ok := s.uniqueMatch([]candidate{
		{id: testUUID(1)}, {id: testUUID(2)},
	}, newPath, "mbid"); ok {
		t.Error("multiple candidates matched")
	}
}

func TestRowConverters(t *testing.T) {
	got := rowsFromMbid([]dbq.FindMissingTrackByMbidRow{mbidRow(1, "/a"), mbidRow(2, "/b")})
	if len(got) != 2 || got[0].id != testUUID(1) || got[1].filePath != "/b" {
		t.Errorf("rowsFromMbid = %+v", got)
	}
	got = rowsFromFingerprint([]dbq.FindMissingTrackByFingerprintRow{fpRow(3, "/c")})
	if len(got) != 1 || got[0].id != testUUID(3) || got[0].filePath != "/c" {
		t.Errorf("rowsFromFingerprint = %+v", got)
	}
}

// pgtype.UUID zero value must not be mistaken for a real id.
func TestUniqueMatch_ZeroUUIDNotValid(t *testing.T) {
	var zero pgtype.UUID
	if zero.Valid {
		t.Fatal("zero pgtype.UUID should not be Valid")
	}
}
