package structerr //nolint:gitaly-linters // Importing testhelper creates a cyclic dependency.

import (
	"errors"
	"fmt"
	"sort"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/proto"
	"google.golang.org/protobuf/types/known/anypb"
)

// MetadataItem is an item that associated a metadata key with an arbitrary value.
type MetadataItem struct {
	// Key is the key of the metadata item that will be used as the logging key.
	Key string
	// Value is the value of the metadata item that will be formatted as the logging value.
	Value any
}

// Error is a structured error that contains additional details.
type Error struct {
	err     error
	code    codes.Code
	details []proto.Message
	// metadata is the array of metadata items added to this error. Note that we explicitly
	// don't use a map here so that we don't have any allocation overhead here in the general
	// case where there is no metadata.
	metadata []MetadataItem
}

type grpcStatuser interface {
	GRPCStatus() *status.Status
}

func newError(code codes.Code, format string, a ...any) Error {
	for i, arg := range a {
		err, ok := arg.(error)
		if !ok {
			continue
		}

		if errors.As(err, &(Error{})) {
			// We need to explicitly handle this, otherwise `status.FromError()` would
			// return these because we implement `GRPCStatus()`.
			continue
		}

		// If we see any wrapped gRPC error, then we retain its error code and details.
		// Note that we cannot use `status.FromError()` here, as that would only return an
		// error in case the immediate error is a gRPC status error.
		var wrappedGRPCStatus grpcStatuser
		if errors.As(err, &wrappedGRPCStatus) {
			grpcStatus := wrappedGRPCStatus.GRPCStatus()

			// The error message from gRPC errors is awkward because they include
			// RPC-specific constructs. This is awkward especially in the case where
			// these are embedded in the middle of an error message.
			//
			// So if we see that the top-level error is a gRPC error, then we only use
			// the status message as error message. But otherwise, we use the top-level
			// error message.
			message := err.Error()
			if st, ok := status.FromError(err); ok {
				message = st.Message()
			}

			var details []proto.Message
			for _, detail := range grpcStatus.Details() {
				if detailProto, ok := detail.(proto.Message); ok {
					details = append(details, detailProto)
				}
			}

			a[i] = Error{
				err:     errors.New(message),
				code:    grpcStatus.Code(),
				details: details,
			}
		}
	}

	formattedErr := fmt.Errorf(format, a...)

	// When we wrap an Error, we retain its error code. The intent of this is to retain the most
	// specific error code we have in the general case. As `Unknown` does not really count as a
	// specific error code, we will ignore these errors.
	//
	// Note that this impacts our middleware status handler, where we wrap non-context-errors
	// via `structerr.NewInternal()`. The result is that the caller should never see any
	// `Unknown` errors.
	var wrappedErr Error
	if errors.As(formattedErr, &wrappedErr) {
		if wrappedErr.code != codes.Unknown {
			code = wrappedErr.code
		}
	}

	return Error{
		err:  formattedErr,
		code: code,
	}
}

// New returns a new Error with an Unknown error code. This constructor should be used in the
// general case where it is not clear what the specific error category is. As Unknown errors get
// treated specially, they will be overridden when wrapped with an error that has a more specific
// error code.
func New(format string, a ...any) Error {
	return newError(codes.Unknown, format, a...)
}

// NewAborted constructs a new error code with the Aborted error code. Please refer to New for
// further details.
func NewAborted(format string, a ...any) Error {
	return newError(codes.Aborted, format, a...)
}

// NewAlreadyExists constructs a new error code with the AlreadyExists error code. Please refer to
// New for further details.
func NewAlreadyExists(format string, a ...any) Error {
	return newError(codes.AlreadyExists, format, a...)
}

// NewCanceled constructs a new error code with the Canceled error code. Please refer to New for
// further details.
func NewCanceled(format string, a ...any) Error {
	return newError(codes.Canceled, format, a...)
}

// NewDataLoss constructs a new error code with the DataLoss error code. Please refer to New for
// further details.
func NewDataLoss(format string, a ...any) Error {
	return newError(codes.DataLoss, format, a...)
}

// NewDeadlineExceeded constructs a new error code with the DeadlineExceeded error code. Please
// refer to New for further details.
func NewDeadlineExceeded(format string, a ...any) Error {
	return newError(codes.DeadlineExceeded, format, a...)
}

