package task

import (
	"context"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"time"

	"github.com/juju/errors"
	"github.com/mholt/archiver"
	"github.com/ngaut/log"
	"github.com/pingcap/schrodinger/buildbot/config"
	"github.com/pingcap/schrodinger/pkg/storage"
	"github.com/pingcap/schrodinger/pkg/types"
	"github.com/pingcap/schrodinger/util"
)

const (
	// used to build tidb / pd image
	dbDockerfile = `
FROM %s/alpine-glibc-schrodinger-%s
LABEL buildTime="%s"
COPY agent /
RUN mkdir -p /preset_daemon/%s/bin/
COPY %s /preset_daemon/%s/bin/%s
`

	// used to build tikv image without fuse serve
	kvDockerfile = `
FROM %s/alpine-glibc-schrodinger-%s
LABEL buildTime="%s"
COPY agent /
RUN mkdir -p /preset_daemon/%s/bin/
COPY tikv-ctl /preset_daemon/%s/bin/
COPY %s /preset_daemon/%s/bin/%s
`

	// used to build tikv image with fuse serve
	kvWithFuseDockerfile = `
FROM %s/alpine-glibc-schrodinger-%s-fuse
LABEL buildTime="%s"
COPY agent /
RUN mkdir -p /preset_daemon/%s/bin/
COPY tikv-ctl /preset_daemon/%s/bin/
COPY %s /preset_daemon/%s/bin/%s
`
)

// TiDBTask builds the image of components,
// components: tidb / tikv / pd
type TiDBTask struct {
	task  *types.BuildTask
	store *storage.Storage
	cfg   config.BaseConfig

	imageIsBuild bool
}

// NewTiDBTask returns an instance of TiDBTask
func NewTiDBTask(
	task *types.BuildTask,
	store *storage.Storage,
	cfg config.BaseConfig,
) *TiDBTask {
	return &TiDBTask{
		task:  task,
		store: store,
		cfg:   cfg,
	}
}

// Run runs BuildTask.
// build tidb / pd / tikv image and push image to registry.
func (t *TiDBTask) Run(ctx context.Context) error {
	if err := t.buildImage(ctx); err != nil {
		return errors.Trace(err)
	}

	return nil
}

// Clean cleans up residual data after the task is finished.
// this function just cleans up the image to prevent the storage space fulled.
func (t *TiDBTask) Clean(ctx context.Context) error {
	// If the image is build in this task, we need to cleans up the the image from local.
	if t.imageIsBuild {
		if err := t.cleanLocalImage(ctx); err != nil {
			return errors.Trace(err)
		}
	}

	return nil
}

func (t *TiDBTask) buildImage(ctx context.Context) error {
	var (
		err   error
		info  string
		exist bool
	)

	defer func() {
		if err != nil {
			t.task.Status = types.TaskFailed
			log.Warnf("[task %d] run task build log: %s", t.task.ID, info)
		}

		t.task.Information.BuildLog = info
		if err := t.store.UpdateBuildTask(t.task); err != nil {
			log.Errorf("[task %d] update task %+v failed, error: %v", t.task.ID, t.task, err)
		}
	}()

	if err = os.MkdirAll(t.task.Compiler.BuildPath, os.ModePerm); err != nil {
		info = fmt.Sprintf("make dir %s failed, error: %v", t.task.Compiler.BuildPath, err)
		return errors.Trace(err)
	}

	exist, err = checkImageIfExist(ctx, t.task.Information.Image, t.cfg.DockerRegistryAPI)
	if err != nil {
		info = fmt.Sprintf("check image %s failed, error: %v", t.task.Information.Image, err)
		return errors.Trace(err)
	}

	if exist {
		log.Infof("[task %d] docker image %s already exist", t.task.ID, t.task.Information.Image)
		if !t.task.Information.Force {
			log.Infof("[task %d] don't force to build image %s, end building image operation",
				t.task.ID, t.task.Information.Image)
			t.task.Status = types.TaskFinished
			return nil
		}
	}

	log.Infof("[task %d] docker image %s not found, build it", t.task.ID, t.task.Information.Image)

	if err = t.buildImageAction(ctx, t.task); err != nil {
		info = fmt.Sprintf("build image %s failed, error: %v", t.task.Information.Image, err)
		return errors.Trace(err)
	}

	if err = t.pushImageAction(ctx); err != nil {
		info = fmt.Sprintf("push image %s failed, error: %v", t.task.Information.Image, err)
		return errors.Trace(err)
	}

	t.task.Status = types.TaskFinished
	t.imageIsBuild = true

	return nil
}

