package matcher

import (
	"context"
	"errors"
	"fmt"
	"testing"

	"buf.build/go/protovalidate"
	"github.com/google/go-cmp/cmp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	otelmetric "go.opentelemetry.io/otel/metric"
	"go.uber.org/mock/gomock"
	"google.golang.org/grpc/metadata"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/testing/protocmp"
)

var (
	_ gomock.Matcher = &cmpMatcher{}
	_ gomock.Matcher = &errorEqMatcher{}
	_ gomock.Matcher = &grpcOutgoingCtx{}
	_ gomock.Matcher = &otelAttributeSetMatcher{}
)

func AssertProtoEqual(t *testing.T, expected, actual any, opts ...cmp.Option) bool {
	return assert.Empty(t, cmp.Diff(expected, actual, append([]cmp.Option{protocmp.Transform()}, opts...)...))
}

// ProtoEq is a better gomock.Eq() that works correctly for protobuf messages.
// Use this matcher when checking equality of structs that:
// - are v1 protobuf messages (i.e. implement "github.com/golang/protobuf/proto".Message).
// - are v2 protobuf messages (i.e. implement "google.golang.org/protobuf/proto".Message).
// - have fields of the above types.
// See https://blog.golang.org/protobuf-apiv2 for v1 vs v2 details.
// t may be nil
func ProtoEq(t *testing.T, msg any, opts ...cmp.Option) gomock.Matcher {
	o := []cmp.Option{protocmp.Transform()} //nolint:prealloc
	o = append(o, opts...)
	return Cmp(t, msg, o...)
}

func ErrorEq(expectedError string) gomock.Matcher {
	return &errorEqMatcher{
		expectedError: expectedError,
	}
}

func ErrorIs(expectedError error) gomock.Matcher {
	return &errorIsMatcher{
		expectedError: expectedError,
	}
}

// Cmp compares the given arguments
// t may be nil
func Cmp(t *testing.T, expected any, opts ...cmp.Option) gomock.Matcher {
	validateProto(t, expected)
	return &cmpMatcher{
		t:        t,
		expected: expected,
		options:  opts,
	}
}

type cmpMatcher struct {
	t        *testing.T // may be nil
	expected any
	options  []cmp.Option
}

func (e cmpMatcher) Matches(x any) bool {
	diff := cmp.Diff(e.expected, x, e.options...)
	if diff != "" && e.t != nil {
		e.t.Log(diff)
	}
	return diff == ""
}

func (e cmpMatcher) String() string {
	return fmt.Sprintf("equals %s with %d option(s)", e.expected, len(e.options))
}

type errorEqMatcher struct {
	expectedError string
}

func (e *errorEqMatcher) Matches(x any) bool {
	if err, ok := x.(error); ok {
		return err.Error() == e.expectedError
	}
	return false
}

func (e *errorEqMatcher) String() string {
	return fmt.Sprintf("error with message %q", e.expectedError)
}

type errorIsMatcher struct {
	expectedError error
}

func (e *errorIsMatcher) Matches(x any) bool {
	if err, ok := x.(error); ok {
		return errors.Is(err, e.expectedError)
	}
	return false
}

func (e *errorIsMatcher) String() string {
	return fmt.Sprintf("error Is(%v)", e.expectedError)
}

type grpcOutgoingCtx struct {
	kv map[string]string
}

// GRPCOutgoingCtx returns a matcher for context.Context that must contain gRPC outgoing metadata
// with certain key-value pairs.
func GRPCOutgoingCtx(kv map[string]string) gomock.Matcher {
	return grpcOutgoingCtx{kv: kv}
}

func (c grpcOutgoingCtx) Matches(x any) bool {
	ctx, ok := x.(context.Context)
	if !ok {
		return false
	}
	md, ok := metadata.FromOutgoingContext(ctx)
	if !ok {
		return false
	}
	for k, v := range c.kv {
		vals := md[k]
		if len(vals) != 1 || vals[0] != v {
			return false
		}
	}
	return true
}

func (c grpcOutgoingCtx) String() string {
	return fmt.Sprintf("context %v", c.kv)
}

// From: https://github.com/golang/mock/issues/43#issuecomment-1292042897
// doMatch keeps state of the custom lambda matcher.
// match is a lambda function that asserts actual value matching.
// x keeps actual value.
type doMatch[V any] struct {
	match func(v V) bool
	x     any
}

// DoMatch creates lambda matcher instance equipped with
// lambda function to detect if actual value matches
// some arbitrary criteria.
// Lambda matcher implements gomock customer matcher
// interface https://github.com/uber/mock/blob/5b455625bd2c8ffbcc0de6a0873f864ba3820904/gomock/matchers.go#L25.
// Sample of usage:
//
// mock.EXPECT().Foo(gomock.All(
//
//	   DoMatch(func(v Bar) bool {
//		      v.Greeting == "Hello world"
//	   }),
//
// ))
func DoMatch[V any](m func(v V) bool) gomock.Matcher {
	return &doMatch[V]{
		match: m,
	}
}

// Matches receives actual value x casts it to specific type defined as a type parameter V
// and calls lambda function 'match' to resolve if x matches or not.
func (o *doMatch[V]) Matches(x any) bool {
	o.x = x
	v, ok := x.(V)
	if !ok {
		return false
	}

	return o.match(v)
}

// String describes what matcher matches.
func (o *doMatch[V]) String() string {
	return fmt.Sprintf("is matched to %v", o.x)
}

// t may be nil
func validateProto(t *testing.T, value any) {
	targetMsg, ok := value.(proto.Message)
	if !ok {
		return
	}
	err := protovalidate.Validate(targetMsg)
	if err != nil {
		if t != nil {
			require.NoErrorf(t, err, "validation failed for message: %T", targetMsg)
		} else {
			panic(err)
		}
	}
	_, err = proto.Marshal(targetMsg)
	if err != nil {
		if t != nil {
			require.NoErrorf(t, err, "proto.Marshal failed for message: %T", targetMsg)
		} else {
			panic(err)
		}
	}
}

type otelAttributeSetMatcher struct {
	expectedAttrs map[string]any
}

func (m *otelAttributeSetMatcher) Matches(x any) bool {
	options, ok := x.([]otelmetric.AddOption)
	if !ok {
		return false
	}

	// Extract the attribute set from the option
	config := otelmetric.NewAddConfig(options)
	attrSet := config.Attributes()

	// Convert to map for comparison
	iter := attrSet.Iter()
	actualAttrs := make(map[string]any, iter.Len())
	for iter.Next() {
		kv := iter.Attribute()
		actualAttrs[string(kv.Key)] = kv.Value.AsInterface()
	}

	return gomock.Eq(m.expectedAttrs).Matches(actualAttrs)
}

func (m *otelAttributeSetMatcher) String() string {
	return "matches otel attribute set"
}

func MatchOtelAttributes(attrs map[string]any) gomock.Matcher {
	return &otelAttributeSetMatcher{expectedAttrs: attrs}
}
