// 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 async_test

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

	"github.com/google/cel-go/cel/async"
	"github.com/google/cel-go/common/decls"
	"github.com/google/cel-go/common/functions"
	"github.com/google/cel-go/common/types"
	"github.com/google/cel-go/common/types/ref"
)

type retryableTestErr struct{}

func (retryableTestErr) Error() string     { return "retry me" }
func (retryableTestErr) IsRetryable() bool { return true }

type nonRetryableTestErr struct{}

func (nonRetryableTestErr) Error() string     { return "do not retry me" }
func (nonRetryableTestErr) IsRetryable() bool { return false }

// buildZeroArgAsync builds a zero-arity async overload from an option and returns its AsyncOp.
func buildZeroArgAsync(t *testing.T, opt decls.OverloadOpt) functions.AsyncOp {
	t.Helper()
	fnDecl, err := decls.NewFunction("fn", decls.Overload("fn_zero", []*types.Type{}, types.IntType, opt))
	if err != nil {
		t.Fatalf("NewFunction() failed: %v", err)
	}
	bindings, err := fnDecl.Bindings()
	if err != nil {
		t.Fatalf("Bindings() failed: %v", err)
	}
	for _, b := range bindings {
		if b.Async != nil {
			return b.Async
		}
	}
	t.Fatal("no async binding produced")
	return nil
}

func TestRetryMultipleAttemptsTimerReset(t *testing.T) {
	var attempts atomic.Int32
	op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		a := attempts.Add(1)
		if a < 3 {
			return types.WrapErr(retryableTestErr{})
		}
		return types.Int(100)
	}, async.RetryAttempts(4), async.RetryBackoff(5*time.Millisecond)))

	res := <-op(context.Background())
	if res.Equal(types.Int(100)) != types.True {
		t.Fatalf("result = %v, want 100", res)
	}
	if got := attempts.Load(); got != 3 {
		t.Errorf("attempts = %d, want 3", got)
	}
}

func TestRetryMaxAttemptsExhausted(t *testing.T) {
	var attempts atomic.Int32
	op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		a := attempts.Add(1)
		return types.WrapErr(fmt.Errorf("retry attempt %d failed: %w", a, retryableTestErr{}))
	}, async.RetryAttempts(3), async.RetryBackoff(5*time.Millisecond)))

	res := <-op(context.Background())
	if !types.IsError(res) {
		t.Fatalf("result = %v, want error", res)
	}
	msg := res.(*types.Err).Error()
	if !strings.Contains(msg, "retry attempt 3 failed") {
		t.Errorf("result error = %q, want last error 'retry attempt 3 failed'", msg)
	}
	if got := attempts.Load(); got != 3 {
		t.Errorf("attempts = %d, want 3", got)
	}
}

func TestRetryBindingCancellation(t *testing.T) {
	var attempts atomic.Int32
	op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		attempts.Add(1)
		return types.WrapErr(retryableTestErr{})
	}, async.RetryAttempts(5), async.RetryBackoff(500*time.Millisecond)))

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		time.Sleep(40 * time.Millisecond)
		cancel()
	}()

	start := time.Now()
	var res ref.Val
	select {
	case res = <-op(ctx):
	case <-time.After(2 * time.Second):
		t.Fatal("retry op did not return after cancellation")
	}
	elapsed := time.Since(start)

	if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "cancelled") {
		t.Errorf("result = %v, want a cancellation error", res)
	}
	// Cancellation must interrupt the backoff wait rather than running it to completion.
	if elapsed >= 500*time.Millisecond {
		t.Errorf("retry waited the full backoff (%v); cancellation did not interrupt it", elapsed)
	}
}

func TestRetryNonRetryableError(t *testing.T) {
	var attempts atomic.Int32
	op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		attempts.Add(1)
		return types.WrapErr(nonRetryableTestErr{})
	}, async.RetryAttempts(5), async.RetryBackoff(10*time.Millisecond)))

	res := <-op(context.Background())
	if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "do not retry me") {
		t.Fatalf("result = %v, want 'do not retry me'", res)
	}
	if got := attempts.Load(); got != 1 {
		t.Errorf("attempts = %d, want 1 (non-retryable error should not be retried)", got)
	}
}

