package service

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"testing"
	"time"

	edgeevents "iop/apps/edge/internal/events"
	edgenode "iop/apps/edge/internal/node"
	"iop/packages/go/config"
	iop "iop/proto/gen/iop"
)

// TestModelQueueFIFOOrdering verifies that queued items are dispatched in FIFO
// order when a slot becomes available.
func TestModelQueueFIFOOrdering(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID:      "node-q1",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})

	entry := &edgenode.NodeEntry{NodeID: "node-q1"}
	cands := []candidateNode{{entry: entry, capacity: 1}}
	defPolicy := groupPolicy{}

	m := newModelQueueManager(store)

	// Fill the only capacity slot.
	first, err := m.admit(context.Background(), "g-fifo", "", "", cands, defPolicy, nil, false, false)
	if err != nil || first == nil {
		t.Fatalf("initial admit: %v", err)
	}

	// Manually insert two items in known order so we can verify FIFO.
	item1 := &queueItem{
		candidates: cands,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(2 * time.Second),
	}
	item2 := &queueItem{
		candidates: cands,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(2 * time.Second),
	}
	m.mu.Lock()
	g := m.getOrCreateGroupLocked("g-fifo", groupPolicy{})
	g.queue = append(g.queue, item1, item2)
	m.mu.Unlock()

	// Release one slot — item1 (head) must be dispatched first.
	m.mu.Lock()
	m.releaseSlotLocked("g-fifo", "node-q1", "", false)
	m.mu.Unlock()

	select {
	case res := <-item1.waitCh:
		if res.err != nil {
			t.Fatalf("item1 expected dispatch, got error: %v", res.err)
		}
		if res.candidate == nil || res.candidate.entry.NodeID != "node-q1" {
			t.Fatalf("item1: unexpected candidate %v", res.candidate)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout: item1 was not dispatched after slot release")
	}

	// item2 must still be waiting (no slot available yet).
	select {
	case <-item2.waitCh:
		t.Fatal("item2 should not have been dispatched yet")
	default:
	}

	// Release the slot item1 holds so item2 gets dispatched.
	m.mu.Lock()
	m.releaseSlotLocked("g-fifo", "node-q1", "", false)
	m.mu.Unlock()

	select {
	case res := <-item2.waitCh:
		if res.err != nil {
			t.Fatalf("item2 expected dispatch, got error: %v", res.err)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout: item2 was not dispatched after second slot release")
	}
}

// TestModelQueueOverflow verifies that admit rejects requests when the queue
// is at max_queue capacity.
func TestModelQueueOverflow(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID:      "node-ov1",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})

	entry := &edgenode.NodeEntry{NodeID: "node-ov1"}
	cands := []candidateNode{{entry: entry, capacity: 1}}

	m := newModelQueueManager(store)

	// Fill the node's capacity and set max_queue=1.
	m.mu.Lock()
	g := m.getOrCreateGroupLocked("g-overflow", groupPolicy{})
	g.policy.maxQueue = 1
	g.inflight["node-ov1"] = 1
	m.mu.Unlock()

	// Queue one item — should succeed.
	item := &queueItem{
		candidates: cands,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(2 * time.Second),
	}
	m.mu.Lock()
	m.groups["g-overflow"].queue = append(m.groups["g-overflow"].queue, item)
	m.mu.Unlock()

	// Second admit should fail immediately with errQueueFull.
	_, err := m.admit(context.Background(), "g-overflow", "", "", cands, groupPolicy{}, nil, false, false)
	if !errors.Is(err, errQueueFull) {
		t.Fatalf("expected errQueueFull, got: %v", err)
	}
}

// TestModelQueueTimeout verifies that queued items receive a timeout error
// when no slot becomes available before the deadline.
func TestModelQueueTimeout(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID:      "node-to1",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})

	entry := &edgenode.NodeEntry{NodeID: "node-to1"}
	cands := []candidateNode{{entry: entry, capacity: 1}}

	m := newModelQueueManager(store)

	// Fill capacity and set a very short queue timeout.
	m.mu.Lock()
	g := m.getOrCreateGroupLocked("g-timeout", groupPolicy{})
	g.policy.queueTimeout = 20 * time.Millisecond
	g.inflight["node-to1"] = 1
	m.mu.Unlock()

	start := time.Now()
	_, err := m.admit(context.Background(), "g-timeout", "", "", cands, groupPolicy{}, nil, false, false)
	elapsed := time.Since(start)

	if !errors.Is(err, errQueueTimeout) {
		t.Fatalf("expected errQueueTimeout, got: %v", err)
	}
	if elapsed < 15*time.Millisecond {
		t.Fatalf("timed out too fast: %v", elapsed)
	}
}

// TestModelQueueTerminalReleaseDispatchesNext verifies that a terminal run event
// releases the in-flight slot and dispatches the next queued item.
func TestModelQueueTerminalReleaseDispatchesNext(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID:      "node-tr1",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})

	entry := &edgenode.NodeEntry{NodeID: "node-tr1"}
	cands := []candidateNode{{entry: entry, capacity: 1}}
	defPolicy := groupPolicy{}

	m := newModelQueueManager(store)

	// Fill capacity and record inflight.
	selected, err := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy, nil, false, false)
	if err != nil {
		t.Fatalf("admit: %v", err)
	}
	m.trackLease(selected.leaseID, "run-tr-001")

	// Queue a second item in a goroutine.
	resultCh := make(chan admitResult, 1)
	go func() {
		n, e := m.admit(context.Background(), "g-tr", "", "", cands, defPolicy, nil, false, false)
		resultCh <- admitResult{candidate: n, err: e}
	}()

	waitForQueueLen(t, m, "g-tr", 1)

	// Terminal event for the first run — should unblock the queued admit.
	for _, termType := range []string{"complete", "error", "cancelled"} {
		t.Run(termType, func(t *testing.T) {
			// Reset state for each sub-test.
			m.mu.Lock()
			g := m.getOrCreateGroupLocked("g-tr-"+termType, groupPolicy{})
			g.inflight["node-tr1"] = 1
			item := &queueItem{
				candidates: cands,
				waitCh:     make(chan admitResult, 1),
				deadline:   time.Now().Add(2 * time.Second),
			}
			g.queue = []*queueItem{item}
			runID := "run-tr-" + termType
			m.mu.Unlock()
			trackTestLease(m, "g-tr-"+termType, runID, "node-tr1", "", false)

			// The transport lifecycle hook calls this directly; the event bus is
			// not in the release path.
			m.releaseRun(runID, termType)

			select {
			case res := <-item.waitCh:
				if res.err != nil {
					t.Fatalf("expected dispatch, got error: %v", res.err)
				}
				if res.candidate == nil {
					t.Fatal("expected non-nil candidate")
				}
			case <-time.After(500 * time.Millisecond):
				t.Fatalf("timeout: queued item not dispatched after %q terminal event", termType)
			}
		})
	}

	// Cleanup the goroutine from the outer admit.
	m.mu.Lock()
	if g, ok := m.groups["g-tr"]; ok && len(g.queue) > 0 {
		g.inflight["node-tr1"] = 1
		m.pumpAllLocked()
	}
	m.mu.Unlock()
	select {
	case <-resultCh:
	case <-time.After(100 * time.Millisecond):
	}
}

