package docker

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/docker/docker/api/types"
	"github.com/sirupsen/logrus"

	"gitlab.com/gitlab-org/gitlab-runner/common"
	"gitlab.com/gitlab-org/gitlab-runner/executors"
	"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/exec"
	"gitlab.com/gitlab-org/gitlab-runner/executors/docker/internal/user"
	"gitlab.com/gitlab-org/gitlab-runner/helpers/docker"
	"gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags"
	"gitlab.com/gitlab-org/gitlab-runner/helpers/limitwriter"
)

const (
	buildContainerType      = "build"
	predefinedContainerType = "predefined"
	stepRunnerContainerType = "step-runner"
)

type commandExecutor struct {
	executor
	helperContainer                 *types.ContainerJSON
	buildContainer                  *types.ContainerJSON
	lock                            sync.Mutex
	terminalWaitForContainerTimeout time.Duration
	stepRunnerContainerOnce         sync.Once
}

func (s *commandExecutor) getBuildContainer() *types.ContainerJSON {
	s.lock.Lock()
	defer s.lock.Unlock()

	return s.buildContainer
}

func (s *commandExecutor) Prepare(options common.ExecutorPrepareOptions) error {
	err := s.executor.Prepare(options)
	if err != nil {
		return err
	}

	s.BuildLogger.Debugln("Starting Docker command...")

	if len(s.BuildShell.DockerCommand) == 0 {
		return errors.New("script is not compatible with Docker")
	}

	_, err = s.getHelperImage()
	if err != nil {
		return err
	}

	_, err = s.getBuildImage()
	if err != nil {
		return err
	}

	if s.isUmaskDisabled() {
		s.BuildLogger.Println("Not using umask - FF_DISABLE_UMASK_FOR_DOCKER_EXECUTOR is set!")
	}

	return nil
}

func (s *commandExecutor) isUmaskDisabled() bool {
	// Not usable with docker-windows executor
	if s.info.OSType == osTypeWindows {
		return false
	}

	if !s.Build.IsFeatureFlagOn(featureflags.DisableUmaskForDockerExecutor) {
		return false
	}

	return true
}

func (s *commandExecutor) Run(cmd common.ExecutorCommand) error {
	if cmd.Predefined {
		// if steps is enabled, run the step-runner container with the helper container. Eventually we can remove the
		// helper execution path.
		if s.Build.UseNativeSteps() {
			var err error
			s.stepRunnerContainerOnce.Do(func() { err = s.runContainer(stepRunnerContainerType, common.ExecutorCommand{Context: cmd.Context}) })
			if err != nil {
				return err
			}
		}
		return s.runContainer(predefinedContainerType, cmd)
	} else {
		return s.runContainer(buildContainerType, cmd)
	}
}

func (s *commandExecutor) runContainer(containerType string, cmd common.ExecutorCommand) error {
	maxAttempts := s.Build.GetExecutorJobSectionAttempts()

	var runErr error
	for attempts := 1; attempts <= maxAttempts; attempts++ {
		if attempts > 1 {
			s.BuildLogger.Infoln(fmt.Sprintf("Retrying %s", cmd.Stage))
		}

		ctr, err := s.requestContainer(containerType)
		if err != nil {
			return err
		}

		s.BuildLogger.Debugln("Executing on", ctr.Name, "the", cmd.Script)
		s.SetCurrentStage(ExecutorStageRun)

		runErr = s.startAndWatchContainer(cmd.Context, ctr.ID, bytes.NewBufferString(cmd.Script))
		if !docker.IsErrNotFound(runErr) {
			return runErr
		}

		s.BuildLogger.Errorln(fmt.Sprintf("Container %q not found or removed. Will retry...", ctr.ID))
	}

	if runErr != nil && maxAttempts > 1 {
		s.BuildLogger.Errorln("Execution attempts exceeded")
	}

	return runErr
}

