package playlist

import (
	"math"
	"testing"
)

func TestScoreCandidateExactFilename(t *testing.T) {
	t.Parallel()

	pp := newPhantomProfile(
		"/old/path/Artist/Album/01 - Song.flac",
		"Artist - Song",
		243,
	)

	score := scoreCandidate(
		pp,
		"/new/path/Artist/Album/01 - Song.flac",
		"Song",
		"Artist",
		243000,
	)

	if score < 0.9 {
		t.Errorf("expected score >= 0.9, got %f", score)
	}
}

func TestScoreCandidateNoMatch(t *testing.T) {
	t.Parallel()

	pp := newPhantomProfile(
		"/music/Artist/Album/01 - Song.flac",
		"Artist - Song",
		243,
	)

	score := scoreCandidate(
		pp,
		"/music/Completely/Different/track.mp3",
		"Other Title",
		"Other Artist",
		180000,
	)

	if score > 0.3 {
		t.Errorf("expected score <= 0.3, got %f", score)
	}
}

func TestScoreCandidateSameFilenameNewDir(t *testing.T) {
	t.Parallel()

	// Common case: file moved to a different directory.
	pp := newPhantomProfile(
		"/music/Old Dir/Artist/01 - Song.flac",
		"Artist - Song",
		243,
	)

	score := scoreCandidate(
		pp,
		"/music/New Dir/Artist/01 - Song.flac",
		"Song",
		"Artist",
		243000,
	)

	if score < 0.8 {
		t.Errorf(
			"expected score >= 0.8 for same filename, got %f",
			score,
		)
	}
}

func TestScoreCandidateDurationOnly(t *testing.T) {
	t.Parallel()

	// Very close duration, but different filenames.
	score := scoreDuration(243, 243500)
	if score < 0.8 {
		t.Errorf(
			"expected duration score >= 0.8 for ~0.5s diff, got %f",
			score,
		)
	}

	// Exact match.
	score = scoreDuration(180, 180000)
	if score != 1.0 {
		t.Errorf(
			"expected 1.0 for exact match, got %f",
			score,
		)
	}

	// Far apart.
	score = scoreDuration(100, 200000)
	if score != 0.0 {
		t.Errorf(
			"expected 0.0 for 100s diff, got %f",
			score,
		)
	}

	// Unknown duration.
	score = scoreDuration(0, 180000)
	if score != 0.0 {
		t.Errorf(
			"expected 0.0 for unknown, got %f",
			score,
		)
	}
}

func TestScoreFilename(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		phantom  string
		cand     string
		minScore float64
		maxScore float64
	}{
		{
			name:     "exact match",
			phantom:  "/a/b/song.flac",
			cand:     "/c/d/song.flac",
			minScore: 1.0,
			maxScore: 1.0,
		},
		{
			name:     "same stem different ext",
			phantom:  "/a/song.flac",
			cand:     "/b/song.mp3",
			minScore: 0.7,
			maxScore: 0.9,
		},
		{
			name:     "completely different",
			phantom:  "/a/song.flac",
			cand:     "/b/other.mp3",
			minScore: 0.0,
			maxScore: 0.2,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			pp := newPhantomProfile(tt.phantom, "", 0)
			score := scoreFilename(pp, tt.cand)

			if score < tt.minScore || score > tt.maxScore {
				t.Errorf(
					"scoreFilename(%q, %q) = %f, want [%f, %f]",
					tt.phantom, tt.cand,
					score, tt.minScore, tt.maxScore,
				)
			}
		})
	}
}

