package protobuf

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strings"

	"github.com/openshift/api/tools/codegen/pkg/utils"

	kerrors "k8s.io/apimachinery/pkg/util/errors"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/code-generator/cmd/go-to-protobuf/protobuf"
)

const (
	defaultDropEmbeddedFields   = "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta"
	defaultAPIMachineryPackages = "-k8s.io/apimachinery/pkg/util/intstr,-k8s.io/apimachinery/pkg/api/resource,-k8s.io/apimachinery/pkg/runtime/schema,-k8s.io/apimachinery/pkg/runtime,-k8s.io/apimachinery/pkg/apis/meta/v1,-k8s.io/apimachinery/pkg/apis/meta/v1beta1,-k8s.io/api/core/v1,-k8s.io/api/rbac/v1"
)

var (
	// generatedFiles are the file names that are generated by the protobuf generator.
	generatedFiles = sets.New("generated.pb.go", "generated.proto")
)

// generateProtobufFunctions generates the Protobuf functions for the given API package paths.
func generateProtobufFunctions(path, packagePath, headerFilePath string, verify bool) error {
	wd, err := os.Getwd()
	if err != nil {
		return fmt.Errorf("failed to get working directory: %w", err)
	}

	pathPrefix, err := getPathPrefix(wd, path, packagePath)
	if err != nil {
		return fmt.Errorf("failed to get path prefix: %w", err)
	}

	vendor, err := findDirectoryInParent(path, "vendor")
	if err != nil {
		return fmt.Errorf("failed to find vendor directory: %w", err)
	}

	generator := protobuf.Generator{
		GoHeaderFile:         headerFilePath,
		APIMachineryPackages: defaultAPIMachineryPackages,
		DropEmbeddedFields:   defaultDropEmbeddedFields,
		Packages:             packagePath,
		OutputDir:            strings.TrimSuffix(wd, pathPrefix),
		ProtoImport:          []string{vendor},
		DropGogoGo:           true,
	}

	// The generator doesn't return an error an instead `log.Fatal`s when there's an issue.
	// Short of rewriting it, this is the best we can do.
	protobuf.Run(&generator)

	if verify {
		if err := verifyFiles(path); err != nil {
			return fmt.Errorf("failed to verify generated files: %w", err)
		}
	}

	return nil
}

// getPathPrefix calculates the pathPrefix that needs to be trimmed from the current working directory.
// The generator will generate the output file to the current working directory plus the
// package path name.
// This function calculates what is needed to be trimmed from the working directory path name to
// make sure the output ends up in the correct directory.
// Eg. if the package is github.com/openshift.io/api/machine/v1,
//   - the current working directory is /home/user/go/src, then the path would be
//     github.com/openshift.io/api/machine/v1 and so the output would be the empty string.
//   - the current working directory is /home/user/go/src/github.com/openshift.io/api, then
//     the path would be machine/v1 and so the output would be github.com/openshift.io/api.
func getPathPrefix(wd, path, packagePath string) (string, error) {
	relPath, err := filepath.Rel(wd, path)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path: %w", err)
	}

	if strings.HasPrefix(relPath, "../") {
		return "", errors.New("cannot generate deepcopy functions for a path outside of the working directory")
	}

	if !strings.HasSuffix(packagePath, relPath) {
		return "", fmt.Errorf("package path %s does not match with input path %s, expected package path to end with input path", packagePath, relPath)
	}

	return filepath.Clean(strings.TrimSuffix(packagePath, relPath)), nil
}

// findDirectoryInParent finds the directory with the given name in the parent directories of the given path.
// It returns the path to the directory if found, otherwise it returns an error.
func findDirectoryInParent(path string, target string) (string, error) {
	for path != "." && path != "/" {
		if _, err := os.Stat(filepath.Join(path, target)); err == nil {
			return filepath.Join(path, target), nil
		}
		path = filepath.Dir(path)
	}

	return "", errors.New("could not find vendor directory")
}

// verifyFiles verifies that the generated files are the same as the previously generated files.
func verifyFiles(outputDir string) error {
	errs := []error{}

	if err := filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return fmt.Errorf("failed to walk directory: %w", err)
		}

		if d.IsDir() {
			return nil
		}

		if err := verifyFile(outputDir, path); err != nil {
			errs = append(errs, fmt.Errorf("failed to verify file %s: %w", path, err))
		}

		return nil
	}); err != nil {
		return fmt.Errorf("failed to walk directory: %w", err)
	}

	if len(errs) > 0 {
		return kerrors.NewAggregate(errs)
	}

	return nil
}

// verifyFile verifies that the generated file is the same as the previously generated file.
func verifyFile(outputDir, path string) error {
	if filepath.Ext(path) != ".go" && filepath.Ext(path) != ".proto" {
		// Only go and proto files are generated.
		return nil
	}

	filename := filepath.Base(path)

	diff, err := utils.GitDiff(outputDir, filename)
	if err != nil {
		return fmt.Errorf("could not calculate git diff: %w", err)
	}

	if len(diff) > 0 && (generatedFiles.Has(filepath.Base(filename)) || diffContainsProtoChanges(diff)) {
		return fmt.Errorf("protobuf generation for %s is out of date, please regenerate the protobuf code:\n%s", outputDir, diff)
	}

	return nil
}

func diffContainsProtoChanges(diff string) bool {
	// When the protobuf generator adds or changes protobuf tags, they will show up alongside the types.
	// Any line containing `protobuf` is considered a change.
	return strings.Contains(diff, "protobuf")
}
