/*
Copyright 2014 The Kubernetes Authors.

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 framework

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"

	ginkgotypes "github.com/onsi/ginkgo/v2/types"
	"github.com/onsi/gomega"
	"github.com/onsi/gomega/format"
	"github.com/onsi/gomega/types"
)

// MakeMatcher builds a gomega.Matcher based on a single callback function.
// That function is passed the actual value that is to be checked.
// There are three possible outcomes of the check:
//   - An error is returned, which then is converted into a failure
//     by Gomega.
//   - A non-nil failure function is returned, which then is called
//     by Gomega once a failure string is needed. This is useful
//     to avoid unnecessarily preparing a failure string for intermediate
//     failures in Eventually or Consistently.
//   - Both function and error are nil, which means that the check
//     succeeded.
func MakeMatcher[T interface{}](match func(actual T) (failure func() string, err error)) types.GomegaMatcher {
	return &matcher[T]{
		match: match,
	}
}

type matcher[T interface{}] struct {
	match   func(actual T) (func() string, error)
	failure func() string
}

func (m *matcher[T]) Match(actual interface{}) (success bool, err error) {
	if actual, ok := actual.(T); ok {
		failure, err := m.match(actual)
		if err != nil {
			return false, err
		}
		m.failure = failure
		if failure != nil {
			return false, nil
		}
		return true, nil
	}
	var empty T
	return false, gomega.StopTrying(fmt.Sprintf("internal error: expected %T, got:\n%s", empty, format.Object(actual, 1)))
}

func (m *matcher[T]) FailureMessage(actual interface{}) string {
	return m.failure()
}

func (m matcher[T]) NegatedFailureMessage(actual interface{}) string {
	return m.failure()
}

var _ types.GomegaMatcher = &matcher[string]{}

// Gomega returns an interface that can be used like gomega to express
// assertions. The difference is that failed assertions are returned as an
// error:
//
//	if err := Gomega().Expect(pod.Status.Phase).To(gomega.Equal(v1.Running)); err != nil {
//	    return fmt.Errorf("test pod not running: %w", err)
//	}
//
// This error can get wrapped to provide additional context for the
// failure. The test then should use ExpectNoError to turn a non-nil error into
// a failure.
//
// When using this approach, there is no need for call offsets and extra
// descriptions for the Expect call because the call stack will be dumped when
// ExpectNoError is called and the additional description(s) can be added by
// wrapping the error.
//
// Asynchronous assertions use the framework's Poll interval and PodStart timeout
// by default.
func Gomega() GomegaInstance {
	return gomegaInstance{}
}

type GomegaInstance interface {
	Expect(actual interface{}) Assertion
	Eventually(ctx context.Context, args ...interface{}) AsyncAssertion
	Consistently(ctx context.Context, args ...interface{}) AsyncAssertion
}

type Assertion interface {
	Should(matcher types.GomegaMatcher) error
	ShouldNot(matcher types.GomegaMatcher) error
	To(matcher types.GomegaMatcher) error
	ToNot(matcher types.GomegaMatcher) error
	NotTo(matcher types.GomegaMatcher) error
}

type AsyncAssertion interface {
	Should(matcher types.GomegaMatcher) error
	ShouldNot(matcher types.GomegaMatcher) error

	WithTimeout(interval time.Duration) AsyncAssertion
	WithPolling(interval time.Duration) AsyncAssertion
}

type gomegaInstance struct{}

var _ GomegaInstance = gomegaInstance{}

func (g gomegaInstance) Expect(actual interface{}) Assertion {
	return assertion{actual: actual}
}

func (g gomegaInstance) Eventually(ctx context.Context, args ...interface{}) AsyncAssertion {
	return newAsyncAssertion(ctx, args, false)
}

func (g gomegaInstance) Consistently(ctx context.Context, args ...interface{}) AsyncAssertion {
	return newAsyncAssertion(ctx, args, true)
}

func newG() (*FailureError, gomega.Gomega) {
	var failure FailureError
	g := gomega.NewGomega(func(msg string, callerSkip ...int) {
		failure = FailureError{
			msg: msg,
		}
	})

	return &failure, g
}

type assertion struct {
	actual interface{}
}

func (a assertion) Should(matcher types.GomegaMatcher) error {
	err, g := newG()
	if !g.Expect(a.actual).Should(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a assertion) ShouldNot(matcher types.GomegaMatcher) error {
	err, g := newG()
	if !g.Expect(a.actual).ShouldNot(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a assertion) To(matcher types.GomegaMatcher) error {
	err, g := newG()
	if !g.Expect(a.actual).To(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a assertion) ToNot(matcher types.GomegaMatcher) error {
	err, g := newG()
	if !g.Expect(a.actual).ToNot(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a assertion) NotTo(matcher types.GomegaMatcher) error {
	err, g := newG()
	if !g.Expect(a.actual).NotTo(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

type asyncAssertion struct {
	ctx          context.Context
	args         []interface{}
	timeout      time.Duration
	interval     time.Duration
	consistently bool
}

func newAsyncAssertion(ctx context.Context, args []interface{}, consistently bool) asyncAssertion {
	return asyncAssertion{
		ctx:  ctx,
		args: args,
		// PodStart is used as default because waiting for a pod is the
		// most common operation.
		timeout:      TestContext.timeouts.PodStart,
		interval:     TestContext.timeouts.Poll,
		consistently: consistently,
	}
}

func (a asyncAssertion) newAsync() (*FailureError, gomega.AsyncAssertion) {
	err, g := newG()
	var assertion gomega.AsyncAssertion
	if a.consistently {
		assertion = g.Consistently(a.ctx, a.args...)
	} else {
		assertion = g.Eventually(a.ctx, a.args...)
	}
	assertion = assertion.WithTimeout(a.timeout).WithPolling(a.interval)
	return err, assertion
}

func (a asyncAssertion) Should(matcher types.GomegaMatcher) error {
	err, assertion := a.newAsync()
	if !assertion.Should(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a asyncAssertion) ShouldNot(matcher types.GomegaMatcher) error {
	err, assertion := a.newAsync()
	if !assertion.ShouldNot(matcher) {
		err.backtrace()
		return *err
	}
	return nil
}

func (a asyncAssertion) WithTimeout(timeout time.Duration) AsyncAssertion {
	a.timeout = timeout
	return a
}

func (a asyncAssertion) WithPolling(interval time.Duration) AsyncAssertion {
	a.interval = interval
	return a
}

// FailureError is an error where the error string is meant to be passed to
// ginkgo.Fail directly, i.e. adding some prefix like "unexpected error" is not
// necessary. It is also not necessary to dump the error struct.
type FailureError struct {
	msg            string
	fullStackTrace string
}

func (f FailureError) Error() string {
	return f.msg
}

func (f FailureError) Backtrace() string {
	return f.fullStackTrace
}

func (f FailureError) Is(target error) bool {
	return target == ErrFailure
}

func (f *FailureError) backtrace() {
	f.fullStackTrace = ginkgotypes.NewCodeLocationWithStackTrace(2).FullStackTrace
}

// ErrFailure is an empty error that can be wrapped to indicate that an error
// is a FailureError. It can also be used to test for a FailureError:.
//
//	return fmt.Errorf("some problem%w", ErrFailure)
//	...
//	err := someOperation()
//	if errors.Is(err, ErrFailure) {
//	    ...
//	}
var ErrFailure error = FailureError{}

// ExpectNoError checks if "err" is set, and if so, fails assertion while logging the error.
//
// As in [gomega.Expect], the explain parameters can be used to provide
// additional information in case of a failure in one of these two ways:
//   - A single string is used as first line of the failure message directly.
//   - A string with additional parameters is passed through [fmt.Sprintf].
func ExpectNoError(err error, explain ...interface{}) {
	ExpectNoErrorWithOffset(1, err, explain...)
}

// ExpectNoErrorWithOffset checks if "err" is set, and if so, fails assertion while logging the error at "offset" levels above its caller
// (for example, for call chain f -> g -> ExpectNoErrorWithOffset(1, ...) error would be logged for "f").
//
// As in [gomega.Expect], the explain parameters can be used to provide
// additional information in case of a failure in one of these two ways:
//   - A single string is used as first line of the failure message directly.
//   - A string with additional parameters is passed through [fmt.Sprintf].
func ExpectNoErrorWithOffset(offset int, err error, explain ...interface{}) {
	if err == nil {
		return
	}

	// Errors usually contain unexported fields. We have to use
	// a formatter here which can print those.
	prefix := ""
	if len(explain) > 0 {
		if str, ok := explain[0].(string); ok {
			prefix = fmt.Sprintf(str, explain[1:]...) + ": "
		} else {
			prefix = fmt.Sprintf("unexpected explain arguments, need format string: %v", explain)
		}
	}

	// This intentionally doesn't use gomega.Expect. Instead we take
	// full control over what information is presented where:
	// - The complete error object is logged because it may contain
	//   additional information that isn't included in its error
	//   string.
	// - It is not included in the failure message because
	//   it might make the failure message very large and/or
	//   cause error aggregation to work less well: two
	//   failures at the same code line might not be matched in
	//   https://go.k8s.io/triage because the error details are too
	//   different.
	//
	// Some errors include all relevant information in the Error
	// string. For those we can skip the redundant log message.
	// For our own failures we only log the additional stack backtrace
	// because it is not included in the failure message.
	var failure FailureError
	if errors.As(err, &failure) && failure.Backtrace() != "" {
		log(offset+1, fmt.Sprintf("Failed inside E2E framework:\n    %s", strings.ReplaceAll(failure.Backtrace(), "\n", "\n    ")))
	} else if !errors.Is(err, ErrFailure) {
		log(offset+1, fmt.Sprintf("Unexpected error: %s\n%s", prefix, format.Object(err, 1)))
	}
	Fail(prefix+err.Error(), 1+offset)
}
