// 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"
	"io"
	"io/fs"
	"os"
	"path"
	"runtime/debug"
	"time"

	"github.com/harness/gitness/errors"
	"github.com/harness/gitness/git/api"
	"github.com/harness/gitness/git/check"
	"github.com/harness/gitness/git/hash"

	gonanoid "github.com/matoous/go-nanoid/v2"
	"github.com/rs/zerolog/log"
)

const (
	// repoGitUIDLength is the length of the generated repo uid.
	repoGitUIDLength = 42

	// repoGitUIDAlphabet is the alphabet used for generating repo uids
	// NOTE: keep it lowercase and alphanumerical to avoid issues with case insensitive filesystems.
	repoGitUIDAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"

	gitReferenceNamePrefixBranch = "refs/heads/"
	gitReferenceNamePrefixTag    = "refs/tags/"

	gitHooksDir = "hooks"

	fileMode700 = 0o700
)

var (
	gitServerHookNames = []string{
		"pre-receive",
		// "update", // update is disabled for performance reasons (called once for every ref)
		"post-receive",
	}
)

type CreateRepositoryParams struct {
	// Create operation is different from all (from user side), as UID doesn't exist yet.
	// Only take actor and envars as input and create WriteParams manually
	RepoUID string
	Actor   Identity
	EnvVars map[string]string

	DefaultBranch string
	Files         []File

	// Committer overwrites the git committer used for committing the files
	// (optional, default: actor)
	Committer *Identity
	// CommitterDate overwrites the git committer date used for committing the files
	// (optional, default: current time on server)
	CommitterDate *time.Time
	// Author overwrites the git author used for committing the files
	// (optional, default: committer)
	Author *Identity
	// AuthorDate overwrites the git author date used for committing the files
	// (optional, default: committer date)
	AuthorDate *time.Time
}

func (p *CreateRepositoryParams) Validate() error {
	return p.Actor.Validate()
}

type CreateRepositoryOutput struct {
	UID string
}

type DeleteRepositoryParams struct {
	WriteParams
}

type GetRepositorySizeParams struct {
	ReadParams
}

type GetRepositorySizeOutput struct {
	// Total size of the repository in KiB.
	Size int64
}

type SyncRepositoryParams struct {
	WriteParams
	Source            string
	CreateIfNotExists bool

	// RefSpecs [OPTIONAL] allows to override the refspecs that are being synced from the remote repository.
	// By default all references present on the remote repository will be fetched (including scm internal ones).
	RefSpecs []string
	// DefaultBranch [OPTIONAL] allows to override the default branch of the repository.
	// If empty, the default branch will be set to match the remote repository's default branch.
	// WARNING: If the remote repo is empty and no value is provided, an api.ErrNoDefaultBranch error is returned.
	DefaultBranch string
}

type SyncRepositoryOutput struct {
	DefaultBranch string
}

type HashRepositoryParams struct {
	ReadParams
	HashType        hash.Type
	AggregationType hash.AggregationType
}

func (p *HashRepositoryParams) Validate() error {
	return p.ReadParams.Validate()
}

type HashRepositoryOutput struct {
	Hash []byte
}
type UpdateDefaultBranchParams struct {
	WriteParams
	// BranchName is the name of the branch (not the full reference).
	BranchName string
}

type GetDefaultBranchParams struct {
	ReadParams
}
type GetDefaultBranchOutput struct {
	// BranchName is the name of the branch (not the full reference).
	BranchName string
}

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

	log := log.Ctx(ctx)

	if params.RepoUID == "" {
		uid, err := NewRepositoryUID()
		if err != nil {
			return nil, fmt.Errorf("failed to create new uid: %w", err)
		}
		params.RepoUID = uid
	}
	log.Info().
		Msgf("Create new git repository with uid '%s' and default branch '%s'", params.RepoUID, params.DefaultBranch)

	writeParams := WriteParams{
		RepoUID: params.RepoUID,
		Actor:   params.Actor,
		EnvVars: params.EnvVars,
	}

	committer := params.Actor
	if params.Committer != nil {
		committer = *params.Committer
	}
	committerDate := time.Now().UTC()
	if params.CommitterDate != nil {
		committerDate = *params.CommitterDate
	}

	author := committer
	if params.Author != nil {
		author = *params.Author
	}
	authorDate := committerDate
	if params.AuthorDate != nil {
		authorDate = *params.AuthorDate
	}

	err := s.createRepositoryInternal(
		ctx,
		&writeParams,
		params.DefaultBranch,
		params.Files,
		&committer,
		committerDate,
		&author,
		authorDate,
	)
	if err != nil {
		return nil, err
	}

	return &CreateRepositoryOutput{
		UID: params.RepoUID,
	}, nil
}

