// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package interpreter

import (
	"context"
	"errors"
	"math"
	"strings"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"github.com/google/cel-go/checker"
	"github.com/google/cel-go/common"
	"github.com/google/cel-go/common/containers"
	"github.com/google/cel-go/common/decls"
	"github.com/google/cel-go/common/functions"
	"github.com/google/cel-go/common/overloads"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
	"github.com/google/cel-go/parser"
)

// asyncReturning returns an AsyncOp that immediately produces the given value, while counting the
// number of times the implementation is invoked.
func asyncReturning(val ref.Val, calls *atomic.Int32) functions.AsyncOp {
	return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
		if calls != nil {
			calls.Add(1)
		}
		ch := make(chan ref.Val, 1)
		ch <- val
		close(ch)
		return ch
	}
}

// newTestFrame creates an ExecutionFrame with an evaluation context attached.
func newTestFrame(t *testing.T, ctx context.Context) (*ExecutionFrame, func()) {
	t.Helper()
	frame, err := NewExecutionFrame(EmptyActivation())
	if err != nil {
		t.Fatalf("NewExecutionFrame() failed: %v", err)
	}
	if err := frame.SetContext(ctx, 0); err != nil {
		t.Fatalf("SetContext() failed: %v", err)
	}
	return frame, frame.Close
}

// awaitResult re-evaluates ComputeResult until it returns a non-Unknown value or the deadline fires.
func awaitResult(t *testing.T, frame *ExecutionFrame, completions <-chan int64, id int64, fn, overload string, impl functions.AsyncOp, args ...ref.Val) ref.Val {
	t.Helper()
	deadline := time.After(2 * time.Second)
	for {
		res := frame.ComputeResult(id, fn, overload, impl, args)
		if !types.IsUnknown(res) {
			return res
		}
		if unk, ok := res.(*types.Unknown); ok {
			frame.DispatchPendingAsyncCalls(unk.IDs())
		}
		select {
		case <-completions:
		case <-deadline:
			t.Fatal("timed out waiting for async result")
		}
	}
}

func TestComputeResultWithoutContext(t *testing.T) {
	frame, err := NewExecutionFrame(EmptyActivation())
	if err != nil {
		t.Fatalf("NewExecutionFrame() failed: %v", err)
	}
	defer frame.Close()
	// No SetContext call, so async tracking is uninitialized.
	res := frame.ComputeResult(1, "fn", "fn_overload", asyncReturning(types.Int(1), nil), nil)
	if !types.IsError(res) {
		t.Errorf("ComputeResult() got %v, wanted error when tracking uninitialized", res)
	}
	if frame.ActiveAsyncCalls() != 0 {
		t.Errorf("ActiveAsyncCalls() = %d, wanted 0", frame.ActiveAsyncCalls())
	}
	if call := frame.AsyncCall(1); call != nil {
		t.Errorf("AsyncCall() = %v, wanted nil", call)
	}
}

func TestComputeResultResolves(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	var calls atomic.Int32
	impl := asyncReturning(types.Int(42), &calls)
	res := awaitResult(t, frame, completions, 1, "fn", "fn_int", impl, types.Int(1))
	if res.Equal(types.Int(42)) != types.True {
		t.Errorf("async result = %v, wanted 42", res)
	}
	if got := calls.Load(); got != 1 {
		t.Errorf("impl invoked %d times, wanted exactly 1", got)
	}
}

func TestTrackerDedupAndCallIDs(t *testing.T) {
	// Exercise the tracker directly to keep bookkeeping assertions isolated from the
	// process-global frame/tracker pools used by ExecutionFrame.
	tracker := newAsyncCallStateTracker()
	completions := make(chan int64, 4)
	gate := newAsyncGate(0, completions)
	impl := asyncReturning(types.Int(1), nil)

	// Same node id, same args, invoked repeatedly: must dedup to a single registered call.
	a1 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
	a2 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
	if a1 != a2 {
		t.Error("getOrCreate returned distinct states for identical (id, args)")
	}
	if a1.CallID() != a2.CallID() {
		t.Errorf("dedup callIDs differ: %d vs %d", a1.CallID(), a2.CallID())
	}
	if got := tracker.getByID(a1.CallID()); got != a1 {
		t.Errorf("getByID(%d) did not return the registered call", a1.CallID())
	}

	// Same node id, different args: a new call must be registered (re-evaluation with new inputs).
	a3 := tracker.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(2)}, impl, gate)
	if a3.CallID() == a1.CallID() {
		t.Error("arg change reused the prior callID, wanted a fresh call")
	}
	if got := tracker.getByID(a3.CallID()); got != a3 {
		t.Errorf("getByID(%d) did not return the second registered call", a3.CallID())
	}
	// Registration alone does not mark a call as in-flight; pending counts launched calls only.
	if got := gate.ActiveCalls(); got != 0 {
		t.Errorf("ActiveCalls() after registration only = %d, wanted 0", got)
	}
}

