package notifications

import (
	"context"
	"errors"
	"fmt"
	"io"
	"sync"
	"testing"
	"testing/synctest"
	"time"

	"github.com/lightninglabs/aperture/l402"
	"github.com/lightninglabs/loop/swapserverrpc"
	"github.com/lightningnetwork/lnd/lntypes"
	"github.com/stretchr/testify/require"
	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
)

var (
	testReservationId  = []byte{0x01, 0x02}
	testReservationId2 = []byte{0x03, 0x04}
)

// mockNotificationsClient implements the NotificationsClient interface for testing.
type mockNotificationsClient struct {
	sync.Mutex

	mockStream   swapserverrpc.SwapServer_SubscribeNotificationsClient
	subscribeErr error
	attemptTimes []time.Time
	timesCalled  int
}

func (m *mockNotificationsClient) SubscribeNotifications(ctx context.Context,
	in *swapserverrpc.SubscribeNotificationsRequest,
	opts ...grpc.CallOption) (
	swapserverrpc.SwapServer_SubscribeNotificationsClient, error) {

	m.Lock()
	defer m.Unlock()

	m.timesCalled++
	m.attemptTimes = append(m.attemptTimes, time.Now())
	if m.subscribeErr != nil {
		return nil, m.subscribeErr
	}
	return m.mockStream, nil
}

// mockSubscribeNotificationsClient simulates the server stream.
type mockSubscribeNotificationsClient struct {
	grpc.ClientStream

	recvChan    chan *swapserverrpc.SubscribeNotificationsResponse
	recvErrChan chan error
}

func (m *mockSubscribeNotificationsClient) Recv() (
	*swapserverrpc.SubscribeNotificationsResponse, error) {

	select {
	case err := <-m.recvErrChan:
		return nil, err
	case notif, ok := <-m.recvChan:
		if !ok {
			return nil, io.EOF
		}
		return notif, nil
	}
}

func (m *mockSubscribeNotificationsClient) Header() (metadata.MD, error) {
	return nil, nil
}

func (m *mockSubscribeNotificationsClient) Trailer() metadata.MD {
	return nil
}

func (m *mockSubscribeNotificationsClient) CloseSend() error {
	return nil
}

func (m *mockSubscribeNotificationsClient) Context() context.Context {
	return context.TODO()
}

func (m *mockSubscribeNotificationsClient) SendMsg(any) error {
	return nil
}

func (m *mockSubscribeNotificationsClient) RecvMsg(any) error {
	return nil
}

// TestManager_ReservationNotification tests that the Manager correctly
// forwards reservation notifications to subscribers.
func TestManager_ReservationNotification(t *testing.T) {
	t.Parallel()

	// Create a mock notification client
	recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse, 1)
	errChan := make(chan error, 1)
	mockStream := &mockSubscribeNotificationsClient{
		recvChan:    recvChan,
		recvErrChan: errChan,
	}
	mockClient := &mockNotificationsClient{
		mockStream: mockStream,
	}

	// Create a Manager with the mock client
	mgr := NewManager(&Config{
		Client: mockClient,
		CurrentToken: func() (*l402.Token, error) {
			// Simulate successful fetching of L402
			return &l402.Token{
				Preimage: lntypes.Preimage{1, 2, 3},
			}, nil
		},
	})

	// Subscribe to reservation notifications.
	subCtx, subCancel := context.WithCancel(context.Background())
	subChan := mgr.SubscribeReservations(subCtx)

	// Run the manager.
	ctx := t.Context()

	go func() {
		err := mgr.Run(ctx)
		require.NoError(t, err)
	}()

	// Wait a bit to ensure manager is running and has subscribed to the
	// server notifications stream.
	require.Eventually(t, func() bool {
		mockClient.Lock()
		defer mockClient.Unlock()

		return mockClient.timesCalled > 0
	}, time.Second*5, 10*time.Millisecond)

	require.Eventually(t, func() bool {
		mockClient.Lock()
		defer mockClient.Unlock()
		return mockClient.timesCalled == 1
	}, time.Second*5, 10*time.Millisecond)

	// Send a test notification
	testNotif := getTestNotification(testReservationId)

	// Send the notification to the recvChan
	recvChan <- testNotif

	// Collect the notification in the callback
	receivedNotification := <-subChan

	// Now, check that the notification received in the callback matches the one sent
	require.NotNil(t, receivedNotification)
	require.Equal(t, testReservationId, receivedNotification.ReservationId)

	// Cancel the subscription
	subCancel()

	// Send another test notification`
	testNotif2 := getTestNotification(testReservationId2)
	recvChan <- testNotif2

	// Check that the subChan is eventually closed.
	require.Eventually(t, func() bool {
		select {
		case _, ok := <-subChan:
			return !ok
		default:
			return false
		}
	}, time.Second*5, 10*time.Millisecond)
}