func (t *TiDBTask) buildImageAction(ctx context.Context, task *types.BuildTask) error {
	if err := prepareBinary(ctx, task, t.cfg.DownloadAddr, t.cfg.AgentBinaryAddr); err != nil {
		return errors.Trace(err)
	}

	dockerfileDestPath := filepath.Join(task.Compiler.BuildPath, "bin")
	if err := prepareDockerfile(task, t.cfg.DockerRegistryAddr, dockerfileDestPath); err != nil {
		return errors.Trace(err)
	}

	// exec docker build image
	dockerBuildCmd := fmt.Sprintf("docker build -t %s %s --network=host",
		t.task.Information.Image, dockerfileDestPath)
	if err := runRetryShell(ctx, dockerBuildCmd, 3, 5*time.Second); err != nil {
		return errors.Trace(err)
	}

	return nil
}

func (t *TiDBTask) pushImageAction(ctx context.Context) error {
	// exec docker push image
	dockerPushCmd := fmt.Sprintf("docker push %s", t.task.Information.Image)
	if err := runRetryShell(ctx, dockerPushCmd, 3, 5*time.Second); err != nil {
		return errors.Trace(err)
	}

	return nil
}

func (t *TiDBTask) cleanLocalImage(ctx context.Context) error {
	// exec docker rmi image
	dockerRmiCmd := fmt.Sprintf("docker rmi %s", t.task.Information.Image)

	err := util.RetryOnError2(ctx, 3, 5*time.Second, func() (error, bool) {
		info, err := exec.Command("/bin/sh", "-c", dockerRmiCmd).CombinedOutput()
		if err != nil {
			if strings.Contains(err.Error(), "No such image") ||
				strings.Contains(string(info), "conflict:") {
				log.Warnf("run shell %s, output: %s, error: %v", dockerRmiCmd, info, err)
				return nil, true
			}

			return errors.Errorf("fail to run shell: %s, output: %s, error: %v",
				dockerRmiCmd, info, err), false
		}

		return nil, true
	})

	if err != nil {
		return errors.Trace(err)
	}

	return nil
}

func runRetryShell(ctx context.Context, cmd string, retryCount int, interval time.Duration) error {
	err := util.RetryOnError2(ctx, retryCount, interval, func() (error, bool) {
		info, err := exec.Command("/bin/sh", "-c", cmd).CombinedOutput()
		if err != nil {
			return errors.Errorf("run shell %s failed, output: %s, error: %v",
				cmd, string(info), err), false
		}

		return nil, true
	})

	if err != nil {
		return errors.Trace(err)
	}

	return nil
}

// prepare binary used in image,
// prepare component binary and agent binary
func prepareBinary(ctx context.Context, task *types.BuildTask, downAddr, agentAddr string) error {
	if err := prepareComponentBinary(ctx, task, downAddr); err != nil {
		return errors.Trace(err)
	}

	// TODO: remove agent from image in the future
	if err := prepareAgentBinary(ctx, task, agentAddr); err != nil {
		return errors.Trace(err)
	}

	// special action for tikv / pd / tidb
	if err := setBuildTime(task); err != nil {
		return errors.Trace(err)
	}

	return nil
}