func TestHashCall(t *testing.T) {
	type hashInput struct {
		id       int64
		overload string
		args     []ref.Val
	}
	tests := []struct {
		name     string
		input    hashInput
		wantHash uint64
	}{
		{
			name:     "node id 1 with string arg a",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.String("a")}},
			wantHash: 13175600815575489707,
		},
		{
			name:     "node id 1 with string arg b",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.String("b")}},
			wantHash: 13172731090226426672,
		},
		{
			name:     "node id 1 with bool arg true",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Bool(true)}},
			wantHash: 5368363472817480916,
		},
		{
			name:     "node id 1 with bool arg false",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Bool(false)}},
			wantHash: 5369320047933835261,
		},
		{
			name:     "node id 1 with nil args",
			input:    hashInput{1, overloads.ContainsString, nil},
			wantHash: 16571860343215109013,
		},
		{
			name:     "node id 2 with nil args",
			input:    hashInput{2, overloads.ContainsString, nil},
			wantHash: 7332562693386296450,
		},
		{
			name:     "node id 1 with overload matches_string",
			input:    hashInput{1, overloads.MatchesString, nil},
			wantHash: 6112375249444952169,
		},
		{
			name:     "node id 1 with overload starts_with_string",
			input:    hashInput{1, overloads.StartsWithString, nil},
			wantHash: 17791913581187873402,
		},
		{
			name:     "node id 1 with int arg 1",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Int(1)}},
			wantHash: 6250879603390619476,
		},
		{
			name:     "node id 1 with uint arg 1",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Uint(1)}},
			wantHash: 6250879603390619476,
		},
		{
			name:     "node id 1 with double arg 1.0",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(1.0)}},
			wantHash: 6250879603390619476,
		},
		{
			name:     "node id 1 with int arg 2",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Int(2)}},
			wantHash: 2269972419901218387,
		},
		{
			name:     "node id 1 with double arg 1.5",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(1.5)}},
			wantHash: 1181031487041842476,
		},
		{
			name:     "node id 1 with double arg 9.9",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(9.9)}},
			wantHash: 6763353195175059609,
		},
		{
			name:     "separation a, bc",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.String("a"), types.String("bc")}},
			wantHash: 16286284200968323077,
		},
		{
			name:     "separation ab, c",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.String("ab"), types.String("c")}},
			wantHash: 14974838604657976619,
		},
		{
			name:     "node id 1 with double arg NaN",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(math.NaN())}},
			wantHash: 4464412628189895594,
		},
		{
			name:     "node id 1 with string arg NaN",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.String("NaN")}},
			wantHash: 17422508148545277865,
		},
		{
			name:     "node id 1 with double arg 0.0",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(0.0)}},
			wantHash: 2331193227347896467,
		},
		{
			name:     "node id 1 with double arg -0.0",
			input:    hashInput{1, overloads.ContainsString, []ref.Val{types.Double(math.Copysign(0.0, -1.0))}},
			wantHash: 2331193227347896467,
		},
	}

	for _, tc := range tests {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			got := hashCall(tc.input.id, tc.input.overload, tc.input.args)
			if got != tc.wantHash {
				t.Errorf("hashCall() = %d, wanted %d", got, tc.wantHash)
			}
		})
	}
}

