// 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 git

import (
	"context"
	"fmt"
	"time"

	"github.com/harness/gitness/errors"
	"github.com/harness/gitness/git/api"
	"github.com/harness/gitness/git/hook"
	"github.com/harness/gitness/git/sha"
	"github.com/harness/gitness/git/sharedrepo"

	"github.com/rs/zerolog/log"
)

var (
	listCommitTagsRefFields = []api.GitReferenceField{
		api.GitReferenceFieldRefName,
		api.GitReferenceFieldObjectType,
		api.GitReferenceFieldObjectName,
	}
	listCommitTagsObjectTypeFilter = []api.GitObjectType{
		api.GitObjectTypeCommit,
		api.GitObjectTypeTag,
	}
)

type TagSortOption int

const (
	TagSortOptionDefault TagSortOption = iota
	TagSortOptionName
	TagSortOptionDate
)

type ListCommitTagsParams struct {
	ReadParams
	IncludeCommit bool
	Query         string
	Sort          TagSortOption
	Order         SortOrder
	Page          int32
	PageSize      int32
}

type ListCommitTagsOutput struct {
	Tags []CommitTag
}

type CommitTag struct {
	Name        string
	SHA         sha.SHA
	IsAnnotated bool
	Title       string
	Message     string
	Tagger      *Signature
	Commit      *Commit
}

type CreateCommitTagParams struct {
	WriteParams
	Name string

	// Target is the commit (or points to the commit) the new tag will be pointing to.
	Target string

	// Message is the optional message the tag will be created with - if the message is empty
	// the tag will be lightweight, otherwise it'll be annotated
	Message string

	// Tagger overwrites the git author used in case the tag is annotated
	// (optional, default: actor)
	Tagger *Identity
	// TaggerDate overwrites the git author date used in case the tag is annotated
	// (optional, default: current time on server)
	TaggerDate *time.Time
}

func (p *CreateCommitTagParams) Validate() error {
	if p == nil {
		return ErrNoParamsProvided
	}

	if p.Name == "" {
		return errors.New("tag name cannot be empty")
	}
	if p.Target == "" {
		return errors.New("target cannot be empty")
	}

	return nil
}

type CreateCommitTagOutput struct {
	CommitTag
}

type DeleteTagParams struct {
	WriteParams
	Name string
}

func (p DeleteTagParams) Validate() error {
	if p.Name == "" {
		return errors.New("tag name cannot be empty")
	}
	return nil
}

//nolint:gocognit
func (s *Service) ListCommitTags(
	ctx context.Context,
	params *ListCommitTagsParams,
) (*ListCommitTagsOutput, error) {
	if err := params.Validate(); err != nil {
		return nil, err
	}

	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)

	// get all required information from git references
	tags, err := s.listCommitTagsLoadReferenceData(ctx, repoPath, params)
	if err != nil {
		return nil, fmt.Errorf("ListCommitTags: failed to get git references: %w", err)
	}

	// get all tag and commit SHAs
	annotatedTagSHAs := make([]string, 0, len(tags))
	commitSHAs := make([]string, len(tags))

	for i, tag := range tags {
		// always set the commit sha (will be overwritten for annotated tags)
		commitSHAs[i] = tag.SHA.String()

		if tag.IsAnnotated {
			annotatedTagSHAs = append(annotatedTagSHAs, tag.SHA.String())
		}
	}

	// populate annotation data for all annotated tags
	if len(annotatedTagSHAs) > 0 {
		var aTags []api.Tag
		aTags, err = s.git.GetAnnotatedTags(ctx, repoPath, annotatedTagSHAs)
		if err != nil {
			return nil, fmt.Errorf("ListCommitTags: failed to get annotated tag: %w", err)
		}

		ai := 0 // index for annotated tags
		ri := 0 // read index for all tags
		wi := 0 // write index for all tags (as we might remove some non-commit tags)
		for ; ri < len(tags); ri++ {
			// always copy the current read element to the latest write position (doesn't mean it's kept)
			tags[wi] = tags[ri]
			commitSHAs[wi] = commitSHAs[ri]

			// keep the tag as is if it's not annotated
			if !tags[ri].IsAnnotated {
				wi++
				continue
			}

			// filter out annotated tags that don't point to commit objects (blobs, trees, nested tags, ...)
			// we don't actually wanna write it, so keep write index
			// TODO: Support proper pagination: https://harness.atlassian.net/browse/CODE-669
			if aTags[ai].TargetType != api.GitObjectTypeCommit {
				ai++
				continue
			}

			// correct the commitSHA for the annotated tag (currently it is the tag sha, not the commit sha)
			commitSHAs[wi] = aTags[ai].TargetSha.String()

			// update tag information with annotation details
			// NOTE: we keep the name from the reference and ignore the annotated name (similar to github)
			tags[wi].Message = aTags[ai].Message
			tags[wi].Title = aTags[ai].Title
			tagger, err := mapSignature(&aTags[ai].Tagger)
			if err != nil {
				return nil, fmt.Errorf("signature mapping error: %w", err)
			}
			tags[wi].Tagger = tagger

			ai++
			wi++
		}

		// truncate slices based on what was removed
		tags = tags[:wi]
		commitSHAs = commitSHAs[:wi]
	}

	// get commits if needed (single call for perf savings: 1s-4s vs 5s-20s)
	if params.IncludeCommit {
		gitCommits, err := s.git.GetCommits(ctx, repoPath, commitSHAs)
		if err != nil {
			return nil, fmt.Errorf("ListCommitTags: failed to get commits: %w", err)
		}

		for i := range gitCommits {
			c, err := mapCommit(gitCommits[i])
			if err != nil {
				return nil, fmt.Errorf("commit mapping error: %w", err)
			}
			tags[i].Commit = c
		}
	}

	return &ListCommitTagsOutput{
		Tags: tags,
	}, nil
}