func getTestNotification(resId []byte) *swapserverrpc.SubscribeNotificationsResponse {
	return &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.SubscribeNotificationsResponse_ReservationNotification{
			ReservationNotification: &swapserverrpc.ServerReservationNotification{
				ReservationId: resId,
			},
		},
	}
}

// unfinishedSwapNotification builds an unfinished swap notification.
func unfinishedSwapNotification(
	swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {

	return &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.
			SubscribeNotificationsResponse_UnfinishedSwap{
			UnfinishedSwap: &swapserverrpc.
				ServerUnfinishedSwapNotification{
				SwapHash: swapHash[:],
			},
		},
	}
}

// staticLoopInSweepNotification builds a static loop-in sweep notification.
func staticLoopInSweepNotification(
	swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {

	return &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.
			SubscribeNotificationsResponse_StaticLoopInSweep{
			StaticLoopInSweep: &swapserverrpc.
				ServerStaticLoopInSweepNotification{
				SwapHash: swapHash[:],
			},
		},
	}
}

// staticLoopInRiskAcceptedNotification builds a risk accepted notification.
func staticLoopInRiskAcceptedNotification(
	swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {

	return &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.
			SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
			StaticLoopInRiskAccepted: &swapserverrpc.
				ServerStaticLoopInRiskAcceptedNotification{
				SwapHash: swapHash[:],
			},
		},
	}
}

// staticLoopInRiskRejectedNotification builds a risk rejected notification.
func staticLoopInRiskRejectedNotification(
	swapHash lntypes.Hash) *swapserverrpc.SubscribeNotificationsResponse {

	return &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.
			SubscribeNotificationsResponse_StaticLoopInRiskRejected{
			StaticLoopInRiskRejected: &swapserverrpc.
				ServerStaticLoopInRiskRejectedNotification{
				SwapHash: swapHash[:],
			},
		},
	}
}

type staticLoopInRiskNotification interface {
	GetSwapHash() []byte
}

// assertStaticLoopInRiskNotificationSwapScoped checks swap-scoped fanout.
func assertStaticLoopInRiskNotificationSwapScoped[
	T staticLoopInRiskNotification](t *testing.T,
	subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
	notification func(lntypes.Hash) *swapserverrpc.
		SubscribeNotificationsResponse, label string,
	swapHashA, swapHashB lntypes.Hash) {

	t.Helper()

	mgr := NewManager(&Config{})

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	subChanA := subscribe(mgr, subCtx, swapHashA)
	subChanB := subscribe(mgr, subCtx, swapHashB)

	mgr.handleNotification(t.Context(), notification(swapHashA))

	select {
	case received := <-subChanA:
		require.Equal(t, swapHashA[:], received.GetSwapHash())

	case <-time.After(time.Second):
		t.Fatalf("did not receive first swap risk %s notification",
			label)
	}

	select {
	case received := <-subChanB:
		t.Fatalf("second swap received wrong notification: %x",
			received.GetSwapHash())

	default:
	}

	mgr.handleNotification(t.Context(), notification(swapHashB))

	select {
	case received := <-subChanB:
		require.Equal(t, swapHashB[:], received.GetSwapHash())

	case <-time.After(time.Second):
		t.Fatalf("did not receive second swap risk %s notification",
			label)
	}
}

// TestManager_SlowReservationSubscriberDoesNotBlock tests that a reservation
// subscriber with a full notification channel does not block delivery to other
// subscribers. Reservation notifications are best-effort, so slow subscribers
// drop new notifications instead of queueing them.
func TestManager_SlowReservationSubscriberDoesNotBlock(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	slowCtx, slowCancel := context.WithCancel(t.Context())
	defer slowCancel()
	slowChan := mgr.SubscribeReservations(slowCtx)

	fastCtx, fastCancel := context.WithCancel(t.Context())
	defer fastCancel()
	fastChan := mgr.SubscribeReservations(fastCtx)

	firstNotif := getTestNotification(testReservationId)
	mgr.handleNotification(t.Context(), firstNotif)

	received := <-fastChan
	require.Equal(t, testReservationId, received.ReservationId)

	secondNotif := getTestNotification(testReservationId2)
	done := make(chan struct{})
	go func() {
		mgr.handleNotification(t.Context(), secondNotif)
		close(done)
	}()

	require.Eventually(t, func() bool {
		select {
		case <-done:
			return true
		default:
			return false
		}
	}, time.Second, 10*time.Millisecond)

	select {
	case received = <-fastChan:
		require.Equal(t, testReservationId2, received.ReservationId)

	case <-time.After(time.Second):
		t.Fatal("fast subscriber did not receive notification")
	}

	require.Len(t, slowChan, 1)

	select {
	case received = <-slowChan:
		require.Equal(t, testReservationId, received.ReservationId)

	case <-time.After(time.Second):
		t.Fatal("slow subscriber did not receive first notification")
	}

	select {
	case received = <-slowChan:
		t.Fatalf("slow subscriber received dropped notification %x",
			received.ReservationId)

	case <-time.After(50 * time.Millisecond):
	}
}

