package app

import (
	"archive/tar"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"time"

	"coopcloud.tech/abra/cli/internal"
	"coopcloud.tech/abra/pkg/autocomplete"
	"coopcloud.tech/abra/pkg/client"
	"coopcloud.tech/abra/pkg/config"
	containerPkg "coopcloud.tech/abra/pkg/container"
	"coopcloud.tech/abra/pkg/recipe"
	"coopcloud.tech/abra/pkg/runtime"
	"coopcloud.tech/abra/pkg/upstream/container"
	"github.com/docker/cli/cli/command"
	"github.com/docker/docker/api/types"
	"github.com/docker/docker/api/types/filters"
	dockerClient "github.com/docker/docker/client"
	"github.com/docker/docker/pkg/archive"
	"github.com/docker/docker/pkg/system"
	"github.com/klauspost/pgzip"
	"github.com/sirupsen/logrus"
	"github.com/urfave/cli"
)

type backupConfig struct {
	preHookCmd  string
	postHookCmd string
	backupPaths []string
}

var appBackupCommand = cli.Command{
	Name:      "backup",
	Aliases:   []string{"bk"},
	Usage:     "Run app backup",
	ArgsUsage: "<domain> [<service>]",
	Flags: []cli.Flag{
		internal.DebugFlag,
	},
	Before:       internal.SubCommandBefore,
	BashComplete: autocomplete.AppNameComplete,
	Description: `
Run an app backup.

A backup command and pre/post hook commands are defined in the recipe
configuration. Abra reads this configuration and run the comands in the context
of the deployed services. Pass <service> if you only want to back up a single
service. All backups are placed in the ~/.abra/backups directory.

A single backup file is produced for all backup paths specified for a service.
If we have the following backup configuration:

    - "backupbot.backup.path=/var/lib/foo,/var/lib/bar"

And we run "abra app backup example.com app", Abra will produce a file that
looks like:

    ~/.abra/backups/example_com_app_609341138.tar.gz

This file is a compressed archive which contains all backup paths. To see paths, run:

    tar -tf ~/.abra/backups/example_com_app_609341138.tar.gz

(Make sure to change the name of the backup file)

This single file can be used to restore your app. See "abra app restore" for more.
`,
	Action: func(c *cli.Context) error {
		app := internal.ValidateApp(c)
		conf := runtime.New()

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

		recipe, err := recipe.Get(app.Recipe, conf)
		if err != nil {
			logrus.Fatal(err)
		}

		backupConfigs := make(map[string]backupConfig)
		for _, service := range recipe.Config.Services {
			if backupsEnabled, ok := service.Deploy.Labels["backupbot.backup"]; ok {
				if backupsEnabled == "true" {
					fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), service.Name)
					bkConfig := backupConfig{}

					logrus.Debugf("backup config detected for %s", fullServiceName)

					if paths, ok := service.Deploy.Labels["backupbot.backup.path"]; ok {
						logrus.Debugf("detected backup paths for %s: %s", fullServiceName, paths)
						bkConfig.backupPaths = strings.Split(paths, ",")
					}

					if preHookCmd, ok := service.Deploy.Labels["backupbot.backup.pre-hook"]; ok {
						logrus.Debugf("detected pre-hook command for %s: %s", fullServiceName, preHookCmd)
						bkConfig.preHookCmd = preHookCmd
					}

					if postHookCmd, ok := service.Deploy.Labels["backupbot.backup.post-hook"]; ok {
						logrus.Debugf("detected post-hook command for %s: %s", fullServiceName, postHookCmd)
						bkConfig.postHookCmd = postHookCmd
					}

					backupConfigs[service.Name] = bkConfig
				}
			}
		}

		serviceName := c.Args().Get(1)
		if serviceName != "" {
			backupConfig, ok := backupConfigs[serviceName]
			if !ok {
				logrus.Fatalf("no backup config for %s? does %s exist?", serviceName, serviceName)
			}

			logrus.Infof("running backup for the %s service", serviceName)

			if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
				logrus.Fatal(err)
			}
		} else {
			if len(backupConfigs) == 0 {
				logrus.Fatalf("no backup configs discovered for %s?", app.Name)
			}

			for serviceName, backupConfig := range backupConfigs {
				logrus.Infof("running backup for the %s service", serviceName)

				if err := runBackup(cl, app, serviceName, backupConfig); err != nil {
					logrus.Fatal(err)
				}
			}
		}

		return nil
	},
}