func TestTrackerComprehensionReuse(t *testing.T) {
	// Regression: a single AST node id evaluated repeatedly with distinct argument values (as
	// happens for an async call inside a comprehension) must register one call per distinct
	// argument set, and re-lookups must return the existing state rather than relaunching. A
	// node-id-keyed map collapses these into a single slot, causing every re-evaluation pass to
	// relaunch every iteration and never converge.
	tracker := newAsyncCallStateTracker()
	completions := make(chan int64, 8)
	gate := newAsyncGate(0, completions)
	impl := asyncReturning(types.Int(0), nil)
	const id = int64(1)

	args := [][]ref.Val{
		{types.Int(1)},
		{types.Int(2)},
		{types.Int(3)},
	}
	states := make([]*asyncCallState, len(args))
	for i, a := range args {
		states[i] = tracker.getOrCreate(id, "fn", "fn_int", a, impl, gate)
	}

	// Each distinct argument set is a distinct, uniquely-identified call.
	seen := map[int64]bool{}
	for _, s := range states {
		if seen[s.CallID()] {
			t.Errorf("duplicate callID %d across distinct args", s.CallID())
		}
		seen[s.CallID()] = true
	}

	// Re-evaluation: every prior (id, args) tuple must resolve to its existing state and must
	// not register a new call.
	for i, a := range args {
		if got := tracker.getOrCreate(id, "fn", "fn_int", a, impl, gate); got != states[i] {
			t.Errorf("re-lookup of args %v returned a new state, wanted the existing one", a)
		}
	}

	// Identical string arguments at the same node dedup to a single call.
	s1 := tracker.getOrCreate(2, "fn", "fn_str", []ref.Val{types.String("k")}, impl, gate)
	s2 := tracker.getOrCreate(2, "fn", "fn_str", []ref.Val{types.String("k")}, impl, gate)
	if s1 != s2 {
		t.Error("identical string args did not dedup to a single call")
	}
}

func TestTrackerRegistrationLookup(t *testing.T) {
	tracker := newAsyncCallStateTracker()
	completions := make(chan int64, 2)
	gate := newAsyncGate(0, completions)
	impl := asyncReturning(types.Int(1), nil)

	a := tracker.getOrCreate(1, "fn", "a", []ref.Val{types.Int(1)}, impl, gate)
	b := tracker.getOrCreate(2, "fn", "b", []ref.Val{types.Int(2)}, impl, gate)

	// Registered calls must be retrievable by callID.
	for _, want := range []*asyncCallState{a, b} {
		if got := tracker.getByID(want.CallID()); got != want {
			t.Errorf("getByID(%d) returned a different call record", want.CallID())
		}
	}
	// Unknown callIDs resolve to nil rather than panicking.
	if got := tracker.getByID(99999); got != nil {
		t.Errorf("getByID(unknown) = %v, wanted nil", got)
	}
}

func TestAsyncObserverLifecycle(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	obs := &recordingObserver{}
	if err := frame.SetAsyncObserver(obs); err != nil {
		t.Fatalf("SetAsyncObserver() failed: %v", err)
	}
	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	awaitResult(t, frame, completions, 1, "fn", "fn_int", asyncReturning(types.Int(7), nil), types.Int(1))

	if got := obs.started.Load(); got != 1 {
		t.Errorf("OnCallStarted called %d times, wanted 1", got)
	}
	if got := obs.finished.Load(); got != 1 {
		t.Errorf("OnCallFinished called %d times, wanted 1", got)
	}
}