// TestManager_UnfinishedSwapNotificationWaitsForSubscriber verifies that
// unfinished swap recovery notifications are not dropped when the local
// subscriber is briefly behind.
func TestManager_UnfinishedSwapNotificationWaitsForSubscriber(t *testing.T) {
	t.Parallel()

	assertQueuedSwapHashNotifications(
		t,
		func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
			ServerUnfinishedSwapNotification {

			return mgr.SubscribeUnfinishedSwaps(ctx)
		},
		unfinishedSwapNotification,
		func(ntfn *swapserverrpc.ServerUnfinishedSwapNotification) []byte {
			return ntfn.SwapHash
		},
		lntypes.Hash{0x02, 0x03}, lntypes.Hash{0x04, 0x05},
		"did not receive first unfinished swap notification",
		"second unfinished swap notification was dropped",
	)
}

// TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber verifies
// that a full static-loop-in sweep subscriber channel does not block the global
// notification receive loop.
func TestManager_StaticLoopInSweepNotificationQueuesForSlowSubscriber(
	t *testing.T) {

	t.Parallel()

	assertQueuedSwapHashNotifications(
		t,
		func(mgr *Manager, ctx context.Context) <-chan *swapserverrpc.
			ServerStaticLoopInSweepNotification {

			return mgr.SubscribeStaticLoopInSweepRequests(ctx)
		},
		staticLoopInSweepNotification,
		func(ntfn *swapserverrpc.ServerStaticLoopInSweepNotification) []byte {
			return ntfn.SwapHash
		},
		lntypes.Hash{0x12, 0x13}, lntypes.Hash{0x14, 0x15},
		"did not receive first sweep notification",
		"second sweep notification was not queued",
	)
}