// NewFailedPrecondition constructs a new error code with the FailedPrecondition error code. Please
// refer to New for further details.
func NewFailedPrecondition(format string, a ...any) Error {
	return newError(codes.FailedPrecondition, format, a...)
}

// NewInternal constructs a new error code with the Internal error code. Please refer to New for
// further details.
func NewInternal(format string, a ...any) Error {
	return newError(codes.Internal, format, a...)
}

// NewInvalidArgument constructs a new error code with the InvalidArgument error code. Please refer
// to New for further details.
func NewInvalidArgument(format string, a ...any) Error {
	return newError(codes.InvalidArgument, format, a...)
}

// NewNotFound constructs a new error code with the NotFound error code. Please refer to New for
// further details.
func NewNotFound(format string, a ...any) Error {
	return newError(codes.NotFound, format, a...)
}

// NewPermissionDenied constructs a new error code with the PermissionDenied error code. Please
// refer to New for further details.
func NewPermissionDenied(format string, a ...any) Error {
	return newError(codes.PermissionDenied, format, a...)
}

// NewResourceExhausted constructs a new error code with the ResourceExhausted error code. Please
// refer to New for further details.
func NewResourceExhausted(format string, a ...any) Error {
	return newError(codes.ResourceExhausted, format, a...)
}

// NewUnavailable constructs a new error code with the Unavailable error code. Please refer to New for further details.
// Please note that the Unavailable status code is a signal telling clients to retry automatically. This auto-retry
// mechanism is handled at the library layer, without client consensus. Typically, it is used for the situations where
// the gRPC is not available or is not responding. Here are some discrete examples:
//
//   - Server downtime: The server hosting the gRPC service is down for maintenance or has crashed.
//   - Network issues: Connectivity problems between the client and server, like network congestion or a broken connection,
//     can cause the service to appear unavailable.
//   - Load balancing failure: In a distributed system, the load balancer may be unable to route the client's request to a
//     healthy instance of the gRPC service. This can happen if all instances are down or if the load balancer is
//     misconfigured.
//   - TLS/SSL handshake failure: If there's a problem during the TLS/SSL handshake between the client and the server, the
//     connection may fail, leading to an UNAVAILABLE status code.
//
// Thus, this status code should be used by interceptors or network-related components. gRPC handlers should use another
// status code instead.
func NewUnavailable(format string, a ...any) Error {
	return newError(codes.Unavailable, format, a...)
}

// NewUnauthenticated constructs a new error code with the Unauthenticated error code. Please refer
// to New for further details.
func NewUnauthenticated(format string, a ...any) Error {
	return newError(codes.Unauthenticated, format, a...)
}

// NewUnimplemented constructs a new error code with the Unimplemented error code. Please refer to
// New for further details.
func NewUnimplemented(format string, a ...any) Error {
	return newError(codes.Unimplemented, format, a...)
}

// Error returns the error message of the Error.
func (e Error) Error() string {
	return e.err.Error()
}

// Unwrap returns the wrapped error if any, otherwise it returns nil.
func (e Error) Unwrap() error {
	return errors.Unwrap(e.err)
}

// Is checks whether the error is equivalent to the target error. Errors are only considered
// equivalent if the GRPC representation of this error is the same.
func (e Error) Is(targetErr error) bool {
	target, ok := targetErr.(Error)
	if !ok {
		return false
	}

	return errors.Is(e.GRPCStatus().Err(), target.GRPCStatus().Err())
}

// Code returns the error code of the Error.
func (e Error) Code() codes.Code {
	return e.code
}

// GRPCStatus returns the gRPC status of this error.
func (e Error) GRPCStatus() *status.Status {
	st := status.New(e.Code(), e.Error())

	if details := e.Details(); len(details) > 0 {
		proto := st.Proto()

		for _, detail := range details {
			marshaled, err := anypb.New(detail)
			if err != nil {
				return status.New(codes.Internal, fmt.Sprintf("marshaling error details: %v", err))
			}

			proto.Details = append(proto.Details, marshaled)
		}

		st = status.FromProto(proto)
	}

	return st
}