// TestModelQueueNodeDisconnectReleasesInflight verifies that a node disconnect
// event removes the node from queued item candidate lists, and that subsequent
// dispatch goes to a remaining live candidate (not the disconnected node).
func TestModelQueueNodeDisconnectReleasesInflight(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID:      "node-nd1",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})
	store.Add(&edgenode.NodeRecord{
		ID:      "node-nd2",
		Runtime: config.RuntimeConf{Concurrency: 1},
	})

	entry1 := &edgenode.NodeEntry{NodeID: "node-nd1"}
	entry2 := &edgenode.NodeEntry{NodeID: "node-nd2"}
	// Queued item that can use either node (both at capacity 1).
	bothCands := []candidateNode{
		{entry: entry1, capacity: 1},
		{entry: entry2, capacity: 1},
	}

	m := newModelQueueManager(store)

	// Fill node-nd1 and node-nd2 capacities and record inflight.
	m.mu.Lock()
	g := m.getOrCreateGroupLocked("g-nd", groupPolicy{})
	g.inflight["node-nd1"] = 1
	g.inflight["node-nd2"] = 1
	// Queue an item that can use either node.
	item := &queueItem{
		candidates: bothCands,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(2 * time.Second),
	}
	g.queue = []*queueItem{item}
	m.mu.Unlock()
	trackTestLease(m, "g-nd", "run-nd-x", "node-nd1", "", false)
	trackTestLease(m, "g-nd", "run-nd-y", "node-nd2", "", false)

	// node-nd1 disconnects: its inflight is freed and it is removed from candidates.
	// node-nd2 is still full, so no dispatch yet. Generation 0 fences the node
	// unconditionally (untracked/whole-node release).
	m.releaseNode("node-nd1", 0, "disconnected")

	// node-nd2's run terminates: now nd2 has capacity, dispatch goes to nd2.
	m.releaseRun("run-nd-y", "complete")

	select {
	case res := <-item.waitCh:
		if res.err != nil {
			t.Fatalf("expected dispatch after disconnect+terminal, got error: %v", res.err)
		}
		if res.candidate == nil {
			t.Fatal("expected non-nil candidate after disconnect release")
		}
		// Must be nd2 — nd1 was removed from candidates on disconnect.
		if res.candidate.entry.NodeID != "node-nd2" {
			t.Errorf("expected dispatch to node-nd2, got %q (nd1 was disconnected)", res.candidate.entry.NodeID)
		}
	case <-time.After(500 * time.Millisecond):
		t.Fatal("timeout: queued item not dispatched after node disconnect and terminal event")
	}

	// The lease for run-nd-x should be gone (released at disconnect).
	m.mu.Lock()
	_, stillInFlight := m.leaseByRun["run-nd-x"]
	m.mu.Unlock()
	if stillInFlight {
		t.Error("run-nd-x lease should be released after node disconnect")
	}
}

// TestRunTerminalReleasesLeaseWhenEventSubscriberFull pins that lease accounting
// survives a lossy observability fanout. The bus drops into full subscriber
// channels by design, so the transport calls the service lifecycle hook directly;
// this test saturates the bus, confirms it is actually dropping, and then checks
// the terminal event still returns the slot.
func TestRunTerminalReleasesLeaseWhenEventSubscriberFull(t *testing.T) {
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID: "node-drop",
		Providers: []config.NodeProviderConf{
			{ID: "prov-drop", Capacity: 1, Adapter: "vllm"},
		},
	})

	bus := edgeevents.NewBus()
	svc := New(edgenode.NewRegistry(), bus)
	svc.SetNodeStore(store)

	// A slow observer with a one-deep buffer: the fanout starts dropping as soon
	// as it stops reading, which is exactly the delivery correctness must not
	// depend on.
	_, unsub := bus.SubscribeAllRuns(1)
	defer unsub()

	entry := &edgenode.NodeEntry{NodeID: "node-drop"}
	cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-drop"}}
	admitted, _, err := svc.queue.admitWithReason(context.Background(), "g-drop", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || admitted == nil {
		t.Fatalf("admit: %v", err)
	}
	svc.queue.trackLease(admitted.leaseID, "run-drop-1")

	for i := 0; i < 8; i++ {
		bus.PublishRun(&iop.RunEvent{RunId: "run-drop-1", Type: "chunk"})
	}
	if dropped := bus.Stats().DroppedRunEvents; dropped == 0 {
		t.Fatal("expected the saturated subscriber to drop run events; the test is not exercising the drop path")
	}

	// The terminal event is delivered the way the transport delivers it.
	svc.HandleRunLifecycleEvent(&iop.RunEvent{RunId: "run-drop-1", Type: "complete"})

	svc.queue.mu.Lock()
	inflight, _ := svc.queue.getStatsForProviderLocked("node-drop", "prov-drop")
	svc.queue.mu.Unlock()
	if inflight != 0 {
		t.Errorf("in-flight after terminal event with a dropping bus = %d, want 0", inflight)
	}
	if got := leaseCount(svc.queue); got != 0 {
		t.Errorf("live leases after terminal event = %d, want 0", got)
	}
}

// TestModelQueueCancelledTerminalReleasesProviderInflight verifies that a
// cancelled terminal RunEvent releases provider in-flight accounting back to
// zero, the same as complete/error. This guards the BUG-2 recovery contract:
// once Edge OpenAI cancel propagation reaches Node and Node replies with a
// cancelled terminal event, the provider snapshot's in_flight must not stay
// stuck positive.
func TestModelQueueCancelledTerminalReleasesProviderInflight(t *testing.T) {
	entry := &edgenode.NodeEntry{NodeID: "node-x"}
	cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-1"}}
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID: "node-x",
		Providers: []config.NodeProviderConf{
			{ID: "prov-1", Capacity: 1, Adapter: "vllm"},
		},
	})
	m := newModelQueueManager(store)
	m.setStore(store)

	selected, err := m.admit(context.Background(), "g-cancel", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || selected == nil {
		t.Fatalf("admit failed: %v", err)
	}

	m.trackLease(selected.leaseID, "run-cancel-1")

	m.mu.Lock()
	inf, _ := m.getStatsForProviderLocked("node-x", "prov-1")
	m.mu.Unlock()
	if inf != 1 {
		t.Fatalf("inflight before cancel: got %d, want 1", inf)
	}

	m.releaseRun("run-cancel-1", "cancelled")

	deadline := time.Now().Add(200 * time.Millisecond)
	for {
		m.mu.Lock()
		inf, _ = m.getStatsForProviderLocked("node-x", "prov-1")
		m.mu.Unlock()
		if inf == 0 {
			return
		}
		if time.Now().After(deadline) {
			t.Fatalf("inflight did not recover to 0 after cancelled terminal event, got %d", inf)
		}
		time.Sleep(1 * time.Millisecond)
	}
}