// TestManager_QueuedNotificationChannelClosesOnCancel verifies that queued
// subscribers own their channel shutdown even when delivery is blocked.
func TestManager_QueuedNotificationChannelClosesOnCancel(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	subCtx, subCancel := context.WithCancel(t.Context())
	subChan := mgr.SubscribeUnfinishedSwaps(subCtx)

	swapHashA := lntypes.Hash{0x21, 0x22}
	mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashA))

	require.Eventually(t, func() bool {
		return len(subChan) == 1
	}, time.Second, 10*time.Millisecond)

	swapHashB := lntypes.Hash{0x23, 0x24}
	done := make(chan struct{})
	go func() {
		mgr.handleNotification(t.Context(), unfinishedSwapNotification(swapHashB))
		close(done)
	}()

	require.Eventually(t, func() bool {
		select {
		case <-done:
			return true
		default:
			return false
		}
	}, time.Second, 10*time.Millisecond)

	subCancel()

	select {
	case received, ok := <-subChan:
		require.True(t, ok)
		require.Equal(t, swapHashA[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("first unfinished swap notification was not delivered")
	}

	require.Eventually(t, func() bool {
		select {
		case _, ok := <-subChan:
			return !ok
		default:
			return false
		}
	}, time.Second, 10*time.Millisecond)
}

// TestNotificationQueueDropsAtCapacity checks the queue's explicit drop policy
// once a subscriber reaches its configured backlog limit.
func TestNotificationQueueDropsAtCapacity(t *testing.T) {
	t.Parallel()

	ctx, cancel := context.WithCancel(t.Context())
	defer cancel()

	recvChan := make(chan int, 1)
	enqueue := newNotificationQueue(ctx, recvChan, 0)

	enqueue(1)

	select {
	case ntfn := <-recvChan:
		t.Fatalf("received dropped notification %d", ntfn)

	case <-time.After(50 * time.Millisecond):
	}
}

// assertQueuedSwapHashNotifications checks queued delivery for swap hashes.
func assertQueuedSwapHashNotifications[T any](t *testing.T,
	subscribe func(*Manager, context.Context) <-chan T,
	notification func(lntypes.Hash) *swapserverrpc.
		SubscribeNotificationsResponse,
	swapHash func(T) []byte, swapHashA, swapHashB lntypes.Hash,
	firstFailureMsg, secondFailureMsg string) {

	t.Helper()

	mgr := NewManager(&Config{})

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	subChan := subscribe(mgr, subCtx)

	mgr.handleNotification(t.Context(), notification(swapHashA))

	done := make(chan struct{})
	go func() {
		mgr.handleNotification(t.Context(), notification(swapHashB))
		close(done)
	}()

	require.Eventually(t, func() bool {
		select {
		case <-done:
			return true
		default:
			return false
		}
	}, time.Second, 10*time.Millisecond)

	select {
	case received := <-subChan:
		require.Equal(t, swapHashA[:], swapHash(received))

	case <-time.After(time.Second):
		t.Fatal(firstFailureMsg)
	}

	select {
	case received := <-subChan:
		require.Equal(t, swapHashB[:], swapHash(received))

	case <-time.After(time.Second):
		t.Fatal(secondFailureMsg)
	}
}

// TestManager_StaticLoopInRiskAcceptedNotification tests that the Manager
// forwards static loop in risk accepted notifications to subscribers.
func TestManager_StaticLoopInRiskAcceptedNotification(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	swapHash := lntypes.Hash{0x04, 0x05}

	subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)

	mgr.handleNotification(
		t.Context(),
		&swapserverrpc.SubscribeNotificationsResponse{
			Notification: &swapserverrpc.
				SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
				StaticLoopInRiskAccepted: &swapserverrpc.
					ServerStaticLoopInRiskAcceptedNotification{
					SwapHash: swapHash[:],
				},
			},
		},
	)

	select {
	case received := <-subChan:
		require.Equal(t, swapHash[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("did not receive risk accepted notification")
	}
}

// TestManager_StaticLoopInRiskDecisionPersists verifies that risk decisions are
// handed to the durable callback before they are treated as delivered.
func TestManager_StaticLoopInRiskDecisionPersists(t *testing.T) {
	t.Parallel()

	type persistedDecision struct {
		swapHash lntypes.Hash
		accepted bool
	}

	persisted := make(chan persistedDecision, 2)
	mgr := NewManager(&Config{
		PersistStaticLoopInRiskDecision: func(_ context.Context,
			swapHash lntypes.Hash, accepted bool) error {

			persisted <- persistedDecision{
				swapHash: swapHash,
				accepted: accepted,
			}

			return nil
		},
	})

	acceptedHash := lntypes.Hash{0x16, 0x17}
	rejectedHash := lntypes.Hash{0x18, 0x19}

	mgr.handleNotification(
		t.Context(), staticLoopInRiskAcceptedNotification(acceptedHash),
	)
	mgr.handleNotification(
		t.Context(), staticLoopInRiskRejectedNotification(rejectedHash),
	)

	select {
	case decision := <-persisted:
		require.Equal(t, acceptedHash, decision.swapHash)
		require.True(t, decision.accepted)

	case <-time.After(time.Second):
		t.Fatal("accepted risk decision was not persisted")
	}

	select {
	case decision := <-persisted:
		require.Equal(t, rejectedHash, decision.swapHash)
		require.False(t, decision.accepted)

	case <-time.After(time.Second):
		t.Fatal("rejected risk decision was not persisted")
	}
}

// TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure verifies that an
// early risk notification is still cached if the swap row does not exist yet.
func TestManager_StaticLoopInRiskDecisionReplayOnPersistFailure(t *testing.T) {
	t.Parallel()

	swapHash := lntypes.Hash{0x1a, 0x1b}
	mgr := NewManager(&Config{
		PersistStaticLoopInRiskDecision: func(_ context.Context,
			_ lntypes.Hash, _ bool) error {

			return errors.New("swap not stored yet")
		},
	})

	mgr.handleNotification(
		t.Context(), staticLoopInRiskAcceptedNotification(swapHash),
	)

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)

	select {
	case received := <-subChan:
		require.Equal(t, swapHash[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("did not replay risk notification after persist failure")
	}
}

// TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel verifies that
// a non-persisted risk decision remains replayable if the subscriber is canceled
// before the FSM has a chance to process it.
func TestManager_StaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
	t *testing.T) {

	t.Parallel()

	assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
		t,
		(*Manager).SubscribeStaticLoopInRiskAccepted,
		staticLoopInRiskAcceptedNotification,
	)
	assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel(
		t,
		(*Manager).SubscribeStaticLoopInRiskRejected,
		staticLoopInRiskRejectedNotification,
	)
}

