// Package docker provides the v2 of GoReleaser's docker pipe.
package docker

import (
	"bytes"
	"cmp"
	stdctx "context"
	"errors"
	"fmt"
	"io"
	"maps"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"slices"
	"strings"

	"github.com/caarlos0/log"
	"github.com/goreleaser/go-shellwords"
	"github.com/goreleaser/goreleaser/v2/internal/artifact"
	"github.com/goreleaser/goreleaser/v2/internal/deprecate"
	"github.com/goreleaser/goreleaser/v2/internal/gerrors"
	"github.com/goreleaser/goreleaser/v2/internal/gio"
	"github.com/goreleaser/goreleaser/v2/internal/ids"
	"github.com/goreleaser/goreleaser/v2/internal/logext"
	"github.com/goreleaser/goreleaser/v2/internal/pipe"
	"github.com/goreleaser/goreleaser/v2/internal/redact"
	"github.com/goreleaser/goreleaser/v2/internal/retryx"
	"github.com/goreleaser/goreleaser/v2/internal/semerrgroup"
	"github.com/goreleaser/goreleaser/v2/internal/shell"
	"github.com/goreleaser/goreleaser/v2/internal/skips"
	"github.com/goreleaser/goreleaser/v2/internal/summary"
	"github.com/goreleaser/goreleaser/v2/internal/tmpl"
	"github.com/goreleaser/goreleaser/v2/pkg/config"
	"github.com/goreleaser/goreleaser/v2/pkg/context"
)

// ExtraPlatforms is the artifact Extra key holding the list of platforms a v2
// docker image was built for (e.g. ["linux/amd64", "linux/arm64"]).
const ExtraPlatforms = "Platforms"

// Base v2 docker pipe.
type Base struct{}

// Snapshot is a pipe that only runs on snapshot builds.
type Snapshot struct{ Base }

// Publish is a pipe that only runs on non-snapshot builds.
type Publish struct{ Base }

// String implements pipeline.Piper.
func (p Base) String() string { return "docker images (v2)" }

// Dependencies implements DependencyChecker.
func (Base) Dependencies(*context.Context) []string { return []string{"docker buildx"} }

// Healthcheck implements Healthchecker.
func (Base) Healthcheck(ctx *context.Context) error {
	driver := getBuildxDriver(ctx)
	if isDriverValid(driver) {
		return nil
	}
	return fmt.Errorf("invalid docker buildx driver: %s", driver)
}

// Skip implements Skipper.
func (Base) Skip(ctx *context.Context) bool {
	return len(ctx.Config.DockersV2) == 0 || skips.Any(ctx, skips.Docker)
}

// Skip implements Skipper.
func (p Snapshot) Skip(ctx *context.Context) bool {
	if p.Base.Skip(ctx) {
		return true
	}
	if ctx.Snapshot {
		return false
	}
	if skips.Any(ctx, skips.Publish) {
		log.Warn(
			logext.Keyword("dockers_v2") +
				logext.Warning(" builds and pushes images in a single step, so no images will be built while publishing is skipped, check ") +
				logext.URL("https://goreleaser.com/customization/package/dockers_v2/#building-and-pushing-are-a-single-step") +
				logext.Warning(" for more info"),
		)
	}
	return true
}

// Default implements defaults.Defaulter.
func (Base) Default(ctx *context.Context) error {
	ids := ids.New("dockersv2")
	for i := range ctx.Config.DockersV2 {
		docker := &ctx.Config.DockersV2[i]
		if docker.ID == "" {
			docker.ID = ctx.Config.ProjectName
		}
		if docker.Dockerfile == "" {
			docker.Dockerfile = "Dockerfile"
		}
		if len(docker.Tags) == 0 {
			docker.Tags = []string{"{{.Tag}}"}
		}
		if len(docker.Platforms) == 0 {
			docker.Platforms = []string{"linux/amd64", "linux/arm64"}
		}
		if docker.SBOM == "" {
			docker.SBOM = "true"
		}
		if docker.Retry.Attempts > 0 ||
			docker.Retry.MaxDelay > 0 ||
			docker.Retry.Delay > 0 {
			deprecate.Notice(ctx, "dockers_v2.retry")
		}
		docker.Retry.Attempts = cmp.Or(docker.Retry.Attempts, ctx.Config.Retry.Attempts)
		docker.Retry.Delay = cmp.Or(docker.Retry.Delay, ctx.Config.Retry.Delay)
		docker.Retry.MaxDelay = cmp.Or(docker.Retry.MaxDelay, ctx.Config.Retry.MaxDelay)

		ids.Inc(docker.ID)
	}
	return ids.Validate()
}

