package ingest

import (
	"context"
	"errors"
	"sync"
	"testing"
	"time"

	"github.com/jan/fm-rds-tx/internal/audio"
)

type fakeSource struct {
	desc   SourceDescriptor
	chunks chan PCMChunk
	errs   chan error
	title  chan string
	stats  SourceStats
	once   sync.Once
}

func newFakeSource() *fakeSource {
	return &fakeSource{
		desc:   SourceDescriptor{ID: "fake", Kind: "stdin-pcm"},
		chunks: make(chan PCMChunk, 4),
		errs:   make(chan error, 1),
		title:  make(chan string, 4),
		stats:  SourceStats{State: "running", Connected: true},
	}
}

func (s *fakeSource) Descriptor() SourceDescriptor { return s.desc }
func (s *fakeSource) Start(context.Context) error  { return nil }
func (s *fakeSource) Stop() error                  { s.once.Do(func() { close(s.chunks) }); return nil }
func (s *fakeSource) Chunks() <-chan PCMChunk      { return s.chunks }
func (s *fakeSource) Errors() <-chan error         { return s.errs }
func (s *fakeSource) StreamTitleUpdates() <-chan string {
	return s.title
}
func (s *fakeSource) Stats() SourceStats { return s.stats }

func TestRuntimeWritesFramesToStreamSink(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	rt := NewRuntime(sink, src)
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 44100,
		Samples:      []int32{1000 << 16, -1000 << 16},
	}

	deadline := time.Now().Add(1 * time.Second)
	for sink.Available() < 1 && time.Now().Before(deadline) {
		time.Sleep(10 * time.Millisecond)
	}
	if sink.Available() < 1 {
		t.Fatal("expected at least one frame in sink")
	}
}

func TestRuntimeRecoversToRunningAfterSourceError(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	rt := NewRuntime(sink, src)
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.errs <- errors.New("decode transient failure")
	waitForRuntimeState(t, rt, "degraded")

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 44100,
		Samples:      []int32{500 << 16, -500 << 16},
	}
	waitForRuntimeState(t, rt, "running")
}

func TestRuntimeRecoversToRunningAfterConvertError(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	rt := NewRuntime(sink, src)
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	// Invalid stereo chunk: odd sample count causes conversion error.
	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 44100,
		Samples:      []int32{100 << 16},
	}
	waitForRuntimeState(t, rt, "degraded")

	if got := rt.Stats().Runtime.ConvertErrors; got != 1 {
		t.Fatalf("convertErrors=%d want 1", got)
	}

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 44100,
		Samples:      []int32{300 << 16, -300 << 16},
	}
	waitForRuntimeState(t, rt, "running")
}

func TestRuntimeWithMissingSourceStaysIdleAndReturnsZeroSourceStats(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	rt := NewRuntime(sink, nil)

	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	stats := rt.Stats()
	if stats.Runtime.State != "idle" {
		t.Fatalf("runtime state=%q want idle", stats.Runtime.State)
	}
	if stats.Active.ID != "" || stats.Active.Kind != "" {
		t.Fatalf("expected empty active descriptor, got %+v", stats.Active)
	}
	if stats.Source.State != "" {
		t.Fatalf("expected zero-value source stats, got state=%q", stats.Source.State)
	}
}

func TestRuntimeStatsExposeActiveDescriptorAndSourceReconnectState(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	src.desc = SourceDescriptor{ID: "icecast-primary", Kind: "icecast"}
	src.stats = SourceStats{
		State:      "reconnecting",
		Connected:  false,
		Reconnects: 4,
		LastError:  "stream ended",
	}
	rt := NewRuntime(sink, src)

	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	stats := rt.Stats()
	if stats.Active.ID != "icecast-primary" {
		t.Fatalf("active id=%q want icecast-primary", stats.Active.ID)
	}
	if stats.Active.Kind != "icecast" {
		t.Fatalf("active kind=%q want icecast", stats.Active.Kind)
	}
	if stats.Source.Reconnects != 4 {
		t.Fatalf("source reconnects=%d want 4", stats.Source.Reconnects)
	}
	if stats.Source.LastError != "stream ended" {
		t.Fatalf("source lastError=%q want stream ended", stats.Source.LastError)
	}
}