// TimeStamp generates a file name friendly timestamp.
func TimeStamp() string {
	ts := time.Now().UTC().Format(time.RFC3339)
	return strings.Replace(ts, ":", "-", -1)
}

// runBackup does the actual backup logic.
func runBackup(cl *dockerClient.Client, app config.App, serviceName string, bkConfig backupConfig) error {
	if len(bkConfig.backupPaths) == 0 {
		return fmt.Errorf("backup paths are empty for %s?", serviceName)
	}

	// FIXME: avoid instantiating a new CLI
	dcli, err := command.NewDockerCli()
	if err != nil {
		return err
	}

	filters := filters.NewArgs()
	filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))

	targetContainer, err := containerPkg.GetContainer(context.Background(), cl, filters, true)
	if err != nil {
		return err
	}

	fullServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
	if bkConfig.preHookCmd != "" {
		splitCmd := internal.SafeSplit(bkConfig.preHookCmd)

		logrus.Debugf("split pre-hook command for %s into %s", fullServiceName, splitCmd)

		preHookExecOpts := types.ExecConfig{
			AttachStderr: true,
			AttachStdin:  true,
			AttachStdout: true,
			Cmd:          splitCmd,
			Detach:       false,
			Tty:          true,
		}

		if err := container.RunExec(dcli, cl, targetContainer.ID, &preHookExecOpts); err != nil {
			return fmt.Errorf("failed to run %s on %s: %s", bkConfig.preHookCmd, targetContainer.ID, err.Error())
		}

		logrus.Infof("succesfully ran %s pre-hook command: %s", fullServiceName, bkConfig.preHookCmd)
	}

	var tempBackupPaths []string
	for _, remoteBackupPath := range bkConfig.backupPaths {
		sanitisedPath := strings.ReplaceAll(remoteBackupPath, "/", "_")
		localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s%s_%s.tar.gz", fullServiceName, sanitisedPath, TimeStamp()))
		logrus.Debugf("temporarily backing up %s:%s to %s", fullServiceName, remoteBackupPath, localBackupPath)

		logrus.Infof("backing up %s:%s", fullServiceName, remoteBackupPath)

		content, _, err := cl.CopyFromContainer(context.Background(), targetContainer.ID, remoteBackupPath)
		if err != nil {
			logrus.Debugf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
			if err := cleanupTempArchives(tempBackupPaths); err != nil {
				return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
			}
			return fmt.Errorf("failed to copy %s from container: %s", remoteBackupPath, err.Error())
		}
		defer content.Close()

		_, srcBase := archive.SplitPathDirEntry(remoteBackupPath)
		preArchive := archive.RebaseArchiveEntries(content, srcBase, remoteBackupPath)
		if err := copyToFile(localBackupPath, preArchive); err != nil {
			logrus.Debugf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
			if err := cleanupTempArchives(tempBackupPaths); err != nil {
				return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
			}
			return fmt.Errorf("failed to create tar archive (%s): %s", localBackupPath, err.Error())
		}

		tempBackupPaths = append(tempBackupPaths, localBackupPath)
	}

	logrus.Infof("compressing and merging archives...")

	if err := mergeArchives(tempBackupPaths, fullServiceName); err != nil {
		logrus.Debugf("failed to merge archive files: %s", err.Error())
		if err := cleanupTempArchives(tempBackupPaths); err != nil {
			return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
		}
		return fmt.Errorf("failed to merge archive files: %s", err.Error())
	}

	if err := cleanupTempArchives(tempBackupPaths); err != nil {
		return fmt.Errorf("failed to clean up temporary archives: %s", err.Error())
	}

	if bkConfig.postHookCmd != "" {
		splitCmd := internal.SafeSplit(bkConfig.postHookCmd)

		logrus.Debugf("split post-hook command for %s into %s", fullServiceName, splitCmd)

		postHookExecOpts := types.ExecConfig{
			AttachStderr: true,
			AttachStdin:  true,
			AttachStdout: true,
			Cmd:          splitCmd,
			Detach:       false,
			Tty:          true,
		}

		if err := container.RunExec(dcli, cl, targetContainer.ID, &postHookExecOpts); err != nil {
			return err
		}

		logrus.Infof("succesfully ran %s post-hook command: %s", fullServiceName, bkConfig.postHookCmd)
	}

	return nil
}