// TestModelQueueContextCancelRemovesQueuedItem verifies that cancelling the
// context of a queued admit removes the item and returns context.Canceled.
func TestModelQueueContextCancelRemovesQueuedItem(t *testing.T) {
	entry := &edgenode.NodeEntry{NodeID: "node-cc1"}
	cands := []candidateNode{{entry: entry, capacity: 1}}
	defPolicy := groupPolicy{}

	m := newModelQueueManager(nil)

	// Fill the only slot.
	n, err := m.admit(context.Background(), "g-cc", "", "", cands, defPolicy, nil, false, false)
	if err != nil || n == nil {
		t.Fatalf("initial admit: %v", err)
	}

	// Queue a second admit with a cancellable context.
	ctx, cancel := context.WithCancel(context.Background())
	resultCh := make(chan error, 1)
	go func() {
		_, err := m.admit(ctx, "g-cc", "", "", cands, defPolicy, nil, false, false)
		resultCh <- err
	}()

	waitForQueueLen(t, m, "g-cc", 1)
	cancel()

	select {
	case err := <-resultCh:
		if !errors.Is(err, context.Canceled) {
			t.Errorf("expected context.Canceled, got: %v", err)
		}
	case <-time.After(200 * time.Millisecond):
		t.Fatal("timeout: context cancellation not handled")
	}

	// Queue must be empty after cancellation.
	m.mu.Lock()
	qLen := 0
	if g, ok := m.groups["g-cc"]; ok {
		qLen = len(g.queue)
	}
	m.mu.Unlock()
	if qLen != 0 {
		t.Errorf("queue should be empty after cancel, got %d items", qLen)
	}
}

func TestProviderResourceCapacitySharedAcrossOrnithModelAliases(t *testing.T) {
	const (
		nodeID     = "rtx5090-lemonade-node"
		providerID = "rtx5090-lemonade"
	)
	modelAliases := []string{"ornith:35b", "ornith-fast"}
	entry := &edgenode.NodeEntry{NodeID: nodeID}
	cands := []candidateNode{
		{entry: entry, capacity: 1, providerID: providerID},
	}
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID: nodeID,
		Providers: []config.NodeProviderConf{
			{ID: providerID, Capacity: 1, Adapter: "vllm"},
		},
	})
	m := newModelQueueManager(store)
	m.setStore(store)

	start := make(chan struct{})
	resCh := make(chan concurrentAdmitResult, 2)

	for i := 0; i < 2; i++ {
		go func(id int) {
			<-start
			ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
			defer cancel()
			groupKey := modelAliases[id]
			sel, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 1, queueTimeout: 30 * time.Millisecond}, nil, false, false)
			resCh <- concurrentAdmitResult{candidate: sel, err: err}
		}(i)
	}

	close(start)

	var results []concurrentAdmitResult
	for i := 0; i < 2; i++ {
		results = append(results, <-resCh)
	}

	successCount := 0
	failCount := 0
	var successSel *candidateNode
	for _, res := range results {
		if res.err == nil {
			successCount++
			successSel = res.candidate
		} else {
			failCount++
		}
	}

	if successCount != 1 || failCount != 1 {
		t.Fatalf("expected exactly 1 success and 1 failure under concurrent admission, got success=%d fail=%d", successCount, failCount)
	}

	// Verify in-flight count peak is 1
	m.mu.Lock()
	res := m.resources[providerResourceKey{nodeID: nodeID, providerID: providerID}]
	if res == nil {
		m.mu.Unlock()
		t.Fatal("expected provider resource state to exist")
	}
	if res.inFlight != 1 {
		t.Errorf("expected inFlight peak to be 1, got %d", res.inFlight)
	}
	m.mu.Unlock()

	// Release the lease held by whichever alias won the shared provider slot.
	m.releaseLease(successSel.leaseID, "test-complete")

	// Verify count is 0
	m.mu.Lock()
	if res.inFlight != 0 {
		t.Errorf("expected inFlight to recover to 0, got %d", res.inFlight)
	}
	m.mu.Unlock()
}

func TestProviderSnapshotReadsAdmissionResourceState(t *testing.T) {
	reg := edgenode.NewRegistry()
	reg.Register(&edgenode.NodeEntry{NodeID: "node-snap-read", Alias: "node-snap-read"})

	store := edgenode.NewNodeStore()
	rec := &edgenode.NodeRecord{
		ID:      "node-snap-read",
		Runtime: config.RuntimeConf{Concurrency: 5},
		Providers: []config.NodeProviderConf{
			{
				ID:                  "snap-prov-read",
				Adapter:             "vllm",
				Capacity:            3,
				LongContextCapacity: 2,
				Enabled:             nil,
			},
		},
	}
	store.Add(rec)

	bus := edgeevents.NewBus()
	svc := New(reg, bus)
	svc.SetNodeStore(store)

	cands := []candidateNode{
		{entry: reg.All()[0], capacity: 3, providerID: "snap-prov-read", longContextCapacity: 2},
	}

	sel, err := svc.queue.admit(context.Background(), "g-snap-read", "", "", cands, groupPolicy{}, nil, true, false)
	if err != nil || sel == nil {
		t.Fatalf("admit failed: %v", err)
	}

	snaps := svc.ListNodeSnapshots()
	if len(snaps) != 1 {
		t.Fatalf("expected 1 node snapshot")
	}
	p := snaps[0].ProviderSnapshots[0]
	if p.InFlight != 1 {
		t.Errorf("expected InFlight 1, got %d", p.InFlight)
	}
	if p.LongInFlight != 1 {
		t.Errorf("expected LongInFlight 1, got %d", p.LongInFlight)
	}

	svc.queue.releaseSlotWithLong("g-snap-read", "node-snap-read", "snap-prov-read", true)

	snaps2 := svc.ListNodeSnapshots()
	p2 := snaps2[0].ProviderSnapshots[0]
	if p2.InFlight != 0 {
		t.Errorf("expected InFlight 0 after release, got %d", p2.InFlight)
	}
	if p2.LongInFlight != 0 {
		t.Errorf("expected LongInFlight 0 after release, got %d", p2.LongInFlight)
	}
}

