package icecast

import (
	"bytes"
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

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

type testDecoder struct {
	name   string
	err    error
	called int
}

func (d *testDecoder) Name() string { return d.name }

func (d *testDecoder) DecodeStream(_ context.Context, _ io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
	d.called++
	return d.err
}

type consumingUnsupportedDecoder struct {
	n      int
	called int
}

func (d *consumingUnsupportedDecoder) Name() string { return "native-consuming-unsupported" }

func (d *consumingUnsupportedDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
	d.called++
	buf := make([]byte, d.n)
	_, _ = io.ReadFull(r, buf)
	return decoder.ErrUnsupported
}

type captureStreamDecoder struct {
	name    string
	called  int
	payload []byte
}

func (d *captureStreamDecoder) Name() string { return d.name }

func (d *captureStreamDecoder) DecodeStream(_ context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
	d.called++
	data, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	d.payload = data
	return nil
}

func TestDecodeWithPreferenceAutoFallsBackFromNativeUnsupported(t *testing.T) {
	native := &testDecoder{name: "native", err: decoder.ErrUnsupported}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/mpeg",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if native.called != 1 {
		t.Fatalf("native called %d times", native.called)
	}
	if fallback.called != 1 {
		t.Fatalf("fallback called %d times", fallback.called)
	}
}

func TestDecodeWithPreferenceNativeDoesNotFallback(t *testing.T) {
	nativeErr := errors.New("decode failed")
	native := &testDecoder{name: "native", err: nativeErr}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("native"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/mpeg",
		SourceID:    "ice-test",
	})
	if !errors.Is(err, nativeErr) {
		t.Fatalf("expected native error, got %v", err)
	}
	if fallback.called != 0 {
		t.Fatalf("fallback should not be called, got %d", fallback.called)
	}
}

func TestDecodeWithPreferenceFFmpegOnly(t *testing.T) {
	native := &testDecoder{name: "native"}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("ffmpeg"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/mpeg",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if native.called != 0 {
		t.Fatalf("native should not be called in ffmpeg mode, got %d", native.called)
	}
	if fallback.called != 1 {
		t.Fatalf("fallback called %d times", fallback.called)
	}
}

func TestDecodeWithPreferenceAutoUnsupportedContentTypeFallsBack(t *testing.T) {
	fallback := &testDecoder{name: "ffmpeg"}
	reg := decoder.NewRegistry()
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "application/octet-stream",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if fallback.called != 1 {
		t.Fatalf("fallback called %d times", fallback.called)
	}
}

func TestDecodeWithPreferenceAutoUsesOggNativeForOggContentType(t *testing.T) {
	ogg := &testDecoder{name: "oggvorbis"}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("oggvorbis", func() decoder.Decoder { return ogg })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/ogg",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if ogg.called != 1 {
		t.Fatalf("ogg decoder called %d times", ogg.called)
	}
	if fallback.called != 0 {
		t.Fatalf("fallback should not be called, got %d", fallback.called)
	}
}

func TestDecodeWithPreferenceAutoUsesMP3NativeForMPEGContentType(t *testing.T) {
	mp3Native := &testDecoder{name: "mp3"}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return mp3Native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/mpeg; charset=utf-8",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if mp3Native.called != 1 {
		t.Fatalf("mp3 native decoder called %d times", mp3Native.called)
	}
	if fallback.called != 0 {
		t.Fatalf("fallback should not be called, got %d", fallback.called)
	}
}

func TestDecodeWithPreferenceAutoNativeErrorDoesNotFallback(t *testing.T) {
	nativeErr := errors.New("native hard failure")
	mp3Native := &testDecoder{name: "mp3", err: nativeErr}
	fallback := &testDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return mp3Native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "audio/mpeg",
		SourceID:    "ice-test",
	})
	if !errors.Is(err, nativeErr) {
		t.Fatalf("expected native error, got %v", err)
	}
	if fallback.called != 0 {
		t.Fatalf("fallback should not be called on native hard error, got %d", fallback.called)
	}
}

func TestDecodeWithPreferenceAutoFallbackSeesFullStreamAfterNativeConsumesPrefix(t *testing.T) {
	const consumed = 4
	input := []byte("0123456789abcdef")

	native := &consumingUnsupportedDecoder{n: consumed}
	fallback := &captureStreamDecoder{name: "ffmpeg"}

	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return native })
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(input), decoder.StreamMeta{
		ContentType: "audio/mpeg",
		SourceID:    "ice-test",
	})
	if err != nil {
		t.Fatalf("decode: %v", err)
	}
	if native.called != 1 {
		t.Fatalf("native called %d times", native.called)
	}
	if fallback.called != 1 {
		t.Fatalf("fallback called %d times", fallback.called)
	}
	if !bytes.Equal(fallback.payload, input) {
		t.Fatalf("fallback payload mismatch: got %q want %q", string(fallback.payload), string(input))
	}
}