// Run implements pipeline.Piper.
func (p Snapshot) Run(ctx *context.Context) error {
	checkBuildxDriver(ctx)
	log.Warn("snapshot build: will not push any images")

	canLoad := isDockerDaemonAvailable(ctx)
	if !canLoad {
		log.Warn("no docker daemon available, build result will only remain in the build cache")
	}

	g := semerrgroup.NewSkipAware(semerrgroup.New(ctx.Parallelism))
	for i := range ctx.Config.DockersV2 {
		d := ctx.Config.DockersV2[i]

		if !canLoad {
			// not running on a docker daemon, `--load` won't work, and
			// without it, images will have `--output=type=cacheonly`.
			g.Go(func() error {
				return buildImage(ctx, d)
			})
			continue
		}

		// buildx won't allow us to `--load` a manifest, so we create
		// one image per platform, adding it to the tags.
		for _, plat := range d.Platforms {
			g.Go(func() error {
				d := d
				d.Platforms = []string{plat}
				return buildImage(ctx, d, "--load")
			})
		}
	}
	return g.Wait()
}

// Publish implements publish.Publisher.
func (p Publish) Publish(ctx *context.Context) error {
	checkBuildxDriver(ctx)
	g := semerrgroup.NewSkipAware(semerrgroup.New(ctx.Parallelism))
	for _, d := range ctx.Config.DockersV2 {
		g.Go(func() error {
			extraArgs, err := p.extraArgs(ctx, d)
			if err != nil {
				return fmt.Errorf("dockers_v2.sbom: %w", err)
			}
			return buildImage(ctx, d, extraArgs...)
		})
	}
	err := g.Wait()
	// reports whatever was actually pushed, even if another configuration was
	// skipped or failed.
	for _, img := range ctx.Artifacts.Filter(artifact.ByType(artifact.DockerImageV2)).List() {
		summary.Appendf("Pushed Docker image `%s`", img.Name)
	}
	return err
}

func (Publish) extraArgs(ctx *context.Context, d config.DockerV2) ([]string, error) {
	sbom, err := tmpl.New(ctx).Bool(d.SBOM)
	if err != nil {
		return nil, fmt.Errorf("dockers_v2.sbom: %w", err)
	}
	extraArgs := []string{"--push"}
	if sbom {
		extraArgs = append(extraArgs, "--attest=type=sbom")
	}
	return extraArgs, nil
}

func buildImage(ctx *context.Context, d config.DockerV2, extraArgs ...string) error {
	tpl := tmpl.New(ctx)
	if err := tpl.ApplySlice(&d.Platforms, tmpl.NonEmpty()); err != nil {
		return err
	}
	if len(d.Platforms) == 0 {
		return pipe.Skip("no platforms to build")
	}

	disable, err := tpl.Bool(d.Disable)
	if err != nil {
		return err
	}
	if disable {
		return pipe.Skip("configuration is disabled")
	}

	da, err := makeArgs(ctx, d, extraArgs)
	if err != nil {
		return err
	}

	log.WithField("id", d.ID).
		WithField("images", strings.Join(da.images, "\n")).
		Info("creating images")

	wd, err := makeContext(d, contextArtifacts(ctx, d), da.dockerfile)
	if err != nil {
		return err
	}
	defer os.RemoveAll(wd)

	hookFields := tmpl.Fields{
		keyImages:     da.images,
		keyDockerfile: da.dockerfile,
		keyContextDir: wd,
	}
	if err := runHook(ctx, hookFields, d.Hooks.Pre); err != nil {
		return fmt.Errorf("pre hook failed: %w", err)
	}

	digest, err := doBuild(ctx, d, wd, da.args)
	if err != nil {
		return err
	}

	hookFields[keyDigest] = digest
	if err := runHook(ctx, hookFields, d.Hooks.Post); err != nil {
		return fmt.Errorf("post hook failed: %w", err)
	}

	log.WithField("id", d.ID).
		WithField("images", strings.Join(da.images, "\n")).
		WithField("digest", digest).
		Info("created images")

	for _, img := range da.images {
		ctx.Artifacts.Add(&artifact.Artifact{
			Name: img,
			Path: img,
			Type: artifact.DockerImageV2,
			Extra: map[string]any{
				artifact.ExtraID:     d.ID,
				artifact.ExtraDigest: digest,
				ExtraPlatforms:       slices.Clone(d.Platforms),
			},
		})
	}

	return nil
}