func TestProviderResourceCapacityRefreshPreservesInflight(t *testing.T) {
	store := edgenode.NewNodeStore()
	rec := &edgenode.NodeRecord{
		ID: "node-refresh",
		Providers: []config.NodeProviderConf{
			{
				ID:       "prov-refresh",
				Capacity: 2,
				Enabled:  nil, // defaults to true
			},
		},
	}
	store.Add(rec)

	m := newModelQueueManager(store)
	m.setStore(store)
	entry := &edgenode.NodeEntry{NodeID: "node-refresh"}
	cands := []candidateNode{
		{entry: entry, capacity: 2, providerID: "prov-refresh"},
	}

	// Admit 2 items (matching capacity)
	sel1, err := m.admit(context.Background(), "group-1", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || sel1 == nil {
		t.Fatalf("admit 1 failed: %v", err)
	}
	sel2, err := m.admit(context.Background(), "group-2", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || sel2 == nil {
		t.Fatalf("admit 2 failed: %v", err)
	}

	// Verify inFlight = 2
	m.mu.Lock()
	res := m.resources[providerResourceKey{nodeID: "node-refresh", providerID: "prov-refresh"}]
	if res.inFlight != 2 {
		t.Fatalf("expected inFlight = 2, got %d", res.inFlight)
	}
	m.mu.Unlock()

	// Update store with decreased capacity (2 -> 1)
	newStore := edgenode.NewNodeStore()
	newRec := &edgenode.NodeRecord{
		ID: "node-refresh",
		Providers: []config.NodeProviderConf{
			{
				ID:       "prov-refresh",
				Capacity: 1,
				Enabled:  nil,
			},
		},
	}
	newStore.Add(newRec)

	// Refresh store
	m.setStore(newStore)

	// Verify capacity is updated but inFlight is preserved
	m.mu.Lock()
	if res.capacity != 1 {
		t.Errorf("expected updated capacity to be 1, got %d", res.capacity)
	}
	if res.inFlight != 2 {
		t.Errorf("expected inFlight to remain 2, got %d", res.inFlight)
	}
	m.mu.Unlock()

	// Third admit should fail because capacity is full (capacity 1, inflight 2)
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
	defer cancel()
	_, err = m.admit(ctx, "group-3", "", "", cands, groupPolicy{queueTimeout: 10 * time.Millisecond}, nil, false, false)
	if !errors.Is(err, errQueueTimeout) {
		t.Fatalf("expected queue timeout for third admit, got: %v", err)
	}
}

// TestProviderResourceRemovedProviderDoesNotReadmitQueuedCandidate verifies
// that a stale queued candidate cannot re-admit a removed provider. After the
// last release deletes the orphan resource, a queue pump must not recreate an
// active resource from the stale candidate's old capacity.
//
// Arrange order is critical: the second admit must be in-flight in the queue
// BEFORE the release that triggers pumpAllLocked. This exercises the real
// release-triggered queue-pump path where releaseSlotLocked → pumpAllLocked
// would otherwise hand a stale candidate to a deleted provider.
func TestProviderResourceRemovedProviderDoesNotReadmitQueuedCandidate(t *testing.T) {
	store := edgenode.NewNodeStore()
	rec := &edgenode.NodeRecord{
		ID: "node-stale",
		Providers: []config.NodeProviderConf{
			{ID: "prov-stale", Capacity: 1, Adapter: "vllm"},
		},
	}
	store.Add(rec)

	entry := &edgenode.NodeEntry{NodeID: "node-stale"}
	cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-stale"}}

	m := newModelQueueManager(store)
	m.setStore(store)

	// 1. Admit the first request — fills the only capacity slot.
	first, err := m.admit(context.Background(), "g-stale", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || first == nil {
		t.Fatalf("initial admit: %v", err)
	}

	key := providerResourceKey{nodeID: "node-stale", providerID: "prov-stale"}
	m.mu.Lock()
	res := m.resources[key]
	m.mu.Unlock()
	if res == nil || res.orphan {
		t.Fatal("expected active non-orphan resource after first admit")
	}

	// 2. Start the second admit in a goroutine so it blocks in the queue
	//    while the first request is still running. Use a short timeout so the
	//    test can observe the terminal result quickly.
	resultCh := make(chan admitResult, 1)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
		defer cancel()
		c, _, e := m.admitWithReason(ctx, "g-stale", "", "", cands, groupPolicy{maxQueue: 16, queueTimeout: 500 * time.Millisecond}, nil, false, false)
		resultCh <- admitResult{candidate: c, err: e}
	}()

	// 3. Confirm the second request actually entered the queue.
	waitForQueueLen(t, m, "g-stale", 1)

	// 4. Remove the provider from the store so the resource becomes orphan.
	rec.Providers = nil
	m.setStore(store)

	m.mu.Lock()
	res, ok := m.resources[key]
	m.mu.Unlock()
	if !ok || res == nil || !res.orphan {
		t.Fatalf("expected resource to be orphan after store update, ok=%t, res=%v", ok, res)
	}

	// 5. Release the first slot to trigger the queue pump. The orphan
	//    resource must be deleted (inFlight=0) and the queue pump must
	//    NOT recreate it via pumpAllLocked → findAvailableNodeLocked.
	m.releaseSlotWithLong("g-stale", "node-stale", "prov-stale", false)

	// 6. The second request must NOT succeed — the stale candidate must
	//    not be dispatched and the resource must not be recreated.
	select {
	case res := <-resultCh:
		if res.err == nil {
			t.Fatal("expected error/timeout for queued candidate of removed provider, got nil error")
		}
		if res.candidate != nil {
			t.Fatalf("expected no candidate dispatch, got %+v", res.candidate)
		}
	case <-time.After(1 * time.Second):
		t.Fatal("timeout: second admit did not return within expected window")
	}

	// 7. Verify the resource was not recreated as an active entry.
	m.mu.Lock()
	_, exists := m.resources[key]
	m.mu.Unlock()
	if exists {
		t.Fatal("expected orphan resource to remain deleted after queue pump")
	}
}

// queueItemForTest builds a pending item with a generous deadline so the test
// controls when it resolves.
func queueItemForTest(cands []candidateNode, long bool) *queueItem {
	return &queueItem{
		candidates: cands,
		waitCh:     make(chan admitResult, 1),
		deadline:   time.Now().Add(5 * time.Second),
		long:       long,
	}
}

// enqueueForTest registers an item in the given group through the same helper
// admission uses, so it receives a real global enqueue sequence. The optional
// resolveCandidates closure is forwarded to the manager; nil keeps the legacy
// snapshot path for existing fixtures.
func enqueueForTest(m *modelQueueManager, groupKey string, item *queueItem, resolveCandidates func() ([]candidateNode, error)) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.enqueueItemLocked(m.getOrCreateGroupLocked(groupKey, groupPolicy{}), item, resolveCandidates)
}

// TestGlobalPumpWakesDifferentModelGroup verifies the cross-group wake-up
// contract (S03 / cross-group-wakeup): provider resources are shared across
// model groups, so a lease released by one group must wake a waiter queued in
// another. Before the global pump, the waiter slept until its own group saw an
// event or its queue timeout expired.
func TestGlobalPumpWakesDifferentModelGroup(t *testing.T) {
	entry := &edgenode.NodeEntry{NodeID: "node-wake"}
	cands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-wake"}}
	m := newModelQueueManager(nil)

	// Group A takes the provider's only slot.
	first, err := m.admit(context.Background(), "g-wake-a", "", "", cands, groupPolicy{}, nil, false, false)
	if err != nil || first == nil {
		t.Fatalf("group A admit: %v", err)
	}

	// Group B queues a request for the same provider.
	waiter := queueItemForTest(cands, false)
	enqueueForTest(m, "g-wake-b", waiter, nil)

	// Releasing group A's lease must dispatch group B's waiter.
	m.releaseLease(first.leaseID, "complete")

	select {
	case res := <-waiter.waitCh:
		if res.err != nil {
			t.Fatalf("group B waiter expected dispatch, got error: %v", res.err)
		}
		if res.candidate == nil || res.candidate.providerID != "prov-wake" {
			t.Fatalf("group B waiter: unexpected candidate %v", res.candidate)
		}
	default:
		t.Fatal("group B waiter was not woken by the group A release (cross-group wake-up missing)")
	}
}

// TestGlobalPumpPreservesEarliestDispatchableSequence verifies that the pump
// orders waiters by global arrival, not by group: an item blocked on a full
// provider is skipped, and among the items that can dispatch the earliest
// enqueued one wins even when it sits in a different group than the release.
func TestGlobalPumpPreservesEarliestDispatchableSequence(t *testing.T) {
	entry := &edgenode.NodeEntry{NodeID: "node-seq"}
	blockedCands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-seq-blocked"}}
	openCands := []candidateNode{{entry: entry, capacity: 1, providerID: "prov-seq-open"}}
	m := newModelQueueManager(nil)

	// Fill both providers so every queued item starts out blocked.
	if _, err := m.admit(context.Background(), "g-seq-a", "", "", blockedCands, groupPolicy{}, nil, false, false); err != nil {
		t.Fatalf("fill blocked provider: %v", err)
	}
	openHolder, err := m.admit(context.Background(), "g-seq-a", "", "", openCands, groupPolicy{}, nil, false, false)
	if err != nil {
		t.Fatalf("fill open provider: %v", err)
	}

	// Arrival order: the earliest item can never dispatch (its provider is never
	// released); the next two compete for the one slot that does free.
	blocked := queueItemForTest(blockedCands, false)
	enqueueForTest(m, "g-seq-a", blocked, nil)
	earlier := queueItemForTest(openCands, false)
	enqueueForTest(m, "g-seq-b", earlier, nil)
	later := queueItemForTest(openCands, false)
	enqueueForTest(m, "g-seq-a", later, nil)

	m.releaseLease(openHolder.leaseID, "complete")

	select {
	case res := <-earlier.waitCh:
		if res.err != nil {
			t.Fatalf("earlier item dispatch error: %v", res.err)
		}
	default:
		t.Fatal("earliest dispatchable item was not dispatched first")
	}
	select {
	case <-later.waitCh:
		t.Fatal("later item dispatched out of global enqueue order")
	default:
	}
	select {
	case <-blocked.waitCh:
		t.Fatal("item on a still-full provider must stay queued")
	default:
	}

	// Freeing the slot again promotes the later item; the blocked one stays put,
	// proving skipping does not consume its turn.
	m.mu.Lock()
	pending := len(m.pendingItemsLocked())
	m.mu.Unlock()
	if pending != 2 {
		t.Fatalf("expected 2 pending items after the first dispatch, got %d", pending)
	}
}

// TestGlobalPumpConcurrentReleaseCancelRefreshNoLeak drives releases, cancels,
// and config refreshes against the global pump concurrently. The invariants it
// pins are the ones a shared scheduler can silently break: no request is handed
// two leases, provider in-flight never exceeds configured capacity, and every
// counter returns to zero once the storm ends.
func TestGlobalPumpConcurrentReleaseCancelRefreshNoLeak(t *testing.T) {
	const (
		capacity   = 3
		groups     = 3
		perGroup   = 12
		nodeID     = "node-race"
		providerID = "prov-race"
	)

	newStore := func(cap int) *edgenode.NodeStore {
		store := edgenode.NewNodeStore()
		store.Add(&edgenode.NodeRecord{
			ID: nodeID,
			Providers: []config.NodeProviderConf{
				{ID: providerID, Adapter: "vllm", Capacity: cap, LongContextCapacity: 1},
			},
		})
		return store
	}

	m := newModelQueueManager(nil)
	m.setStore(newStore(capacity))

	entry := &edgenode.NodeEntry{NodeID: nodeID}
	cands := []candidateNode{{entry: entry, capacity: capacity, providerID: providerID, longContextCapacity: 1}}

	var (
		mu         sync.Mutex
		seenLeases = map[uint64]int{}
		peak       int
		peakLong   int
	)
	observe := func() {
		inFlight, longInFlight := providerResourceCounts(m, nodeID, providerID)
		mu.Lock()
		if inFlight > peak {
			peak = inFlight
		}
		if longInFlight > peakLong {
			peakLong = longInFlight
		}
		mu.Unlock()
	}

	stop := make(chan struct{})
	var refreshWG sync.WaitGroup
	refreshWG.Add(1)
	go func() {
		defer refreshWG.Done()
		for {
			select {
			case <-stop:
				return
			default:
			}
			// Config refresh alternates capacity; both values are pumped, so the
			// scheduler re-evaluates every waiter on each swap.
			m.setStore(newStore(capacity))
			m.setStore(newStore(capacity + 1))
			observe()
		}
	}()

	var wg sync.WaitGroup
	for g := 0; g < groups; g++ {
		groupKey := fmt.Sprintf("g-race-%d", g)
		for i := 0; i < perGroup; i++ {
			wg.Add(1)
			long := i%3 == 0
			// A share of the callers abandon their request mid-flight, so cancel
			// races the dispatch hand-off.
			cancelEarly := i%4 == 0
			go func() {
				defer wg.Done()
				ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
				defer cancel()
				if cancelEarly {
					go func() {
						time.Sleep(time.Millisecond)
						cancel()
					}()
				}
				admitted, err := m.admit(ctx, groupKey, "", "", cands, groupPolicy{maxQueue: 64, queueTimeout: 150 * time.Millisecond}, nil, long, false)
				if err != nil || admitted == nil {
					return
				}
				mu.Lock()
				seenLeases[admitted.leaseID]++
				mu.Unlock()
				observe()
				time.Sleep(time.Millisecond)
				m.releaseLease(admitted.leaseID, "complete")
			}()
		}
	}

	wg.Wait()
	close(stop)
	refreshWG.Wait()

	// Every admitted request holds exactly one lease identity.
	mu.Lock()
	for id, count := range seenLeases {
		if count != 1 {
			mu.Unlock()
			t.Fatalf("lease %d handed out %d times; a request must own exactly one lease", id, count)
		}
	}
	observedPeak, observedPeakLong := peak, peakLong
	mu.Unlock()

	if observedPeak > capacity+1 {
		t.Errorf("provider in-flight peaked at %d, above the highest configured capacity %d", observedPeak, capacity+1)
	}
	if observedPeakLong > 1 {
		t.Errorf("long in-flight peaked at %d, above the configured long capacity 1", observedPeakLong)
	}

	// Final state must be fully drained: no held resource, lease, or pending item.
	inFlight, longInFlight := providerResourceCounts(m, nodeID, providerID)
	if inFlight != 0 || longInFlight != 0 {
		t.Errorf("expected zero in-flight after drain, got inflight=%d long=%d", inFlight, longInFlight)
	}
	if got := leaseCount(m); got != 0 {
		t.Errorf("expected zero live leases after drain, got %d", got)
	}
	if got := pendingItemCount(m); got != 0 {
		t.Errorf("expected zero pending items after drain, got %d", got)
	}
}

// TestModelQueueTerminalUnavailableOnNoCandidates verifies that when a queued
// item's live resolver returns zero candidates, the item is removed from the
// queue and the waiter receives a typed errProviderUnavailable error exactly
// once. This is the terminal-unavailable outcome contract.
func TestModelQueueTerminalUnavailableOnNoCandidates(t *testing.T) {
	store := edgenode.NewNodeStore()
	m := newModelQueueManager(store)

	candidates := []candidateNode{
		{entry: &edgenode.NodeEntry{NodeID: "node-x"}, capacity: 1, providerID: "prov-x"},
	}
	item := &queueItem{
		candidates: candidates,
		resolveCandidates: func() ([]candidateNode, error) {
			return nil, nil // no live candidates
		},
		waitCh:       make(chan admitResult, 1),
		deadline:     time.Now().Add(5 * time.Second),
		providerPool: true,
	}
	enqueueForTest(m, "g-no-cand", item, item.resolveCandidates)

	// Run the pump — should deliver terminal unavailable and remove the item.
	m.mu.Lock()
	m.pumpAllLocked()
	m.mu.Unlock()

	// The waiter should have received the terminal unavailable error.
	select {
	case res := <-item.waitCh:
		if res.candidate != nil {
			t.Fatalf("expected no candidate, got %+v", res.candidate)
		}
		if !errors.Is(res.err, errProviderUnavailable) {
			t.Fatalf("expected errProviderUnavailable, got: %v", res.err)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("timeout: waiter was not notified of terminal unavailable")
	}

	// Queue must be empty after terminal delivery.
	m.mu.Lock()
	qLen := len(m.groups["g-no-cand"].queue)
	m.mu.Unlock()
	if qLen != 0 {
		t.Errorf("expected queue to be empty after terminal delivery, got %d", qLen)
	}
}

type providerPoolQueueFixture struct {
	svc        *Service
	registry   *edgenode.Registry
	store      *edgenode.NodeStore
	catalog    []config.ModelCatalogEntry
	policy     groupPolicy
	req        SubmitRunRequest
	candidates []candidateNode
	resolver   func() ([]candidateNode, error)
	entry      *edgenode.NodeEntry
}

func newProviderPoolQueueFixture(t *testing.T, suffix string) *providerPoolQueueFixture {
	t.Helper()
	nodeID := "node-" + suffix
	providerID := "provider-" + suffix
	servedModel := "served-" + suffix
	groupKey := "group-" + suffix

	registry := edgenode.NewRegistry()
	entry := &edgenode.NodeEntry{NodeID: nodeID, Alias: nodeID}
	registry.Register(entry)
	store := edgenode.NewNodeStore()
	store.Add(&edgenode.NodeRecord{
		ID: nodeID,
		Providers: []config.NodeProviderConf{
			{
				ID:       providerID,
				Type:     "vllm",
				Category: config.CategoryAPI,
				Models:   []string{servedModel},
				Health:   "available",
				Capacity: 1,
			},
		},
	})
	catalog := []config.ModelCatalogEntry{
		{ID: groupKey, Providers: map[string]string{providerID: servedModel}},
	}
	policy := NewGroupPolicy(16, 5*time.Second)
	svc := New(registry, edgeevents.NewBus())
	svc.SetRuntimeConfig(store, catalog, policy)
	req := SubmitRunRequest{ModelGroupKey: groupKey, ProviderPool: true}
	candidates, _, err := svc.resolveQueueCandidates(req)
	if err != nil {
		t.Fatalf("resolve initial provider-pool candidates: %v", err)
	}

	return &providerPoolQueueFixture{
		svc:        svc,
		registry:   registry,
		store:      store,
		catalog:    catalog,
		policy:     policy,
		req:        req,
		candidates: candidates,
		resolver:   svc.resolveQueueCandidatesClosure(req),
		entry:      entry,
	}
}

func (f *providerPoolQueueFixture) admitHolder(t *testing.T) *candidateNode {
	t.Helper()
	holder, err := f.svc.queue.admit(
		t.Context(), f.req.ModelGroupKey, "", "", f.candidates, f.policy,
		f.resolver, false, true,
	)
	if err != nil {
		t.Fatalf("admit capacity holder: %v", err)
	}
	f.svc.queue.trackLease(holder.leaseID, "run-holder-"+f.req.ModelGroupKey)
	return holder
}

func (f *providerPoolQueueFixture) enqueueWaiter() *queueItem {
	item := queueItemForTest(f.candidates, false)
	item.providerPool = true
	enqueueForTest(f.svc.queue, f.req.ModelGroupKey, item, f.resolver)
	return item
}

func providerQueuePressureForTest(m *modelQueueManager, nodeID, providerID string) (queued, longQueued int) {
	m.mu.Lock()
	defer m.mu.Unlock()
	pressure := m.providerQueuePressureLocked()
	if byProvider := pressure[nodeID]; byProvider != nil {
		if value := byProvider[providerID]; value != nil {
			return value.queued, value.longQueued
		}
	}
	return 0, 0
}

// TestProviderPoolQueuedDisconnectReturnsUnavailableImmediately verifies that
// the production registry/store/catalog resolver classifies the last live
// provider's disconnect as terminal unavailable. The queue and candidate
// pressure are cleared in the same disconnect pump, and a later pump cannot
// deliver a duplicate terminal result.
func TestProviderPoolQueuedDisconnectReturnsUnavailableImmediately(t *testing.T) {
	fixture := newProviderPoolQueueFixture(t, "disconnect-unavailable")
	holder := fixture.admitHolder(t)
	item := fixture.enqueueWaiter()

	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending before disconnect = %d, want 1", pending)
	}
	queued, longQueued := providerQueuePressureForTest(
		fixture.svc.queue, holder.entry.NodeID, holder.providerID,
	)
	if queued != 1 || longQueued != 0 {
		t.Fatalf("candidate pressure before disconnect = (%d, %d), want (1, 0)", queued, longQueued)
	}

	// Keep an unrelated Node connected so production resolution reaches the
	// filtered zero-candidate branch instead of the simpler empty-registry branch.
	fixture.registry.Register(&edgenode.NodeEntry{NodeID: "node-unrelated-disconnect-unavailable"})
	fixture.registry.Unregister(fixture.entry.NodeID)
	fixture.svc.HandleNodeDisconnect(
		fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "test disconnect",
	)

	select {
	case result := <-item.waitCh:
		if result.candidate != nil {
			t.Fatalf("disconnect waiter candidate = %+v, want nil", result.candidate)
		}
		if !errors.Is(result.err, errProviderUnavailable) {
			t.Fatalf("disconnect waiter error = %v, want errProviderUnavailable", result.err)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("disconnect waiter did not receive terminal unavailable immediately")
	}

	if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
		t.Fatalf("pending after disconnect = %d, want 0", pending)
	}
	queued, longQueued = providerQueuePressureForTest(
		fixture.svc.queue, holder.entry.NodeID, holder.providerID,
	)
	if queued != 0 || longQueued != 0 {
		t.Fatalf("candidate pressure after disconnect = (%d, %d), want (0, 0)", queued, longQueued)
	}
	if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
		t.Fatalf("in-flight after disconnect = %d, want 0", inFlight)
	}
	if leases := leaseCount(fixture.svc.queue); leases != 0 {
		t.Fatalf("live leases after disconnect = %d, want 0", leases)
	}

	fixture.svc.queue.mu.Lock()
	fixture.svc.queue.pumpAllLocked()
	fixture.svc.queue.mu.Unlock()
	select {
	case duplicate := <-item.waitCh:
		t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
	default:
	}
}

