package app

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

	"coopcloud.tech/abra/cli/internal"
	"coopcloud.tech/abra/pkg/autocomplete"
	"coopcloud.tech/abra/pkg/client"
	containerPkg "coopcloud.tech/abra/pkg/container"
	"coopcloud.tech/abra/pkg/formatter"
	"coopcloud.tech/abra/pkg/log"
	"coopcloud.tech/abra/pkg/upstream/container"
	"github.com/docker/cli/cli/command"
	"github.com/docker/docker/api/types"
	dockerClient "github.com/docker/docker/client"
	"github.com/docker/docker/errdefs"
	"github.com/docker/docker/pkg/archive"
	"github.com/spf13/cobra"
)

var AppCpCommand = &cobra.Command{
	Use:     "cp <app> <src> <dst> [flags]",
	Aliases: []string{"c"},
	Short:   "Copy files to/from a deployed app service",
	Example: `  # copy myfile.txt to the root of the app service
  abra app cp 1312.net myfile.txt app:/

  # copy that file back to your current working directory locally
  abra app cp 1312.net app:/myfile.txt`,
	Args: cobra.ExactArgs(3),
	ValidArgsFunction: func(
		cmd *cobra.Command,
		args []string,
		toComplete string) ([]string, cobra.ShellCompDirective) {
		switch l := len(args); l {
		case 0:
			return autocomplete.AppNameComplete()
		default:
			return nil, cobra.ShellCompDirectiveDefault
		}
	},
	Run: func(cmd *cobra.Command, args []string) {
		app := internal.ValidateApp(args)

		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
			log.Fatal(err)
		}

		src := args[1]
		dst := args[2]
		srcPath, dstPath, service, toContainer, err := parseSrcAndDst(src, dst)
		if err != nil {
			log.Fatal(err)
		}

		cl, err := client.New(app.Server)
		if err != nil {
			log.Fatal(err)
		}

		container, err := containerPkg.GetContainerFromStackAndService(cl, app.StackName(), service)
		if err != nil {
			log.Fatal(err)
		}
		log.Debugf("retrieved %s as target container on %s", formatter.ShortenID(container.ID), app.Server)

		if toContainer {
			err = CopyToContainer(cl, container.ID, srcPath, dstPath)
		} else {
			err = CopyFromContainer(cl, container.ID, srcPath, dstPath)
		}
		if err != nil {
			log.Fatal(err)
		}
	},
}

var errServiceMissing = errors.New("one of <src>/<dest> arguments must take $SERVICE:$PATH form")

// parseSrcAndDst parses src and dest string. One of src or dst must be of the form $SERVICE:$PATH
func parseSrcAndDst(src, dst string) (srcPath string, dstPath string, service string, toContainer bool, err error) {
	parsedSrc := strings.SplitN(src, ":", 2)
	parsedDst := strings.SplitN(dst, ":", 2)
	if len(parsedSrc)+len(parsedDst) != 3 {
		return "", "", "", false, errServiceMissing
	}
	if len(parsedSrc) == 2 {
		return parsedSrc[1], dst, parsedSrc[0], false, nil
	}
	if len(parsedDst) == 2 {
		return src, parsedDst[1], parsedDst[0], true, nil
	}
	return "", "", "", false, errServiceMissing
}