// errorChain returns the complete chain of `structerr.Error`s wrapped by this error, including the
// error itself.
func (e Error) errorChain() []Error {
	var result []Error
	for err := error(e); err != nil; err = errors.Unwrap(err) {
		var structErr Error
		if errors.As(err, &structErr) {
			result = append(result, structErr)
		}
	}
	return result
}

// Metadata returns the Error's metadata. Please refer to `ExtractMetadata()` for the exact semantics of this function.
func (e Error) Metadata() map[string]any {
	return ExtractMetadata(e)
}

// MetadataItems returns a copy of all metadata items added to this error. This function has the
// same semantics as `Metadata()`. The results are sorted by their metadata key.
func (e Error) MetadataItems() []MetadataItem {
	metadata := e.Metadata()

	items := make([]MetadataItem, 0, len(metadata))
	for key, value := range metadata {
		items = append(items, MetadataItem{Key: key, Value: value})
	}

	sort.Slice(items, func(i, j int) bool {
		return items[i].Key < items[j].Key
	})

	return items
}

// WithMetadata adds an additional metadata item to the Error. The metadata has the intent to
// provide more context around errors to the consumer of the Error. Calling this function multiple
// times with the same key will override any previous values.
func (e Error) WithMetadata(key string, value any) Error {
	for i, metadataItem := range e.metadata {
		// In case the key already exists we override it.
		if metadataItem.Key == key {
			e.metadata[i].Value = value
			return e
		}
	}

	// Otherwise we append a new metadata item.
	e.metadata = append(e.metadata, MetadataItem{
		Key: key, Value: value,
	})
	return e
}

// WithMetadataItems is a convenience function to append multiple metadata items to an error. It
// behaves the same as if `WithMetadata()` was called for each of the items separately.
func (e Error) WithMetadataItems(items ...MetadataItem) Error {
	for _, item := range items {
		e = e.WithMetadata(item.Key, item.Value)
	}
	return e
}

// Details returns the chain error details set by this and any wrapped Error. The returned array
// contains error details ordered from top-level error details to bottom-level error details.
func (e Error) Details() []proto.Message {
	var details []proto.Message
	for _, err := range e.errorChain() {
		details = append(details, err.details...)
	}
	return details
}

// WithDetail sets the Error detail that provides additional structured information about the error
// via gRPC so that callers can programmatically determine the exact circumstances of an error.
func (e Error) WithDetail(detail proto.Message) Error {
	e.details = append(e.details, detail)
	return e
}

// WithGRPCCode overrides the gRPC code embedded into the error.
func (e Error) WithGRPCCode(code codes.Code) Error {
	e.code = code
	return e
}

// ErrorMetadater is an interface that can be implemented by error types in order to provide custom metadata items
// without itself being a `structerr.Error`.
type ErrorMetadater interface {
	// ErrorMetadata returns the list of metadata items attached to this error.
	ErrorMetadata() []MetadataItem
}

// ExtractMetadata extracts metadata from the given error if any of the errors in its chain contain any. Errors may
// contain in case they are either a `structerr.Error` or in case they implement the `ErrorMetadater` interface. The
// metadata will contain the combination of all added metadata of this error as well as any wrapped Errors.
//
// When the same metadata key exists multiple times in the error chain, then the value that is
// highest up the callchain will be returned. This is done because in general, the higher up the
// callchain one is the more context is available.
func ExtractMetadata(err error) map[string]any {
	metadata := map[string]any{}
	for _, m := range combineMetadataItems(err) {
		if _, exists := metadata[m.Key]; !exists {
			metadata[m.Key] = m.Value
		}
	}

	return metadata
}

//nolint:errorlint
func combineMetadataItems(err error) []MetadataItem {
	var metadataItems []MetadataItem

	switch metadataErr := err.(type) {
	case Error:
		metadataItems = metadataErr.metadata
	case ErrorMetadater:
		metadataItems = metadataErr.ErrorMetadata()
	}

	switch wrapErr := err.(type) {
	case interface{ Unwrap() error }:
		metadataItems = append(metadataItems, combineMetadataItems(wrapErr.Unwrap())...)
	case interface{ Unwrap() []error }:
		for _, joinedErr := range wrapErr.Unwrap() {
			metadataItems = append(metadataItems, combineMetadataItems(joinedErr)...)
		}
	}

	return metadataItems
}