// TestModelQueueStaysQueuedWhenAllCandidatesFull verifies through the
// production provider-pool resolver that a connected, available provider at
// capacity is a temporary block, not terminal unavailability.
func TestModelQueueStaysQueuedWhenAllCandidatesFull(t *testing.T) {
	fixture := newProviderPoolQueueFixture(t, "all-candidates-full")
	holder := fixture.admitHolder(t)
	item := fixture.enqueueWaiter()

	// Pump — the item should be skipped (temporarily blocked), not delivered.
	fixture.svc.queue.mu.Lock()
	fixture.svc.queue.pumpAllLocked()
	fixture.svc.queue.mu.Unlock()

	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending when all candidates full = %d, want 1", pending)
	}
	queued, longQueued := providerQueuePressureForTest(
		fixture.svc.queue, holder.entry.NodeID, holder.providerID,
	)
	if queued != 1 || longQueued != 0 {
		t.Fatalf("candidate pressure while full = (%d, %d), want (1, 0)", queued, longQueued)
	}
	select {
	case <-item.waitCh:
		t.Fatal("waiter should not have been notified when all candidates full")
	default:
	}

	fixture.svc.HandleRunLifecycleEvent(&iop.RunEvent{
		RunId: "run-holder-" + fixture.req.ModelGroupKey,
		Type:  "complete",
	})
	select {
	case result := <-item.waitCh:
		if result.err != nil || result.candidate == nil {
			t.Fatalf("waiter after holder release = %+v, want dispatch", result)
		}
		fixture.svc.queue.releaseLease(result.candidate.leaseID, "test cleanup")
	case <-time.After(100 * time.Millisecond):
		t.Fatal("full candidate waiter did not dispatch after holder release")
	}
}