func TestRetryStandardError(t *testing.T) {
	var attempts atomic.Int32
	op := buildZeroArgAsync(t, async.RetryBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		attempts.Add(1)
		return types.NewErr("generic error")
	}, async.RetryAttempts(5), async.RetryBackoff(10*time.Millisecond)))

	res := <-op(context.Background())
	if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "generic error") {
		t.Fatalf("result = %v, want 'generic error'", res)
	}
	if got := attempts.Load(); got != 1 {
		t.Errorf("attempts = %d, want 1 (standard error should not be retried)", got)
	}
}

type mockAsyncCall struct {
	id       int64
	function string
	overload string
}

func (m mockAsyncCall) CallID() int64    { return m.id }
func (m mockAsyncCall) Function() string { return m.function }
func (m mockAsyncCall) Overload() string { return m.overload }

func TestAsyncCallMethods(t *testing.T) {
	m := mockAsyncCall{id: 1, function: "f", overload: "o"}
	if m.CallID() != 1 {
		t.Errorf("got %d, want 1", m.CallID())
	}
	if m.Function() != "f" {
		t.Errorf("got %s, want f", m.Function())
	}
	if m.Overload() != "o" {
		t.Errorf("got %s, want o", m.Overload())
	}
}

func TestDrainNone(t *testing.T) {
	s := async.DrainNone()
	// No completions, calls still pending -> no re-evaluation
	if s.NextAction(nil, 1).Reevaluate {
		t.Error("DrainNone re-evaluated with nil batch")
	}
	// One completion -> re-evaluate
	if !s.NextAction([]async.Call{mockAsyncCall{}}, 1).Reevaluate {
		t.Error("DrainNone did not re-evaluate with 1 completion")
	}
	// No pending -> re-evaluate regardless of batch
	if !s.NextAction(nil, 0).Reevaluate {
		t.Error("DrainNone did not re-evaluate when nothing pending")
	}
}

func TestDrainAll(t *testing.T) {
	s := async.DrainAll()
	// Pending calls remain -> no re-evaluation
	if s.NextAction([]async.Call{mockAsyncCall{}}, 1).Reevaluate {
		t.Error("DrainAll re-evaluated while calls are pending")
	}
	// No pending calls -> re-evaluate
	if !s.NextAction([]async.Call{mockAsyncCall{}}, 0).Reevaluate {
		t.Error("DrainAll did not re-evaluate when no calls pending")
	}
}

func TestDrainReady(t *testing.T) {
	debounce := 10 * time.Millisecond
	s := async.DrainReady(debounce)

	// No pending calls -> re-evaluate immediately
	action := s.NextAction([]async.Call{mockAsyncCall{}}, 0)
	if !action.Reevaluate {
		t.Error("DrainReady did not re-evaluate when no calls pending")
	}

	// No completions -> wait indefinitely
	action = s.NextAction(nil, 1)
	if action.Reevaluate || action.WaitDuration != 0 {
		t.Errorf("DrainReady NextAction(nil, 1) = %v, want {false, 0}", action)
	}

	// Some completions, still pending -> wait for the debounce window
	action = s.NextAction([]async.Call{mockAsyncCall{}}, 1)
	if action.Reevaluate || action.WaitDuration != debounce {
		t.Errorf("DrainReady NextAction(batch, 1) = %v, want {false, %v}", action, debounce)
	}
}

func TestTimeoutBindingEnforcesAgainstContextIgnoringOp(t *testing.T) {
	// The wrapped op sleeps well past the timeout without consulting its context. TimeoutBinding
	// must still return at the deadline rather than waiting for the op to finish.
	op := buildZeroArgAsync(t, async.TimeoutBinding(func(ctx context.Context, args ...ref.Val) ref.Val {
		time.Sleep(2 * time.Second) // ignores ctx entirely
		return types.Int(1)
	}, 50*time.Millisecond))

	start := time.Now()
	var res ref.Val
	select {
	case res = <-op(context.Background()):
	case <-time.After(2 * time.Second):
		t.Fatal("TimeoutBinding did not return; the op was not abandoned")
	}
	elapsed := time.Since(start)

	if !types.IsError(res) || !strings.Contains(res.(*types.Err).Error(), "timed out") {
		t.Errorf("result = %v, want a timeout error", res)
	}
	if elapsed >= 500*time.Millisecond {
		t.Errorf("TimeoutBinding waited %v; the timeout must abandon a ctx-ignoring op", elapsed)
	}
}