func TestDecodeWithPreferenceNativeUnsupportedContentTypeFailsWithoutFallback(t *testing.T) {
	fallback := &testDecoder{name: "ffmpeg"}
	reg := decoder.NewRegistry()
	reg.Register("ffmpeg", func() decoder.Decoder { return fallback })

	src := New("ice-test", "http://example", nil, ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("native"),
	)

	err := src.decodeWithPreference(context.Background(), bytes.NewReader(nil), decoder.StreamMeta{
		ContentType: "application/octet-stream",
		SourceID:    "ice-test",
	})
	if err == nil {
		t.Fatal("expected native-mode select error for unsupported content-type")
	}
	if fallback.called != 0 {
		t.Fatalf("fallback should not be called in native mode, got %d", fallback.called)
	}
}

func TestWithDecoderPreferenceFallbackAliasNormalizesToFFmpeg(t *testing.T) {
	src := New("ice-test", "http://example", nil, ReconnectConfig{}, WithDecoderPreference("fallback"))
	if got := src.Descriptor().Codec; got != "ffmpeg" {
		t.Fatalf("codec=%s want ffmpeg", got)
	}
}

func TestDescriptorOriginRedactsCredentialsAndQuery(t *testing.T) {
	src := New("ice-test", "http://user:secret@example.org:8000/live.mp3?token=abc", nil, ReconnectConfig{})
	desc := src.Descriptor()
	if desc.Origin == nil {
		t.Fatalf("expected descriptor origin")
	}
	if desc.Origin.Kind != "url" {
		t.Fatalf("origin kind=%q want url", desc.Origin.Kind)
	}
	if desc.Origin.Endpoint != "http://example.org:8000/live.mp3" {
		t.Fatalf("origin endpoint=%q", desc.Origin.Endpoint)
	}
}

func TestConnectAndRunRequestsICYAndPublishesStreamTitle(t *testing.T) {
	const (
		audioPrefix = "ABCD"
		audioSuffix = "EFGH"
		title       = "Artist - Track"
	)
	var reqIcyHeader atomic.Value
	reqIcyHeader.Store("")

	metadata := buildICYMetadataBlock("StreamTitle='" + title + "';")
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		reqIcyHeader.Store(r.Header.Get("Icy-Metadata"))
		w.Header().Set("Content-Type", "audio/mpeg")
		w.Header().Set("icy-metaint", "4")
		_, _ = w.Write([]byte(audioPrefix))
		_, _ = w.Write([]byte{byte(len(metadata) / 16)})
		_, _ = w.Write(metadata)
		_, _ = w.Write([]byte(audioSuffix))
	}))
	defer srv.Close()

	native := &captureStreamDecoder{name: "mp3"}
	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return native })
	reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

	src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{},
		WithDecoderRegistry(reg),
		WithDecoderPreference("auto"),
	)

	if err := src.connectAndRun(context.Background()); err != nil {
		t.Fatalf("connectAndRun: %v", err)
	}
	if got := reqIcyHeader.Load().(string); got != "1" {
		t.Fatalf("Icy-Metadata header=%q want 1", got)
	}
	if got := string(native.payload); got != audioPrefix+audioSuffix {
		t.Fatalf("decoded payload=%q want %q", got, audioPrefix+audioSuffix)
	}
	stats := src.Stats()
	if stats.StreamTitle != title {
		t.Fatalf("streamTitle=%q want %q", stats.StreamTitle, title)
	}
	if stats.MetadataUpdates < 1 {
		t.Fatalf("metadataUpdates=%d want >=1", stats.MetadataUpdates)
	}
	if stats.IcyMetaInt != 4 {
		t.Fatalf("icyMetaInt=%d want 4", stats.IcyMetaInt)
	}
}

type scriptedLoopDecoder struct {
	mu             sync.Mutex
	actions        []decodeAction
	calls          int
	totalBytesRead int
}

type decodeAction struct {
	err            error
	blockUntilStop bool
}

func (d *scriptedLoopDecoder) Name() string { return "scripted-loop" }