// TestProviderPoolQueuedResolverErrorStaysQueuedAndRecovers verifies that a
// production catalog/config resolver fault is not confused with the typed
// no-live-provider outcome. The waiter remains queued across the fault and
// dispatches after a valid snapshot is restored and capacity is released.
func TestProviderPoolQueuedResolverErrorStaysQueuedAndRecovers(t *testing.T) {
	fixture := newProviderPoolQueueFixture(t, "resolver-recovery")
	fixture.admitHolder(t)
	item := fixture.enqueueWaiter()

	// Remove the model catalog entry while the request is queued. This is a
	// resolver/config fault, not a live-candidate absence, so it must not carry
	// errProviderUnavailable or terminate the waiter.
	fixture.svc.SetRuntimeConfig(fixture.store, nil, fixture.policy)
	if _, err := fixture.resolver(); err == nil {
		t.Fatal("resolver with missing catalog returned nil error")
	} else if errors.Is(err, errProviderUnavailable) {
		t.Fatalf("missing catalog error incorrectly classified unavailable: %v", err)
	}
	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending during resolver fault = %d, want 1", pending)
	}
	select {
	case result := <-item.waitCh:
		t.Fatalf("resolver fault unexpectedly notified waiter: %+v", result)
	default:
	}

	fixture.svc.SetRuntimeConfig(fixture.store, fixture.catalog, fixture.policy)
	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending after resolver recovery while full = %d, want 1", pending)
	}
	queued, longQueued := providerQueuePressureForTest(
		fixture.svc.queue, fixture.entry.NodeID, fixture.candidates[0].providerID,
	)
	if queued != 1 || longQueued != 0 {
		t.Fatalf("candidate pressure after resolver recovery = (%d, %d), want (1, 0)", queued, longQueued)
	}

	fixture.svc.HandleRunLifecycleEvent(&iop.RunEvent{
		RunId: "run-holder-" + fixture.req.ModelGroupKey,
		Type:  "complete",
	})
	select {
	case result := <-item.waitCh:
		if result.err != nil || result.candidate == nil {
			t.Fatalf("waiter after resolver recovery = %+v, want dispatch", result)
		}
		fixture.svc.queue.releaseLease(result.candidate.leaseID, "test cleanup")
	case <-time.After(100 * time.Millisecond):
		t.Fatal("resolver-recovered waiter did not dispatch after capacity release")
	}
}