func TestRuntimePrebufferGateAppliesBeforeSinkWrites(t *testing.T) {
	sink := audio.NewStreamSource(512, 1000)
	src := newFakeSource()
	rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond))
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 1000,
		Samples:      stereoSamples(80, 100),
	}

	time.Sleep(30 * time.Millisecond)
	if sink.Available() != 0 {
		t.Fatalf("sink available=%d want 0 while prebuffering", sink.Available())
	}
	stats := rt.Stats()
	if stats.Runtime.State != "prebuffering" || !stats.Runtime.Prebuffering {
		t.Fatalf("runtime state=%q prebuffering=%t", stats.Runtime.State, stats.Runtime.Prebuffering)
	}
	if stats.Runtime.BufferedSeconds <= 0 {
		t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds)
	}

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 1000,
		Samples:      stereoSamples(40, 120),
	}
	waitForSinkFrames(t, sink, 1)
	waitForRuntimeState(t, rt, "running")
	if got := rt.Stats().Runtime.Prebuffering; got {
		t.Fatalf("runtime prebuffering=%t want false", got)
	}
}

func TestRuntimeWriteBlockedRetainsWorkingBuffer(t *testing.T) {
	sink := audio.NewStreamSource(1, 1000)
	src := newFakeSource()
	rt := NewRuntime(sink, src)
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 1000,
		Samples:      stereoSamples(4, 200),
	}
	waitForSinkFrames(t, sink, 1)
	waitForRuntimeState(t, rt, "running")
	stats := rt.Stats()
	if stats.Runtime.WriteBlocked {
		t.Fatalf("runtime writeBlocked=%t want false", stats.Runtime.WriteBlocked)
	}
	if stats.Runtime.BufferedSeconds <= 0 {
		t.Fatalf("runtime bufferedSeconds=%f want > 0", stats.Runtime.BufferedSeconds)
	}
	if stats.Runtime.DroppedFrames != 0 {
		t.Fatalf("runtime droppedFrames=%d want 0", stats.Runtime.DroppedFrames)
	}
	if got := sink.Stats().Overflows; got != 0 {
		t.Fatalf("sink overflows=%d want 0", got)
	}
}

func TestRuntimeDrainWorkingBufferIsBurstBounded(t *testing.T) {
	sink := audio.NewStreamSource(64, 1000)
	rt := NewRuntime(sink, nil)

	rt.gateOpen = true
	for i := 0; i < 40; i++ {
		if !rt.work.push(audio.NewFrame(0.1, -0.1)) {
			t.Fatalf("failed to seed work frame %d", i)
		}
	}
	rt.lastDrainAt = time.Now().Add(-time.Second)

	rt.drainWorkingBuffer()

	if got := sink.Available(); got != 20 {
		t.Fatalf("sink available=%d want 20 (20ms burst at 1kHz)", got)
	}
	if got := rt.work.available(); got != 20 {
		t.Fatalf("work available=%d want 20", got)
	}
	if got := rt.Stats().Runtime.WriteBlocked; got {
		t.Fatalf("runtime writeBlocked=%t want false", got)
	}
}