func doBuild(ctx *context.Context, d config.DockerV2, wd string, arg []string) (string, error) {
	if err := retryx.Do(
		ctx,
		d.Retry,
		func() error {
			log.WithField("arg", arg).
				Debug("running docker build")
			cmd := exec.CommandContext(ctx, "docker", arg...)
			cmd.Dir = wd
			cmd.Env = append(ctx.Env.Strings(), cmd.Environ()...)
			var b bytes.Buffer
			w := gio.Safe(&b)
			cmd.Stderr = redact.Writer(io.MultiWriter(logext.NewWriter(), w), cmd.Env)
			cmd.Stdout = redact.Writer(io.MultiWriter(logext.NewWriter(), w), cmd.Env)
			if err := cmd.Run(); err != nil {
				if isFileNotFoundError(b.String()) {
					return gerrors.Wrap(
						err,
						gerrors.WithMessage("could not build docker image"),
						gerrors.WithOutput(b.String()),
						gerrors.WithDetails(
							"id", d.ID,
							"details", fileNotFoundDetails(wd),
						),
					)
				}
				return gerrors.Wrap(
					err,
					gerrors.WithMessage("could not build docker image"),
					gerrors.WithOutput(b.String()),
					gerrors.WithDetails(
						"args", strings.Join(cmd.Args, " "),
						"id", d.ID,
					),
				)
			}
			return nil
		},
		IsRetriableBuild,
	); err != nil {
		return "", err
	}

	digest, err := os.ReadFile(filepath.Join(wd, "id.txt"))
	if err != nil {
		return "", gerrors.Wrap(
			err,
			gerrors.WithMessage("could not get image digest"),
			gerrors.WithDetails("id", d.ID),
		)
	}
	return string(digest), nil
}

type dockerArgs struct {
	dockerfile string
	args       []string
	images     []string
}

func makeArgs(ctx *context.Context, d config.DockerV2, extraArgs []string) (dockerArgs, error) {
	tpl := tmpl.New(ctx)

	dockerfile, err := tpl.Apply(d.Dockerfile)
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid dockerfile: %w", err)
	}

	baseImg, err := getBaseImage(ctx, dockerfile)
	if err != nil && !errors.Is(err, errNoBaseImage) {
		log.WithField("dockerfile", d.Dockerfile).
			WithError(err).
			Debug("could not resolve base image")
	}

	tpl = tpl.WithExtraFields(tmpl.Fields{
		keyBaseImage:       baseImg.name,
		keyBaseImageDigest: baseImg.digest,
	})

	images, err := tpl.Slice(d.Images, tmpl.NonEmpty())
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid images: %w", err)
	}
	if len(images) == 0 {
		return dockerArgs{}, pipe.Skip("no images")
	}
	tags, err := tpl.Slice(d.Tags, tmpl.NonEmpty())
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid tags: %w", err)
	}
	if len(tags) == 0 {
		return dockerArgs{}, errors.New("no tags provided")
	}
	// Append the -platform bit to non-empty tags.
	if len(d.Platforms) == 1 && ctx.Snapshot {
		suffix := tagSuffix(d.Platforms[0])
		for j := range tags {
			tags[j] += "-" + suffix
		}
	}
	allImages := makeImageList(images, tags)

	labelFlags, err := tplMapFlags(tpl, "--label", d.Labels)
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid labels: %w", err)
	}

	annotationFlags, err := tplMapFlags(tpl, "--annotation", d.Annotations)
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid annotations: %w", err)
	}
	if len(d.Platforms) > 1 {
		for i := 1; i < len(annotationFlags); i += 2 {
			if !hasAnnotationScope(annotationFlags[i]) {
				annotationFlags[i] = "index:" + annotationFlags[i]
			}
		}
	}

	buildFlags, err := tplMapFlags(tpl, "--build-arg", d.BuildArgs)
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid build args: %w", err)
	}

	flags, err := tpl.Slice(d.Flags, tmpl.NonEmpty())
	if err != nil {
		return dockerArgs{}, fmt.Errorf("invalid flags: %w", err)
	}

	arg := []string{
		"buildx",
		"build",
		"--platform", strings.Join(d.Platforms, ","),
	}
	for _, img := range allImages {
		arg = append(arg, "-t", img)
	}
	arg = append(arg, extraArgs...)
	arg = append(arg, "--iidfile=id.txt")
	arg = append(arg, labelFlags...)
	arg = append(arg, annotationFlags...)
	arg = append(arg, buildFlags...)
	arg = append(arg, flags...)
	arg = append(arg, ".")
	return dockerArgs{
		dockerfile: dockerfile,
		args:       arg,
		images:     allImages,
	}, nil
}