func (s *commandExecutor) requestContainer(containerType string) (*types.ContainerJSON, error) {
	switch containerType {
	case buildContainerType:
		return s.requestBuildContainer()
	case predefinedContainerType:
		return s.requestHelperContainer()
	case stepRunnerContainerType:
		return s.requestStepRunnerContainer()
	default:
		return nil, fmt.Errorf("invalid container-type %q", containerType)
	}
}

func (s *commandExecutor) hasExistingContainer(containerType string, container *types.ContainerJSON) bool {
	if container == nil {
		return false
	}

	_, err := s.client.ContainerInspect(s.Context, container.ID)
	if err == nil {
		return true
	}

	if docker.IsErrNotFound(err) {
		return false
	}

	s.BuildLogger.Warningln("Failed to inspect", containerType, "container", container.ID, err.Error())

	return false
}

func (s *commandExecutor) requestHelperContainer() (*types.ContainerJSON, error) {
	if s.hasExistingContainer(predefinedContainerType, s.helperContainer) {
		return s.helperContainer, nil
	}

	prebuildImage, err := s.getHelperImage()
	if err != nil {
		return nil, err
	}

	buildImage := common.Image{
		Name: prebuildImage.ID,
	}

	s.helperContainer, err = s.createContainer(
		predefinedContainerType,
		buildImage,
		[]string{prebuildImage.ID},
		newDefaultContainerConfigurator(&s.executor, predefinedContainerType, buildImage, s.getHelperImageCmd(), []string{prebuildImage.ID}),
	)
	if err != nil {
		return nil, err
	}

	return s.helperContainer, nil
}

func (s *commandExecutor) getHelperImageCmd() []string {
	if s.isUmaskDisabled() {
		return []string{"/bin/bash"}
	}

	return s.helperImageInfo.Cmd
}

var stepRunnerServiceCommand = []string{"step-runner", "serve"}

func (s *commandExecutor) requestBuildContainer() (*types.ContainerJSON, error) {
	s.lock.Lock()
	defer s.lock.Unlock()

	if s.hasExistingContainer(buildContainerType, s.buildContainer) {
		return s.buildContainer, nil
	}

	// Overwrite the build container command if using steps
	cmd := s.BuildShell.DockerCommand
	if s.Build.UseNativeSteps() {
		cmd = stepRunnerServiceCommand
	}

	var err error
	s.buildContainer, err = s.createContainer(
		buildContainerType,
		s.Build.Image,
		[]string{},
		newDefaultContainerConfigurator(&s.executor, buildContainerType, s.Build.Image, cmd, []string{}),
	)
	if err != nil {
		return nil, err
	}

	err = s.changeFilesOwnership()
	if err != nil {
		return nil, err
	}

	return s.buildContainer, nil
}

func (s *commandExecutor) changeFilesOwnership() error {
	if !s.isUmaskDisabled() {
		return nil
	}

	dockerExec := exec.NewDocker(s.Context, s.client, s.waiter, s.Build.Log())
	inspect := user.NewInspect(s.client, dockerExec)
	imageSHA := s.buildContainer.Image
	imageName := s.Build.Image.Name

	log := s.Build.Log().WithFields(logrus.Fields{
		"imageSHA":  imageSHA,
		"imageName": imageName,
	})
	log.Debug("Checking if image runs with root user")

	usesRoot, err := inspect.IsRoot(s.Context, imageSHA)
	if err != nil {
		return fmt.Errorf("checking if image %q runs as root: %w", imageName, err)
	}

	if usesRoot {
		log.Debug("Image uses root user")
		return nil
	}

	log.Debug("Image doesn't use root user")

	uid, gid, err := getUIDandGID(s.Context, log, inspect, s.buildContainer.ID, imageSHA)
	if err != nil {
		return err
	}

	if uid == 0 {
		return nil
	}

	return s.executeChown(dockerExec, uid, gid)
}