func NewRepositoryUID() (string, error) {
	return gonanoid.Generate(repoGitUIDAlphabet, repoGitUIDLength)
}

func (s *Service) DeleteRepository(ctx context.Context, params *DeleteRepositoryParams) error {
	if err := params.Validate(); err != nil {
		return err
	}
	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)

	if _, err := os.Stat(repoPath); err != nil && errors.Is(err, fs.ErrNotExist) {
		return errors.NotFound("repository path not found")
	} else if err != nil {
		return fmt.Errorf("failed to check the status of the repository %v: %w", repoPath, err)
	}

	return s.DeleteRepositoryBestEffort(ctx, params.RepoUID)
}

func (s *Service) DeleteRepositoryBestEffort(ctx context.Context, repoUID string) error {
	repoPath := getFullPathForRepo(s.reposRoot, repoUID)
	tempPath := path.Join(s.reposGraveyard, repoUID)

	// delete should not fail if repoGraveyard dir does not exist.
	if _, err := os.Stat(s.reposGraveyard); errors.Is(err, fs.ErrNotExist) {
		if errdir := os.MkdirAll(s.reposGraveyard, fileMode700); errdir != nil {
			return fmt.Errorf("clean up dir '%s' doesn't exist and can't be created: %w", s.reposGraveyard, errdir)
		}
	}

	// move current dir to a temp dir (prevent partial deletion)
	err := os.Rename(repoPath, tempPath)
	if errors.Is(err, fs.ErrNotExist) {
		return nil // repository directory doesn't exist - nothing to do
	}
	if err != nil {
		return fmt.Errorf("couldn't move dir %s to %s : %w", repoPath, tempPath, err)
	}

	if err := os.RemoveAll(tempPath); err != nil {
		log.Ctx(ctx).Warn().Err(err).Msgf("failed to delete dir %s from graveyard", tempPath)
	}

	return nil
}

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

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

	// create repo if requested
	_, err := os.Stat(repoPath)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return nil, errors.Internal(err, "failed to create repository")
	}

	if errors.Is(err, fs.ErrNotExist) {
		if !params.CreateIfNotExists {
			return nil, errors.NotFound("repository not found")
		}

		// the default branch doesn't matter for a sync,
		// we create an empty repo and the head will by updated later.
		const syncDefaultBranch = "main"
		if err = s.createRepositoryInternal(
			ctx,
			&params.WriteParams,
			syncDefaultBranch,
			nil,
			nil,
			time.Time{},
			nil,
			time.Time{},
		); err != nil {
			return nil, err
		}
	}

	// sync repo content
	err = s.git.Sync(ctx, repoPath, params.Source, params.RefSpecs)
	if err != nil {
		return nil, fmt.Errorf("failed to sync from source repo: %w", err)
	}

	defaultBranch := params.DefaultBranch
	if defaultBranch == "" {
		// get default branch from remote repo (returns api.ErrNoDefaultBranch if repo is empty!)
		defaultBranch, err = s.git.GetRemoteDefaultBranch(ctx, params.Source)
		if err != nil {
			return nil, fmt.Errorf("failed to get default branch from source repo: %w", err)
		}
	}

	// set default branch
	err = s.git.SetDefaultBranch(ctx, repoPath, defaultBranch, true)
	if err != nil {
		return nil, fmt.Errorf("failed to set default branch of repo: %w", err)
	}

	return &SyncRepositoryOutput{
		DefaultBranch: defaultBranch,
	}, nil
}

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

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

	// add all references of the repo to the channel in a separate go routine, to allow streamed processing.
	// Ensure we cancel the go routine in case we exit the func early.
	goCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	hashChan := make(chan hash.SourceNext)

	go func() {
		// always close channel last before leaving go routine
		defer close(hashChan)
		defer func() {
			if r := recover(); r != nil {
				hashChan <- hash.SourceNext{
					Err: fmt.Errorf("panic received while filling data source: %s", debug.Stack()),
				}
			}
		}()

		// add default branch to hash
		// IMPORTANT: Has to stay as is to ensure hash consistency! (e.g. "refs/heads/main/n")
		defaultBranchRef, err := s.git.GetSymbolicRefHeadRaw(goCtx, repoPath)
		if err != nil {
			hashChan <- hash.SourceNext{
				Err: fmt.Errorf("HashRepository: failed to get default branch: %w", err),
			}
			return
		}

		hashChan <- hash.SourceNext{
			Data: hash.SerializeHead(defaultBranchRef),
		}

		err = s.git.WalkReferences(goCtx, repoPath, func(wre api.WalkReferencesEntry) error {
			ref, ok := wre[api.GitReferenceFieldRefName]
			if !ok {
				return errors.New("ref entry didn't contain the ref name")
			}
			sha, ok := wre[api.GitReferenceFieldObjectName]
			if !ok {
				return errors.New("ref entry didn't contain the ref object sha")
			}

			hashChan <- hash.SourceNext{
				Data: hash.SerializeReference(ref, sha),
			}

			return nil
		}, &api.WalkReferencesOptions{})
		if err != nil {
			hashChan <- hash.SourceNext{
				Err: fmt.Errorf("HashRepository: failed to walk references: %w", err),
			}
		}
	}()

	hasher, err := hash.New(params.HashType, params.AggregationType)
	if err != nil {
		return nil, fmt.Errorf("HashRepository: failed to get new reference hasher: %w", err)
	}
	source := hash.SourceFromChannel(ctx, hashChan)

	res, err := hasher.Hash(source)
	if err != nil {
		return nil, fmt.Errorf("HashRepository: failed to hash repository: %w", err)
	}

	return &HashRepositoryOutput{
		Hash: res,
	}, nil
}