// CopyToContainer copies a file or directory from the local file system to the container.
// See the possible copy modes and their documentation.
func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
	srcStat, err := os.Stat(srcPath)
	if err != nil {
		return fmt.Errorf("local %s ", err)
	}

	dstStat, err := cl.ContainerStatPath(context.Background(), containerID, dstPath)
	dstExists := true
	if err != nil {
		if errdefs.IsNotFound(err) {
			dstExists = false
		} else {
			return fmt.Errorf("remote path: %s", err)
		}
	}

	mode, err := copyMode(srcPath, dstPath, srcStat.Mode(), dstStat.Mode, dstExists)
	if err != nil {
		return err
	}
	movePath := ""
	switch mode {
	case CopyModeDirToDir:
		// Add the src directory to the destination path
		_, srcDir := path.Split(srcPath)
		dstPath = path.Join(dstPath, srcDir)

		// Make sure the dst directory exits.
		dcli, err := command.NewDockerCli()
		if err != nil {
			return err
		}
		if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
			AttachStderr: true,
			AttachStdin:  true,
			AttachStdout: true,
			Cmd:          []string{"mkdir", "-p", dstPath},
			Detach:       false,
			Tty:          true,
		}); err != nil {
			return fmt.Errorf("create remote directory: %s", err)
		}
	case CopyModeFileToFile:
		// Remove the file component from the path, since docker can only copy
		// to a directory.
		dstPath, _ = path.Split(dstPath)
	case CopyModeFileToFileRename:
		// Copy the file to the temp directory and move it to its dstPath
		// afterwards.
		movePath = dstPath
		dstPath = "/tmp"
	}

	toTarOpts := &archive.TarOptions{IncludeSourceDir: true, NoOverwriteDirNonDir: true, Compression: archive.Gzip}
	content, err := archive.TarWithOptions(srcPath, toTarOpts)
	if err != nil {
		return err
	}

	log.Debugf("copy %s from local to %s on container", srcPath, dstPath)
	copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
	if err := cl.CopyToContainer(context.Background(), containerID, dstPath, content, copyOpts); err != nil {
		return err
	}

	if movePath != "" {
		_, srcFile := path.Split(srcPath)
		dcli, err := command.NewDockerCli()
		if err != nil {
			return err
		}
		if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
			AttachStderr: true,
			AttachStdin:  true,
			AttachStdout: true,
			Cmd:          []string{"mv", path.Join("/tmp", srcFile), movePath},
			Detach:       false,
			Tty:          true,
		}); err != nil {
			return fmt.Errorf("create remote directory: %s", err)
		}
	}

	return nil
}

// CopyFromContainer copies a file or directory from the given container to the local file system.
// See the possible copy modes and their documentation.
func CopyFromContainer(cl *dockerClient.Client, containerID, srcPath, dstPath string) error {
	srcStat, err := cl.ContainerStatPath(context.Background(), containerID, srcPath)
	if err != nil {
		if errdefs.IsNotFound(err) {
			return fmt.Errorf("remote: %s does not exist", srcPath)
		} else {
			return fmt.Errorf("remote path: %s", err)
		}
	}

	dstStat, err := os.Stat(dstPath)
	dstExists := true
	var dstMode os.FileMode
	if err != nil {
		if os.IsNotExist(err) {
			dstExists = false
		} else {
			return fmt.Errorf("remote path: %s", err)
		}
	} else {
		dstMode = dstStat.Mode()
	}

	mode, err := copyMode(srcPath, dstPath, srcStat.Mode, dstMode, dstExists)
	if err != nil {
		return err
	}

	moveDstDir := ""
	moveDstFile := ""
	switch mode {
	case CopyModeFileToFile:
		// Remove the file component from the path, since docker can only copy
		// to a directory.
		dstPath, _ = path.Split(dstPath)
	case CopyModeFileToFileRename:
		// Copy the file to the temp directory and move it to its dstPath
		// afterwards.
		moveDstFile = dstPath
		dstPath = "/tmp"
	case CopyModeFilesToDir:
		// Copy the directory to the temp directory and move it to its
		// dstPath afterwards.
		moveDstDir = path.Join(dstPath, "/")
		dstPath = "/tmp"

		// Make sure the temp directory always gets removed
		defer os.Remove(path.Join("/tmp"))
	}

	content, _, err := cl.CopyFromContainer(context.Background(), containerID, srcPath)
	if err != nil {
		return fmt.Errorf("copy: %s", err)
	}
	defer content.Close()
	if err := archive.Untar(content, dstPath, &archive.TarOptions{
		NoOverwriteDirNonDir: true,
		Compression:          archive.Gzip,
		NoLchown:             true,
	}); err != nil {
		return fmt.Errorf("untar: %s", err)
	}

	if moveDstFile != "" {
		_, srcFile := path.Split(strings.TrimSuffix(srcPath, "/"))
		if err := moveFile(path.Join("/tmp", srcFile), moveDstFile); err != nil {
			return err
		}
	}
	if moveDstDir != "" {
		_, srcDir := path.Split(strings.TrimSuffix(srcPath, "/"))
		if err := moveDir(path.Join("/tmp", srcDir), moveDstDir); err != nil {
			return err
		}
	}
	return nil
}