// TestProviderPoolDisconnectAuthoritativeSettlementNoLeak verifies that an
// authoritative disconnect with the live resolver settles the last live
// candidate as terminal unavailable in a single deterministic pass: the waiter
// receives errProviderUnavailable, the queue is empty, in-flight and candidate
// pressure are zero, and every lease is released. It also runs with a
// saturated event subscriber to prove that event bus backpressure does not
// affect the settlement contract — the transport drives releaseNode directly.
func TestProviderPoolDisconnectAuthoritativeSettlementNoLeak(t *testing.T) {
	bus := edgeevents.NewBus()
	fixture := newProviderPoolQueueFixture(t, "auth-settlement")
	// Override the bus so we can verify it is actually saturated (dropping).
	// We achieve this by subscribing with a one-deep buffer and flooding it
	// before the disconnect.
	fixture.svc = New(fixture.registry, bus)
	fixture.svc.SetRuntimeConfig(fixture.store, fixture.catalog, fixture.policy)
	// Re-resolve candidates against the new service so the resolver closure
	// points at the correct service instance.
	cands, _, err := fixture.svc.resolveQueueCandidates(fixture.req)
	if err != nil {
		t.Fatalf("re-resolve candidates after bus swap: %v", err)
	}
	fixture.candidates = cands
	fixture.resolver = fixture.svc.resolveQueueCandidatesClosure(fixture.req)

	// Saturated subscriber: one-deep buffer, then flood so drops are real.
	_, unsub := bus.SubscribeAllRuns(1)
	defer unsub()
	for i := 0; i < 8; i++ {
		bus.PublishRun(&iop.RunEvent{RunId: "run-flood", Type: "chunk"})
	}
	if dropped := bus.Stats().DroppedRunEvents; dropped == 0 {
		t.Fatal("expected the saturated subscriber to drop events; the test must exercise the drop path")
	}

	holder := fixture.admitHolder(t)
	item := fixture.enqueueWaiter()

	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending before disconnect = %d, want 1", pending)
	}

	// Keep an unrelated node connected so production resolution reaches the
	// filtered zero-candidate branch (not the empty-registry shortcut).
	fixture.registry.Register(&edgenode.NodeEntry{NodeID: "node-unrelated-auth"})

	// Authoritative disconnect: the registry confirms the disconnecting
	// client still owned the entry.
	fixture.svc.HandleNodeDisconnect(
		fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "test disconnect",
	)

	// Waiter must receive typed unavailable exactly once.
	select {
	case result := <-item.waitCh:
		if result.candidate != nil {
			t.Fatalf("disconnect waiter candidate = %+v, want nil", result.candidate)
		}
		if !errors.Is(result.err, errProviderUnavailable) {
			t.Fatalf("disconnect waiter error = %v, want errProviderUnavailable", result.err)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("disconnect waiter did not receive terminal unavailable immediately")
	}

	// Post-settlement invariants.
	if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
		t.Fatalf("pending after authoritative disconnect = %d, want 0", pending)
	}
	queued, longQueued := providerQueuePressureForTest(
		fixture.svc.queue, holder.entry.NodeID, holder.providerID,
	)
	if queued != 0 || longQueued != 0 {
		t.Fatalf("candidate pressure after disconnect = (%d, %d), want (0, 0)", queued, longQueued)
	}
	if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
		t.Fatalf("in-flight after disconnect = %d, want 0", inFlight)
	}
	if leases := leaseCount(fixture.svc.queue); leases != 0 {
		t.Fatalf("live leases after disconnect = %d, want 0", leases)
	}

	// Re-pump must not deliver a duplicate terminal result.
	fixture.svc.queue.mu.Lock()
	fixture.svc.queue.pumpAllLocked()
	fixture.svc.queue.mu.Unlock()
	select {
	case duplicate := <-item.waitCh:
		t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
	default:
	}
}

