package orchestrate_test

import (
	"context"
	"encoding/json"
	"fmt"
	"sync"
	"testing"
	"time"

	"github.com/alicebob/miniredis/v2"
	"github.com/redis/go-redis/v9"

	"github.com/wangjia/pangolin/server/internal/alert"
	"github.com/wangjia/pangolin/server/internal/scheduler/orchestrate"
	"github.com/wangjia/pangolin/server/internal/scheduler/probe"
)

// ─────────────────────────────────────────────────────────────────────────────
// In-process Redis
// ─────────────────────────────────────────────────────────────────────────────

func newTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
	t.Helper()
	mr := miniredis.RunT(t)
	rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
	return rdb, mr
}

// ─────────────────────────────────────────────────────────────────────────────
// Mock ProvisionService
// ─────────────────────────────────────────────────────────────────────────────

type createCall struct {
	Spec           orchestrate.NodeSpec
	IdempotencyKey string
	ReturnedID     string
}

type mockProvision struct {
	mu  sync.Mutex
	seq int

	providers    []orchestrate.ProviderInfo
	createCalls  []createCall
	destroyCalls []string
	createErr    error

	// idem maps idempotency key → nodeID (simulates provider idempotency).
	idem map[string]string
}

func newMockProvision(providers ...orchestrate.ProviderInfo) *mockProvision {
	return &mockProvision{
		providers: providers,
		idem:      map[string]string{},
	}
}

func (m *mockProvision) CreateNode(_ context.Context, spec orchestrate.NodeSpec, idemKey string) (string, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.createErr != nil {
		return "", m.createErr
	}
	// Idempotency replay.
	if existing, ok := m.idem[idemKey]; ok {
		m.createCalls = append(m.createCalls, createCall{spec, idemKey, existing})
		return existing, nil
	}
	m.seq++
	nodeID := fmt.Sprintf("new-node-%d", m.seq)
	m.idem[idemKey] = nodeID
	m.createCalls = append(m.createCalls, createCall{spec, idemKey, nodeID})
	return nodeID, nil
}

func (m *mockProvision) DestroyNode(_ context.Context, nodeID string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.destroyCalls = append(m.destroyCalls, nodeID)
	return nil
}

func (m *mockProvision) RotateIP(_ context.Context, _ string) (string, error) { return "", nil }

func (m *mockProvision) ListProviders(_ context.Context, _, _ string) ([]orchestrate.ProviderInfo, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	return append([]orchestrate.ProviderInfo{}, m.providers...), nil
}

func (m *mockProvision) createCount() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.createCalls)
}

func (m *mockProvision) destroyCount() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return len(m.destroyCalls)
}

// ─────────────────────────────────────────────────────────────────────────────
// Mock LifecycleService
// ─────────────────────────────────────────────────────────────────────────────

type transitionEvent struct {
	NodeID, From, To string
}

type mockLC struct {
	mu          sync.Mutex
	nodes       map[string]*orchestrate.NodeInfo
	weights     map[string]int
	version     int64
	auditLogs   []string
	transitions []transitionEvent
}

func newMockLC(nodes ...*orchestrate.NodeInfo) *mockLC {
	m := &mockLC{
		nodes:   make(map[string]*orchestrate.NodeInfo),
		weights: make(map[string]int),
	}
	for _, n := range nodes {
		cp := *n
		m.nodes[n.ID] = &cp
	}
	return m
}

func (m *mockLC) addNode(n *orchestrate.NodeInfo) {
	m.mu.Lock()
	defer m.mu.Unlock()
	cp := *n
	m.nodes[n.ID] = &cp
}

func (m *mockLC) GetNode(_ context.Context, nodeID string) (*orchestrate.NodeInfo, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	n, ok := m.nodes[nodeID]
	if !ok {
		return nil, nil
	}
	cp := *n
	return &cp, nil
}

func (m *mockLC) TransitionStatus(_ context.Context, nodeID, from, to string, _ map[string]any) (int, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.transitions = append(m.transitions, transitionEvent{nodeID, from, to})
	return 1, nil // always succeed in tests
}

