package mvcc

import (
	"context"
	"errors"
	"strings"
	"sync"

	"gitlab.com/gitlab-org/gitaly/v18/internal/git"
	"gitlab.com/gitlab-org/gitaly/v18/internal/git/gitcmd"
	"gitlab.com/gitlab-org/gitaly/v18/internal/git/mvcc"
	"gitlab.com/gitlab-org/gitaly/v18/internal/gitaly/storage"
	"gitlab.com/gitlab-org/gitaly/v18/internal/grpc/protoregistry"
	logger "gitlab.com/gitlab-org/gitaly/v18/internal/log"
	"gitlab.com/gitlab-org/gitaly/v18/internal/structerr"
	"gitlab.com/gitlab-org/gitaly/v18/middleware"
	"gitlab.com/gitlab-org/gitaly/v18/proto/go/gitalypb"
	"google.golang.org/grpc"
	grpchealth "google.golang.org/grpc/health/grpc_health_v1"
	"google.golang.org/protobuf/proto"
)

// CacheInterceptor is a middleware that injects an mvcc.Cache into the context
// when the repository is using the mvcc reference backend.
type CacheInterceptor struct {
	runtimeDir        string
	registry          *protoregistry.Registry
	locator           storage.Locator
	logger            logger.Logger
	objectStoragePool map[string]mvcc.ObjectStorage
	poolMutex         sync.Mutex
}

// NewMvccInterceptor returns a new MVCC interceptor.
func NewMvccInterceptor(runtimeDir string, logger logger.Logger, registry *protoregistry.Registry, locator storage.Locator) *CacheInterceptor {
	return &CacheInterceptor{
		runtimeDir:        runtimeDir,
		logger:            logger,
		registry:          registry,
		locator:           locator,
		objectStoragePool: make(map[string]mvcc.ObjectStorage),
		poolMutex:         sync.Mutex{},
	}
}

// UnaryInterceptor returns a Unary Interceptor
func (m *CacheInterceptor) UnaryInterceptor() grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
		methodInfo, err := m.registry.LookupMethod(info.FullMethod)
		if err != nil {
			if !recognizedNonGitalyMethod(info.FullMethod) {
				m.logger.WithError(err).Warn("mvcc unary interceptor: unable to lookup method")
			}
			return handler(ctx, req)
		}

		targetRepo, err := methodInfo.TargetRepo(req.(proto.Message))
		if err != nil {
			return handler(ctx, req)
		}

		cache, err := m.buildCache(ctx, targetRepo, methodInfo.Operation)
		if err != nil || cache == nil {
			return handler(ctx, req)
		}

		cache.Prepare(ctx)
		defer cache.Cleanup()
		ctx = mvcc.ContextWithCache(ctx, cache)
		ctx = gitcmd.ContextWithReferenceBackend(ctx, git.ReferenceBackendMVCC)
		res, err := handler(ctx, req)
		if err != nil {
			return nil, err
		}
		if err := cache.Commit(ctx); err != nil {
			return nil, mapCommitError(err)
		}

		return res, nil
	}
}

// StreamInterceptor returns a Stream Interceptor
func (m *CacheInterceptor) StreamInterceptor() grpc.StreamServerInterceptor {
	return func(srv interface{}, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
		ctx := stream.Context()

		methodInfo, err := m.registry.LookupMethod(info.FullMethod)
		if err != nil {
			if !recognizedNonGitalyMethod(info.FullMethod) {
				m.logger.WithError(err).Warn("mvcc unary interceptor: unable to lookup method")
			}
			return handler(srv, stream)
		}

		req := methodInfo.NewRequest()
		if err := stream.RecvMsg(req); err != nil {
			return handler(srv, middleware.NewPeekedStream(ctx, nil, err, stream))
		}

		targetRepo, err := methodInfo.TargetRepo(req)
		if err != nil {
			return handler(srv, middleware.NewPeekedStream(ctx, req, nil, stream))
		}

		cache, err := m.buildCache(ctx, targetRepo, methodInfo.Operation)
		if err != nil || cache == nil {
			return handler(srv, middleware.NewPeekedStream(ctx, req, nil, stream))
		}

		cache.Prepare(ctx)
		defer cache.Cleanup()
		ctx = mvcc.ContextWithCache(ctx, cache)
		ctx = gitcmd.ContextWithReferenceBackend(ctx, git.ReferenceBackendMVCC)
		if err := handler(srv, middleware.NewPeekedStream(ctx, req, nil, stream)); err != nil {
			return err
		}
		if err := cache.Commit(ctx); err != nil {
			return mapCommitError(err)
		}

		return nil
	}
}

// mapCommitError translates a Cache.Commit error into an appropriate gRPC status. A
// manifest pointer conflict is reported as Aborted so the client may retry against the
// new base; all other errors are returned as-is.
func mapCommitError(err error) error {
	if errors.Is(err, mvcc.ErrManifestPointerConflict) {
		return structerr.NewAborted("mvcc: %w", err)
	}
	return err
}

func (m *CacheInterceptor) buildCache(ctx context.Context, repo *gitalypb.Repository, op protoregistry.OpType) (*mvcc.LocalCache, error) {
	if !m.isMvccReferenceBackend(ctx, repo) {
		return nil, nil
	}

	storagePath, err := m.locator.GetStorageByName(ctx, repo.GetStorageName())
	if err != nil {
		return nil, err
	}

	// This is a temporary solution. Ideally, the ObjectStorage here should be a shared-instance
	// (singleton)for the whole Gitaly process. However, Gitaly has the concept of `Storages`,
	// which are different locations on disk where a repository can be stored. Given the state of
	// discussions with how we want to go with object pools, I don't think we will support multiple
	// `Storages` in the long term. For now, let's just create the ObjectStorage here. Once the
	// `Storages` concept becomes obsolete, we can move the creation of the ObjectStorage further
	// up in the call stack to have a singleton.
	m.poolMutex.Lock()
	defer m.poolMutex.Unlock()

	objectStorage, ok := m.objectStoragePool[storagePath]
	if !ok {
		st := mvcc.NewLocalStorage(storagePath)
		m.objectStoragePool[storagePath] = st
		objectStorage = st
	}

	if fields := logger.CustomFieldsFromContext(ctx); fields != nil {
		fields.RecordMetadata("mvcc.used", true)
	}

	cache := mvcc.NewLocalCache(objectStorage, m.runtimeDir, storagePath, repo.GetRelativePath(), op == protoregistry.OpAccessor)
	return cache, nil
}

func (m *CacheInterceptor) isMvccReferenceBackend(ctx context.Context, repo *gitalypb.Repository) bool {
	repoPath, err := m.locator.GetRepoPath(ctx, repo)
	if err != nil {
		return false
	}
	backend, err := gitcmd.DetectReferenceBackend(ctx, repoPath)
	if err != nil {
		return false
	}
	return git.ReferenceBackendMVCC.Name == backend.Name
}

// recognizedNonGitalyMethod returns true if `fullMethod` is a non-Gitaly method
// that the server still accepts.

func recognizedNonGitalyMethod(fullMethod string) bool {
	return strings.Contains(fullMethod, grpchealth.Health_ServiceDesc.ServiceName)
}
