package server

import (
	"context"
	"encoding/json"
	"net/http"
	"runtime"
	"strings"
	"sync"

	"github.com/pkg/errors"
	"golang.org/x/sync/semaphore"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/peer"
	"google.golang.org/grpc/status"

	"github.com/kopia/kopia/internal/auth"
	"github.com/kopia/kopia/internal/gather"
	"github.com/kopia/kopia/internal/grpcapi"
	"github.com/kopia/kopia/repo"
	"github.com/kopia/kopia/repo/compression"
	"github.com/kopia/kopia/repo/content"
	"github.com/kopia/kopia/repo/manifest"
	"github.com/kopia/kopia/repo/object"
)

type grpcServerState struct {
	sendMutex sync.RWMutex

	grpcapi.UnimplementedKopiaRepositoryServer

	sem *semaphore.Weighted
}

// send sends the provided session response with the provided request ID.
func (s *Server) send(srv grpcapi.KopiaRepository_SessionServer, requestID int64, resp *grpcapi.SessionResponse) error {
	s.grpcServerState.sendMutex.Lock()
	defer s.grpcServerState.sendMutex.Unlock()

	resp.RequestId = requestID

	if err := srv.Send(resp); err != nil {
		return errors.Wrap(err, "unable to send response")
	}

	return nil
}

func (s *Server) authenticateGRPCSession(ctx context.Context) (string, error) {
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return "", status.Errorf(codes.PermissionDenied, "metadata not found in context")
	}

	if u, h, p := md.Get("kopia-username"), md.Get("kopia-hostname"), md.Get("kopia-password"); len(u) == 1 && len(p) == 1 && len(h) == 1 {
		username := u[0] + "@" + h[0]
		password := p[0]

		if s.authenticator.IsValid(ctx, s.rep, username, password) {
			return username, nil
		}

		return "", status.Errorf(codes.PermissionDenied, "access denied for %v", username)
	}

	return "", status.Errorf(codes.PermissionDenied, "missing credentials")
}

// Session handles GRPC session from a repository client.
func (s *Server) Session(srv grpcapi.KopiaRepository_SessionServer) error {
	ctx := srv.Context()

	dr, ok := s.rep.(repo.DirectRepository)
	if !ok {
		return status.Errorf(codes.Unavailable, "not connected to a direct repository")
	}

	username, err := s.authenticateGRPCSession(ctx)
	if err != nil {
		return err
	}

	authz := s.authorizer.Authorize(ctx, dr, username)
	if authz == nil {
		authz = auth.NoAccess()
	}

	p, ok := peer.FromContext(ctx)
	if !ok {
		return status.Errorf(codes.PermissionDenied, "peer not found in context")
	}

	log(ctx).Infof("starting session for user %q from %v", username, p.Addr)
	defer log(ctx).Infof("session ended for user %q from %v", username, p.Addr)

	opt, err := s.handleInitialSessionHandshake(srv, dr)
	if err != nil {
		log(ctx).Errorf("session handshake error: %v", err)
		return err
	}

	// nolint:wrapcheck
	return repo.DirectWriteSession(ctx, dr, opt, func(ctx context.Context, dw repo.DirectRepositoryWriter) error {
		// channel to which workers will be sending errors, only holds 1 slot and sends are non-blocking.
		lastErr := make(chan error, 1)

		for req, err := srv.Recv(); err == nil; req, err = srv.Recv() {
			req := req

			// propagate any error from the goroutines
			select {
			case err := <-lastErr:
				log(ctx).Errorf("error handling session request: %v", err)
				return err

			default:
			}

			// enforce limit on concurrent handling
			if err := s.grpcServerState.sem.Acquire(ctx, 1); err != nil {
				return errors.Wrap(err, "unable to acquire semaphore")
			}

			go func() {
				defer s.grpcServerState.sem.Release(1)

				resp := handleSessionRequest(ctx, dw, authz, req)

				if err := s.send(srv, req.RequestId, resp); err != nil {
					select {
					case lastErr <- err:
					default:
					}
				}
			}()
		}

		return nil
	})
}

func handleSessionRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.SessionRequest) *grpcapi.SessionResponse {
	switch inner := req.GetRequest().(type) {
	case *grpcapi.SessionRequest_GetContentInfo:
		return handleGetContentInfoRequest(ctx, dw, authz, inner.GetContentInfo)

	case *grpcapi.SessionRequest_GetContent:
		return handleGetContentRequest(ctx, dw, authz, inner.GetContent)

	case *grpcapi.SessionRequest_WriteContent:
		return handleWriteContentRequest(ctx, dw, authz, inner.WriteContent)

	case *grpcapi.SessionRequest_Flush:
		return handleFlushRequest(ctx, dw, authz, inner.Flush)

	case *grpcapi.SessionRequest_GetManifest:
		return handleGetManifestRequest(ctx, dw, authz, inner.GetManifest)

	case *grpcapi.SessionRequest_PutManifest:
		return handlePutManifestRequest(ctx, dw, authz, inner.PutManifest)

	case *grpcapi.SessionRequest_FindManifests:
		return handleFindManifestsRequest(ctx, dw, authz, inner.FindManifests)

	case *grpcapi.SessionRequest_DeleteManifest:
		return handleDeleteManifestRequest(ctx, dw, authz, inner.DeleteManifest)

	case *grpcapi.SessionRequest_InitializeSession:
		return errorResponse(errors.Errorf("InitializeSession must be the first request in a session"))

	default:
		return errorResponse(errors.Errorf("unhandled session request"))
	}
}

func handleGetContentInfoRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.GetContentInfoRequest) *grpcapi.SessionResponse {
	if authz.ContentAccessLevel() < auth.AccessLevelRead {
		return accessDeniedResponse()
	}

	ci, err := dw.ContentManager().ContentInfo(ctx, content.ID(req.GetContentId()))
	if err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_GetContentInfo{
			GetContentInfo: &grpcapi.GetContentInfoResponse{
				Info: &grpcapi.ContentInfo{
					Id:               string(ci.GetContentID()),
					PackedLength:     ci.GetPackedLength(),
					TimestampSeconds: ci.GetTimestampSeconds(),
					PackBlobId:       string(ci.GetPackBlobID()),
					PackOffset:       ci.GetPackOffset(),
					Deleted:          ci.GetDeleted(),
					FormatVersion:    uint32(ci.GetFormatVersion()),
					OriginalLength:   ci.GetOriginalLength(),
				},
			},
		},
	}
}

func handleGetContentRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.GetContentRequest) *grpcapi.SessionResponse {
	if authz.ContentAccessLevel() < auth.AccessLevelRead {
		return accessDeniedResponse()
	}

	data, err := dw.ContentManager().GetContent(ctx, content.ID(req.GetContentId()))
	if err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_GetContent{
			GetContent: &grpcapi.GetContentResponse{
				Data: data,
			},
		},
	}
}

func handleWriteContentRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.WriteContentRequest) *grpcapi.SessionResponse {
	if authz.ContentAccessLevel() < auth.AccessLevelAppend {
		return accessDeniedResponse()
	}

	if strings.HasPrefix(req.GetPrefix(), manifest.ContentPrefix) {
		// it's not allowed to create contents prefixed with 'm' since those could be mistaken for manifest contents.
		return accessDeniedResponse()
	}

	contentID, err := dw.ContentManager().WriteContent(ctx, gather.FromSlice(req.GetData()), content.ID(req.GetPrefix()), compression.HeaderID(req.GetCompression()))
	if err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_WriteContent{
			WriteContent: &grpcapi.WriteContentResponse{
				ContentId: string(contentID),
			},
		},
	}
}

func handleFlushRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, _ *grpcapi.FlushRequest) *grpcapi.SessionResponse {
	if authz.ContentAccessLevel() < auth.AccessLevelAppend {
		return accessDeniedResponse()
	}

	err := dw.Flush(ctx)
	if err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_Flush{
			Flush: &grpcapi.FlushResponse{},
		},
	}
}

func handleGetManifestRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.GetManifestRequest) *grpcapi.SessionResponse {
	var data json.RawMessage

	em, err := dw.GetManifest(ctx, manifest.ID(req.GetManifestId()), &data)
	if err != nil {
		return errorResponse(err)
	}

	if authz.ManifestAccessLevel(em.Labels) < auth.AccessLevelRead {
		return accessDeniedResponse()
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_GetManifest{
			GetManifest: &grpcapi.GetManifestResponse{
				JsonData: data,
				Metadata: makeEntryMetadata(em),
			},
		},
	}
}

func handlePutManifestRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.PutManifestRequest) *grpcapi.SessionResponse {
	if authz.ManifestAccessLevel(req.GetLabels()) < auth.AccessLevelAppend {
		return accessDeniedResponse()
	}

	manifestID, err := dw.PutManifest(ctx, req.GetLabels(), json.RawMessage(req.GetJsonData()))
	if err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_PutManifest{
			PutManifest: &grpcapi.PutManifestResponse{
				ManifestId: string(manifestID),
			},
		},
	}
}

func handleFindManifestsRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.FindManifestsRequest) *grpcapi.SessionResponse {
	em, err := dw.FindManifests(ctx, req.GetLabels())
	if err != nil {
		return errorResponse(err)
	}

	// only return manifests which the caller can read
	var filtered []*manifest.EntryMetadata

	for _, m := range em {
		if authz.ManifestAccessLevel(m.Labels) < auth.AccessLevelRead {
			continue
		}

		filtered = append(filtered, m)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_FindManifests{
			FindManifests: &grpcapi.FindManifestsResponse{
				Metadata: makeEntryMetadataList(filtered),
			},
		},
	}
}

