package grpcapi

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

	gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
)

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

	stream := newHeartbeatingStream(newCapturingStream(t), 0, nil)
	assert.Nil(t, stream, "zero interval must not allocate a wrapper")

	negative := newHeartbeatingStream(newCapturingStream(t), -time.Second, nil)
	assert.Nil(t, negative, "negative interval must not allocate a wrapper")
}

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

	inner := newCapturingStream(t)
	hb := newHeartbeatingStream(inner, 30*time.Millisecond, nil)
	require.NotNil(t, hb)
	defer hb.Stop()

	go func() { _ = hb.Run(t.Context()) }()

	event := inner.recv(t, 200*time.Millisecond)
	assert.Equal(t, gatewayHeartbeatEventType, event.GetEventType())
	// Heartbeat envelope: only the event type travels. Every other
	// field stays at proto3 default so the wire frame stays minimal.
	assert.Empty(t, event.GetEventId())
	assert.Zero(t, event.GetTimestampMs())
	assert.Empty(t, event.GetPayloadBytes())
	assert.Empty(t, event.GetPayloadHash())
	assert.Empty(t, event.GetSignature())
	assert.Empty(t, event.GetRequestId())
	assert.Empty(t, event.GetTraceId())
}

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

	inner := newCapturingStream(t)
	hb := newHeartbeatingStream(inner, 50*time.Millisecond, nil)
	require.NotNil(t, hb)
	defer hb.Stop()

	go func() { _ = hb.Run(t.Context()) }()

	// Reset the timer every 20ms for 120ms — the silence window never
	// elapses, so the heartbeat goroutine must stay quiet and the
	// channel must only carry the manual real-event Sends.
	go func() {
		ticker := time.NewTicker(20 * time.Millisecond)
		defer ticker.Stop()
		for range 6 {
			<-ticker.C
			if err := hb.Send(&gatewayv1.GatewayEvent{EventType: "real.event"}); err != nil {
				t.Errorf("real Send failed: %v", err)
				return
			}
		}
	}()

	for range 6 {
		event := inner.recv(t, 100*time.Millisecond)
		assert.Equal(t, "real.event", event.GetEventType(), "only real events should appear while Send keeps resetting the silence window")
	}
}

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

	inner := newCapturingStream(t)
	hb := newHeartbeatingStream(inner, 20*time.Millisecond, nil)
	require.NotNil(t, hb)

	runDone := make(chan error, 1)
	go func() { runDone <- hb.Run(context.Background()) }()

	hb.Stop()
	select {
	case err := <-runDone:
		require.NoError(t, err)
	case <-time.After(200 * time.Millisecond):
		t.Fatal("Run did not exit after Stop")
	}

	// Stop is idempotent; the second call must not panic on the
	// already-closed done channel.
	assert.NotPanics(t, hb.Stop)
}

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

	inner := newCapturingStream(t)
	hb := newHeartbeatingStream(inner, 20*time.Millisecond, nil)
	require.NotNil(t, hb)
	defer hb.Stop()

	ctx, cancel := context.WithCancel(context.Background())
	runDone := make(chan error, 1)
	go func() { runDone <- hb.Run(ctx) }()

	cancel()
	select {
	case err := <-runDone:
		require.NoError(t, err)
	case <-time.After(200 * time.Millisecond):
		t.Fatal("Run did not exit after context cancel")
	}
}

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

	wantErr := errors.New("send failed")
	inner := newCapturingStream(t)
	inner.sendErr.Store(&errorBox{err: wantErr})

	hb := newHeartbeatingStream(inner, time.Minute, nil)
	require.NotNil(t, hb)
	defer hb.Stop()

	err := hb.Send(&gatewayv1.GatewayEvent{EventType: "real.event"})
	require.ErrorIs(t, err, wantErr)
}

// capturingStream is a minimal grpc.ServerStreamingServer that pushes
// every Send into a channel so tests can assert on the wire frame.
type capturingStream struct {
	grpc.ServerStreamingServer[gatewayv1.GatewayEvent]

	events  chan *gatewayv1.GatewayEvent
	sendErr atomic.Pointer[errorBox]
}

type errorBox struct{ err error }

func newCapturingStream(t *testing.T) *capturingStream {
	t.Helper()

	return &capturingStream{events: make(chan *gatewayv1.GatewayEvent, 16)}
}

func (s *capturingStream) Send(event *gatewayv1.GatewayEvent) error {
	if box := s.sendErr.Load(); box != nil {
		return box.err
	}
	s.events <- event

	return nil
}

func (s *capturingStream) Context() context.Context { return context.Background() }

func (s *capturingStream) SetHeader(metadata.MD) error  { return nil }
func (s *capturingStream) SendHeader(metadata.MD) error { return nil }
func (s *capturingStream) SetTrailer(metadata.MD)       {}
func (s *capturingStream) SendMsg(any) error            { return errors.New("capturingStream.SendMsg: unused") }
func (s *capturingStream) RecvMsg(any) error            { return errors.New("capturingStream.RecvMsg: unused") }

func (s *capturingStream) recv(t *testing.T, timeout time.Duration) *gatewayv1.GatewayEvent {
	t.Helper()

	select {
	case event := <-s.events:
		return event
	case <-time.After(timeout):
		t.Fatalf("no event captured within %s", timeout)
		return nil
	}
}