func TestRuntimeDrainWorkingBufferHonorsSinkHeadroom(t *testing.T) {
	sink := audio.NewStreamSource(64, 1000)
	rt := NewRuntime(sink, nil)

	for i := 0; i < 63; i++ {
		if !sink.WriteFrame(audio.NewFrame(0.2, -0.2)) {
			t.Fatalf("failed to seed sink frame %d", i)
		}
	}
	rt.gateOpen = true
	for i := 0; i < 8; i++ {
		if !rt.work.push(audio.NewFrame(0.3, -0.3)) {
			t.Fatalf("failed to seed work frame %d", i)
		}
	}
	rt.lastDrainAt = time.Now().Add(-time.Second)

	rt.drainWorkingBuffer()

	if got := sink.Available(); got != 64 {
		t.Fatalf("sink available=%d want 64", got)
	}
	if got := rt.work.available(); got != 7 {
		t.Fatalf("work available=%d want 7", got)
	}
	if got := sink.Stats().Overflows; got != 0 {
		t.Fatalf("sink overflows=%d want 0", got)
	}
	if got := rt.Stats().Runtime.WriteBlocked; got {
		t.Fatalf("runtime writeBlocked=%t want false", got)
	}
}

func TestRuntimeStatsSourceBufferedSecondsIncludesWorkingBuffer(t *testing.T) {
	sink := audio.NewStreamSource(32, 1000)
	src := newFakeSource()
	src.stats = SourceStats{State: "running", Connected: true, BufferedSeconds: 0}
	rt := NewRuntime(sink, src, WithPrebuffer(100*time.Millisecond))
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 1000,
		Samples:      stereoSamples(50, 300),
	}
	time.Sleep(20 * time.Millisecond)
	stats := rt.Stats()
	if stats.Source.BufferedSeconds <= 0 {
		t.Fatalf("source bufferedSeconds=%f want > 0", stats.Source.BufferedSeconds)
	}
}

func TestRuntimeUpdatesActiveDescriptorFromChunkMetadata(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	src.desc = SourceDescriptor{
		ID:           "icecast-primary",
		Kind:         "icecast",
		Channels:     0,
		SampleRateHz: 0,
	}
	rt := NewRuntime(sink, src)
	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.chunks <- PCMChunk{
		Channels:     2,
		SampleRateHz: 48000,
		Samples:      []int32{100 << 16, -100 << 16},
	}

	waitForRuntimeState(t, rt, "running")
	stats := rt.Stats()
	if stats.Active.SampleRateHz != 48000 {
		t.Fatalf("active sampleRateHz=%d want 48000", stats.Active.SampleRateHz)
	}
	if stats.Active.Channels != 2 {
		t.Fatalf("active channels=%d want 2", stats.Active.Channels)
	}
}

func TestRuntimeForwardsStreamTitleUpdatesToHandler(t *testing.T) {
	sink := audio.NewStreamSource(128, 44100)
	src := newFakeSource()
	got := make(chan string, 1)
	rt := NewRuntime(sink, src, WithStreamTitleHandler(func(title string) {
		got <- title
	}))

	if err := rt.Start(context.Background()); err != nil {
		t.Fatalf("start: %v", err)
	}
	defer rt.Stop()

	src.title <- "Artist - Song"
	select {
	case title := <-got:
		if title != "Artist - Song" {
			t.Fatalf("title=%q want %q", title, "Artist - Song")
		}
	case <-time.After(1 * time.Second):
		t.Fatal("timed out waiting for forwarded title")
	}
}

func waitForRuntimeState(t *testing.T, rt *Runtime, want string) {
	t.Helper()
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) {
		if got := rt.Stats().Runtime.State; got == want {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("timeout waiting for runtime state %q; last=%q", want, rt.Stats().Runtime.State)
}

func waitForSinkFrames(t *testing.T, sink *audio.StreamSource, minFrames int) {
	t.Helper()
	deadline := time.Now().Add(1 * time.Second)
	for time.Now().Before(deadline) {
		if sink.Available() >= minFrames {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("timeout waiting for sink frames: have=%d want>=%d", sink.Available(), minFrames)
}

func stereoSamples(frames int, v int32) []int32 {
	out := make([]int32, 0, frames*2)
	for i := 0; i < frames; i++ {
		out = append(out, v<<16, -v<<16)
	}
	return out
}