func makeImageList(imgs, tags []string) []string {
	result := map[string]struct{}{}
	for _, i := range imgs {
		for _, t := range tags {
			result[i+":"+t] = struct{}{}
		}
	}
	keys := slices.Collect(maps.Keys(result))
	slices.Sort(keys)
	return keys
}

// makeContext creates a new temporary directory, copies the artifacts and any
// extra files, returning its path.
//
// The caller is responsible for removing the temporary directory.
func makeContext(d config.DockerV2, artifacts []*artifact.Artifact, dockerfile string) (string, error) {
	if len(artifacts) == 0 {
		log.Warn("no binaries or packages found for the given platform - COPY/ADD may not work")
	}

	if strings.TrimSpace(dockerfile) == "" {
		return "", pipe.Skip("no dockerfile")
	}

	tmp, err := os.MkdirTemp("", "goreleaserdocker")
	if err != nil {
		return "", fmt.Errorf("failed to create temporary dir: %w", err)
	}

	if err := gio.Copy(dockerfile, filepath.Join(tmp, "Dockerfile")); err != nil {
		return "", fmt.Errorf("failed to copy dockerfile: %w: %s", err, d.ID)
	}

	if markers := findRootProjectExtraFiles(d.ExtraFiles); len(markers) > 0 {
		emitExtraFilesWarning(markers)
	}

	for _, file := range d.ExtraFiles {
		if err := os.MkdirAll(filepath.Join(tmp, filepath.Dir(file)), 0o755); err != nil {
			return "", fmt.Errorf("failed to copy extra file '%s': %w", file, err)
		}
		if err := gio.Copy(file, filepath.Join(tmp, file)); err != nil {
			return "", fmt.Errorf("failed to copy extra file '%s': %w", file, err)
		}
	}

	for _, art := range artifacts {
		// if it's an "all" goos (e.g. python artifact), we make it available
		// for all platforms being built.
		if art.Goos == "all" {
			for _, plat := range d.Platforms {
				target := filepath.Join(tmp, plat, art.Name)
				if err := copyArtifact(art.Path, target); err != nil {
					return "", err
				}
			}
			continue
		}

		plat, err := toPlatform(art)
		if err != nil {
			return "", fmt.Errorf("failed to make dir for artifact: %w", err)
		}
		target := filepath.Join(tmp, plat, art.Name)
		if err := copyArtifact(art.Path, target); err != nil {
			return "", err
		}
	}

	return tmp, nil
}

func copyArtifact(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return fmt.Errorf("failed to make dir for artifact: %w", err)
	}

	if err := gio.Copy(src, dst); err != nil {
		return fmt.Errorf("failed to copy artifact: %w", err)
	}
	return nil
}