func (m *mockLC) SetWeight(_ context.Context, nodeID string, weight int) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.weights[nodeID] = weight
	return nil
}

func (m *mockLC) BumpVersion(_ context.Context) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.version++
	return nil
}

func (m *mockLC) WriteAuditLog(_ context.Context, actor, action, target, _ string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.auditLogs = append(m.auditLogs, actor+"|"+action+"|"+target)
	return nil
}

func (m *mockLC) weightOf(nodeID string) int {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.weights[nodeID]
}

func (m *mockLC) versionOf() int64 {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.version
}

func (m *mockLC) hasAudit(entry string) bool {
	m.mu.Lock()
	defer m.mu.Unlock()
	for _, l := range m.auditLogs {
		if l == entry {
			return true
		}
	}
	return false
}

// ─────────────────────────────────────────────────────────────────────────────
// Mock ProbeSnapshotter
// ─────────────────────────────────────────────────────────────────────────────

type mockSnaps struct {
	mu   sync.Mutex
	data map[string]map[string]probe.ProbeSnapshot
}

func (s *mockSnaps) SnapshotsByNode(_ context.Context, nodeID string) (map[string]probe.ProbeSnapshot, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	d, ok := s.data[nodeID]
	if !ok {
		return nil, nil
	}
	out := make(map[string]probe.ProbeSnapshot, len(d))
	for k, v := range d {
		out[k] = v
	}
	return out, nil
}

func (s *mockSnaps) setPass(nodeID string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data == nil {
		s.data = make(map[string]map[string]probe.ProbeSnapshot)
	}
	s.data[nodeID] = passingSnapshots()
}

// passingSnapshots returns a snapshot set where 2/3 domestic ISPs pass + overseas OK.
func passingSnapshots() map[string]probe.ProbeSnapshot {
	return map[string]probe.ProbeSnapshot{
		"CN:Telecom": {
			Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaTelecom"},
			Report: probe.NodeReport{
				L1: probe.L1Result{OK: true},
				L3: &probe.L3Result{OK: true},
			},
		},
		"CN:Unicom": {
			Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaUnicom"},
			Report: probe.NodeReport{
				L1: probe.L1Result{OK: true},
				L3: &probe.L3Result{OK: true},
			},
		},
		"CN:Mobile": {
			Vantage: probe.VantagePoint{Country: "CN", ISP: "ChinaMobile"},
			Report: probe.NodeReport{
				L1: probe.L1Result{OK: false},
				L3: &probe.L3Result{OK: false},
			},
		},
		"SG:AWS": {
			Vantage: probe.VantagePoint{Country: "SG", ISP: "AWS"},
			Report:  probe.NodeReport{L1: probe.L1Result{OK: true}},
		},
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Mock Notifier
// ─────────────────────────────────────────────────────────────────────────────

type mockNotifier struct {
	mu     sync.Mutex
	events []alert.Event
}

func (n *mockNotifier) Notify(_ context.Context, e alert.Event) error {
	n.mu.Lock()
	defer n.mu.Unlock()
	n.events = append(n.events, e)
	return nil
}

// count returns the number of Notify calls received.
func (n *mockNotifier) count() int {
	n.mu.Lock()
	defer n.mu.Unlock()
	return len(n.events)
}

// countByType returns the number of Notify calls with the given EventType.
func (n *mockNotifier) countByType(t alert.EventType) int {
	n.mu.Lock()
	defer n.mu.Unlock()
	c := 0
	for _, e := range n.events {
		if e.Type == t {
			c++
		}
	}
	return c
}

// ─────────────────────────────────────────────────────────────────────────────
// Mock Clock
// ─────────────────────────────────────────────────────────────────────────────

type mockClock struct {
	mu  sync.Mutex
	now time.Time
}

func newMockClock(t time.Time) *mockClock { return &mockClock{now: t} }

func (c *mockClock) Now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.now
}

func (c *mockClock) advance(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.now = c.now.Add(d)
}

// ─────────────────────────────────────────────────────────────────────────────
// Test harness
// ─────────────────────────────────────────────────────────────────────────────

type harness struct {
	rdb      *redis.Client
	prov     *mockProvision
	lc       *mockLC
	snaps    *mockSnaps
	notifier *mockNotifier
	clock    *mockClock
	replacer *orchestrate.Replacer
}

func newHarness(t *testing.T, nodes ...*orchestrate.NodeInfo) *harness {
	t.Helper()
	rdb, _ := newTestRedis(t)
	prov := newMockProvision(
		orchestrate.ProviderInfo{ID: "provider-A"},
		orchestrate.ProviderInfo{ID: "provider-B"},
		orchestrate.ProviderInfo{ID: "provider-C"},
	)
	lc := newMockLC(nodes...)
	snaps := &mockSnaps{}
	notifier := &mockNotifier{}
	clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())

	r := orchestrate.NewReplacer(orchestrate.Config{
		RDB:      rdb,
		Prov:     prov,
		LC:       lc,
		Snaps:    snaps,
		Notifier: notifier,
		Clock:    clock,
	})
	return &harness{rdb: rdb, prov: prov, lc: lc, snaps: snaps, notifier: notifier, clock: clock, replacer: r}
}