func (d *scriptedLoopDecoder) DecodeStream(ctx context.Context, r io.Reader, _ decoder.StreamMeta, _ func(ingest.PCMChunk) error) error {
	data, err := io.ReadAll(r)
	if err != nil {
		return err
	}

	d.mu.Lock()
	d.calls++
	d.totalBytesRead += len(data)
	callIdx := d.calls - 1
	action := decodeAction{}
	if callIdx < len(d.actions) {
		action = d.actions[callIdx]
	}
	d.mu.Unlock()

	if action.blockUntilStop {
		<-ctx.Done()
		return nil
	}
	return action.err
}

func (d *scriptedLoopDecoder) callCount() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.calls
}

func TestSourceReconnectsWhenStreamEndsCleanly(t *testing.T) {
	var requests atomic.Int64
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requests.Add(1)
		w.Header().Set("Content-Type", "audio/mpeg")
		_, _ = w.Write([]byte("test-stream"))
	}))
	defer srv.Close()

	dec := &scriptedLoopDecoder{
		actions: []decodeAction{
			{}, // first connection ends cleanly (EOS-like)
			{blockUntilStop: true},
		},
	}
	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return dec })
	reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

	src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
		Enabled:          true,
		InitialBackoffMs: 1,
		MaxBackoffMs:     1,
	}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

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

	waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after clean EOS")

	stats := src.Stats()
	if stats.Reconnects < 1 {
		t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
	}
	if got := requests.Load(); got < 2 {
		t.Fatalf("requests=%d want >=2", got)
	}
}

func TestSourceClearsLastErrorAfterSuccessfulReconnect(t *testing.T) {
	const boom = "decoder boom"
	var requests atomic.Int64
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requests.Add(1)
		w.Header().Set("Content-Type", "audio/mpeg")
		_, _ = w.Write([]byte("test-stream"))
	}))
	defer srv.Close()

	dec := &scriptedLoopDecoder{
		actions: []decodeAction{
			{err: errors.New(boom)}, // first attempt fails
			{blockUntilStop: true},  // second attempt recovers and stays running
		},
	}
	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return dec })
	reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

	src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
		Enabled:          true,
		InitialBackoffMs: 1,
		MaxBackoffMs:     1,
	}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

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

	select {
	case err := <-src.Errors():
		if err == nil || !strings.Contains(err.Error(), boom) {
			t.Fatalf("error=%v want contains %q", err, boom)
		}
	case <-time.After(1 * time.Second):
		t.Fatal("timed out waiting for source error reporting")
	}

	waitForCondition(t, func() bool {
		st := src.Stats()
		return dec.callCount() >= 2 && st.LastError == ""
	}, "lastError cleared after successful reconnect")

	if got := requests.Load(); got < 2 {
		t.Fatalf("requests=%d want >=2", got)
	}
}

func TestNewWithoutClientUsesStreamingSafeHTTPClient(t *testing.T) {
	src := New("ice-test", "http://example", nil, ReconnectConfig{})
	if src.client == nil {
		t.Fatal("expected default http client")
	}
	if src.client.Timeout != 0 {
		t.Fatalf("client timeout=%v want 0 for streaming", src.client.Timeout)
	}
}

func TestSourceReconnectsAfterDeadlineExceededError(t *testing.T) {
	var requests atomic.Int64
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		requests.Add(1)
		w.Header().Set("Content-Type", "audio/mpeg")
		_, _ = w.Write([]byte("test-stream"))
	}))
	defer srv.Close()

	dec := &scriptedLoopDecoder{
		actions: []decodeAction{
			{err: context.DeadlineExceeded}, // first attempt fails transiently
			{blockUntilStop: true},          // second attempt recovers and stays running
		},
	}
	reg := decoder.NewRegistry()
	reg.Register("mp3", func() decoder.Decoder { return dec })
	reg.Register("ffmpeg", func() decoder.Decoder { return &testDecoder{name: "ffmpeg"} })

	src := New("ice-test", srv.URL, srv.Client(), ReconnectConfig{
		Enabled:          true,
		InitialBackoffMs: 1,
		MaxBackoffMs:     1,
	}, WithDecoderRegistry(reg), WithDecoderPreference("auto"))

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

	waitForCondition(t, func() bool { return dec.callCount() >= 2 }, "second decode call after deadline exceeded")

	stats := src.Stats()
	if stats.Reconnects < 1 {
		t.Fatalf("reconnects=%d want >=1", stats.Reconnects)
	}
	if got := requests.Load(); got < 2 {
		t.Fatalf("requests=%d want >=2", got)
	}
}

func waitForCondition(t *testing.T, cond func() bool, label string) {
	t.Helper()
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		if cond() {
			return
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("timeout waiting for condition: %s", label)
}