func contextArtifacts(ctx *context.Context, d config.DockerV2) []*artifact.Artifact {
	var platFilters []artifact.Filter
	for _, p := range d.Platforms {
		plat := parsePlatform(p)
		filters := []artifact.Filter{
			artifact.ByGoos(plat.os),
			artifact.ByGoarch(plat.arch),
		}
		if plat.arm != "" {
			filters = append(filters, artifact.ByGoarm(plat.arm))
		}
		platFilters = append(platFilters, artifact.And(filters...))
	}

	filters := []artifact.Filter{
		artifact.Or(platFilters...),
		artifact.ByTypes(
			artifact.Binary,
			artifact.LinuxPackage,
			artifact.CArchive,
			artifact.CShared,
		),
		artifact.ByIDs(d.IDs...),
	}

	artifacts := ctx.Artifacts.Filter(
		artifact.Or(
			artifact.And(filters...),
			artifact.ByType(artifact.PyWheel),
		),
	)

	return artifacts.List()
}

func tagSuffix(plat string) string {
	plat = plat[strings.Index(plat, "/")+1:]
	plat = strings.ReplaceAll(plat, "/", "")
	return plat
}

func toPlatform(a *artifact.Artifact) (string, error) {
	var parts []string
	switch a.Goos {
	case "linux", "windows":
		parts = append(parts, a.Goos)
	default:
		return "", fmt.Errorf("unsupported OS: %q", a.Goos)
	}
	switch a.Goarch {
	case "amd64", "arm64", "386", "ppc64le", "s390x", "riscv64":
		parts = append(parts, a.Goarch)
	case "arm":
		parts = append(parts, a.Goarch)
		switch a.Goarm {
		case "5", "6", "7":
			parts = append(parts, "v"+a.Goarm)
		default:
			return "", fmt.Errorf("unsupported arch: arm/v%q", a.Goarm)
		}
	default:
		return "", fmt.Errorf("unsupported arch: %q", a.Goarch)
	}
	return path.Join(parts...), nil
}

type platform struct {
	os, arch string
	arm      string
}

func parsePlatform(p string) platform {
	parts := strings.Split(p, "/")
	result := platform{
		os: parts[0],
	}
	if len(parts) >= 2 {
		result.arch = parts[1]
	}
	if len(parts) >= 3 {
		result.arm = strings.TrimPrefix(parts[2], "v")
	}
	return result
}

// annotationScopes are the annotation types buildx accepts, optionally
// qualified with a platform, e.g. `manifest[linux/amd64]`.
var annotationScopes = []string{
	"index",
	"manifest",
	"index-descriptor",
	"manifest-descriptor",
}

// hasAnnotationScope reports whether a `key=value` annotation already carries a
// buildx scope prefix.
//
// buildx parses annotations as `[types:]key=value`, in which types is a
// comma-separated list of [annotationScopes]. Keys with any other prefix are
// scoped by us, as buildx would reject them.
func hasAnnotationScope(annotation string) bool {
	key, _, _ := strings.Cut(annotation, "=")
	scopes, _, ok := strings.Cut(key, ":")
	if !ok {
		return false
	}
	for scope := range strings.SplitSeq(scopes, ",") {
		scope, _, _ = strings.Cut(scope, "[")
		if !slices.Contains(annotationScopes, scope) {
			return false
		}
	}
	return true
}

// tplMapFlags templates all keys and values in the given map, returning a
// slice of them with the [flag] prefix.
//
// It'll also sort keys so the resulting slice is always in the same order.
// Finally, it will also skip entries with either an empty key or value.
func tplMapFlags(tpl *tmpl.Template, flag string, m map[string]string) ([]string, error) {
	var result []string
	keys := slices.Collect(maps.Keys(m))
	slices.Sort(keys)
	for _, k := range keys {
		v := m[k]
		if err := tpl.ApplyAll(&k, &v); err != nil {
			return nil, err
		}
		if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
			continue
		}
		result = append(result, flag, k+"="+v)
	}
	return result, nil
}