//nolint:gocognit
func (s *Service) CreateCommitTag(ctx context.Context, params *CreateCommitTagParams) (*CreateCommitTagOutput, error) {
	if err := params.Validate(); err != nil {
		return nil, err
	}

	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)

	targetCommit, err := s.git.GetCommit(ctx, repoPath, params.Target)
	if errors.IsNotFound(err) {
		return nil, errors.NotFound("target '%s' doesn't exist", params.Target)
	}
	if err != nil {
		return nil, fmt.Errorf("CreateCommitTag: failed to get commit id for target '%s': %w", params.Target, err)
	}

	tagName := params.Name
	tagRef := api.GetReferenceFromTagName(tagName)
	var tag *api.Tag

	commitSHA, err := s.git.GetRef(ctx, repoPath, tagRef)
	// TODO: Change GetRef to use errors.NotFound and then remove types.IsNotFoundError(err) below.
	if err != nil && !errors.IsNotFound(err) {
		return nil, fmt.Errorf("CreateCommitTag: failed to verify tag existence: %w", err)
	}
	if err == nil && !commitSHA.IsEmpty() {
		return nil, errors.Conflict("tag '%s' already exists", tagName)
	}

	// create tag request

	tagger := params.Actor
	if params.Tagger != nil {
		tagger = *params.Tagger
	}
	taggerDate := time.Now().UTC()
	if params.TaggerDate != nil {
		taggerDate = *params.TaggerDate
	}

	createTagRequest := &api.CreateTagOptions{
		Message: params.Message,
		Tagger: api.Signature{
			Identity: api.Identity{
				Name:  tagger.Name,
				Email: tagger.Email,
			},
			When: taggerDate,
		},
	}

	// ref updater

	refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, tagRef)
	if err != nil {
		return nil, fmt.Errorf("failed to create ref updater to create the tag: %w", err)
	}

	// create the tag

	err = sharedrepo.Run(ctx, refUpdater, s.tmpDir, repoPath, func(r *sharedrepo.SharedRepo) error {
		if err := s.git.CreateTag(ctx, r.Directory(), tagName, targetCommit.SHA, createTagRequest); err != nil {
			return fmt.Errorf("failed to create tag '%s': %w", tagName, err)
		}

		tag, err = s.git.GetAnnotatedTag(ctx, r.Directory(), tagName)
		if err != nil {
			return fmt.Errorf("failed to read annotated tag after creation: %w", err)
		}

		if err := refUpdater.Init(ctx, sha.Nil, tag.Sha); err != nil {
			return fmt.Errorf("failed to init ref updater: %w", err)
		}

		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("CreateCommitTag: failed to create tag in shared repository: %w", err)
	}

	// prepare response

	var commitTag *CommitTag
	if params.Message != "" {
		tag, err = s.git.GetAnnotatedTag(ctx, repoPath, params.Name)
		if err != nil {
			return nil, fmt.Errorf("failed to read annotated tag after creation: %w", err)
		}
		commitTag = mapAnnotatedTag(tag)
	} else {
		commitTag = &CommitTag{
			Name:        params.Name,
			IsAnnotated: false,
			SHA:         targetCommit.SHA,
		}
	}

	c, err := mapCommit(targetCommit)
	if err != nil {
		return nil, err
	}
	commitTag.Commit = c

	return &CreateCommitTagOutput{CommitTag: *commitTag}, nil
}