func getUIDandGID(
	ctx context.Context,
	log logrus.FieldLogger,
	inspect user.Inspect,
	buildContainerID string,
	imageSHA string,
) (int, int, error) {
	containerLog := log.WithField("container", buildContainerID)
	containerLog.Debug("Getting the UID of the container")

	uid, err := inspect.UID(ctx, buildContainerID)
	if err != nil {
		return 0, 0, fmt.Errorf("checking %q image UID: %w", imageSHA, err)
	}

	containerLog.Debugf("Container UID=%d", uid)
	containerLog.Debug("Getting the GID of the container")

	gid, err := inspect.GID(ctx, buildContainerID)
	if err != nil {
		return 0, 0, fmt.Errorf("checking %q image GID: %w", imageSHA, err)
	}

	containerLog.Debugf("Container GID=%d", gid)

	return uid, gid, err
}

func (s *commandExecutor) executeChown(dockerExec exec.Docker, uid int, gid int) error {
	c, err := s.requestHelperContainer()
	if err != nil {
		return fmt.Errorf("requesting new predefined container: %w", err)
	}

	err = s.executeChownOnDir(c, dockerExec, uid, gid, s.Build.FullProjectDir())
	if err != nil {
		return err
	}

	err = s.executeChownOnDir(c, dockerExec, uid, gid, s.Build.TmpProjectDir())
	if err != nil {
		return err
	}

	return nil
}

func (s *commandExecutor) executeChownOnDir(
	c *types.ContainerJSON,
	dockerExec exec.Docker,
	uid int,
	gid int,
	dir string,
) error {
	s.BuildLogger.Println(fmt.Sprintf("Changing ownership of files at %q to %d:%d", dir, uid, gid))

	output := new(bytes.Buffer)
	// limit how much data we read from the container log to
	// avoid memory exhaustion
	lw := limitwriter.New(output, 1024)
	streams := exec.IOStreams{
		Stdin:  strings.NewReader(fmt.Sprintf("chown -RP -- %d:%d %q", uid, gid, dir)),
		Stderr: lw,
		Stdout: lw,
	}

	err := dockerExec.Exec(s.Context, c.ID, streams, nil)

	log := s.Build.Log().WithField("updatedDir", dir)
	log.WithField("output", output.String()).Debug("Changing ownership of files")

	if err != nil {
		log.WithError(err).Error("Failed to change ownership of files")
	}

	return nil
}

func (s *commandExecutor) GetMetricsSelector() string {
	return fmt.Sprintf("instance=%q", s.executor.info.Name)
}

func init() {
	options := executors.ExecutorOptions{
		DefaultCustomBuildsDirEnabled: true,
		DefaultSafeDirectoryCheckout:  true,
		DefaultBuildsDir:              "/builds",
		DefaultCacheDir:               "/cache",
		SharedBuildsDir:               false,
		Shell: common.ShellScriptInfo{
			Shell:         "bash",
			Type:          common.NormalShell,
			RunnerCommand: "/usr/bin/gitlab-runner-helper",
		},
		ShowHostname: true,
	}

	creator := func() common.Executor {
		e := &commandExecutor{
			executor: executor{
				AbstractExecutor: executors.AbstractExecutor{
					ExecutorOptions: options,
				},
			},
		}

		e.SetCurrentStage(common.ExecutorStageCreated)
		return e
	}

	featuresUpdater := func(features *common.FeaturesInfo) {
		features.Variables = true
		features.Image = true
		features.Services = true
		features.Session = true
		features.Terminal = true
		features.ServiceVariables = true
		features.ServiceMultipleAliases = true
		features.ImageExecutorOpts = true
		features.ServiceExecutorOpts = true
		features.NativeStepsIntegration = true
	}

	common.RegisterExecutorProvider("docker", executors.DefaultExecutorProvider{
		Creator:          creator,
		FeaturesUpdater:  featuresUpdater,
		ConfigUpdater:    configUpdater,
		DefaultShellName: options.Shell.Shell,
	})

	common.RegisterExecutorProvider("docker-windows", executors.DefaultExecutorProvider{
		Creator:          creator,
		FeaturesUpdater:  featuresUpdater,
		ConfigUpdater:    configUpdater,
		DefaultShellName: options.Shell.Shell,
	})
}
