/*
Copyright 2019 The Jetstack cert-manager contributors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package images

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path"

	flag "github.com/spf13/pflag"

	"github.com/jetstack/cert-manager/hack/release/pkg/bazel"
	"github.com/jetstack/cert-manager/hack/release/pkg/flags"
	logf "github.com/jetstack/cert-manager/hack/release/pkg/log"
	"github.com/jetstack/cert-manager/hack/release/pkg/util"
)

var (
	Default = &Plugin{}

	supportedComponents = []string{"acmesolver", "controller", "webhook", "cainjector"}
	log                 = logf.Log.WithName("images")
)

type Plugin struct {
	// The list of images to build (e.g. acmesolver, controller, webhook)
	Components []string

	// If true, the built images will be exported to the configured docker
	// daemon when the Build() method is called.
	ExportToDocker bool

	// List of architectures to build images for
	GoArch []string

	// DockerConfig is a path to a directory containing a config.json file that
	// is used for Docker authentication
	DockerConfig string

	// If true, a multi-arch manifest list will be created for each set of
	// images
	CreateManifestList bool

	// TODO: add GOOS support once the build system supports more than linux

	// built is set to true if Build() has completed successfully
	built bool
	// configFileName is computed based on the DockerConfig field
	configFileName string
}

func (g *Plugin) AddFlags(fs *flag.FlagSet) {
	fs.BoolVar(&g.ExportToDocker, "images.export", false, "if true, images will be exported to the currently configured docker daemon")
	fs.BoolVar(&g.CreateManifestList, "images.publish-manifest-list", true, "if true, a multi-arch manifest list will be published when images are pushed")
	fs.StringSliceVar(&g.Components, "images.components", []string{"acmesolver", "controller", "webhook", "cainjector"}, "the list of components to build images for")
	fs.StringSliceVar(&g.GoArch, "images.goarch", []string{"amd64", "arm64", "arm"}, "list of architectures to build images for")
	fs.StringVar(&g.DockerConfig, "images.docker-config", "", "path to a directory containing a docker config.json file used when pushing images")
}

func (g *Plugin) Validate() []error {
	var errs []error

	// validate components flag
	for _, a := range g.Components {
		valid := false
		for _, sa := range supportedComponents {
			if a == sa {
				valid = true
				break
			}
		}
		if !valid {
			errs = append(errs, fmt.Errorf("invalid component name %q", a))
		}
	}

	return errs
}

func (g *Plugin) InitPublish() []error {
	var errs []error

	if g.DockerConfig != "" {
		configFileName := path.Join(g.DockerConfig, "config.json")
		f, err := os.Stat(configFileName)
		if err != nil {
			return []error{fmt.Errorf("error checking config file: %v", err)}
		}
		if f.IsDir() {
			return []error{fmt.Errorf("docker config.json is not a file")}
		}
		g.configFileName = g.DockerConfig
	}

	return errs
}

func (g *Plugin) Build(ctx context.Context) error {
	_, err := g.build(ctx)
	if err != nil {
		return err
	}

	if g.ExportToDocker {
		log.Info("Exporting docker images to local docker daemon")
		if err := g.exportToDocker(ctx); err != nil {
			return err
		}
	} else {
		log.Info("skipping exporting docker images to docker daemon")
	}

	return nil
}

func (g *Plugin) Publish(ctx context.Context) error {
	log.Info("running publish for image plugin")
	// this case should never be reached, but we check it to be safe
	if !g.built {
		if _, err := g.build(ctx); err != nil {
			return err
		}
	}

	log.Info("pushing images")
	targets := g.generateTargets()
	err := g.pushImages(ctx, targets)
	if err != nil {
		return err
	}

	log.Info("published all docker images")

	return nil
}

func (g *Plugin) Complete() error {
	log = log.WithName("default-flags")

	if g.DockerConfig == "" {
		g.DockerConfig = os.Getenv("DOCKER_CONFIG")
		if g.DockerConfig != "" {
			log.Info("set default value", "flag", "images.docker-config", "value", g.DockerConfig)
		}
	}
	return nil
}

func (g *Plugin) build(ctx context.Context) (imageTargets, error) {
	targets := g.generateTargets()

	// only support building docker images for linux for now
	os := "linux"
	for _, arch := range g.GoArch {
		filteredTargets := targets.withOSArch(os, arch)
		bazelTargets := filteredTargets.bazelTargets()
		log := log.WithValues("images", bazelTargets)
		log.Info("building bazel image targets")

		err := bazel.Default.BuildPlatformE(ctx, log, os, arch, bazelTargets...)
		if err != nil {
			return nil, fmt.Errorf("error building docker images (%v): %v", targets, err)
		}
	}

	g.built = true
	return targets, nil
}

func (g *Plugin) exportToDocker(ctx context.Context) error {
	targets := g.generateTargets()
	log.WithValues("images", targets.bazelExportTargets()).Info("exporting images to docker daemon")
	for _, target := range targets {
		log := log.WithValues("target", target.name, "os", target.os, "arch", target.arch)
		log.Info("exporting image to docker daemon")
		exportTarget := target.bazelExportTarget()
		err := bazel.Default.RunPlatformE(ctx, log, target.os, target.arch, exportTarget)
		if err != nil {
			return fmt.Errorf("error exporting image %q to docker daemon: %v", target, err)
		}

		for _, taggedImage := range target.taggedImageNames() {
			log.Info("tagging image", "tag", taggedImage)
			cmd := exec.CommandContext(ctx, "docker", "tag", target.exportedImageName(), taggedImage)
			err := util.RunE(log, cmd)
			if err != nil {
				return err
			}
		}
	}

	log.WithValues("images", targets.taggedImageNames()).Info("exported all docker images")

	return nil
}

// generateTargets generates a list of Bazel target names that must be
// built for this invocation of the image builder
func (g *Plugin) generateTargets() imageTargets {
	var targets []imageTarget
	for _, c := range g.Components {
		for _, a := range g.GoArch {
			targets = append(targets, imageTarget{c, "linux", a})
		}
	}
	return targets
}

// pushImages will push the images built for this release to the registry
// TODO: add support for calling container_push targets instead of just 'docker push'
func (p *Plugin) pushImages(ctx context.Context, targets imageTargets) error {
	err := p.exportToDocker(ctx)
	if err != nil {
		return err
	}

	images := targets.taggedImageNames()

	log.WithValues("images", images).Info("pushing docker images")
	for _, img := range images {
		log := log.WithValues("image", img)
		log.Info("pushing docker image")
		args := []string{}
		if p.configFileName != "" {
			args = append(args, "--config", p.configFileName)
		}
		args = append(args, "push", img)
		cmd := exec.CommandContext(ctx, "docker", args...)
		err := util.RunE(log, cmd)
		if err != nil {
			return err
		}
	}

	manifestsToCreate := make(map[string][]imageTarget)
	for _, target := range targets {
		manifestsToCreate[target.name] = append(manifestsToCreate[target.name], target)
	}

	if p.CreateManifestList {
		for name, targets := range manifestsToCreate {
			log := log.WithValues("component", name)
			log.Info("creating manifest list for component")
			manifestListName := manifestListNameForComponent(name)
			manifestImages := make([]string, len(targets))
			for i, target := range targets {
				manifestImages[i] = target.taggedImageNames()[0]
			}
			log.Info("running manifest create", "manifest_name", manifestListName, "images", manifestImages)
			cmd := exec.CommandContext(ctx, "docker", append([]string{"manifest", "create", manifestListName}, manifestImages...)...)
			if err := util.RunE(log, cmd); err != nil {
				return fmt.Errorf("failed to create manifest list: %v", err)
			}
			for _, target := range targets {
				a := manifestListAnnotationsForTarget(target)
				cmd := exec.CommandContext(ctx, "docker", "manifest", "annotate", "--os", a.os, "--arch", a.arch, "--variant", a.variant, manifestListName, target.taggedImageNames()[0])
				if err := util.RunE(log, cmd); err != nil {
					return fmt.Errorf("failed to annotate manifest list: %v", err)
				}
			}

			cmd = exec.CommandContext(ctx, "docker", "manifest", "push", manifestListName)
			if err := util.RunE(log, cmd); err != nil {
				return fmt.Errorf("failed to push manifest list: %v", err)
			}
		}
	}

	return nil
}

type imageTargets []imageTarget

func (i imageTargets) bazelTargets() []string {
	out := make([]string, len(i))
	for idx, target := range i {
		out[idx] = target.bazelTarget()
	}
	return out
}

func (i imageTargets) bazelExportTargets() []string {
	out := make([]string, len(i))
	for idx, target := range i {
		out[idx] = target.bazelExportTarget()
	}
	return out
}

func (i imageTargets) taggedImageNames() []string {
	out := make([]string, 0)
	for _, target := range i {
		out = append(out, target.taggedImageNames()...)
	}
	return out
}

func (i imageTargets) exportedImageNames() []string {
	out := make([]string, 0)
	for _, target := range i {
		out = append(out, target.taggedImageNames()...)
	}
	return out
}

func (i imageTargets) withOSArch(os, arch string) imageTargets {
	out := make(imageTargets, 0)
	for _, target := range i {
		if target.os == os && target.arch == arch {
			out = append(out, target)
		}
	}
	return out
}

func manifestListNameForComponent(s string) string {
	return fmt.Sprintf("%s/cert-manager-%s:%s", flags.Default.DockerRepo, s, flags.Default.AppVersion)
}

type manifestAnnotation struct {
	os, arch, variant string
}

func manifestListAnnotationsForTarget(t imageTarget) manifestAnnotation {
	if t.arch == "amd64" {
		return manifestAnnotation{
			os:   t.os,
			arch: t.arch,
		}
	}
	if t.arch == "arm" {
		return manifestAnnotation{
			os:      t.os,
			arch:    "arm",
			variant: "v7",
		}
	}
	if t.arch == "arm64" {
		return manifestAnnotation{
			os:      t.os,
			arch:    "arm64",
			variant: "v8",
		}
	}
	return manifestAnnotation{}
}

type imageTarget struct {
	name, os, arch string
}

func (i imageTarget) bazelTarget() string {
	return fmt.Sprintf("//cmd/%s:image", i.name)
}

func (i imageTarget) bazelExportTarget() string {
	return fmt.Sprintf("//cmd/%s:image.export", i.name)
}

func (i imageTarget) exportedImageName() string {
	return fmt.Sprintf("%s:%s", i.name, flags.Default.GitCommitRef)
}

func (i imageTarget) taggedImageNames() []string {
	return []string{
		fmt.Sprintf("%s/cert-manager-%s-%s:%s", flags.Default.DockerRepo, i.name, i.arch, flags.Default.AppVersion),
		fmt.Sprintf("%s/cert-manager-%s-%s:%s", flags.Default.DockerRepo, i.name, i.arch, flags.Default.GitCommitRef),
	}
}