func assertStaticLoopInRiskDecisionReplaysAfterSubscriberCancel[
	T staticLoopInRiskNotification](t *testing.T,
	subscribe func(*Manager, context.Context, lntypes.Hash) <-chan T,
	notification func(lntypes.Hash) *swapserverrpc.
		SubscribeNotificationsResponse) {

	t.Helper()

	swapHash := lntypes.Hash{0x2a, 0x2b}
	mgr := NewManager(&Config{
		PersistStaticLoopInRiskDecision: func(_ context.Context,
			_ lntypes.Hash, _ bool) error {

			return errors.New("swap not stored yet")
		},
	})

	subCtx, subCancel := context.WithCancel(t.Context())
	subChan := subscribe(mgr, subCtx, swapHash)

	mgr.handleNotification(t.Context(), notification(swapHash))

	require.Eventually(t, func() bool {
		return len(subChan) == 1
	}, time.Second, 10*time.Millisecond)

	subCancel()

	select {
	case <-subChan:

	case <-time.After(time.Second):
		t.Fatal("risk decision notification was not delivered before " +
			"cancel")
	}

	select {
	case _, ok := <-subChan:
		require.False(t, ok)

	case <-time.After(time.Second):
		t.Fatal("risk decision subscription did not close after cancel")
	}

	replayCtx, replayCancel := context.WithCancel(t.Context())
	defer replayCancel()

	replayChan := subscribe(mgr, replayCtx, swapHash)
	select {
	case received := <-replayChan:
		require.Equal(t, swapHash[:], received.GetSwapHash())

	case <-time.After(time.Second):
		t.Fatal("cached risk decision was lost after subscriber " +
			"cancellation")
	}
}

// TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskAcceptedNotificationSwapScoped(t *testing.T) {
	t.Parallel()

	assertStaticLoopInRiskNotificationSwapScoped(
		t, func(m *Manager, ctx context.Context,
			swapHash lntypes.Hash) <-chan *swapserverrpc.
			ServerStaticLoopInRiskAcceptedNotification {

			return m.SubscribeStaticLoopInRiskAccepted(ctx, swapHash)
		}, staticLoopInRiskAcceptedNotification, "accepted",
		lntypes.Hash{0x04, 0x05}, lntypes.Hash{0x06, 0x07},
	)
}