func (s *Service) DeleteTag(ctx context.Context, params *DeleteTagParams) error {
	if err := params.Validate(); err != nil {
		return err
	}

	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
	tagRef := api.GetReferenceFromTagName(params.Name)

	refUpdater, err := hook.CreateRefUpdater(s.hookClientFactory, params.EnvVars, repoPath, tagRef)
	if err != nil {
		return fmt.Errorf("failed to create ref updater to delete the tag: %w", err)
	}

	err = refUpdater.Do(ctx, sha.None, sha.Nil) // delete whatever is there
	if errors.IsNotFound(err) {
		return errors.NotFound("tag %q does not exist", params.Name)
	}
	if err != nil {
		return fmt.Errorf("failed to init ref updater: %w", err)
	}

	return nil
}

func (s *Service) listCommitTagsLoadReferenceData(
	ctx context.Context,
	repoPath string,
	params *ListCommitTagsParams,
) ([]CommitTag, error) {
	// TODO: can we be smarter with slice allocation
	tags := make([]CommitTag, 0, 16)
	handler := listCommitTagsWalkReferencesHandler(&tags)
	instructor, _, err := wrapInstructorWithOptionalPagination(
		newInstructorWithObjectTypeFilter(listCommitTagsObjectTypeFilter),
		params.Page,
		params.PageSize,
	)
	if err != nil {
		return nil, errors.InvalidArgument("invalid pagination details: %v", err)
	}

	opts := &api.WalkReferencesOptions{
		Patterns:   createReferenceWalkPatternsFromQuery(gitReferenceNamePrefixTag, params.Query),
		Sort:       mapListCommitTagsSortOption(params.Sort),
		Order:      mapToSortOrder(params.Order),
		Fields:     listCommitTagsRefFields,
		Instructor: instructor,
		// we do post-filtering, so we can't restrict the git output ...
		MaxWalkDistance: 0,
	}

	err = s.git.WalkReferences(ctx, repoPath, handler, opts)
	if err != nil {
		return nil, fmt.Errorf("failed to walk tag references: %w", err)
	}

	log.Ctx(ctx).Trace().Msgf("git api returned %d tags", len(tags))

	return tags, nil
}

func listCommitTagsWalkReferencesHandler(tags *[]CommitTag) api.WalkReferencesHandler {
	return func(e api.WalkReferencesEntry) error {
		fullRefName, ok := e[api.GitReferenceFieldRefName]
		if !ok {
			return fmt.Errorf("entry missing reference name")
		}
		objectSHA, ok := e[api.GitReferenceFieldObjectName]
		if !ok {
			return fmt.Errorf("entry missing object sha")
		}
		objectTypeRaw, ok := e[api.GitReferenceFieldObjectType]
		if !ok {
			return fmt.Errorf("entry missing object type")
		}

		tag := CommitTag{
			Name:        fullRefName[len(gitReferenceNamePrefixTag):],
			SHA:         sha.Must(objectSHA),
			IsAnnotated: objectTypeRaw == string(api.GitObjectTypeTag),
		}

		// TODO: refactor to not use slice pointers?
		*tags = append(*tags, tag)

		return nil
	}
}

func newInstructorWithObjectTypeFilter(filter []api.GitObjectType) api.WalkReferencesInstructor {
	return func(wre api.WalkReferencesEntry) (api.WalkInstruction, error) {
		v, ok := wre[api.GitReferenceFieldObjectType]
		if !ok {
			return api.WalkInstructionStop, fmt.Errorf("ref field for object type is missing")
		}

		// only handle if any of the filters match
		for _, field := range filter {
			if v == string(field) {
				return api.WalkInstructionHandle, nil
			}
		}

		// by default skip
		return api.WalkInstructionSkip, nil
	}
}