//nolint:gocognit // refactor if needed
func (s *Service) createRepositoryInternal(
	ctx context.Context,
	base *WriteParams,
	defaultBranch string,
	files []File,
	committer *Identity,
	committerDate time.Time,
	author *Identity,
	authorDate time.Time,
) error {
	log := log.Ctx(ctx)
	repoPath := getFullPathForRepo(s.reposRoot, base.RepoUID)
	if _, err := os.Stat(repoPath); !errors.Is(err, fs.ErrNotExist) {
		return errors.Conflict("repository already exists at path %q", repoPath)
	}

	// create repository in repos folder
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	err := s.git.InitRepository(ctx, repoPath, true)
	// delete repo dir on error
	defer func() {
		if err != nil {
			cleanuperr := s.DeleteRepositoryBestEffort(ctx, base.RepoUID)
			if cleanuperr != nil {
				log.Warn().Err(cleanuperr).Msg("failed to cleanup repo dir")
			}
		}
	}()

	if err != nil {
		return fmt.Errorf("createRepositoryInternal: failed to initialize the repository: %w", err)
	}

	// update default branch (currently set to non-existent branch)
	err = s.git.SetDefaultBranch(ctx, repoPath, defaultBranch, true)
	if err != nil {
		return fmt.Errorf("createRepositoryInternal: error updating default branch for repo '%s': %w",
			base.RepoUID, err)
	}

	// only execute file creation logic if files are provided
	//nolint: nestif

	// we need temp dir for cloning
	tempDir, err := os.MkdirTemp("", "*-"+base.RepoUID)
	if err != nil {
		return fmt.Errorf("error creating temp dir for repo %s: %w", base.RepoUID, err)
	}
	defer func(path string) {
		// when repo is successfully created remove temp dir
		errRm := os.RemoveAll(path)
		if errRm != nil {
			log.Err(errRm).Msg("failed to cleanup temporary dir.")
		}
	}(tempDir)

	// Clone repository to temp dir
	if err = s.git.Clone(ctx, repoPath, tempDir, api.CloneRepoOptions{}); err != nil {
		return fmt.Errorf("createRepositoryInternal: failed to clone repo: %w", err)
	}

	// logic for receiving files
	filePaths := make([]string, 0, 16)
	for _, file := range files {
		var filePath string
		filePath, err = s.handleFileUploadIfAvailable(ctx, tempDir, file)
		if errors.Is(err, io.EOF) {
			log.Info().Msg("received stream EOF")
			break
		}
		if err != nil {
			return errors.Internal(err, "failed to receive file %s", file)
		}

		filePaths = append(filePaths, filePath)
	}

	if len(filePaths) > 0 {
		if committer == nil {
			committer = &base.Actor
		}
		if author == nil {
			author = committer
		}
		// NOTE: This creates the branch in origin repo (as it doesn't exist as of now)
		// TODO: this should at least be a constant and not hardcoded?
		if err = s.addFilesAndPush(ctx,
			tempDir,
			filePaths, "HEAD:"+defaultBranch,
			nil,
			author,
			authorDate,
			committer,
			committerDate,
			"origin",
			"initial commit",
		); err != nil {
			return err
		}
	}

	// setup server hook symlinks pointing to configured server hook binary
	// IMPORTANT: Setup hooks after repo creation to avoid issues with externally dependent services.
	for _, hook := range gitServerHookNames {
		hookPath := path.Join(repoPath, gitHooksDir, hook)
		err = os.Symlink(s.gitHookPath, hookPath)
		if err != nil {
			return errors.Internal(err, "failed to setup symlink for hook '%s' ('%s' -> '%s')",
				hook, hookPath, s.gitHookPath)
		}
	}

	log.Info().Msgf("repository created. Path: %s", repoPath)
	return nil
}

