package git

import (
	"context"
	"fmt"
	"io"
	"os"
	"regexp"
	"strings"

	"gitlab.com/gitlab-org/gitaly/v16/internal/command"
	"gitlab.com/gitlab-org/gitaly/v16/internal/featureflag"
	"gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/config"
	"gitlab.com/gitlab-org/gitaly/v16/internal/gitaly/storage"
	"gitlab.com/gitlab-org/gitaly/v16/internal/structerr"
	"gitlab.com/gitlab-org/gitaly/v16/internal/x509"
	"gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb"
	"gitlab.com/gitlab-org/labkit/correlation"
	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/proto"
)

const (
	// InternalGitalyURL is a special URL that indicates Gitaly wants to push to or fetch from
	// another internal Gitaly instance.
	InternalGitalyURL = "ssh://gitaly/internal.git"
)

var flagRegex = regexp.MustCompile(`^(-|--)[[:alnum:]]`)

// GlobalOption is an interface for all options which can be globally applied
// to git commands. This is the command-inspecific part before the actual
// command that's being run, e.g. the `-c` part in `git -c foo.bar=value
// command`.
type GlobalOption interface {
	GlobalArgs() ([]string, error)
}

// Option is a git command line flag with validation logic
type Option interface {
	OptionArgs() ([]string, error)
}

// ConfigPair is a GlobalOption that can be passed to Git commands to inject per-command config
// entries via the `git -c` switch.
type ConfigPair = config.GitConfig

// ConfigPairsToGitEnvironment converts the given config pairs into a set of environment variables
// that can be injected into a Git executable.
func ConfigPairsToGitEnvironment(configPairs []ConfigPair) []string {
	env := make([]string, 0, len(configPairs)*2+1)

	for i, configPair := range configPairs {
		env = append(env,
			fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, configPair.Key),
			fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, configPair.Value),
		)
	}

	return append(env, fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(configPairs)))
}

// Flag is a single token optional command line argument that enables or
// disables functionality (e.g. "-L")
type Flag struct {
	Name string
}

// GlobalArgs returns the arguments for the given flag, which should typically
// only be the flag itself. It returns an error if the flag is not sanitary.
func (f Flag) GlobalArgs() ([]string, error) {
	return f.OptionArgs()
}

// OptionArgs returns an error if the flag is not sanitary
func (f Flag) OptionArgs() ([]string, error) {
	if !flagRegex.MatchString(f.Name) {
		return nil, fmt.Errorf("flag %q failed regex validation: %w", f.Name, ErrInvalidArg)
	}
	return []string{f.Name}, nil
}

// ValueFlag is an optional command line argument that is comprised of pair of
// tokens (e.g. "-n 50")
type ValueFlag struct {
	Name  string
	Value string
}

// GlobalArgs returns the arguments for the given value flag, which should
// typically be two arguments: the flag and its value. It returns an error if the value flag is not sanitary.
func (vf ValueFlag) GlobalArgs() ([]string, error) {
	return vf.OptionArgs()
}

// OptionArgs returns an error if the flag is not sanitary
func (vf ValueFlag) OptionArgs() ([]string, error) {
	if !flagRegex.MatchString(vf.Name) {
		return nil, fmt.Errorf("value flag %q failed regex validation: %w", vf.Name, ErrInvalidArg)
	}
	return []string{vf.Name, vf.Value}, nil
}

// ConvertGlobalOptions converts a protobuf message to a CmdOpt.
func ConvertGlobalOptions(options *gitalypb.GlobalOptions) []CmdOpt {
	if options != nil && options.GetLiteralPathspecs() {
		return []CmdOpt{
			WithEnv("GIT_LITERAL_PATHSPECS=1"),
		}
	}

	return nil
}

// ConvertConfigOptions converts `<key>=<value>` config entries into `ConfigPairs`.
func ConvertConfigOptions(options []string) ([]ConfigPair, error) {
	configPairs := make([]ConfigPair, len(options))

	for i, option := range options {
		configPair := strings.SplitN(option, "=", 2)
		if len(configPair) != 2 {
			return nil, fmt.Errorf("cannot convert invalid config key: %q", option)
		}

		configPairs[i] = ConfigPair{Key: configPair[0], Value: configPair[1]}
	}

	return configPairs, nil
}

type cmdCfg struct {
	env             []string
	globals         []GlobalOption
	commandOpts     []command.Option
	hooksConfigured bool
	worktreePath    string
}

// CmdOpt is an option for running a command
type CmdOpt func(context.Context, config.Cfg, CommandFactory, *cmdCfg) error

// WithStdin sets the command's stdin. Pass `command.SetupStdin` to make the
// command suitable for `Write()`ing to.
func WithStdin(r io.Reader) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithStdin(r))
		return nil
	}
}

// WithSetupStdin sets up the command so that it can be `Write()`en to.
func WithSetupStdin() CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithSetupStdin())
		return nil
	}
}

// WithStdout sets the command's stdout.
func WithStdout(w io.Writer) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithStdout(w))
		return nil
	}
}

// WithSetupStdout sets up the command so that it can be `Read()` from.
func WithSetupStdout() CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithSetupStdout())
		return nil
	}
}