func copyToFile(outfile string, r io.Reader) error {
	tmpFile, err := system.TempFileSequential(filepath.Dir(outfile), ".tar_temp")
	if err != nil {
		return err
	}

	tmpPath := tmpFile.Name()

	_, err = io.Copy(tmpFile, r)
	tmpFile.Close()

	if err != nil {
		os.Remove(tmpPath)
		return err
	}

	if err = os.Rename(tmpPath, outfile); err != nil {
		os.Remove(tmpPath)
		return err
	}

	return nil
}

func cleanupTempArchives(tarPaths []string) error {
	for _, tarPath := range tarPaths {
		if err := os.RemoveAll(tarPath); err != nil {
			return err
		}

		logrus.Debugf("remove temporary archive file %s", tarPath)
	}

	return nil
}

func mergeArchives(tarPaths []string, serviceName string) error {
	var out io.Writer
	var cout *pgzip.Writer

	localBackupPath := filepath.Join(config.BACKUP_DIR, fmt.Sprintf("%s_%s.tar.gz", serviceName, TimeStamp()))

	fout, err := os.Create(localBackupPath)
	if err != nil {
		return fmt.Errorf("Failed to open %s: %s", localBackupPath, err)
	}

	defer fout.Close()
	out = fout

	cout = pgzip.NewWriter(out)
	out = cout

	tw := tar.NewWriter(out)

	for _, tarPath := range tarPaths {
		if err := addTar(tw, tarPath); err != nil {
			return fmt.Errorf("failed to merge %s: %v", tarPath, err)
		}
	}

	if err := tw.Close(); err != nil {
		return fmt.Errorf("failed to close tar writer %v", err)
	}

	if cout != nil {
		if err := cout.Flush(); err != nil {
			return fmt.Errorf("failed to flush: %s", err)
		} else if err = cout.Close(); err != nil {
			return fmt.Errorf("failed to close compressed writer: %s", err)
		}
	}

	logrus.Infof("backed up %s to %s", serviceName, localBackupPath)

	return nil
}

func addTar(tw *tar.Writer, pth string) (err error) {
	var tr *tar.Reader
	var rc io.ReadCloser
	var hdr *tar.Header

	if tr, rc, err = openTarFile(pth); err != nil {
		return
	}

	for {
		if hdr, err = tr.Next(); err != nil {
			if err == io.EOF {
				err = nil
			}
			break
		}
		if err = tw.WriteHeader(hdr); err != nil {
			break
		} else if _, err = io.Copy(tw, tr); err != nil {
			break
		}
	}
	if err == nil {
		err = rc.Close()
	} else {
		rc.Close()
	}
	return
}

func openTarFile(pth string) (tr *tar.Reader, rc io.ReadCloser, err error) {
	var fin *os.File
	var n int
	buff := make([]byte, 1024)

	if fin, err = os.Open(pth); err != nil {
		return
	}

	if n, err = fin.Read(buff); err != nil {
		fin.Close()
		return
	} else if n == 0 {
		fin.Close()
		err = fmt.Errorf("%s is empty", pth)
		return
	}

	if _, err = fin.Seek(0, 0); err != nil {
		fin.Close()
		return
	}

	rc = fin
	tr = tar.NewReader(rc)

	return tr, rc, nil
}