// Copyright 2023 Harness, Inc.
//
// 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 service

import (
	"context"
	"fmt"
	"math"
	"strings"

	"github.com/harness/gitness/gitrpc/enum"
	"github.com/harness/gitness/gitrpc/internal/gitea"
	"github.com/harness/gitness/gitrpc/internal/types"
	"github.com/harness/gitness/gitrpc/rpc"

	"code.gitea.io/gitea/modules/git"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

type ReferenceService struct {
	rpc.UnimplementedReferenceServiceServer
	adapter   GitAdapter
	reposRoot string
	tmpDir    string
}

func NewReferenceService(adapter GitAdapter,
	reposRoot string, tmpDir string) (*ReferenceService, error) {
	return &ReferenceService{
		adapter:   adapter,
		reposRoot: reposRoot,
		tmpDir:    tmpDir,
	}, nil
}

// sanitizeReferenceQuery removes characters that aren't allowd in a branch name.
// TODO: should we error out instead of ignore bad chars?
func sanitizeReferenceQuery(query string) (string, bool, bool) {
	if query == "" {
		return "", false, false
	}

	// get special characters before anything else
	matchPrefix := query[0] == '^' // will be removed by mapping
	matchSuffix := query[len(query)-1] == '$'
	if matchSuffix {
		// Special char $ has to be removed manually as it's a valid char
		// TODO: this restricts the query language to a certain degree, can we do better? (escaping)
		query = query[:len(query)-1]
	}

	// strip all unwanted characters
	return strings.Map(func(r rune) rune {
			// See https://git-scm.com/docs/git-check-ref-format#_description for more details.
			switch {
			// rule 4.
			case r < 32 || r == 127 || r == ' ' || r == '~' || r == '^' || r == ':':
				return -1

			// rule 5
			case r == '?' || r == '*' || r == '[':
				return -1

			// everything else we map as is
			default:
				return r
			}
		}, query),
		matchPrefix,
		matchSuffix
}

// createReferenceWalkPatternsFromQuery returns a list of patterns that
// ensure only references matching the basePath and query are part of the walk.
func createReferenceWalkPatternsFromQuery(basePath string, query string) []string {
	if basePath == "" && query == "" {
		return []string{}
	}

	// ensure non-empty basepath ends with "/" for proper matching and concatenation.
	if basePath != "" && basePath[len(basePath)-1] != '/' {
		basePath += "/"
	}

	// in case query is empty, we just match the basePath.
	if query == "" {
		return []string{basePath}
	}

	// sanitze the query and get special chars
	query, matchPrefix, matchSuffix := sanitizeReferenceQuery(query)

	// In general, there are two search patterns:
	//   - refs/tags/**/*QUERY* - finds all refs that have QUERY in the filename.
	//   - refs/tags/**/*QUERY*/** - finds all refs that have a parent folder with QUERY in the name.
	//
	// In case the suffix has to match, they will be the same, so we return only one pattern.
	if matchSuffix {
		// exact match (refs/tags/QUERY)
		if matchPrefix {
			return []string{basePath + query}
		}

		// suffix only match (refs/tags/**/*QUERY)
		//nolint:goconst
		return []string{basePath + "**/*" + query}
	}

	// prefix only match
	//   - refs/tags/QUERY*
	//   - refs/tags/QUERY*/**
	if matchPrefix {
		return []string{
			basePath + query + "*",    // file
			basePath + query + "*/**", // folder
		}
	}

	// arbitrary match
	//   - refs/tags/**/*QUERY*
	//   - refs/tags/**/*QUERY*/**
	return []string{
		basePath + "**/*" + query + "*",    // file
		basePath + "**/*" + query + "*/**", // folder
	}
}

// wrapInstructorWithOptionalPagination wraps the provided walkInstructor with pagination.
// If no paging is enabled, the original instructor is returned.
func wrapInstructorWithOptionalPagination(inner types.WalkReferencesInstructor,
	page int32, pageSize int32) (types.WalkReferencesInstructor, int32, error) {
	// ensure pagination is requested
	if pageSize < 1 {
		return inner, 0, nil
	}

	// sanitize page
	if page < 1 {
		page = 1
	}

	// ensure we don't overflow
	if int64(page)*int64(pageSize) > int64(math.MaxInt) {
		return nil, 0, fmt.Errorf("page %d with pageSize %d is out of range", page, pageSize)
	}

	startAfter := (page - 1) * pageSize
	endAfter := page * pageSize

	// we have to count ourselves for proper pagination
	c := int32(0)
	return func(e types.WalkReferencesEntry) (types.WalkInstruction, error) {
			// execute inner instructor
			inst, err := inner(e)
			if err != nil {
				return inst, err
			}

			// no pagination if element is filtered out
			if inst != types.WalkInstructionHandle {
				return inst, nil
			}

			// increase count iff element is part of filtered output
			c++

			// add pagination on filtered output
			switch {
			case c <= startAfter:
				return types.WalkInstructionSkip, nil
			case c > endAfter:
				return types.WalkInstructionStop, nil
			default:
				return types.WalkInstructionHandle, nil
			}
		},
		endAfter,
		nil
}

func (s ReferenceService) GetRef(ctx context.Context,
	request *rpc.GetRefRequest,
) (*rpc.GetRefResponse, error) {
	if request.Base == nil {
		return nil, types.ErrBaseCannotBeEmpty
	}
	repoPath := getFullPathForRepo(s.reposRoot, request.Base.GetRepoUid())

	refType := enum.RefFromRPC(request.GetRefType())
	if refType == enum.RefTypeUndefined {
		return nil, status.Error(codes.InvalidArgument, "invalid value of RefType argument")
	}
	reference, err := GetRefPath(request.GetRefName(), refType)
	if err != nil {
		return nil, err
	}

	sha, err := s.adapter.GetRef(ctx, repoPath, reference)
	if err != nil {
		return nil, err
	}

	return &rpc.GetRefResponse{Sha: sha}, nil
}

func (s ReferenceService) UpdateRef(ctx context.Context,
	request *rpc.UpdateRefRequest,
) (*rpc.UpdateRefResponse, error) {
	base := request.GetBase()
	if base == nil {
		return nil, types.ErrBaseCannotBeEmpty
	}
	repoPath := getFullPathForRepo(s.reposRoot, base.GetRepoUid())

	refType := enum.RefFromRPC(request.GetRefType())
	if refType == enum.RefTypeUndefined {
		return nil, status.Error(codes.InvalidArgument, "invalid value of RefType argument")
	}
	reference, err := GetRefPath(request.GetRefName(), refType)
	if err != nil {
		return nil, err
	}

	if ok, err := repoIsEmpty(ctx, repoPath); ok {
		return nil, ErrInvalidArgumentf("reference cannot be updated on empty repository", err)
	}

	sharedRepo, err := NewSharedRepo(s.tmpDir, base.GetRepoUid(), repoPath)
	if err != nil {
		return nil, processGitErrorf(err, "failed to create new shared repo")
	}
	defer sharedRepo.Close(ctx)

	// clone repo (with HEAD branch - target might be anything)
	err = sharedRepo.Clone(ctx, "")
	if err != nil {
		return nil, processGitErrorf(err, "failed to clone shared repo")
	}

	pushOpts := types.PushOptions{
		Remote: repoPath,
		Env:    CreateEnvironmentForPush(ctx, base),
	}

	// handle deletion explicitly to avoid any unwanted side effects
	if request.GetNewValue() == "" {
		pushOpts.Branch = ":" + reference
	} else {
		pushOpts.Branch = request.GetNewValue() + ":" + reference
	}

	if request.GetOldValue() == "" {
		pushOpts.Force = true
	} else {
		pushOpts.ForceWithLease = reference + ":" + request.GetOldValue()
	}

	// TODO: why are we using gitea operations here?
	// TODO: our shared repo has so much duplication, that should be changed IMHO.
	err = gitea.Push(ctx, sharedRepo.tmpPath, pushOpts)
	if err != nil {
		return nil, processGitErrorf(err, "failed to push changes to original repo")
	}

	return &rpc.UpdateRefResponse{}, nil
}

func GetRefPath(refName string, refType enum.RefType) (string, error) {
	const (
		refPullReqPrefix      = "refs/pullreq/"
		refPullReqHeadSuffix  = "/head"
		refPullReqMergeSuffix = "/merge"
	)

	switch refType {
	case enum.RefTypeRaw:
		return refName, nil
	case enum.RefTypeBranch:
		return git.BranchPrefix + refName, nil
	case enum.RefTypeTag:
		return git.TagPrefix + refName, nil
	case enum.RefTypePullReqHead:
		return refPullReqPrefix + refName + refPullReqHeadSuffix, nil
	case enum.RefTypePullReqMerge:
		return refPullReqPrefix + refName + refPullReqMergeSuffix, nil
	case enum.RefTypeUndefined:
		fallthrough
	default:
		return "", ErrInvalidArgumentf("provided reference type '%s' is invalid", refType)
	}
}