// TestManager_StaticLoopInRiskAcceptedNotificationReplay tests that the Manager
// replays a risk accepted notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskAcceptedNotificationReplay(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	swapHash := lntypes.Hash{0x06, 0x07}
	mgr.handleNotification(
		t.Context(),
		&swapserverrpc.SubscribeNotificationsResponse{
			Notification: &swapserverrpc.
				SubscribeNotificationsResponse_StaticLoopInRiskAccepted{
				StaticLoopInRiskAccepted: &swapserverrpc.
					ServerStaticLoopInRiskAcceptedNotification{
					SwapHash: swapHash[:],
				},
			},
		},
	)

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	subChan := mgr.SubscribeStaticLoopInRiskAccepted(subCtx, swapHash)

	select {
	case received := <-subChan:
		require.Equal(t, swapHash[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("did not replay risk accepted notification")
	}
}

// TestManager_StaticLoopInRiskRejectedNotification tests that the Manager
// forwards static loop in risk rejected notifications to subscribers.
func TestManager_StaticLoopInRiskRejectedNotification(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	swapHash := lntypes.Hash{0x08, 0x09}

	subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)

	mgr.handleNotification(
		t.Context(),
		&swapserverrpc.SubscribeNotificationsResponse{
			Notification: &swapserverrpc.
				SubscribeNotificationsResponse_StaticLoopInRiskRejected{
				StaticLoopInRiskRejected: &swapserverrpc.
					ServerStaticLoopInRiskRejectedNotification{
					SwapHash: swapHash[:],
				},
			},
		},
	)

	select {
	case received := <-subChan:
		require.Equal(t, swapHash[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("did not receive risk rejected notification")
	}
}

// TestManager_StaticLoopInRiskRejectedNotificationSwapScoped verifies that a
// notification for one swap does not occupy another swap's subscriber channel.
func TestManager_StaticLoopInRiskRejectedNotificationSwapScoped(t *testing.T) {
	t.Parallel()

	assertStaticLoopInRiskNotificationSwapScoped(
		t, func(m *Manager, ctx context.Context,
			swapHash lntypes.Hash) <-chan *swapserverrpc.
			ServerStaticLoopInRiskRejectedNotification {

			return m.SubscribeStaticLoopInRiskRejected(ctx, swapHash)
		}, staticLoopInRiskRejectedNotification, "rejected",
		lntypes.Hash{0x08, 0x09}, lntypes.Hash{0x0a, 0x0b},
	)
}

// TestManager_StaticLoopInRiskRejectedNotificationReplay tests that the Manager
// replays a risk rejected notification that arrives before the swap-specific
// subscriber is registered.
func TestManager_StaticLoopInRiskRejectedNotificationReplay(t *testing.T) {
	t.Parallel()

	mgr := NewManager(&Config{})

	swapHash := lntypes.Hash{0x0a, 0x0b}
	mgr.handleNotification(
		t.Context(),
		&swapserverrpc.SubscribeNotificationsResponse{
			Notification: &swapserverrpc.
				SubscribeNotificationsResponse_StaticLoopInRiskRejected{
				StaticLoopInRiskRejected: &swapserverrpc.
					ServerStaticLoopInRiskRejectedNotification{
					SwapHash: swapHash[:],
				},
			},
		},
	)

	subCtx, subCancel := context.WithCancel(t.Context())
	defer subCancel()

	subChan := mgr.SubscribeStaticLoopInRiskRejected(subCtx, swapHash)

	select {
	case received := <-subChan:
		require.Equal(t, swapHash[:], received.SwapHash)

	case <-time.After(time.Second):
		t.Fatal("did not replay risk rejected notification")
	}
}

// TestManager_Backoff verifies that repeated failures in
// subscribeNotifications cause the Manager to space out subscription attempts
// via a predictable incremental backoff.
func TestManager_Backoff(t *testing.T) {
	t.Parallel()

	// We'll tolerate a bit of jitter in the timing checks.
	const tolerance = 300 * time.Millisecond

	recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
	recvErrChan := make(chan error)

	mockStream := &mockSubscribeNotificationsClient{
		recvChan:    recvChan,
		recvErrChan: recvErrChan,
	}

	// Create a new mock client that will fail to subscribe.
	mockClient := &mockNotificationsClient{
		mockStream:   mockStream,
		subscribeErr: errors.New("failing on purpose"),
	}

	// Manager with a successful CurrentToken so that it always tries
	// to subscribe.
	mgr := NewManager(&Config{
		Client: mockClient,
		CurrentToken: func() (*l402.Token, error) {
			// Simulate successful fetching of L402
			return &l402.Token{
				Preimage: lntypes.Preimage{1, 2, 3},
			}, nil
		},
	})

	// Run the manager in a background goroutine.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	wg.Go(func() {
		// We ignore the returned error because the Manager returns
		// nil on context cancel.
		_ = mgr.Run(ctx)
	})

	// Wait long enough to see at least 3 subscription attempts using
	// the Manager's default pattern.
	// We'll wait ~5 seconds total so we capture at least 3 attempts:
	//   - Attempt #1: immediate
	//   - Attempt #2: ~1 second
	//   - Attempt #3: ~3 seconds after that etc.
	time.Sleep(5 * time.Second)

	// Cancel the context to stop the manager.
	cancel()
	wg.Wait()

	// Check how many attempts we made.
	require.GreaterOrEqual(t, len(mockClient.attemptTimes), 3,
		"expected at least 3 attempts within 5 seconds",
	)

	expectedDelay := time.Second
	for i := 1; i < len(mockClient.attemptTimes); i++ {
		// The expected delay for the i-th gap (comparing attempt i to
		// attempt i-1) is i seconds (because the manager increments
		// the backoff by 1 second each time).
		actualDelay := mockClient.attemptTimes[i].Sub(
			mockClient.attemptTimes[i-1],
		)

		require.InDeltaf(
			t, expectedDelay, actualDelay, float64(tolerance),
			"Attempt %d -> Attempt %d delay should be ~%v, got %v",
			i, i+1, expectedDelay, actualDelay,
		)

		expectedDelay += time.Second
	}
}

// TestManager_MinAliveConnTime verifies that the Manager enforces the minimum
// alive connection time before considering a subscription successful.
func TestManager_MinAliveConnTime(t *testing.T) {
	t.Parallel()

	// Tolerance to allow for scheduling jitter.
	const tolerance = 300 * time.Millisecond

	// Set a small MinAliveConnTime so the test doesn't run too long.
	// Once a subscription stays alive longer than 2s, the manager resets
	// its backoff to 10s on the next loop iteration.
	const minAlive = 1 * time.Second

	// We'll provide a channel for incoming notifications
	// and another for forcing errors to close the subscription.
	recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
	recvErrChan := make(chan error)

	mockStream := &mockSubscribeNotificationsClient{
		recvChan:    recvChan,
		recvErrChan: recvErrChan,
	}

	// No immediate error from SubscribeNotifications, so it "succeeds".
	// We trigger subscription closure by sending an error to recvErrChan.
	mockClient := &mockNotificationsClient{
		mockStream: mockStream,
		// subscribeErr stays nil => success on each call.
	}

	// Create a Manager that uses our mock client and enforces
	// MinAliveConnTime=2s.
	mgr := NewManager(&Config{
		Client:           mockClient,
		MinAliveConnTime: minAlive,
		CurrentToken: func() (*l402.Token, error) {
			// Simulate successful fetching of L402
			return &l402.Token{
				Preimage: lntypes.Preimage{1, 2, 3},
			}, nil
		},
	})

	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	wg.Go(func() {
		_ = mgr.Run(ctx)
	})

	// Let the subscription stay alive for 2s, which is >1s (minAlive).
	// Then force an error to end the subscription. The manager sees
	// it stayed connected ~2s and resets its backoff to 10s.
	go func() {
		time.Sleep(2 * time.Second)
		recvErrChan <- errors.New("mock subscription closed")
	}()

	// Wait enough time (~13s) to see:
	//  - First subscription (2s)
	//  - Manager resets to 10s
	//  - Second subscription attempt starts ~10s later.
	time.Sleep(13 * time.Second)

	// Signal EOF so the subscription stops.
	close(recvChan)

	// Stop the manager and wait for cleanup.
	cancel()
	wg.Wait()

	// Expect at least 2 attempts in attemptTimes:
	//  1) The one that stayed alive for 2s,
	//  2) The next attempt ~10s after that.
	require.GreaterOrEqual(
		t, len(mockClient.attemptTimes), 2,
		"expected at least 2 attempts with a successful subscription",
	)

	require.InDeltaf(
		t, 12*time.Second,
		mockClient.attemptTimes[1].Sub(mockClient.attemptTimes[0]),
		float64(tolerance),
		"Second attempt should occur ~2s after the first",
	)
}