// IsRetriableBuild reports whether a failed docker build is worth retrying.
//
// Besides genuine network errors, docker builds routinely fail for transient
// reasons a retry can recover from: pulling base images may hit registry rate
// limits or 5xx responses, and installing packages from other sources (apt,
// apk, ...) inside RUN steps may hit flaky mirrors or DNS hiccups.
func IsRetriableBuild(err error) bool {
	if err == nil {
		return false
	}
	// For build errors we care about the command output, which docker v2 keeps
	// in the detailed error; v1 embeds it in the error message itself.
	var out string
	if de, ok := errors.AsType[gerrors.ErrDetailed](err); ok {
		out = de.Output()
	} else {
		out = err.Error()
	}
	if retryx.IsNetworkError(errors.New(out)) {
		return true
	}
	out = strings.ToLower(out)
	return slices.ContainsFunc(retriableBuildOutputs, func(s string) bool {
		return strings.Contains(out, s)
	})
}

// retriableBuildOutputs are lower-cased substrings that, when found in a docker
// build output, indicate a transient failure worth retrying.
var retriableBuildOutputs = []string{
	// base image pulls / registry
	"manifest verification failed for digest",
	"toomanyrequests",
	"too many requests",
	"failed to do request",
	"error pulling image",
	"internal server error",
	"bad gateway",
	"service unavailable",
	"gateway timeout",
	"gateway time-out",
	"unexpected eof",
	// dns / name resolution
	"temporary failure in name resolution",
	"temporary failure resolving",
	"could not resolve",
	// package managers (apt, apk, yum, dnf, ...)
	"failed to fetch",
	"connection timed out",
	"could not connect to",
	"unable to connect to",
	"hash sum mismatch",
	"temporary error (try again later)",
}

func isFileNotFoundError(out string) bool {
	return strings.Contains(out, ">>> COPY") ||
		strings.Contains(out, ">>> ADD")
}

// checkBuildxDriver checks if the buildx driver is docker-container and warns if not.
func checkBuildxDriver(ctx stdctx.Context) {
	driver := getBuildxDriver(ctx)
	if isDriverValid(driver) {
		return
	}
	details := logext.Warning("docker buildx is using the ") +
		logext.Keyword(driver) +
		logext.Warning(" driver, which isn't tested and may cause issues. ") +
		logext.Warning("Consider switching to the ") +
		logext.Keyword("docker-container") +
		logext.Warning(" driver.\nLearn more at ") +
		logext.URL("https://docs.docker.com/go/attestations/")
	log.WithField("details", details).
		WithField("driver", driver).
		Warn("invalid docker buildx driver")
}

func isDriverValid(driver string) bool {
	return driver == "docker-container" || driver == "docker"
}

// testForceNoDaemon is a test-only flag to simulate daemon unavailability.
var testForceNoDaemon = false

// isDockerDaemonAvailable checks if the docker daemon is accessible.
func isDockerDaemonAvailable(ctx stdctx.Context) bool {
	if testForceNoDaemon {
		return false
	}
	cmd := exec.CommandContext(ctx, "docker", "info")
	return cmd.Run() == nil
}

// getBuildxDriver returns the current buildx driver name.
func getBuildxDriver(ctx stdctx.Context) string {
	cmd := exec.CommandContext(ctx, "docker", "buildx", "inspect")
	out, err := cmd.CombinedOutput()
	if err != nil {
		return "unknown"
	}

	for line := range strings.SplitSeq(string(out), "\n") {
		if !strings.HasPrefix(line, "Driver:") {
			continue
		}
		parts := strings.Fields(line)
		if len(parts) >= 2 {
			return parts[1]
		}
	}
	return "unknown"
}

func runHook(ctx *context.Context, fields tmpl.Fields, hooks config.Hooks) error {
	for _, hook := range hooks {
		var envs []string
		envs = append(envs, ctx.Env.Strings()...)

		tpl := tmpl.New(ctx).WithExtraFields(fields)
		for _, rawEnv := range hook.Env {
			env, err := tpl.Apply(rawEnv)
			if err != nil {
				return err
			}
			envs = append(envs, env)
		}

		tpl = tpl.WithEnvS(envs)
		dir, err := tpl.Apply(hook.Dir)
		if err != nil {
			return err
		}

		sh, err := tpl.Apply(hook.Cmd)
		if err != nil {
			return err
		}

		log.WithField("hook", sh).Info("running hook")
		cmd, err := shellwords.Parse(sh)
		if err != nil {
			return err
		}

		if err := shell.Run(ctx, dir, cmd, envs, hook.Output); err != nil {
			return err
		}
	}
	return nil
}