func TestScoreTitleArtist(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		display  string
		title    string
		artist   string
		minScore float64
	}{
		{
			name:     "exact match",
			display:  "Pink Floyd - Comfortably Numb",
			title:    "Comfortably Numb",
			artist:   "Pink Floyd",
			minScore: 0.9,
		},
		{
			name:     "title only match",
			display:  "Comfortably Numb",
			title:    "Comfortably Numb",
			artist:   "Pink Floyd",
			minScore: 0.7,
		},
		{
			name:     "no match",
			display:  "Something Else",
			title:    "Completely Different",
			artist:   "Other Artist",
			minScore: 0.0,
		},
		{
			name:     "empty display title",
			display:  "",
			title:    "Any Title",
			artist:   "Any Artist",
			minScore: 0.0,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			pp := newPhantomProfile(
				"/dummy/path.flac", tt.display, 0,
			)
			score := scoreTitleArtist(
				pp, tt.title, tt.artist,
			)

			if score < tt.minScore {
				t.Errorf(
					"scoreTitleArtist(%q, %q, %q) = %f, want >= %f",
					tt.display, tt.title, tt.artist,
					score, tt.minScore,
				)
			}
		})
	}
}

func TestExtractKeywords(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name     string
		path     string
		expected []string
	}{
		{
			name: "typical music path",
			path: "/music/Pink Floyd/The Wall/03 - Another Brick in the Wall.flac",
			expected: []string{
				"music", "pink", "floyd", "the",
				"wall", "another", "brick", "in",
			},
		},
		{
			name:     "simple filename",
			path:     "song.mp3",
			expected: []string{"song"},
		},
		{
			name:     "track number stripped",
			path:     "01 - Song Title.flac",
			expected: []string{"song", "title"},
		},
		{
			name:     "empty path",
			path:     "",
			expected: nil,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()

			result := extractKeywords(tt.path)
			if !stringSliceEqual(result, tt.expected) {
				t.Errorf(
					"extractKeywords(%q) = %v, want %v",
					tt.path, result, tt.expected,
				)
			}
		})
	}
}

func TestParseDisplayTitle(t *testing.T) {
	t.Parallel()

	tests := []struct {
		input  string
		artist string
		title  string
	}{
		{
			input:  "Artist - Title",
			artist: "Artist",
			title:  "Title",
		},
		{
			input:  "Just a Title",
			artist: "",
			title:  "Just a Title",
		},
		{
			input:  "",
			artist: "",
			title:  "",
		},
	}

	for _, tt := range tests {
		t.Run(tt.input, func(t *testing.T) {
			t.Parallel()

			artist, title := parseDisplayTitle(tt.input)
			if artist != tt.artist || title != tt.title {
				t.Errorf(
					"parseDisplayTitle(%q) = (%q, %q), want (%q, %q)",
					tt.input, artist, title,
					tt.artist, tt.title,
				)
			}
		})
	}
}

func TestKeywordOverlap(t *testing.T) {
	t.Parallel()

	// Full overlap.
	score := keywordOverlap(
		[]string{"a", "b", "c"},
		[]string{"a", "b", "c", "d"},
	)

	if score != 1.0 {
		t.Errorf("expected 1.0, got %f", score)
	}

	// Partial overlap.
	score = keywordOverlap(
		[]string{"a", "b", "c"},
		[]string{"a", "d", "e"},
	)

	expected := 1.0 / 3.0
	if math.Abs(score-expected) > 0.01 {
		t.Errorf("expected ~%f, got %f", expected, score)
	}

	// No overlap.
	score = keywordOverlap(
		[]string{"a", "b"},
		[]string{"c", "d"},
	)

	if score != 0.0 {
		t.Errorf("expected 0.0, got %f", score)
	}

	// Empty source.
	score = keywordOverlap(nil, []string{"a"})
	if score != 0.0 {
		t.Errorf("expected 0.0 for empty source, got %f", score)
	}
}

func TestSortCandidatesByScore(t *testing.T) {
	t.Parallel()

	candidates := []CandidateTrack{
		{FilePath: "a", Score: 0.3},
		{FilePath: "b", Score: 0.9},
		{FilePath: "c", Score: 0.6},
	}

	sortCandidatesByScore(candidates)

	if candidates[0].FilePath != "b" {
		t.Errorf(
			"expected first candidate to be 'b', got %q",
			candidates[0].FilePath,
		)
	}

	if candidates[1].FilePath != "c" {
		t.Errorf(
			"expected second candidate to be 'c', got %q",
			candidates[1].FilePath,
		)
	}

	if candidates[2].FilePath != "a" {
		t.Errorf(
			"expected third candidate to be 'a', got %q",
			candidates[2].FilePath,
		)
	}
}

