package queue

import (
	"context"
	"sync"
	"testing"
	"time"
)

// fakeFallbackSource records every call and returns whatever was
// configured, optionally gated by a channel so a test can control
// exactly when resolution completes (to exercise the staleness check).
type fakeFallbackSource struct {
	mu    sync.Mutex
	calls []FallbackContext

	paths  []string
	source Source
	err    error

	// gate, if set, blocks ResolveFallback until closed.
	gate chan struct{}
}

func (f *fakeFallbackSource) ResolveFallback(
	_ context.Context,
	fctx FallbackContext,
) ([]string, Source, error) {
	if f.gate != nil {
		<-f.gate
	}

	f.mu.Lock()
	f.calls = append(f.calls, fctx)
	f.mu.Unlock()

	return f.paths, f.source, f.err
}

func (f *fakeFallbackSource) callCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()

	return len(f.calls)
}

func (f *fakeFallbackSource) lastContext() FallbackContext {
	f.mu.Lock()
	defer f.mu.Unlock()

	return f.calls[len(f.calls)-1]
}

// waitUntil polls cond until it's true or fails the test after a
// short deadline, naming what it was waiting for on timeout.
func waitUntil(t *testing.T, cond func() bool, what string) {
	t.Helper()

	deadline := time.Now().Add(2 * time.Second)

	for time.Now().Before(deadline) {
		if cond() {
			return
		}

		time.Sleep(5 * time.Millisecond)
	}

	t.Fatalf("timed out waiting for: %s", what)
}

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

	q, db := setupTestQueue(t)
	seedPaths := seedAudioFiles(t, db, 1)
	fallbackPaths := seedAudioFiles(t, db, 6)[1:] // distinct from seed

	fake := &fakeFallbackSource{
		paths:  fallbackPaths,
		source: Source{Type: "playlist", ID: 9, Label: "Favorites"},
	}
	q.SetFallbackSource(fake)

	q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
	q.OnPlaybackFinished(nil)

	waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
	waitUntil(t, func() bool {
		return q.GetState().Source == fake.source
	}, "queue to adopt the fallback source")

	state := q.GetState()
	if got := len(state.Tracks); got != len(fallbackPaths) {
		t.Errorf("track count: got %d, want %d", got, len(fallbackPaths))
	}

	ctx := fake.lastContext()
	if ctx.PreviousSource.Type != "album" {
		t.Errorf("previous source type: got %q, want %q", ctx.PreviousSource.Type, "album")
	}

	if len(ctx.SeedPaths) != 1 || ctx.SeedPaths[0] != seedPaths[0] {
		t.Errorf("seed paths: got %v, want %v", ctx.SeedPaths, seedPaths)
	}
}

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

	q, db := setupTestQueue(t)
	seedPaths := seedAudioFiles(t, db, 1)
	fallbackPaths := seedAudioFiles(t, db, 6)[1:]

	fake := &fakeFallbackSource{
		paths:  fallbackPaths,
		source: Source{Type: "dynamicMix", Label: "a mix"},
	}
	q.SetFallbackSource(fake)

	q.SetQueue(seedPaths, 0, false, Source{})
	q.Next() // already at the only/last track: exhausts

	waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
	waitUntil(t, func() bool {
		return len(q.GetState().Tracks) == len(fallbackPaths)
	}, "queue to adopt the fallback tracks")
}

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

	q, db := setupTestQueue(t)
	seedPaths := seedAudioFiles(t, db, 1)
	fallbackPaths := seedAudioFiles(t, db, 6)[1:]

	fake := &fakeFallbackSource{
		paths:  fallbackPaths,
		source: Source{Type: "playlist", Label: "Favorites"},
	}
	q.SetFallbackSource(fake)

	q.SetQueue(seedPaths, 0, false, Source{})
	q.RemoveTrack(0) // removes the only (currently playing) track

	waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
	waitUntil(t, func() bool {
		return len(q.GetState().Tracks) == len(fallbackPaths)
	}, "queue to adopt the fallback tracks")
}

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

	q, db := setupTestQueue(t)
	seedPaths := seedAudioFiles(t, db, 1)

	fake := &fakeFallbackSource{paths: nil, source: Source{}} // "stop"
	q.SetFallbackSource(fake)

	q.SetQueue(seedPaths, 0, false, Source{})
	q.OnPlaybackFinished(nil)

	waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")

	// Give a wrongly-applied fallback a moment to (not) land.
	time.Sleep(20 * time.Millisecond)

	state := q.GetState()
	if len(state.Tracks) != 1 {
		t.Errorf("track count: got %d, want 1 (queue unchanged)", len(state.Tracks))
	}

	if state.CurrentIndex != -1 {
		t.Errorf("currentIndex: got %d, want -1 (still exhausted)", state.CurrentIndex)
	}
}

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

	q, db := setupTestQueue(t)
	seedPaths := seedAudioFiles(t, db, 1)
	stalePaths := seedAudioFiles(t, db, 6)[1:4]
	freshPaths := seedAudioFiles(t, db, 9)[6:9]

	gate := make(chan struct{})
	fake := &fakeFallbackSource{
		paths:  stalePaths,
		source: Source{Type: "dynamicMix", Label: "stale"},
		gate:   gate,
	}
	q.SetFallbackSource(fake)

	q.SetQueue(seedPaths, 0, false, Source{})
	q.OnPlaybackFinished(nil) // starts resolving, blocked on gate

	time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate

	// Something else claims the queue before the stale resolution lands.
	q.SetQueue(freshPaths, 0, false, Source{Type: "album", Label: "fresh"})

	close(gate) // let the stale resolution finish and try to apply

	waitUntil(t, func() bool {
		state := q.GetState()
		if state.Source.Label == "stale" {
			t.Fatal("stale fallback was applied")
		}

		return state.Source.Label == "fresh"
	}, "the fresh queue to survive the stale fallback")
}