func handleDeleteManifestRequest(ctx context.Context, dw repo.DirectRepositoryWriter, authz auth.AuthorizationInfo, req *grpcapi.DeleteManifestRequest) *grpcapi.SessionResponse {
	var data json.RawMessage

	em, err := dw.GetManifest(ctx, manifest.ID(req.GetManifestId()), &data)
	if err != nil {
		return errorResponse(err)
	}

	if authz.ManifestAccessLevel(em.Labels) < auth.AccessLevelFull {
		return accessDeniedResponse()
	}

	if err := dw.DeleteManifest(ctx, manifest.ID(req.GetManifestId())); err != nil {
		return errorResponse(err)
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_DeleteManifest{
			DeleteManifest: &grpcapi.DeleteManifestResponse{},
		},
	}
}

func accessDeniedResponse() *grpcapi.SessionResponse {
	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_Error{
			Error: &grpcapi.ErrorResponse{
				Code:    grpcapi.ErrorResponse_ACCESS_DENIED,
				Message: "access denied",
			},
		},
	}
}

func errorResponse(err error) *grpcapi.SessionResponse {
	var errorCode grpcapi.ErrorResponse_Code

	switch {
	case errors.Is(err, content.ErrContentNotFound):
		errorCode = grpcapi.ErrorResponse_CONTENT_NOT_FOUND
	case errors.Is(err, manifest.ErrNotFound):
		errorCode = grpcapi.ErrorResponse_MANIFEST_NOT_FOUND
	case errors.Is(err, object.ErrObjectNotFound):
		errorCode = grpcapi.ErrorResponse_OBJECT_NOT_FOUND
	default:
		errorCode = grpcapi.ErrorResponse_UNKNOWN_ERROR
	}

	return &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_Error{
			Error: &grpcapi.ErrorResponse{
				Code:    errorCode,
				Message: err.Error(),
			},
		},
	}
}

func makeEntryMetadataList(em []*manifest.EntryMetadata) []*grpcapi.ManifestEntryMetadata {
	var result []*grpcapi.ManifestEntryMetadata

	for _, v := range em {
		result = append(result, makeEntryMetadata(v))
	}

	return result
}

func makeEntryMetadata(em *manifest.EntryMetadata) *grpcapi.ManifestEntryMetadata {
	return &grpcapi.ManifestEntryMetadata{
		Id:           string(em.ID),
		Length:       int32(em.Length),
		ModTimeNanos: em.ModTime.UnixNano(),
		Labels:       em.Labels,
	}
}

func (s *Server) handleInitialSessionHandshake(srv grpcapi.KopiaRepository_SessionServer, dr repo.DirectRepository) (repo.WriteSessionOptions, error) {
	initializeReq, err := srv.Recv()
	if err != nil {
		return repo.WriteSessionOptions{}, errors.Wrap(err, "unable to read initialization request")
	}

	ir := initializeReq.GetInitializeSession()
	if ir == nil {
		return repo.WriteSessionOptions{}, errors.Errorf("missing initialization request")
	}

	if err := s.send(srv, initializeReq.GetRequestId(), &grpcapi.SessionResponse{
		Response: &grpcapi.SessionResponse_InitializeSession{
			InitializeSession: &grpcapi.InitializeSessionResponse{
				Parameters: &grpcapi.RepositoryParameters{
					HashFunction:               dr.ContentReader().ContentFormat().Hash,
					HmacSecret:                 dr.ContentReader().ContentFormat().HMACSecret,
					Splitter:                   dr.ObjectFormat().Splitter,
					SupportsContentCompression: dr.ContentReader().SupportsContentCompression(),
				},
			},
		},
	}); err != nil {
		return repo.WriteSessionOptions{}, errors.Wrap(err, "unable to send response")
	}

	return repo.WriteSessionOptions{
		Purpose: ir.GetPurpose(),
	}, nil
}

// RegisterGRPCHandlers registers server gRPC handler.
func (s *Server) RegisterGRPCHandlers(r grpc.ServiceRegistrar) {
	grpcapi.RegisterKopiaRepositoryServer(r, s)
}

func makeGRPCServerState(maxConcurrency int) grpcServerState {
	if maxConcurrency == 0 {
		maxConcurrency = 2 * runtime.NumCPU() //nolint:gomnd
	}

	return grpcServerState{
		sem: semaphore.NewWeighted(int64(maxConcurrency)),
	}
}

// GRPCRouterHandler returns HTTP handler that supports GRPC services and
// routes non-GRPC calls to the provided handler.
func (s *Server) GRPCRouterHandler(handler http.Handler) http.Handler {
	grpcServer := grpc.NewServer(
		grpc.MaxSendMsgSize(repo.MaxGRPCMessageSize),
		grpc.MaxRecvMsgSize(repo.MaxGRPCMessageSize),
	)

	s.RegisterGRPCHandlers(grpcServer)

	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.ProtoMajor == 2 && strings.Contains(r.Header.Get("Content-Type"), "application/grpc") {
			grpcServer.ServeHTTP(w, r)
		} else {
			handler.ServeHTTP(w, r)
		}
	})
}