func TestAsyncObserverOnCancellation(t *testing.T) {
	ctx, cancel := context.WithCancelCause(context.Background())
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	type finishedEvent struct {
		callID   int64
		function string
		overload string
		res      ref.Val
	}
	finishedChan := make(chan finishedEvent, 1)

	obs := &recordingObserverWithCallback{
		onFinished: func(callID int64, function, overload string, res ref.Val) {
			finishedChan <- finishedEvent{callID, function, overload, res}
		},
	}
	if err := frame.SetAsyncObserver(obs); err != nil {
		t.Fatalf("SetAsyncObserver() failed: %v", err)
	}

	release := make(chan struct{})
	defer close(release)
	var live, maxLive atomic.Int32
	impl := asyncControllable(release, &live, &maxLive)

	res := frame.ComputeResult(1, "fn", "fn_int", impl, []ref.Val{types.Int(1)})
	if unk, ok := res.(*types.Unknown); ok {
		frame.DispatchPendingAsyncCalls(unk.IDs())
	}
	if !types.IsUnknown(res) {
		t.Fatalf("ComputeResult() got %v, wanted Unknown", res)
	}

	cancelErr := errors.New("aborted by user request")
	cancel(cancelErr)

	select {
	case event := <-finishedChan:
		if event.callID != 1 {
			t.Errorf("observer got callID = %d, wanted 1", event.callID)
		}
		if event.function != "fn" || event.overload != "fn_int" {
			t.Errorf("observer got func/overload = %q/%q, wanted fn/fn_int", event.function, event.overload)
		}
		if !types.IsError(event.res) {
			t.Errorf("observer result got %v, wanted error", event.res)
		} else {
			errVal := event.res.(*types.Err)
			if !strings.Contains(errVal.Error(), "aborted by user request") {
				t.Errorf("expected observer error to contain cause, got: %v", errVal)
			}
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for observer completion notification on context cancel")
	}
}

type recordingObserverWithCallback struct {
	onFinished func(callID int64, function, overload string, res ref.Val)
}

func (o *recordingObserverWithCallback) OnCallStarted(callID int64, function, overload string, args []ref.Val) {
}

func (o *recordingObserverWithCallback) OnCallFinished(callID int64, function, overload string, res ref.Val) {
	if o.onFinished != nil {
		o.onFinished(callID, function, overload, res)
	}
}

// asyncControllable returns a channel-based AsyncOp whose calls block until release is closed,
// tracking the number of concurrently live calls and the high-water mark.
func asyncControllable(release <-chan struct{}, live, maxLive *atomic.Int32) functions.AsyncOp {
	return func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
		ch := make(chan ref.Val, 1)
		go func() {
			cur := live.Add(1)
			for {
				old := maxLive.Load()
				if cur <= old || maxLive.CompareAndSwap(old, cur) {
					break
				}
			}
			select {
			case <-release:
			case <-ctx.Done():
			}
			live.Add(-1)
			ch <- args[0]
			close(ch)
		}()
		return ch
	}
}

func TestLaunchAdmissionAndBounding(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	const limit = 2
	const total = 5
	if err := frame.SetAsyncMaxConcurrency(limit); err != nil {
		t.Fatalf("SetAsyncMaxConcurrency() failed: %v", err)
	}
	completions := make(chan int64, total*2)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	release := make(chan struct{})
	var live, maxLive atomic.Int32
	impl := asyncControllable(release, &live, &maxLive)

	// A single evaluation pass: attempt to launch all calls (distinct node ids).
	pass := func() {
		for i := range total {
			res := frame.ComputeResult(int64(i+1), "fn", "fn_int", impl, []ref.Val{types.Int(int64(i))})
			if unk, ok := res.(*types.Unknown); ok {
				frame.DispatchPendingAsyncCalls(unk.IDs())
			}
		}
	}

	// First pass admits only `limit` launches; the rest are deferred. pending is updated
	// synchronously as calls are admitted, so it must equal the limit immediately.
	pass()
	if got := frame.ActiveAsyncCalls(); got != limit {
		t.Fatalf("ActiveAsyncCalls() after first pass = %d, wanted %d", got, limit)
	}

	// Drive completions and re-evaluate until every call has resolved, simulating ConcurrentEval.
	close(release)
	done := map[int64]bool{}
	deadline := time.After(5 * time.Second)
	for len(done) < total {
		pass() // re-evaluate: launch any newly-admissible calls
		select {
		case id := <-completions:
			done[id] = true
		case <-deadline:
			t.Fatalf("only %d/%d calls completed; maxLive=%d", len(done), total, maxLive.Load())
		}
	}

	if got := maxLive.Load(); got > limit {
		t.Errorf("max concurrent launches = %d, wanted <= %d", got, limit)
	}
	if got := frame.ActiveAsyncCalls(); got != 0 {
		t.Errorf("ActiveAsyncCalls() after all completions = %d, wanted 0", got)
	}
}