// create a new file named `Dockerfile` in `destPath`,
func prepareDockerfile(task *types.BuildTask, registryAddr string, destPath string) error {
	var (
		targetBinaryName  string
		sourceBinaryName  string
		binaryFilePath    string
		dockerfileContent []byte
	)

	targetBinaryName = fmt.Sprintf("%s-server", task.Information.Repo)
	sourceBinaryName = targetBinaryName

	switch task.Information.Repo {
	case "tidb", "pd":
		if !util.IsEmpty(task.Information.CompileOption) {
			sourceBinaryName = fmt.Sprintf("%s-%s", targetBinaryName,
				strings.TrimSpace(task.Information.CompileOption))
		}

		binaryFilePath = filepath.Join(destPath, sourceBinaryName)

		dockerfileContent = []byte(fmt.Sprintf(dbDockerfile, registryAddr,
			task.Information.Repo, task.Information.BuildTime,
			task.Information.Repo, sourceBinaryName,
			task.Information.Repo, targetBinaryName))
	case "tikv":
		// sometimes we need to use tikv-server-failpoint instead of tikv-server,
		if !util.IsEmpty(task.Information.CompileOption) {
			sourceBinaryName = fmt.Sprintf("%s-%s", targetBinaryName,
				strings.TrimSpace(task.Information.CompileOption))
		}

		binaryFilePath = filepath.Join(task.Compiler.BuildPath, "bin", sourceBinaryName)

		dockerfile := kvDockerfile
		if task.Information.Fuse {
			dockerfile = kvWithFuseDockerfile
		}
		dockerfileContent = []byte(fmt.Sprintf(dockerfile, registryAddr,
			task.Information.Repo, task.Information.BuildTime,
			task.Information.Repo, task.Information.Repo,
			sourceBinaryName, task.Information.Repo, targetBinaryName))
	default:
		return errors.NotSupportedf("%s", task.Information.Repo)
	}

	if !util.IsFileExist(binaryFilePath) {
		return errors.NotFoundf("%s binary %s", task.Information.Repo, binaryFilePath)
	}

	// create and write data to file named `Dockerfile`,
	// this Dockerfile will be used to build image
	dockerfileName := filepath.Join(destPath, "Dockerfile")
	if err := ioutil.WriteFile(dockerfileName, dockerfileContent, 0777); err != nil {
		return errors.Errorf("create and write Dockerfile to %s failed, error: %v",
			dockerfileName, err)
	}

	return nil
}

func setBuildTime(task *types.BuildTask) error {
	binaryName := task.Information.Repo + "-server"

	if !util.IsEmpty(task.Information.CompileOption) {
		binaryName += "-" + strings.TrimSpace(task.Information.CompileOption)
	}

	cmd := fmt.Sprintf("%s/bin/%s", task.Compiler.BuildPath, binaryName)

	versionCmd := "-V"

	// After TiKV release-3.0, using "-V" to get build time is no longer valid
	if task.Information.Repo == "tikv" {
		versionCmd = "--version"
	}

	info, err := exec.Command(cmd, versionCmd).CombinedOutput()
	if err != nil {
		return errors.Trace(err)
	}

	for _, line := range strings.Split(string(info), "\n") {
		if strings.HasPrefix(line, "UTC Build Time:") {
			arr := strings.Split(line, "Time:")
			if len(arr) != 2 {
				return errors.NotValidf("build time %s", line)
			}
			buildTime := strings.Trim(arr[1], "\t ")
			log.Infof("[task %d] build time is %s", task.ID, buildTime)

			task.Information.BuildTime = buildTime

			return nil
		}
	}

	return errors.NotFoundf("build time")
}

func prepareComponentBinary(ctx context.Context, task *types.BuildTask, downAddr string) error {
	file, err := downloadComponentBinary(ctx, task, downAddr)
	if err != nil {
		return errors.Trace(err)
	}

	if err = archiver.TarGz.Open(file, task.Compiler.BuildPath); err != nil {
		return errors.Trace(err)
	}

	return nil
}

func prepareAgentBinary(ctx context.Context, task *types.BuildTask, agentAddr string) error {
	file, err := downloadAgentBinary(ctx, task, agentAddr)
	if err != nil {
		return errors.Trace(err)
	}

	if err = archiver.TarGz.Open(file, task.Compiler.BuildPath); err != nil {
		return errors.Trace(err)
	}

	return nil
}

