package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/google/go-containerregistry/pkg/authn"
	"github.com/google/go-containerregistry/pkg/name"
	v1 "github.com/google/go-containerregistry/pkg/v1"
	"github.com/google/go-containerregistry/pkg/v1/daemon"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
	log "github.com/sirupsen/logrus"
	"github.com/urfave/cli"

	"github.com/Microsoft/hcsshim/ext4/dmverity"
	"github.com/Microsoft/hcsshim/ext4/tar2ext4"
)

const usage = `dmverity-vhd is a command line tool for creating LCOW layer VHDs with dm-verity hashes.`

const (
	usernameFlag      = "username"
	passwordFlag      = "password"
	imageFlag         = "image"
	verboseFlag       = "verbose"
	outputDirFlag     = "out-dir"
	dockerFlag        = "docker"
	tarballFlag       = "tarball"
	hashDeviceVhdFlag = "hash-dev-vhd"
	maxVHDSize        = dmverity.RecommendedVHDSizeGB
)

func init() {
	log.SetFormatter(&log.TextFormatter{
		DisableTimestamp: true,
	})

	log.SetOutput(os.Stdout)

	log.SetLevel(log.WarnLevel)
}

func main() {
	cli.VersionFlag = cli.BoolFlag{
		Name: "version",
	}

	app := cli.NewApp()
	app.Name = "dmverity-vhd"
	app.Commands = []cli.Command{
		createVHDCommand,
		rootHashVHDCommand,
	}
	app.Usage = usage
	app.Flags = []cli.Flag{
		cli.BoolFlag{
			Name:  verboseFlag + ",v",
			Usage: "Optional: verbose output",
		},
		cli.BoolFlag{
			Name:  dockerFlag + ",d",
			Usage: "Optional: use local docker daemon",
		},
		cli.StringFlag{
			Name:  tarballFlag + ",t",
			Usage: "Optional: path to tarball containing image info",
		},
	}

	if err := app.Run(os.Args); err != nil {
		_, _ = fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

func fetchImageLayers(ctx *cli.Context) (layers []v1.Layer, err error) {
	image := ctx.String(imageFlag)
	tarballPath := ctx.GlobalString(tarballFlag)
	ref, err := name.ParseReference(image)
	if err != nil {
		return nil, fmt.Errorf("failed to parse image reference %s: %w", image, err)
	}

	dockerDaemon := ctx.GlobalBool(dockerFlag)

	// error check to make sure docker and tarball are not both defined
	if dockerDaemon && tarballPath != "" {
		return nil, errors.New("cannot use both docker and tarball for image source")
	}

	// by default, using remote as source
	var img v1.Image
	if tarballPath != "" {
		// create a tag and search the tarball for the image specified
		var imageNameAndTag name.Tag
		imageNameAndTag, err = name.NewTag(image)
		if err != nil {
			return nil, fmt.Errorf("failed to failed to create a tag to search tarball for %s: %w", image, err)
		}
		// if only an image name is provided and not a tag, the default is "latest"
		img, err = tarball.ImageFromPath(tarballPath, &imageNameAndTag)
	} else if dockerDaemon {
		// use the unbuffered opener, the tradeoff being the image will stream as needed
		// so it is slower but much more memory efficient
		var opts []daemon.Option
		opt := daemon.WithUnbufferedOpener()
		opts = append(opts, opt)

		img, err = daemon.Image(ref, opts...)
	} else {
		var remoteOpts []remote.Option
		if ctx.IsSet(usernameFlag) && ctx.IsSet(passwordFlag) {
			auth := authn.Basic{
				Username: ctx.String(usernameFlag),
				Password: ctx.String(passwordFlag),
			}
			authConf, err := auth.Authorization()
			if err != nil {
				return nil, fmt.Errorf("failed to set remote: %w", err)
			}
			log.Debug("using basic auth")
			authOpt := remote.WithAuth(authn.FromConfig(*authConf))
			remoteOpts = append(remoteOpts, authOpt)
		}

		img, err = remote.Image(ref, remoteOpts...)
	}
	if err != nil {
		return nil, fmt.Errorf("unable to fetch image %q, make sure it exists: %w", image, err)
	}
	conf, _ := img.ConfigName()
	log.Debugf("Image id: %s", conf.String())
	return img.Layers()
}

var createVHDCommand = cli.Command{
	Name:  "create",
	Usage: "creates LCOW layer VHDs inside the output directory with dm-verity super block and merkle tree appended at the end",
	Flags: []cli.Flag{
		cli.StringFlag{
			Name:     imageFlag + ",i",
			Usage:    "Required: container image reference",
			Required: true,
		},
		cli.StringFlag{
			Name:     outputDirFlag + ",o",
			Usage:    "Required: output directory path",
			Required: true,
		},
		cli.StringFlag{
			Name:  usernameFlag + ",u",
			Usage: "Optional: custom registry username",
		},
		cli.StringFlag{
			Name:  passwordFlag + ",p",
			Usage: "Optional: custom registry password",
		},
		cli.BoolFlag{
			Name:  hashDeviceVhdFlag + ",hdv",
			Usage: "Optional: save hash-device as a VHD",
		},
	},
	Action: func(ctx *cli.Context) error {
		verbose := ctx.GlobalBool(verboseFlag)
		if verbose {
			log.SetLevel(log.DebugLevel)
		}

		layers, err := fetchImageLayers(ctx)
		if err != nil {
			return fmt.Errorf("failed to fetch image layers: %w", err)
		}

		outDir := ctx.String(outputDirFlag)
		if _, err := os.Stat(outDir); os.IsNotExist(err) {
			log.Debugf("creating output directory %q", outDir)
			if err := os.MkdirAll(outDir, 0755); err != nil {
				return fmt.Errorf("failed to create output directory %s: %w", outDir, err)
			}
		}

		log.Debug("creating layer VHDs with dm-verity")
		for layerNumber, layer := range layers {
			if err := createVHD(layerNumber, layer, ctx.String(outputDirFlag), ctx.Bool(hashDeviceVhdFlag)); err != nil {
				return err
			}
		}
		return nil
	},
}

func createVHD(layerNumber int, layer v1.Layer, outDir string, verityHashDev bool) error {
	diffID, err := layer.DiffID()
	if err != nil {
		return fmt.Errorf("failed to read layer diff: %w", err)
	}

	log.WithFields(log.Fields{
		"layerNumber": layerNumber,
		"layerDiff":   diffID.String(),
	}).Debug("converting tar to layer VHD")

	rc, err := layer.Uncompressed()
	if err != nil {
		return fmt.Errorf("failed to uncompress layer %s: %w", diffID.String(), err)
	}
	defer rc.Close()

	vhdPath := filepath.Join(outDir, diffID.Hex+".vhd")
	out, err := os.Create(vhdPath)
	if err != nil {
		return fmt.Errorf("failed to create layer vhd file %s: %w", vhdPath, err)
	}
	defer out.Close()

	opts := []tar2ext4.Option{
		tar2ext4.ConvertWhiteout,
		tar2ext4.MaximumDiskSize(maxVHDSize),
	}
	if !verityHashDev {
		opts = append(opts, tar2ext4.AppendDMVerity)
	}
	if err := tar2ext4.Convert(rc, out, opts...); err != nil {
		return fmt.Errorf("failed to convert tar to ext4: %w", err)
	}
	if verityHashDev {
		hashDevPath := filepath.Join(outDir, diffID.Hex+".hash-dev.vhd")
		hashDev, err := os.Create(hashDevPath)
		if err != nil {
			return fmt.Errorf("failed to create hash device VHD file: %w", err)
		}
		defer hashDev.Close()

		if err := dmverity.ComputeAndWriteHashDevice(out, hashDev); err != nil {
			return err
		}
		if err := tar2ext4.ConvertToVhd(hashDev); err != nil {
			return err
		}
		fmt.Fprintf(os.Stdout, "hash device created at %s\n", hashDevPath)
	}
	if err := tar2ext4.ConvertToVhd(out); err != nil {
		return fmt.Errorf("failed to append VHD footer: %w", err)
	}
	fmt.Fprintf(os.Stdout, "Layer VHD created at %s\n", vhdPath)
	return nil
}

var rootHashVHDCommand = cli.Command{
	Name:  "roothash",
	Usage: "compute root hashes for each LCOW layer VHD",
	Flags: []cli.Flag{
		cli.StringFlag{
			Name:     imageFlag + ",i",
			Usage:    "Required: container image reference",
			Required: true,
		},
		cli.StringFlag{
			Name:  usernameFlag + ",u",
			Usage: "Optional: custom registry username",
		},
		cli.StringFlag{
			Name:  passwordFlag + ",p",
			Usage: "Optional: custom registry password",
		},
	},
	Action: func(ctx *cli.Context) error {
		verbose := ctx.GlobalBool(verboseFlag)
		if verbose {
			log.SetLevel(log.DebugLevel)
		}

		layers, err := fetchImageLayers(ctx)
		if err != nil {
			return fmt.Errorf("failed to fetch image layers: %w", err)
		}
		log.Debugf("%d layers found", len(layers))

		convertFunc := func(layer v1.Layer) (string, error) {
			rc, err := layer.Uncompressed()
			if err != nil {
				return "", err
			}
			defer rc.Close()

			hash, err := tar2ext4.ConvertAndComputeRootDigest(rc)
			if err != nil {
				return "", err
			}
			return hash, err
		}

		for layerNumber, layer := range layers {
			diffID, err := layer.DiffID()
			if err != nil {
				return fmt.Errorf("failed to read layer diff: %w", err)
			}
			log.WithFields(log.Fields{
				"layerNumber": layerNumber,
				"layerDiff":   diffID.String(),
			}).Debug("uncompressed layer")

			hash, err := convertFunc(layer)
			if err != nil {
				return fmt.Errorf("failed to compute root digest: %w", err)
			}
			fmt.Fprintf(os.Stdout, "Layer %d root hash: %s\n", layerNumber, hash)
		}
		return nil
	},
}
