package guard

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

	log "github.com/sirupsen/logrus"

	"github.com/netbirdio/netbird/client/internal/peer/ice"
	"github.com/netbirdio/netbird/client/netstate"
)

// newTestGuardWithNetState builds a guard with a realistic MaxInterval: the
// backoff must be able to grow well past the outage, as it does in production
// where the timeout is seconds to minutes.
func newTestGuardWithNetState(status connStatusFunc, netState *netstate.State) *Guard {
	srw := NewSRWatcher(nil, nil, nil, ice.Config{})
	return NewGuard(log.WithField("test", "guard"), status, 30*time.Second, srw, netState)
}

// TestGuard_RecoversAfterOfflineToOnline covers a peer that stays disconnected
// across a network outage while neither signal nor relay reports an event —
// both stayed up, as on a short airplane mode toggle over Wi-Fi.
//
// Every tick taken while offline is skipped, but it still advances the
// exponential backoff, so by the time the network returns the next tick can be
// tens of seconds away. Without an explicit reaction to the transition the
// peer waits out that interval for a recovery that could start immediately.
func TestGuard_RecoversAfterOfflineToOnline(t *testing.T) {
	netState := netstate.New()

	var attempts atomic.Int32
	g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	// Start from the reconnect ticker (800ms initial interval), the state a
	// peer is in after it loses its connection.
	go g.Start(ctx, func() { attempts.Add(1) })
	g.SetRelayedConnDisconnected()

	// Let the backoff climb: 0.8s, 1.6s, 3.2s, 6.4s ... every tick is skipped
	// while offline, but each one doubles the wait for the next.
	netState.Set(false)
	time.Sleep(8 * time.Second)

	offlineAttempts := attempts.Load()
	if offlineAttempts != 0 {
		t.Fatalf("callback ran %d times while offline, want 0", offlineAttempts)
	}

	netState.Set(true)

	// The next organic tick is now several seconds out, so anything within
	// this window can only come from reacting to the transition itself.
	pollCtx, stopPolling := context.WithTimeout(ctx, 2*time.Second)
	defer stopPolling()

	select {
	case <-pollCtx.Done():
		t.Fatal("peer was not retried within 2s of the network coming back, " +
			"with neither a signal nor a relay event to fall back on")
	case <-pollUntil(pollCtx, func() bool { return attempts.Load() > 0 }):
	}
}

// TestGuard_OfflineTransitionDoesNotRetry checks the other direction: going
// offline must not itself trigger an attempt.
func TestGuard_OfflineTransitionDoesNotRetry(t *testing.T) {
	netState := netstate.New()

	var attempts atomic.Int32
	g := newTestGuardWithNetState(func() ConnStatus { return ConnStatusDisconnected }, netState)

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go g.Start(ctx, func() { attempts.Add(1) })

	netState.Set(false)
	time.Sleep(5 * time.Second)

	if got := attempts.Load(); got != 0 {
		t.Fatalf("callback ran %d times after going offline, want 0", got)
	}
}

// pollUntil closes the returned channel once cond holds. It gives up when ctx
// is done, so the polling goroutine never outlives the test that started it.
func pollUntil(ctx context.Context, cond func() bool) <-chan struct{} {
	done := make(chan struct{})
	go func() {
		for {
			if cond() {
				close(done)
				return
			}
			select {
			case <-ctx.Done():
				return
			case <-time.After(10 * time.Millisecond):
			}
		}
	}()
	return done
}