// GetRepositorySize accumulates the sizes of counted Git objects.
func (s *Service) GetRepositorySize(
	ctx context.Context,
	params *GetRepositorySizeParams,
) (*GetRepositorySizeOutput, error) {
	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
	count, err := s.git.CountObjects(ctx, repoPath)
	if err != nil {
		return nil, fmt.Errorf("failed to count objects for repo: %w", err)
	}

	return &GetRepositorySizeOutput{
		Size: count.Size + count.SizePack,
	}, nil
}

// GetDefaultBranch returns the default branch of the repo.
func (s *Service) GetDefaultBranch(
	ctx context.Context,
	params *GetDefaultBranchParams,
) (*GetDefaultBranchOutput, error) {
	if err := params.Validate(); err != nil {
		return nil, err
	}

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

	dfltBranch, err := s.git.GetDefaultBranch(ctx, repoPath)
	if err != nil {
		return nil, fmt.Errorf("failed to get repo default branch: %w", err)
	}
	return &GetDefaultBranchOutput{
		BranchName: dfltBranch,
	}, nil
}

// UpdateDefaultBranch updates the default branch of the repo.
func (s *Service) UpdateDefaultBranch(
	ctx context.Context,
	params *UpdateDefaultBranchParams,
) error {
	if err := params.Validate(); err != nil {
		return err
	}
	if err := check.BranchName(params.BranchName); err != nil {
		return errors.InvalidArgument(err.Error())
	}

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

	err := s.git.SetDefaultBranch(ctx, repoPath, params.BranchName, false)
	if err != nil {
		return fmt.Errorf("failed to update repo default branch %q: %w",
			params.BranchName, err)
	}
	return nil
}

type ArchiveParams struct {
	ReadParams
	api.ArchiveParams
}

func (p *ArchiveParams) Validate() error {
	if err := p.ReadParams.Validate(); err != nil {
		return err
	}
	//nolint:revive
	if err := p.ArchiveParams.Validate(); err != nil {
		return err
	}
	return nil
}

func (s *Service) Archive(ctx context.Context, params ArchiveParams, w io.Writer) error {
	if err := params.Validate(); err != nil {
		return err
	}
	repoPath := getFullPathForRepo(s.reposRoot, params.RepoUID)
	err := s.git.Archive(ctx, repoPath, params.ArchiveParams, w)
	if err != nil {
		return fmt.Errorf("failed to run git archive: %w", err)
	}

	return nil
}