// TestManager_Backoff_Pending_Token verifies that the Manager backs off when
// the token is pending.
func TestManager_Backoff_Pending_Token(t *testing.T) {
	t.Parallel()

	// We'll tolerate a bit of jitter in the timing checks.
	const tolerance = 300 * time.Millisecond

	recvChan := make(chan *swapserverrpc.SubscribeNotificationsResponse)
	recvErrChan := make(chan error)

	mockStream := &mockSubscribeNotificationsClient{
		recvChan:    recvChan,
		recvErrChan: recvErrChan,
	}

	// Create a new mock client that will fail to subscribe.
	mockClient := &mockNotificationsClient{
		mockStream: mockStream,
		// subscribeErr stays nil => would succeed on each call.
	}

	var tokenCalls []time.Time
	// Manager with a successful CurrentToken so that it always tries
	// to subscribe.
	mgr := NewManager(&Config{
		Client: mockClient,
		CurrentToken: func() (*l402.Token, error) {
			tokenCalls = append(tokenCalls, time.Now())
			if len(tokenCalls) < 3 {
				// Simulate a pending token.
				return &l402.Token{}, nil
			}

			// Simulate successful fetching of L402
			return &l402.Token{
				Preimage: lntypes.Preimage{1, 2, 3},
			}, nil
		},
	})

	// Run the manager in a background goroutine.
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var wg sync.WaitGroup
	wg.Go(func() {
		// We ignore the returned error because the Manager returns
		// nil on context cancel.
		_ = mgr.Run(ctx)
	})

	// Wait long enough to see at least 3 token calls, so we can see that
	// we'll indeed backoff when the token is pending.
	time.Sleep(5 * time.Second)

	// Signal EOF so the subscription stops.
	close(recvChan)

	// Cancel the context to stop the manager.
	cancel()
	wg.Wait()

	// Expect exactly 3 token calls.
	require.Len(t, tokenCalls, 3)

	require.InDeltaf(
		t, 3*time.Second, tokenCalls[2].Sub(tokenCalls[0]),
		float64(tolerance),
		"Expected to backoff for at ~3 seconds due to pending token",
	)
}