var (
	ErrCopyDirToFile  = fmt.Errorf("can't copy dir to file")
	ErrDstDirNotExist = fmt.Errorf("destination directory does not exist")
)

type CopyMode int

const (
	// Copy a src file to a dest file. The src and dest file names are the same.
	//  <dir_src>/<file> + <dir_dst>/<file> -> <dir_dst>/<file>
	CopyModeFileToFile = CopyMode(iota)
	// Copy a src file to a dest file. The src and dest file names are  not the same.
	//  <dir_src>/<file_src> + <dir_dst>/<file_dst> -> <dir_dst>/<file_dst>
	CopyModeFileToFileRename
	// Copy a src file to dest directory. The dest file gets created in the dest
	// folder with the src filename.
	//  <dir_src>/<file> + <dir_dst> -> <dir_dst>/<file>
	CopyModeFileToDir
	// Copy a src directory to dest directory.
	//  <dir_src> + <dir_dst> -> <dir_dst>/<dir_src>
	CopyModeDirToDir
	// Copy all files in the src directory to the dest directory. This works recursively.
	//  <dir_src>/ + <dir_dst> -> <dir_dst>/<files_from_dir_src>
	CopyModeFilesToDir
)

// copyMode takes a src and dest path and file mode to determine the copy mode.
// See the possible copy modes and their documentation.
func copyMode(srcPath, dstPath string, srcMode os.FileMode, dstMode os.FileMode, dstExists bool) (CopyMode, error) {
	_, srcFile := path.Split(srcPath)
	_, dstFile := path.Split(dstPath)
	if srcMode.IsDir() {
		if !dstExists {
			return -1, ErrDstDirNotExist
		}
		if dstMode.IsDir() {
			if strings.HasSuffix(srcPath, "/") {
				return CopyModeFilesToDir, nil
			}
			return CopyModeDirToDir, nil
		}
		return -1, ErrCopyDirToFile
	}

	if dstMode.IsDir() {
		return CopyModeFileToDir, nil
	}

	if srcFile != dstFile {
		return CopyModeFileToFileRename, nil
	}

	return CopyModeFileToFile, nil
}

// moveDir moves all files from a source path to the destination path recursively.
func moveDir(sourcePath, destPath string) error {
	return filepath.Walk(sourcePath, func(p string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		newPath := path.Join(destPath, strings.TrimPrefix(p, sourcePath))
		if info.IsDir() {
			err := os.Mkdir(newPath, info.Mode())
			if err != nil {
				if os.IsExist(err) {
					return nil
				}
				return err
			}
		}
		if info.Mode().IsRegular() {
			return moveFile(p, newPath)
		}
		return nil
	})
}

// moveFile moves a file from a source path to a destination path.
func moveFile(sourcePath, destPath string) error {
	inputFile, err := os.Open(sourcePath)
	if err != nil {
		return err
	}
	outputFile, err := os.Create(destPath)
	if err != nil {
		inputFile.Close()
		return err
	}
	defer outputFile.Close()
	_, err = io.Copy(outputFile, inputFile)
	inputFile.Close()
	if err != nil {
		return err
	}

	// Remove file after succesfull copy.
	err = os.Remove(sourcePath)
	if err != nil {
		return err
	}
	return nil
}

func init() {
	AppCpCommand.Flags().BoolVarP(
		&internal.Chaos,
		"chaos",
		"C",
		false,
		"ignore uncommitted recipes changes",
	)
}