func TestLaunchUnlimitedWhenNoSemaphore(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()
	// No SetAsyncMaxConcurrency -> nil semaphore -> all calls admitted in one pass.
	completions := make(chan int64, 8)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	release := make(chan struct{})
	defer close(release)
	var live, maxLive atomic.Int32
	impl := asyncControllable(release, &live, &maxLive)

	const total = 5
	for i := 0; i < total; i++ {
		res := frame.ComputeResult(int64(i+1), "fn", "fn_int", impl, []ref.Val{types.Int(int64(i))})
		if unk, ok := res.(*types.Unknown); ok {
			frame.DispatchPendingAsyncCalls(unk.IDs())
		}
	}
	if got := frame.ActiveAsyncCalls(); got != total {
		t.Errorf("ActiveAsyncCalls() with no limit = %d, wanted %d", got, total)
	}
}

func TestAsyncCallStateCancellation(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	var exited sync.WaitGroup
	exited.Add(1)
	blocking := func(ctx context.Context, args ...ref.Val) <-chan ref.Val {
		ch := make(chan ref.Val) // never written; goroutine must exit via ctx.Done
		go func() {
			defer exited.Done()
			<-ctx.Done()
		}()
		return ch
	}
	res := frame.ComputeResult(1, "fn", "fn_int", blocking, []ref.Val{types.Int(1)})
	if unk, ok := res.(*types.Unknown); ok {
		frame.DispatchPendingAsyncCalls(unk.IDs())
	}
	if !types.IsUnknown(res) {
		t.Fatalf("ComputeResult() = %v, wanted Unknown while pending", res)
	}
	cancel()

	done := make(chan struct{})
	go func() { exited.Wait(); close(done) }()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("async impl goroutine did not exit on cancellation")
	}
	// No completion should have been delivered for the cancelled call.
	select {
	case callID := <-completions:
		t.Errorf("unexpected completion for cancelled call: %d", callID)
	default:
	}
}

func TestAsyncTrackerPoolReleaseClearsState(t *testing.T) {
	tracker := newAsyncCallStateTracker()
	completions := make(chan int64, 4)
	gate := newAsyncGate(0, completions)
	for i := int64(1); i <= 3; i++ {
		tracker.getOrCreate(i, "fn", "ov", []ref.Val{types.Int(i)}, asyncReturning(types.Int(i), nil), gate)
	}
	if tracker.getByID(2) == nil {
		t.Fatal("getByID(2) = nil before release, wanted a call record")
	}

	asyncCallStateTrackerPool.release(tracker)

	if got := len(tracker.calls); got != 0 {
		t.Errorf("calls map size after release = %d, wanted 0", got)
	}
	if got := len(tracker.callsByID); got != 0 {
		t.Errorf("callsByID map size after release = %d, wanted 0", got)
	}
	if got := tracker.nextCallID.Load(); got != 0 {
		t.Errorf("nextCallID after release = %d, wanted 0", got)
	}
	// A released tracker is safe to reuse: callIDs restart and bookkeeping is fresh.
	acs := tracker.getOrCreate(1, "fn", "ov", []ref.Val{types.Int(1)}, asyncReturning(types.Int(1), nil), gate)
	if acs.CallID() != 1 {
		t.Errorf("reused tracker assigned callID %d, wanted 1", acs.CallID())
	}
}

func TestAsyncCallStateMatches(t *testing.T) {
	mk := func(fn, ov string, args ...ref.Val) *asyncCallState {
		return newAsyncCallState(1, fn, ov, args, nil)
	}
	base := mk("fn", "ov", types.Int(1), types.String("a"))
	tests := []struct {
		name  string
		other *asyncCallState
		want  bool
	}{
		{"identical", mk("fn", "ov", types.Int(1), types.String("a")), true},
		{"diff function", mk("other", "ov", types.Int(1), types.String("a")), false},
		{"diff overload", mk("fn", "other", types.Int(1), types.String("a")), false},
		{"diff arg value", mk("fn", "ov", types.Int(2), types.String("a")), false},
		{"diff arity", mk("fn", "ov", types.Int(1)), false},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if got := base.matches(1, tc.other.function, tc.other.overload, tc.other.argVals); got != tc.want {
				t.Errorf("matches() = %v, wanted %v", got, tc.want)
			}
		})
	}
	t.Run("nil receiver", func(t *testing.T) {
		var nilState *asyncCallState
		if nilState.matches(1, base.function, base.overload, base.argVals) {
			t.Error("nil.matches() returned true")
		}
	})
	t.Run("NaN equivalence", func(t *testing.T) {
		s1 := mk("fn", "ov", types.Double(math.NaN()))
		s2 := mk("fn", "ov", types.Double(math.NaN()))
		s3 := mk("fn", "ov", types.Double(1.23))

		tests := []struct {
			name  string
			state *asyncCallState
			other *asyncCallState
			want  bool
		}{
			{"NaN vs NaN", s1, s2, true},
			{"NaN vs non-NaN", s1, s3, false},
			{"non-NaN vs NaN", s3, s1, false},
		}
		for _, tc := range tests {
			t.Run(tc.name, func(t *testing.T) {
				if got := tc.state.matches(1, tc.other.function, tc.other.overload, tc.other.argVals); got != tc.want {
					t.Errorf("matches() = %v, wanted %v", got, tc.want)
				}
			})
		}
	})
}