// TestManager_HtlcConfirmedNotification tests that the Manager correctly
// forwards htlc confirmed notifications to subscribers via the end-to-end
// subscription path.
func TestManager_HtlcConfirmedNotification(t *testing.T) {
	t.Parallel()

	// Create a mock notification client.
	recvChan := make(
		chan *swapserverrpc.SubscribeNotificationsResponse, 1,
	)
	errChan := make(chan error, 1)
	mockStream := &mockSubscribeNotificationsClient{
		recvChan:    recvChan,
		recvErrChan: errChan,
	}
	mockClient := &mockNotificationsClient{
		mockStream: mockStream,
	}

	mgr := NewManager(&Config{
		Client: mockClient,
		CurrentToken: func() (*l402.Token, error) {
			return &l402.Token{
				Preimage: lntypes.Preimage{1, 2, 3},
			}, nil
		},
	})

	// Subscribe to htlc confirmed notifications.
	ctx := t.Context()
	subCtx, subCancel := context.WithCancel(ctx)
	defer subCancel()
	subChan := mgr.SubscribeHtlcConfirmed(subCtx)

	// Run the manager.
	go func() {
		_ = mgr.Run(ctx)
	}()

	// Wait for the manager to subscribe to the server stream.
	require.Eventually(t, func() bool {
		mockClient.Lock()
		defer mockClient.Unlock()

		return mockClient.timesCalled > 0
	}, time.Second*5, 10*time.Millisecond)

	// Send an htlc confirmed notification via the mock stream.
	testSwapHash := []byte("test_hash_32_bytes_long_padding!")
	testNtfn := &swapserverrpc.SubscribeNotificationsResponse{
		Notification: &swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed{ // nolint: lll
			HtlcConfirmed: &swapserverrpc.ServerHtlcConfirmedNotification{ // nolint: lll
				SwapHash:     testSwapHash,
				HtlcOutpoint: "abc123:0",
				HtlcAddress:  "tb1qexamplehtlcaddress",
				SatPerVbyte:  25,
			},
		},
	}
	recvChan <- testNtfn

	// Verify the subscriber receives it.
	select {
	case received := <-subChan:
		require.NotNil(t, received)
		require.Equal(t, testSwapHash, received.SwapHash)
		require.Equal(t, "abc123:0", received.HtlcOutpoint)
		require.Equal(t, "tb1qexamplehtlcaddress", received.HtlcAddress)
		require.Equal(t, uint32(25), received.SatPerVbyte)

	case <-time.After(5 * time.Second):
		t.Fatal("did not receive htlc confirmed notification")
	}

	// Cancel the subscription and verify the channel closes.
	subCancel()
	require.Eventually(t, func() bool {
		select {
		case _, ok := <-subChan:
			return !ok

		default:
			return false
		}
	}, time.Second*5, 10*time.Millisecond)
}

// TestManager_HtlcConfirmedNonBlocking tests that a slow htlc confirmed
// subscriber does not block the notification pipeline.
func TestManager_HtlcConfirmedNonBlocking(t *testing.T) {
	t.Parallel()

	synctest.Test(t, func(t *testing.T) {
		recvChan := make(
			chan *swapserverrpc.SubscribeNotificationsResponse, 1,
		)
		errChan := make(chan error, 1)
		mockStream := &mockSubscribeNotificationsClient{
			recvChan:    recvChan,
			recvErrChan: errChan,
		}
		mockClient := &mockNotificationsClient{
			mockStream: mockStream,
		}

		mgr := NewManager(&Config{
			Client: mockClient,
			CurrentToken: func() (*l402.Token, error) {
				return &l402.Token{
					Preimage: lntypes.Preimage{1, 2, 3},
				}, nil
			},
		})

		// Subscribe but never read from the channel.
		subCtx, subCancel := context.WithCancel(t.Context())
		defer subCancel()
		_ = mgr.SubscribeHtlcConfirmed(subCtx)

		ctx, cancel := context.WithCancel(t.Context())
		defer cancel()
		go func() {
			_ = mgr.Run(ctx)
		}()

		// Wait for the manager to connect and block on stream Recv().
		synctest.Wait()
		mockClient.Lock()
		require.Greater(t, mockClient.timesCalled, 0)
		mockClient.Unlock()

		// Also send a reservation notification to prove the pipeline
		// is not blocked — it should still be dispatched once the
		// timed htlc fanout drop has elapsed.
		resChan := mgr.SubscribeReservations(subCtx)

		// Send two notifications rapidly. The subscriber channel has
		// buffer 1, so the first should be delivered and the second
		// should be dropped after waiting for the timeout.
		for i := range 2 {
			ntfn := &swapserverrpc.SubscribeNotificationsResponse{
				Notification: &swapserverrpc.SubscribeNotificationsResponse_HtlcConfirmed{ // nolint: lll
					HtlcConfirmed: &swapserverrpc.ServerHtlcConfirmedNotification{ // nolint: lll
						SwapHash:     fmt.Appendf(nil, "hash_%d_padding_to_32bytes!!", i), // nolint: lll
						HtlcOutpoint: "abc:0",
						HtlcAddress:  "tb1qexamplehtlcaddress",
						SatPerVbyte:  25,
					},
				},
			}
			recvChan <- ntfn
		}

		resNtfn := &swapserverrpc.SubscribeNotificationsResponse{
			Notification: &swapserverrpc.SubscribeNotificationsResponse_ReservationNotification{ // nolint: lll
				ReservationNotification: &swapserverrpc.ServerReservationNotification{ // nolint: lll
					ReservationId: []byte("res1"),
				},
			},
		}
		recvChan <- resNtfn

		select {
		case res := <-resChan:
			require.Equal(t, []byte("res1"), res.ReservationId)

		case <-time.After(5 * htlcConfirmedSubscriberSendTimeout):
			t.Fatal("reservation notification blocked by slow " +
				"htlc confirmed subscriber")
		}

		// Stop the manager and ensure all goroutines in the test exit.
		cancel()
		close(recvChan)
		synctest.Wait()
	})
}
