package license

import (
	"context"
	"crypto/ed25519"
	"errors"
	"testing"
	"time"

	"github.com/redis/go-redis/v9"
)

func TestReporterNextDelayAddsJitter(t *testing.T) {
	reporter := NewReporter(nil, 5*time.Minute, nil)
	reporter.randFloat = func() float64 { return 0 }
	if got, want := reporter.nextDelay(0), 4*time.Minute+30*time.Second; got != want {
		t.Fatalf("nextDelay() = %v, want %v", got, want)
	}
	reporter.randFloat = func() float64 { return 1 }
	if got, want := reporter.nextDelay(0), 5*time.Minute+30*time.Second; got != want {
		t.Fatalf("nextDelay() = %v, want %v", got, want)
	}
}

func TestReporterNextDelayBacksOffAndCaps(t *testing.T) {
	reporter := NewReporter(nil, 5*time.Minute, nil)
	reporter.randFloat = func() float64 { return 0.5 }
	for failures, want := range map[int]time.Duration{
		1: 5 * time.Minute,
		2: 10 * time.Minute,
		3: 20 * time.Minute,
		4: 30 * time.Minute,
		8: 30 * time.Minute,
	} {
		if got := reporter.nextDelay(failures); got != want {
			t.Fatalf("nextDelay(%d) = %v, want %v", failures, got, want)
		}
	}
}

type fakeBoolCmdRedis struct {
	setNXResult bool
	setNXErr    error
	calls       int
	lastKey     string
	lastValue   any
	lastTTL     time.Duration
}

func (f *fakeBoolCmdRedis) SetNX(_ context.Context, key string, value any, expiration time.Duration) *redis.BoolCmd {
	f.calls++
	f.lastKey = key
	f.lastValue = value
	f.lastTTL = expiration
	cmd := redis.NewBoolCmd(context.Background())
	if f.setNXErr != nil {
		cmd.SetErr(f.setNXErr)
	} else {
		cmd.SetVal(f.setNXResult)
	}
	return cmd
}

func TestReporterLeaderSendsHeartbeatWhenLockAcquired(t *testing.T) {
	state := testCredentialState()
	store := &fakeStateStore{state: state}
	remote := &fakeRemote{heartbeatResult: &HeartbeatResult{Status: "active", LicenseNo: "LIC-TEST", InstanceID: testInstanceID, Sequence: state.LeaseSequence + 1}}
	eval := NewEvaluator(nil)
	manager := NewManager(remote, store, "1.0").WithEvaluator(eval)
	manager.hostname = func() (string, error) { return "production-01", nil }

	redisClient := &fakeBoolCmdRedis{setNXResult: true}
	reporter := NewReporter(manager, 5*time.Minute, nil).WithRedis(redisClient, "node-A")

	_ = reporter.process(context.Background())

	if redisClient.calls != 1 {
		t.Fatalf("SetNX calls = %d, want 1", redisClient.calls)
	}
	if redisClient.lastKey != leaderLockKey {
		t.Fatalf("SetNX key = %q, want %q", redisClient.lastKey, leaderLockKey)
	}
	if store.saveHeartbeatCalls != 1 {
		t.Fatalf("saveHeartbeatCalls = %d, want 1 (leader should heartbeat)", store.saveHeartbeatCalls)
	}
}

func TestReporterFollowerSkipsHeartbeatWhenLockNotAcquired(t *testing.T) {
	state := testCredentialState()
	store := &fakeStateStore{state: state}
	remote := &fakeRemote{heartbeatErr: errors.New("remote should not be called")}
	eval := NewEvaluator(make(ed25519.PublicKey, ed25519.PublicKeySize))
	manager := NewManager(remote, store, "1.0").WithEvaluator(eval)
	manager.hostname = func() (string, error) { return "production-01", nil }

	redisClient := &fakeBoolCmdRedis{setNXResult: false} // lock held by another node
	reporter := NewReporter(manager, 5*time.Minute, nil).WithRedis(redisClient, "node-B")

	if err := reporter.process(context.Background()); err != nil {
		t.Fatalf("follower process should not surface an error, got: %v", err)
	}
	for _, call := range remote.calls {
		if call == "heartbeat" {
			t.Fatal("follower must not call remote.Heartbeat")
		}
	}
	if store.saveHeartbeatCalls != 0 {
		t.Fatalf("follower must not persist a heartbeat, saveHeartbeatCalls = %d", store.saveHeartbeatCalls)
	}
}

func TestReporterDegradesToLeaderWhenRedisUnavailable(t *testing.T) {
	state := testCredentialState()
	store := &fakeStateStore{state: state}
	remote := &fakeRemote{heartbeatResult: &HeartbeatResult{Status: "active", LicenseNo: "LIC-TEST", InstanceID: testInstanceID, Sequence: state.LeaseSequence + 1}}
	eval := NewEvaluator(nil)
	manager := NewManager(remote, store, "1.0").WithEvaluator(eval)
	manager.hostname = func() (string, error) { return "production-01", nil }

	redisClient := &fakeBoolCmdRedis{setNXErr: errors.New("redis: connection refused")}
	reporter := NewReporter(manager, 5*time.Minute, nil).WithRedis(redisClient, "node-C")

	// Should NOT return an error just because Redis failed — it degrades to
	// acting as leader and attempts the heartbeat.
	_ = reporter.process(context.Background())
	if redisClient.calls != 1 {
		t.Fatalf("SetNX calls = %d, want 1", redisClient.calls)
	}
	if store.saveHeartbeatCalls != 1 {
		t.Fatalf("saveHeartbeatCalls = %d, want 1 (should degrade to leader)", store.saveHeartbeatCalls)
	}
}

func TestReporterRehydrateFromDBIsReadOnly(t *testing.T) {
	publicKey, raw, signature := signedEvaluatorLease(
		t, "active", []string{FeatureHTTP}, time.Now().Add(time.Hour), time.Now().Add(24*time.Hour),
	)
	state := testCredentialState()
	state.LeaseRaw = raw
	state.LeaseSignature = signature
	store := &fakeStateStore{state: state}
	eval := NewEvaluator(publicKey)
	manager := NewManager(&fakeRemote{}, store, "1.0").WithEvaluator(eval)

	reporter := NewReporter(manager, 5*time.Minute, nil)
	if err := reporter.rehydrateFromDB(context.Background()); err != nil {
		t.Fatalf("rehydrateFromDB: %v", err)
	}
	if store.saveHeartbeatCalls != 0 {
		t.Fatalf("rehydrateFromDB must not write to the store, saveHeartbeatCalls = %d", store.saveHeartbeatCalls)
	}
	if store.saveActivationCalls != 0 {
		t.Fatalf("rehydrateFromDB must not write to the store, saveActivationCalls = %d", store.saveActivationCalls)
	}
	if err := eval.CheckFeature(FeatureHTTP); err != nil {
		t.Fatalf("expected L1 to be loaded from persisted lease, CheckFeature: %v", err)
	}
}