func downloadComponentBinary(ctx context.Context, task *types.BuildTask, addr string) (string, error) {
	var file string

	// special download url for tidb / pd /tikv,
	// TODO: get download url from the information of task in the future
	switch task.Information.Repo {
	case "tidb", "tikv", "pd":
		binaryURL := fmt.Sprintf("%s/download/builds/pingcap/%s/%s/%s/%s-server.tar.gz",
			addr, task.Information.Repo, task.Information.Githash,
			task.Information.Platform, task.Information.Repo)

		binaryURLPR := fmt.Sprintf("%s/download/builds/pingcap/%s/pr/%s/%s/%s-server.tar.gz",
			addr, task.Information.Repo, task.Information.Githash,
			task.Information.Platform, task.Information.Repo)

		err := util.RetryOnError2(ctx, 3, 5*time.Second, func() (error, bool) {
			var err error
			if err = DownloadFile(binaryURL, task.Compiler.BuildPath); err == nil {
				log.Infof("[task %d] download component binary %s successful", task.ID, binaryURL)
				return nil, true
			}

			if err = DownloadFile(binaryURLPR, task.Compiler.BuildPath); err == nil {
				log.Infof("[task %d] download component binary %s successful", task.ID, binaryURLPR)
				return nil, true
			}

			return errors.Trace(err), false
		})
		if err != nil {
			return "", errors.Trace(err)
		}
		file = filepath.Join(task.Compiler.BuildPath,
			fmt.Sprintf("%s-server.tar.gz", task.Information.Repo))
	default:
		return "", errors.NotSupportedf("%s", task.Information.Repo)
	}

	return file, nil
}

func downloadAgentBinary(ctx context.Context, task *types.BuildTask, agentAddr string) (string, error) {
	err := util.RetryOnError2(ctx, 3, 5*time.Second, func() (error, bool) {
		if err := DownloadFile(agentAddr, task.Compiler.BuildPath); err != nil {
			return errors.Trace(err), false
		}

		log.Infof("[task %d] download agent binary %s successful", task.ID, agentAddr)

		return nil, true
	})

	if err != nil {
		return "", errors.Trace(err)
	}

	agentPaths := strings.Split(agentAddr, "/")
	if len(agentPaths) < 2 {
		return "", errors.NotValidf("%s", agentAddr)
	}

	return filepath.Join(task.Compiler.BuildPath, agentPaths[len(agentPaths)-1]), nil
}

func checkImageIfExist(ctx context.Context, image string, registryAPI string) (bool, error) {
	repository, tag, err := getRepositoryAndTag(image)
	if err != nil {
		return false, errors.Trace(err)
	}

	url := util.GenURL(fmt.Sprintf("%s/repositories/%s/tags/%s",
		registryAPI, repository, tag))

	err = util.RetryOnError2(ctx, 5, 5*time.Second, func() (error, bool) {
		resp, err := http.Get(url)
		if err != nil {
			return errors.Trace(err), false
		}

		if resp.StatusCode == http.StatusOK {
			return nil, true
		}

		if resp.StatusCode == http.StatusNotFound {
			return errors.NotFoundf("image %s", image), true
		}

		return nil, true
	})

	if err == nil {
		return true, nil
	}

	if strings.Contains(err.Error(), "not found") {
		return false, nil
	}

	return false, errors.Trace(err)
}

func getRepositoryAndTag(image string) (string, string, error) {
	strs := strings.Split(strings.TrimSpace(image), ":")
	if len(strs) != 2 {
		return "", "", errors.NotValidf("image: %s", image)
	}

	tag := strings.TrimSpace(strs[1])
	if util.IsEmpty(tag) {
		return "", "", errors.NotValidf("image: %s", image)
	}

	registry := strings.TrimSpace(strs[0])
	registry = strings.TrimPrefix(registry, "https://")
	registry = strings.TrimPrefix(registry, "http://")

	regs := strings.Split(registry, "/")
	if len(regs) < 2 {
		return "", "", errors.NotValidf("image: %s", image)
	}

	repository := strings.TrimPrefix(registry, regs[0]+"/")

	return repository, tag, nil
}