// WithStderr sets the command's stderr.
func WithStderr(w io.Writer) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithStderr(w))
		return nil
	}
}

// WithEnv adds environment variables to the command.
func WithEnv(envs ...string) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.env = append(c.env, envs...)
		return nil
	}
}

// WithConfig adds git configuration entries to the command.
func WithConfig(configPairs ...ConfigPair) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		for _, configPair := range configPairs {
			c.globals = append(c.globals, configPair)
		}
		return nil
	}
}

// WithConfigEnv adds git configuration entries to the command's environment. This should be used
// in place of `WithConfig()` in case config entries may contain secrets which shouldn't leak e.g.
// via the process's command line.
func WithConfigEnv(configPairs ...ConfigPair) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.env = append(c.env, ConfigPairsToGitEnvironment(configPairs)...)
		return nil
	}
}

// WithGlobalOption adds the global options to the command. These are universal options which work
// across all git commands.
func WithGlobalOption(opts ...GlobalOption) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.globals = append(c.globals, opts...)
		return nil
	}
}

// WithInternalFetch returns an option which sets up git-fetch(1) to fetch from another internal
// Gitaly node.
func WithInternalFetch(req *gitalypb.SSHUploadPackRequest) CmdOpt {
	return withInternalFetch(req, false)
}

// WithInternalFetchWithSidechannel returns an option which sets up git-fetch(1) to fetch from
// another internal Gitaly node. In contrast to WithInternalFetch, this will call
// SSHUploadPackWithSidechannel instead of SSHUploadPack.
func WithInternalFetchWithSidechannel(req *gitalypb.SSHUploadPackWithSidechannelRequest) CmdOpt {
	return withInternalFetch(req, true)
}

// WithGitalyGPG sets gpg.prgoram to gitaly-gpg for commit signing
func WithGitalyGPG() CmdOpt {
	return func(_ context.Context, cfg config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.globals = append(
			c.globals,
			&ConfigPair{
				Key:   "gpg.program",
				Value: cfg.BinaryPath("gitaly-gpg"),
			},
		)

		return nil
	}
}

type repoScopedRequest interface {
	proto.Message
	GetRepository() *gitalypb.Repository
}

func withInternalFetch(req repoScopedRequest, withSidechannel bool) func(ctx context.Context, cfg config.Cfg, _ CommandFactory, c *cmdCfg) error {
	return func(ctx context.Context, cfg config.Cfg, _ CommandFactory, c *cmdCfg) error {
		payload, err := protojson.Marshal(req)
		if err != nil {
			return structerr.NewInternal("marshalling payload failed: %w", err)
		}

		serversInfo, err := storage.ExtractGitalyServers(ctx)
		if err != nil {
			return structerr.NewInternal("extracting Gitaly servers: %w", err)
		}

		storageInfo, ok := serversInfo[req.GetRepository().GetStorageName()]
		if !ok {
			return structerr.NewInvalidArgument("no storage info for %q", req.GetRepository().GetStorageName())
		}

		if storageInfo.Address == "" {
			return structerr.NewInvalidArgument("empty Gitaly address")
		}

		var flagsWithValue []string
		for flag, value := range featureflag.FromContext(ctx) {
			flagsWithValue = append(flagsWithValue, flag.FormatWithValue(value))
		}

		c.env = append(c.env,
			fmt.Sprintf("GITALY_PAYLOAD=%s", payload),
			fmt.Sprintf("GIT_SSH_COMMAND=%s %s", cfg.BinaryPath("gitaly-ssh"), "upload-pack"),
			fmt.Sprintf("GITALY_ADDRESS=%s", storageInfo.Address),
			fmt.Sprintf("GITALY_TOKEN=%s", storageInfo.Token),
			fmt.Sprintf("GITALY_FEATUREFLAGS=%s", strings.Join(flagsWithValue, ",")),
			fmt.Sprintf("CORRELATION_ID=%s", correlation.ExtractFromContextOrGenerate(ctx)),
			// please see https://github.com/git/git/commit/0da0e49ba12225684b75e86a4c9344ad121652cb for mote details
			"GIT_SSH_VARIANT=simple",
			// Pass through the SSL_CERT_* variables that indicate which
			// system certs to trust
			fmt.Sprintf("%s=%s", x509.SSLCertDir, os.Getenv(x509.SSLCertDir)),
			fmt.Sprintf("%s=%s", x509.SSLCertFile, os.Getenv(x509.SSLCertFile)),
		)

		if withSidechannel {
			c.env = append(c.env, "GITALY_USE_SIDECHANNEL=1")
		}

		return nil
	}
}

// WithFinalizer sets up the finalizer to be run when the command is being wrapped up. It will be
// called after `Wait()` has returned.
func WithFinalizer(finalizer func(context.Context, *command.Command)) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.commandOpts = append(c.commandOpts, command.WithFinalizer(finalizer))
		return nil
	}
}

// WithWorktree sets up the Git command to run in the given worktree path by using the `-C` switch.
func WithWorktree(worktreePath string) CmdOpt {
	return func(_ context.Context, _ config.Cfg, _ CommandFactory, c *cmdCfg) error {
		c.worktreePath = worktreePath
		return nil
	}
}