func (h *harness) pushQueue(t *testing.T, oldNodeID, replacementUUID string) {
	t.Helper()
	data, _ := json.Marshal(map[string]string{
		"nodeId":          oldNodeID,
		"replacementUuid": replacementUUID,
	})
	if err := h.rdb.LPush(context.Background(), "detect:replace:queue", data).Err(); err != nil {
		t.Fatalf("pushQueue: %v", err)
	}
}

func (h *harness) tick(t *testing.T) {
	t.Helper()
	if err := h.replacer.Tick(context.Background()); err != nil {
		t.Fatalf("Tick() error: %v", err)
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// State-machine phase explanation (for reference):
//
//   Tick 1: drainQueue creates pending record → advanceAll: stepPending → creating
//   Tick 2: stepCreating → CreateNode called → probing
//   Tick 3: stepProbing (probeStreak=1, need 2)
//   Tick 4: stepProbing (probeStreak=2) → activating
//   Tick 5: stepActivating → SetWeight(10)+TransitionStatus+BumpVersion+grayscale → draining_old
//   Tick 6: stepDrainingOld → DestroyNode(oldNode) → done
// ─────────────────────────────────────────────────────────────────────────────

// ─────────────────────────────────────────────────────────────────────────────
// Test: happy-path replacement (6 ticks)
// ─────────────────────────────────────────────────────────────────────────────

func TestHappyPathReplacement(t *testing.T) {
	const (
		oldNode         = "old-node-001"
		replacementUUID = "uuid-happy"
	)
	h := newHarness(t,
		&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
	)
	h.pushQueue(t, oldNode, replacementUUID)

	// Tick 1: pending → creating (CreateNode not yet called).
	h.tick(t)
	if h.prov.createCount() != 0 {
		t.Errorf("tick 1: CreateNode should not be called yet; got %d calls", h.prov.createCount())
	}

	// Tick 2: creating → CreateNode → probing.
	h.tick(t)
	if h.prov.createCount() != 1 {
		t.Fatalf("tick 2: CreateNode calls = %d; want 1", h.prov.createCount())
	}
	call0 := h.prov.createCalls[0]
	// The idempotency key for attempt 0 must equal the replacement UUID.
	if call0.IdempotencyKey != replacementUUID {
		t.Errorf("CreateNode idempotency key = %q; want %q", call0.IdempotencyKey, replacementUUID)
	}

	newNodeID := call0.ReturnedID
	// Simulate agent self-registration: add the new node to the lifecycle mock.
	h.lc.addNode(&orchestrate.NodeInfo{ID: newNodeID, Tier: "free", Region: "hkg"})
	// Set probe snapshots to PASS for the new node.
	h.snaps.setPass(newNodeID)

	h.tick(t) // Tick 3: probing streak=1 (< 2 required).
	h.tick(t) // Tick 4: probing streak=2 → phase saved as activating.
	h.tick(t) // Tick 5: activating → SetWeight(10) + TransitionStatus(probing→up) + BumpVersion + grayscale → draining_old.

	// After stepActivating:
	if w := h.lc.weightOf(newNodeID); w != 10 {
		t.Errorf("new node weight after activating = %d; want 10", w)
	}
	if h.lc.versionOf() == 0 {
		t.Error("BumpVersion was not called after activating")
	}

	h.tick(t) // Tick 6: draining_old → DestroyNode(oldNode) → done.

	// Old node must be destroyed.
	oldDestroyed := false
	for _, id := range h.prov.destroyCalls {
		if id == oldNode {
			oldDestroyed = true
			break
		}
	}
	if !oldDestroyed {
		t.Errorf("old node %s not destroyed; destroyCalls = %v", oldNode, h.prov.destroyCalls)
	}

	// Grayscale record must exist for the new node.
	if _, err := h.rdb.Get(context.Background(), "sched:gray:"+newNodeID).Result(); err == redis.Nil {
		t.Errorf("grayscale record for %s not found", newNodeID)
	}

	// Audit log must contain replacement_done.
	if !h.lc.hasAudit("orchestrate|replacement_done|node:" + oldNode) {
		t.Errorf("replacement_done audit log not found; got: %v", h.lc.auditLogs)
	}

	// New node must NOT have been destroyed.
	for _, id := range h.prov.destroyCalls {
		if id == newNodeID {
			t.Errorf("new node %s was incorrectly destroyed", newNodeID)
		}
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Test: probe failure exhausts MaxAttempts → failed + alert (7 ticks + 3 clock advances)
// ─────────────────────────────────────────────────────────────────────────────

func TestProbeFailMaxAttempts(t *testing.T) {
	const (
		oldNode         = "old-node-fail"
		replacementUUID = "uuid-fail"
	)
	h := newHarness(t,
		&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
	)
	h.pushQueue(t, oldNode, replacementUUID)

	ctx := context.Background()

	// ── Attempt 0 ────────────────────────────────────────────────────────────

	h.tick(t) // Tick 1: pending → creating.
	h.tick(t) // Tick 2: creating → CreateNode(idem=uuid) → probing.

	if h.prov.createCount() != 1 {
		t.Fatalf("attempt 0: CreateNode calls = %d; want 1", h.prov.createCount())
	}
	if h.prov.createCalls[0].IdempotencyKey != replacementUUID {
		t.Errorf("attempt 0 idem key = %q; want %q",
			h.prov.createCalls[0].IdempotencyKey, replacementUUID)
	}

	newNode0 := h.prov.createCalls[0].ReturnedID
	h.snaps.setPass(newNode0) // even if snapshots pass, timeout overrides

	h.clock.advance(orchestrate.ProbeTimeout + time.Second) // cause timeout

	h.tick(t) // Tick 3: probing → timeout → DestroyNode(newNode0) → creating (attempts=1).

	if h.prov.destroyCount() != 1 {
		t.Fatalf("after attempt 0 timeout: DestroyNode calls = %d; want 1", h.prov.destroyCount())
	}
	if h.prov.destroyCalls[0] != newNode0 {
		t.Errorf("attempt 0: expected %s destroyed; got %s", newNode0, h.prov.destroyCalls[0])
	}
	// Old node must NOT have been destroyed yet.
	for _, id := range h.prov.destroyCalls {
		if id == oldNode {
			t.Errorf("old node destroyed during attempt 0 (want: only after success)")
		}
	}

	// ── Attempt 1 ────────────────────────────────────────────────────────────

	h.tick(t) // Tick 4: creating → CreateNode(idem=uuid:retry:1) → probing.

	if h.prov.createCount() != 2 {
		t.Fatalf("attempt 1: CreateNode calls = %d; want 2", h.prov.createCount())
	}
	call1 := h.prov.createCalls[1]
	if call1.IdempotencyKey != replacementUUID+":retry:1" {
		t.Errorf("attempt 1 idem key = %q; want %q:retry:1",
			call1.IdempotencyKey, replacementUUID)
	}

	newNode1 := call1.ReturnedID
	h.snaps.setPass(newNode1)
	h.clock.advance(orchestrate.ProbeTimeout + time.Second)

	h.tick(t) // Tick 5: probing → timeout → DestroyNode(newNode1) → creating (attempts=2).

	if h.prov.destroyCount() != 2 {
		t.Fatalf("after attempt 1 timeout: DestroyNode calls = %d; want 2", h.prov.destroyCount())
	}

	// ── Attempt 2 (final) ────────────────────────────────────────────────────

	h.tick(t) // Tick 6: creating → CreateNode(idem=uuid:retry:2) → probing.

	if h.prov.createCount() != 3 {
		t.Fatalf("attempt 2: CreateNode calls = %d; want 3", h.prov.createCount())
	}
	call2 := h.prov.createCalls[2]
	if call2.IdempotencyKey != replacementUUID+":retry:2" {
		t.Errorf("attempt 2 idem key = %q; want %q:retry:2",
			call2.IdempotencyKey, replacementUUID)
	}

	newNode2 := call2.ReturnedID
	h.snaps.setPass(newNode2)
	h.clock.advance(orchestrate.ProbeTimeout + time.Second)

	h.tick(t) // Tick 7: probing → timeout → DestroyNode(newNode2) → FAILED.

	if h.prov.destroyCount() != 3 {
		t.Fatalf("after attempt 2 timeout: DestroyNode calls = %d; want 3", h.prov.destroyCount())
	}

	// Old node must never have been destroyed.
	for _, id := range h.prov.destroyCalls {
		if id == oldNode {
			t.Errorf("old node %s was incorrectly destroyed during failed replacement", oldNode)
		}
	}

	// Notify(EventTypeReplenishFailed) must be called exactly once.
	if n := h.notifier.countByType(alert.EventTypeReplenishFailed); n != 1 {
		t.Errorf("Notify(ReplenishFailed) calls = %d; want 1", n)
	}

	// Record must be in failed phase.
	raw, err := h.rdb.Get(ctx, "sched:replace:"+replacementUUID).Result()
	if err != nil {
		t.Fatalf("read final record: %v", err)
	}
	var p struct {
		Phase orchestrate.Phase `json:"phase"`
	}
	if err := json.Unmarshal([]byte(raw), &p); err != nil {
		t.Fatalf("unmarshal phase: %v", err)
	}
	if p.Phase != orchestrate.PhaseFailed {
		t.Errorf("final phase = %q; want %q", p.Phase, orchestrate.PhaseFailed)
	}

	// Each attempt must have used a different provider (rotation check).
	p0 := h.prov.createCalls[0].Spec.ProviderID
	p1 := h.prov.createCalls[1].Spec.ProviderID
	p2 := h.prov.createCalls[2].Spec.ProviderID
	if p0 == p1 || p1 == p2 || p0 == p2 {
		t.Errorf("providers should differ across retries; got [%s, %s, %s]", p0, p1, p2)
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Test: crash recovery — CreateNode NOT called again when creating is complete
//
// Simulates a crash where CreateNode succeeded and newNode was set in memory
// but the record was NOT yet saved with phase=probing. On restart the record
// shows phase=creating with newNode set; we expect the Replacer to resume
// from probing without calling CreateNode again.
// ─────────────────────────────────────────────────────────────────────────────

func TestCrashRecovery(t *testing.T) {
	const (
		oldNode          = "old-node-crash"
		replacementUUID  = "uuid-crash"
		simulatedNewNode = "new-node-crash"
	)
	h := newHarness(t,
		&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg", Role: "entry"},
	)

	ctx := context.Background()

	// Inject a pre-crash record: phase=creating, newNode already set.
	// This models a crash AFTER CreateNode returned but BEFORE the record was
	// saved with phase=probing.
	type crashRecord struct {
		Phase             string    `json:"phase"`
		OldNode           string    `json:"oldNode"`
		NewNode           string    `json:"newNode"`
		CurrentProviderID string    `json:"currentProviderId"`
		Attempts          int       `json:"attempts"`
		ProviderTried     []string  `json:"providerTried"`
		ProbeStreak       int       `json:"probeStreak"`
		PhaseStartedAt    time.Time `json:"phaseStartedAt"`
		UpdatedAt         time.Time `json:"updatedAt"`
	}
	recJSON, _ := json.Marshal(crashRecord{
		Phase:          "creating",
		OldNode:        oldNode,
		NewNode:        simulatedNewNode,
		ProviderTried:  []string{},
		PhaseStartedAt: h.clock.Now(),
		UpdatedAt:      h.clock.Now(),
	})
	if err := h.rdb.Set(ctx, "sched:replace:"+replacementUUID, recJSON, 0).Err(); err != nil {
		t.Fatalf("inject crash record: %v", err)
	}
	if err := h.rdb.SAdd(ctx, "sched:replace:index", replacementUUID).Err(); err != nil {
		t.Fatalf("inject index: %v", err)
	}

	// Add the simulated new node to the lifecycle mock.
	h.lc.addNode(&orchestrate.NodeInfo{ID: simulatedNewNode, Tier: "free", Region: "hkg"})
	h.snaps.setPass(simulatedNewNode)

	// Tick 1 ("restart"): phase=creating, newNode already set → skip CreateNode → probing.
	h.tick(t)
	if h.prov.createCount() != 0 {
		t.Fatalf("tick 1 (recovery): CreateNode called %d times; want 0", h.prov.createCount())
	}

	h.tick(t) // Tick 2: probing streak=1.
	h.tick(t) // Tick 3: probing streak=2 → activating.
	h.tick(t) // Tick 4: activating → SetWeight(10) + TransitionStatus + BumpVersion + grayscale → draining_old.
	h.tick(t) // Tick 5: draining_old → DestroyNode(oldNode) → done.

	// CreateNode must never have been called.
	if h.prov.createCount() != 0 {
		t.Errorf("CreateNode called %d times during crash recovery; want 0", h.prov.createCount())
	}

	// New node must be at weight 10.
	if w := h.lc.weightOf(simulatedNewNode); w != 10 {
		t.Errorf("new node weight = %d; want 10", w)
	}

	// Old node must be destroyed.
	oldDestroyed := false
	for _, id := range h.prov.destroyCalls {
		if id == oldNode {
			oldDestroyed = true
			break
		}
	}
	if !oldDestroyed {
		t.Errorf("old node %s not destroyed in crash-recovery path; destroyCalls = %v",
			oldNode, h.prov.destroyCalls)
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Test: idempotency — duplicate queue entries produce a single replacement
// ─────────────────────────────────────────────────────────────────────────────

func TestQueueReplayIdempotent(t *testing.T) {
	const (
		oldNode         = "old-node-idem"
		replacementUUID = "uuid-idem"
	)
	h := newHarness(t,
		&orchestrate.NodeInfo{ID: oldNode, Tier: "free", Region: "hkg"},
	)

	// Push the same entry twice (duplicate delivery).
	h.pushQueue(t, oldNode, replacementUUID)
	h.pushQueue(t, oldNode, replacementUUID)

	// Tick 1: both entries drained; SetNX ensures a single record; stepPending → creating.
	h.tick(t)
	if h.prov.createCount() != 0 {
		t.Errorf("tick 1: CreateNode should not be called yet; got %d", h.prov.createCount())
	}

	// Tick 2: creating → CreateNode called exactly once.
	h.tick(t)
	if h.prov.createCount() != 1 {
		t.Errorf("CreateNode calls = %d after two queue entries; want 1", h.prov.createCount())
	}

	// Only one UUID must be in the in-flight index.
	members, err := h.rdb.SMembers(context.Background(), "sched:replace:index").Result()
	if err != nil {
		t.Fatalf("smembers: %v", err)
	}
	if len(members) != 1 {
		t.Errorf("index member count = %d; want 1", len(members))
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Test: grayscale weight ramp 10 → 25 → 50 → 75 → 100 (each step = 6 h)
// ─────────────────────────────────────────────────────────────────────────────

func TestGrayscaleAdvance(t *testing.T) {
	const nodeID = "gray-node-001"

	rdb, _ := newTestRedis(t)
	lc := newMockLC(&orchestrate.NodeInfo{ID: nodeID})
	clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())

	// Seed a grayscale record at weight 10.
	grayKey := "sched:gray:" + nodeID
	type grayRec struct {
		NodeID         string    `json:"nodeId"`
		CurrentWeight  int       `json:"currentWeight"`
		StartedAt      time.Time `json:"startedAt"`
		LastAdvancedAt time.Time `json:"lastAdvancedAt"`
	}
	data, _ := json.Marshal(grayRec{
		NodeID:         nodeID,
		CurrentWeight:  10,
		StartedAt:      clock.Now(),
		LastAdvancedAt: clock.Now(),
	})
	if err := rdb.Set(context.Background(), grayKey, data, 0).Err(); err != nil {
		t.Fatalf("seed gray record: %v", err)
	}

	g := orchestrate.NewGrayscale(rdb, lc, clock)
	ctx := context.Background()

	// Advance the clock by GrayscaleInterval (6 h) and call Advance() four times.
	// Expected progression: 10 → 25 → 50 → 75 → 100 (record deleted).
	wantWeights := []int{25, 50, 75, 100}

	for step, want := range wantWeights {
		clock.advance(orchestrate.GrayscaleInterval)

		if err := g.Advance(ctx); err != nil {
			t.Fatalf("step %d Advance(): %v", step, err)
		}

		if got := lc.weightOf(nodeID); got != want {
			t.Errorf("step %d: weight = %d; want %d", step, got, want)
		}

		// Audit log must record the weight advance.
		auditKey := "grayscale|weight_advanced|node:" + nodeID
		if !lc.hasAudit(auditKey) {
			t.Errorf("step %d: audit log %q not found; logs = %v", step, auditKey, lc.auditLogs)
		}
	}

	// After weight 100, the grayscale record must be deleted.
	if _, err := rdb.Get(ctx, grayKey).Result(); err != redis.Nil {
		t.Error("grayscale record should be deleted after weight 100")
	}

	// Final weight must be 100.
	if lc.weightOf(nodeID) != 100 {
		t.Errorf("final weight = %d; want 100", lc.weightOf(nodeID))
	}
}

// ─────────────────────────────────────────────────────────────────────────────
// Test: grayscale does not advance before the interval elapses
// ─────────────────────────────────────────────────────────────────────────────

func TestGrayscaleNoAdvanceBeforeInterval(t *testing.T) {
	const nodeID = "gray-node-wait"

	rdb, _ := newTestRedis(t)
	lc := newMockLC(&orchestrate.NodeInfo{ID: nodeID})
	clock := newMockClock(time.Unix(1_700_000_000, 0).UTC())

	grayKey := "sched:gray:" + nodeID
	type grayRec struct {
		NodeID         string    `json:"nodeId"`
		CurrentWeight  int       `json:"currentWeight"`
		StartedAt      time.Time `json:"startedAt"`
		LastAdvancedAt time.Time `json:"lastAdvancedAt"`
	}
	data, _ := json.Marshal(grayRec{
		NodeID:         nodeID,
		CurrentWeight:  10,
		StartedAt:      clock.Now(),
		LastAdvancedAt: clock.Now(),
	})
	rdb.Set(context.Background(), grayKey, data, 0)

	g := orchestrate.NewGrayscale(rdb, lc, clock)

	// Advance only 5 h (< 6 h interval).
	clock.advance(5 * time.Hour)

	if err := g.Advance(context.Background()); err != nil {
		t.Fatalf("Advance(): %v", err)
	}

	// Weight must NOT have been updated.
	if w := lc.weightOf(nodeID); w != 0 {
		t.Errorf("weight was updated too early: got %d; want 0", w)
	}
}