// TestProviderPoolLastProviderDisconnectTerminalSettlement verifies that when
// the very last live candidate for a queued provider-pool item disappears
// (because the node disconnects), the waiter receives errProviderUnavailable
// immediately — not via queue timeout — and all accounting (queue, lease,
// in-flight, candidate pressure) returns to zero in the same pass.
func TestProviderPoolLastProviderDisconnectTerminalSettlement(t *testing.T) {
	fixture := newProviderPoolQueueFixture(t, "last-provider")
	holder := fixture.admitHolder(t)
	item := fixture.enqueueWaiter()

	// Verify pre-disconnect state.
	if pending := pendingItemCount(fixture.svc.queue); pending != 1 {
		t.Fatalf("pending before last-provider disconnect = %d, want 1", pending)
	}

	// The last live candidate disappears via disconnect.
	fixture.svc.HandleNodeDisconnect(
		fixture.entry.NodeID, fixture.entry.ConnectionGeneration, "last provider disconnect",
	)

	select {
	case result := <-item.waitCh:
		if result.candidate != nil {
			t.Fatalf("last-provider waiter candidate = %+v, want nil", result.candidate)
		}
		if !errors.Is(result.err, errProviderUnavailable) {
			t.Fatalf("last-provider waiter error = %v, want errProviderUnavailable", result.err)
		}
	case <-time.After(100 * time.Millisecond):
		t.Fatal("last-provider waiter did not receive terminal unavailable immediately")
	}

	// Queue, lease, and pressure must all be zero.
	if pending := pendingItemCount(fixture.svc.queue); pending != 0 {
		t.Fatalf("pending after last-provider disconnect = %d, want 0", pending)
	}
	if leases := leaseCount(fixture.svc.queue); leases != 0 {
		t.Fatalf("live leases after last-provider disconnect = %d, want 0", leases)
	}

	// No duplicate terminal delivery after a subsequent pump.
	fixture.svc.queue.mu.Lock()
	fixture.svc.queue.pumpAllLocked()
	fixture.svc.queue.mu.Unlock()
	select {
	case duplicate := <-item.waitCh:
		t.Fatalf("duplicate terminal delivery after re-pump: %+v", duplicate)
	default:
	}

	// Also verify that the holder's resource is cleaned up.
	if inFlight, _ := providerResourceCounts(fixture.svc.queue, holder.entry.NodeID, holder.providerID); inFlight != 0 {
		t.Fatalf("holder in-flight after last-provider disconnect = %d, want 0", inFlight)
	}
}