// stringSliceEqual compares two string slices.
func stringSliceEqual(a, b []string) bool {
	if len(a) == 0 && len(b) == 0 {
		return true
	}

	if len(a) != len(b) {
		return false
	}

	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}

	return true
}

// TestScoreCandidateUnknownCandidateDuration pins which side may be the
// one that does not know a duration.  An M3U8 without EXTINF lines was
// already handled; a *library* file whose length was never read was not,
// so an otherwise exact match was scored out of 0.9 and could not reach
// the auto-match threshold.
func TestScoreCandidateUnknownCandidateDuration(t *testing.T) {
	t.Parallel()

	pp := newPhantomProfile(
		"/old/Music/Artist/01 - Song.mp3",
		"Artist - Song",
		240,
	)

	score := scoreCandidate(
		pp,
		"/new/Music/Artist/01 - Song.mp3",
		"Song",
		"Artist",
		0, // the library never read this file's length
	)

	if score < autoMatchMinimum {
		t.Errorf(
			"score = %f, want >= %f for an exact match with no "+
				"candidate duration",
			score, autoMatchMinimum,
		)
	}
}

// TestAssignBestFirst covers the rule that one library file cannot
// resolve two phantom tracks, and which phantom gets it when both want
// the same one.
func TestAssignBestFirst(t *testing.T) {
	t.Parallel()

	offers := []phantomOffer{
		// The playlist's first phantom wants this file, but only
		// just — and the third one is a better answer for it.
		{
			phantomPath: "/gone/a.mp3",
			candidate: CandidateTrack{
				FilePath: "/lib/shared.mp3", Score: 0.86,
			},
		},
		{
			phantomPath: "/gone/b.mp3",
			candidate: CandidateTrack{
				FilePath: "/lib/b.mp3", Score: 0.90,
			},
		},
		{
			phantomPath: "/gone/c.mp3",
			candidate: CandidateTrack{
				FilePath: "/lib/shared.mp3", Score: 0.98,
			},
		},
	}

	matches := assignBestFirst(offers)

	if len(matches) != 2 {
		t.Fatalf("matches = %d, want 2", len(matches))
	}

	got := make(map[string]string, len(matches))
	for _, m := range matches {
		got[m.PhantomPath] = m.Candidate.FilePath
	}

	if got["/gone/c.mp3"] != "/lib/shared.mp3" {
		t.Errorf(
			"the shared file went to %v, want /gone/c.mp3 to have it",
			got,
		)
	}

	if _, claimed := got["/gone/a.mp3"]; claimed {
		t.Error("/gone/a.mp3 took a file a better match had claimed")
	}

	if got["/gone/b.mp3"] != "/lib/b.mp3" {
		t.Errorf("/gone/b.mp3 matched %q, want /lib/b.mp3", got["/gone/b.mp3"])
	}
}

// TestAssignBestFirstKeepsOnePerPhantom: a phantom with several
// confident candidates takes its best one and no more.
func TestAssignBestFirstKeepsOnePerPhantom(t *testing.T) {
	t.Parallel()

	matches := assignBestFirst([]phantomOffer{
		{
			phantomPath: "/gone/a.mp3",
			candidate: CandidateTrack{
				FilePath: "/lib/one.mp3", Score: 0.90,
			},
		},
		{
			phantomPath: "/gone/a.mp3",
			candidate: CandidateTrack{
				FilePath: "/lib/two.mp3", Score: 0.95,
			},
		},
	})

	if len(matches) != 1 {
		t.Fatalf("matches = %d, want 1", len(matches))
	}

	if matches[0].Candidate.FilePath != "/lib/two.mp3" {
		t.Errorf(
			"matched %q, want the higher-scoring /lib/two.mp3",
			matches[0].Candidate.FilePath,
		)
	}
}