func TestExecutionFrameChildSharesAsyncContext(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	// Counts are taken relative to a baseline, since ExecutionFrame draws its tracker from a
	// process-global pool whose starting pending count is not guaranteed to be zero.
	base := frame.ActiveAsyncCalls()

	child := frame.Push(EmptyActivation())
	if got := child.ActiveAsyncCalls(); got != base {
		t.Errorf("child ActiveAsyncCalls() = %d, wanted %d (shared parent ctx)", got, base)
	}

	// A call launched from the child frame must be visible through the shared parent tracker.
	resChild := child.ComputeResult(1, "fn", "fn_int", asyncReturning(types.Int(5), nil), []ref.Val{types.Int(1)})
	if unk, ok := resChild.(*types.Unknown); ok {
		child.DispatchPendingAsyncCalls(unk.IDs())
	}
	if got := child.ActiveAsyncCalls(); got != base+1 {
		t.Errorf("child ActiveAsyncCalls() after launch = %d, wanted %d", got, base+1)
	}

	parent := child.Pop()
	if parent != frame {
		t.Fatalf("Pop() did not return the parent frame")
	}
	if got := frame.ActiveAsyncCalls(); got != base+1 {
		t.Errorf("parent ActiveAsyncCalls() after Pop = %d, wanted %d", got, base+1)
	}
	// The launched call is retrievable via the parent frame's AsyncCall accessor.
	select {
	case callID := <-completions:
		if call := frame.AsyncCall(callID); call == nil {
			t.Errorf("AsyncCall(%d) = nil, wanted a call record from the shared tracker", callID)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("timed out waiting for child call completion")
	}
}

type recordingObserver struct {
	started  atomic.Int32
	finished atomic.Int32
}

func (o *recordingObserver) OnCallStarted(callID int64, function, overload string, args []ref.Val) {
	o.started.Add(1)
}

func (o *recordingObserver) OnCallFinished(callID int64, function, overload string, res ref.Val) {
	o.finished.Add(1)
}

func TestEvalAsyncFuncGetters(t *testing.T) {
	fn := &evalAsyncFunc{
		id:       42,
		function: "async_fn",
		overload: "async_fn_overload",
		args: []InterpretableV2{
			&evalConst{val: types.Int(1)},
		},
		impl: asyncReturning(types.Int(100), nil),
	}

	if fn.ID() != 42 {
		t.Errorf("ID() = %d, wanted 42", fn.ID())
	}
	if fn.Function() != "async_fn" {
		t.Errorf("Function() = %s, wanted async_fn", fn.Function())
	}
	if fn.OverloadID() != "async_fn_overload" {
		t.Errorf("OverloadID() = %s, wanted async_fn_overload", fn.OverloadID())
	}
	if len(fn.Args()) != 1 {
		t.Errorf("Args() length = %d, wanted 1", len(fn.Args()))
	}
}

func TestEvalAsyncFuncLifecycle(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	impl := asyncReturning(types.Int(100), nil)
	fn := &evalAsyncFunc{
		id:       42,
		function: "async_fn",
		overload: "async_fn_overload",
		args: []InterpretableV2{
			&evalConst{val: types.Int(1)},
		},
		impl: impl,
	}

	// Test Exec first pass
	res := fn.Exec(frame)
	if !types.IsUnknown(res) {
		t.Errorf("Exec() first call got %v, wanted Unknown", res)
	}
	if unk, ok := res.(*types.Unknown); ok {
		frame.DispatchPendingAsyncCalls(unk.IDs())
	}

	// Now await completion and re-evaluate
	select {
	case <-completions:
	case <-time.After(time.Second):
		t.Fatal("timed out waiting for completion")
	}

	res2 := fn.Exec(frame)
	if res2.Equal(types.Int(100)) != types.True {
		t.Errorf("Exec() second call got %v, wanted 100", res2)
	}

	// Test Eval method
	res3 := fn.Eval(frame)
	if res3.Equal(types.Int(100)) != types.True {
		t.Errorf("Eval() got %v, wanted 100", res3)
	}
}

func TestEvalAsyncFuncEarlyReturn(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, closeFrame := newTestFrame(t, ctx)
	defer closeFrame()

	impl := asyncReturning(types.Int(100), nil)

	// Test Exec returning early when an argument is error
	fnErrArg := &evalAsyncFunc{
		id:       43,
		function: "async_fn",
		overload: "async_fn_overload",
		args: []InterpretableV2{
			&evalConst{val: types.NewErr("argument error")},
		},
		impl: impl,
	}
	resErr := fnErrArg.Exec(frame)
	if !types.IsError(resErr) {
		t.Errorf("Exec with error arg got %v, wanted error", resErr)
	}

	// Test Exec returning early when an argument is unknown
	fnUnknownArg := &evalAsyncFunc{
		id:       44,
		function: "async_fn",
		overload: "async_fn_overload",
		args: []InterpretableV2{
			&evalConst{val: types.NewUnknown(999, nil)},
		},
		impl: impl,
	}
	resUnknown := fnUnknownArg.Exec(frame)
	if !types.IsUnknown(resUnknown) {
		t.Errorf("Exec with unknown arg got %v, wanted Unknown", resUnknown)
	}
}

func TestTrackerPoolShrink(t *testing.T) {
	pool := newAsyncCallTrackerPool()
	tracker := pool.create()
	completions := make(chan int64, 1)
	gate := newAsyncGate(0, completions)
	impl := asyncReturning(types.Int(1), nil)

	// Register more than trackerShrinkThreshold calls to trigger map reallocation
	for i := int64(1); i <= trackerShrinkThreshold+10; i++ {
		tracker.getOrCreate(i, "fn", "fn_int", []ref.Val{types.Int(i)}, impl, gate)
	}

	// Release the tracker; since size exceeds threshold, maps should be reallocated
	pool.release(tracker)

	// Retrieve again from pool to make sure it's clean and empty
	tracker2 := pool.create()
	if len(tracker2.calls) != 0 || len(tracker2.callsByID) != 0 {
		t.Errorf("released tracker was not cleared: calls size = %d, callsByID size = %d", len(tracker2.calls), len(tracker2.callsByID))
	}

	// Now register a small number of calls, release, and check that delete path works
	tracker2.getOrCreate(1, "fn", "fn_int", []ref.Val{types.Int(1)}, impl, gate)
	pool.release(tracker2)

	tracker3 := pool.create()
	if len(tracker3.calls) != 0 || len(tracker3.callsByID) != 0 {
		t.Errorf("released tracker (delete path) was not cleared: calls size = %d, callsByID size = %d", len(tracker3.calls), len(tracker3.callsByID))
	}
	pool.release(tracker3)
}

func TestAsyncWithTraceAndExhaustiveEval(t *testing.T) {
	asyncFnDecl, err := decls.NewFunction("async_eq_true",
		decls.Overload("async_eq_true_int",
			[]*types.Type{types.IntType},
			types.BoolType,
			decls.AsyncBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
				val := args[0].(types.Int)
				select {
				case <-time.After(50 * time.Millisecond):
					return types.Bool(val == 1)
				case <-ctx.Done():
					return types.NewErr("cancelled")
				}
			}),
		),
	)
	if err != nil {
		t.Fatalf("NewFunction failed: %v", err)
	}

	exprStr := "async_eq_true(1) && async_eq_true(2)"
	s := common.NewTextSource(exprStr)
	p, err := parser.NewParser(
		parser.Macros(parser.AllMacros...),
	)
	if err != nil {
		t.Fatalf("NewParser failed: %v", err)
	}
	parsed, errs := p.Parse(s)
	if len(errs.GetErrors()) != 0 {
		t.Fatalf("Parse errors: %s", errs.ToDisplayString())
	}

	reg := newTestRegistry(t)
	env := newTestEnv(t, containers.DefaultContainer, reg)
	err = env.AddFunctions(asyncFnDecl)
	if err != nil {
		t.Fatalf("AddFunctions failed: %v", err)
	}
	checked, errs := checker.Check(parsed, s, env)
	if len(errs.GetErrors()) != 0 {
		t.Fatalf("Check errors: %s", errs.ToDisplayString())
	}

	disp := NewDispatcher()
	addFunctionBindings(t, disp)
	bindings, err := asyncFnDecl.Bindings()
	if err != nil {
		t.Fatalf("Bindings() failed: %v", err)
	}
	err = disp.Add(bindings...)
	if err != nil {
		t.Fatalf("dispatcher.Add() failed: %v", err)
	}

	attrs := NewAttributeFactory(containers.DefaultContainer, reg, reg)
	interp := NewInterpreter(disp, containers.DefaultContainer, reg, reg, attrs)

	state := NewEvalState()
	prg, err := interp.NewInterpretable(checked,
		ExhaustiveEval(),
		EvalStateObserver(EvalStateFactory(func() EvalState { return state })),
	)
	if err != nil {
		t.Fatalf("NewInterpretable failed: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	frame, err := NewExecutionFrame(EmptyActivation())
	if err != nil {
		t.Fatalf("NewExecutionFrame failed: %v", err)
	}
	defer frame.Close()
	if err := frame.SetContext(ctx, 0); err != nil {
		t.Fatalf("SetContext failed: %v", err)
	}

	completions := make(chan int64, 4)
	if err := frame.SetCompletions(completions); err != nil {
		t.Fatalf("SetCompletions() failed: %v", err)
	}

	oi, isObservable := prg.(*ObservableInterpretable)
	if !isObservable {
		t.Fatalf("expected program to be observable")
	}

	runPass := func() ref.Val {
		return oi.ObserveExec(frame, func(observed any) {
		})
	}

	var res ref.Val
	deadline := time.After(2 * time.Second)
loop:
	for {
		res = runPass()
		if !types.IsUnknown(res) && frame.ActiveAsyncCalls() == 0 {
			break loop
		}
		if unk, ok := res.(*types.Unknown); ok {
			frame.DispatchPendingAsyncCalls(unk.IDs())
		}
		select {
		case <-completions:
		case <-deadline:
			t.Fatal("timed out waiting for async result")
		}
	}

	if res.Equal(types.False) != types.True {
		t.Errorf("expected false, got %v", res)
	}

	state.Reset()
	resLast := runPass()
	if resLast.Equal(types.False) != types.True {
		t.Errorf("expected final pass result to be false, got %v", resLast)
	}

	for _, id := range state.IDs() {
		val, found := state.Value(id)
		if !found {
			continue
		}
		if types.IsUnknown(val) {
			t.Errorf("found unknown value in final eval state for node %d: %v", id, val)
		}
	}
}

func TestAsyncSetupWithoutContextErrors(t *testing.T) {
	frame, err := NewExecutionFrame(EmptyActivation())
	if err != nil {
		t.Fatalf("NewExecutionFrame() failed: %v", err)
	}
	defer frame.Close()

	// No frame.SetContext called, so context is nil.
	completions := make(chan int64, 1)
	if err := frame.SetCompletions(completions); err == nil {
		t.Error("SetCompletions() succeeded without context, wanted error")
	}
	obs := &recordingObserver{}
	if err := frame.SetAsyncObserver(obs); err == nil {
		t.Error("SetAsyncObserver() succeeded without context, wanted error")
	}
	if err := frame.SetAsyncMaxConcurrency(2); err == nil {
		t.Error("SetAsyncMaxConcurrency() succeeded without context, wanted error")
	}
}
