package ko

import (
	"fmt"
	"maps"
	"strconv"
	"strings"
	"testing"
	"time"

	_ "github.com/distribution/distribution/v3/registry/auth/htpasswd"
	_ "github.com/distribution/distribution/v3/registry/storage/driver/inmemory"
	"github.com/google/go-containerregistry/pkg/name"
	"github.com/google/go-containerregistry/pkg/v1/remote"
	"github.com/goreleaser/goreleaser/v2/internal/artifact"
	"github.com/goreleaser/goreleaser/v2/internal/skips"
	"github.com/goreleaser/goreleaser/v2/internal/testctx"
	"github.com/goreleaser/goreleaser/v2/internal/testlib"
	"github.com/goreleaser/goreleaser/v2/pkg/config"
	"github.com/goreleaser/goreleaser/v2/pkg/context"
	"github.com/stretchr/testify/require"
)

const (
	registryPort = "5052"
	registry     = "localhost:5052/"
)

func TestDefault(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		Env: []string{
			"KO_DOCKER_REPO=" + registry,
			"COSIGN_REPOSITORY=" + registry,
			"LDFLAGS=foobar",
			"FLAGS=barfoo",
			"LE_ENV=test",
		},
		ProjectName: "test",
		Builds: []config.Build{
			{
				ID:  "test",
				Dir: ".",
				BuildDetails: config.BuildDetails{
					Ldflags: []string{"{{.Env.LDFLAGS}}"},
					Flags:   []string{"{{.Env.FLAGS}}"},
					Env:     []string{"SOME_ENV={{.Env.LE_ENV}}"},
				},
			},
		},
		Kos: []config.Ko{
			{},
		},
	})
	require.NoError(t, Pipe{}.Default(ctx))
	require.Equal(t, config.Ko{
		ID:         "test",
		Build:      "test",
		BaseImage:  chainguardStatic,
		Repository: registry,
		Platforms:  []string{"linux/amd64"},
		SBOM:       "spdx",
		Tags:       []string{"latest"},
		WorkingDir: ".",
		Ldflags:    []string{"{{.Env.LDFLAGS}}"},
		Flags:      []string{"{{.Env.FLAGS}}"},
		Env:        []string{"SOME_ENV={{.Env.LE_ENV}}"},
	}, ctx.Config.Kos[0])
}

func TestDefaultCycloneDX(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		ProjectName: "test",
		Env:         []string{"KO_DOCKER_REPO=" + registry},
		Kos: []config.Ko{
			{SBOM: "cyclonedx"},
		},
		Builds: []config.Build{
			{ID: "test"},
		},
	})
	require.NoError(t, Pipe{}.Default(ctx))
	require.True(t, ctx.Deprecated)
	require.Equal(t, "none", ctx.Config.Kos[0].SBOM)
}

func TestDefaultGoVersionM(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		ProjectName: "test",
		Env:         []string{"KO_DOCKER_REPO=" + registry},
		Kos: []config.Ko{
			{SBOM: "go.version-m"},
		},
		Builds: []config.Build{
			{ID: "test"},
		},
	})
	require.NoError(t, Pipe{}.Default(ctx))
	require.True(t, ctx.Deprecated)
	require.Equal(t, "none", ctx.Config.Kos[0].SBOM)
}

func TestDefaultNoImage(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		ProjectName: "test",
		Builds: []config.Build{
			{
				ID: "test",
			},
		},
		Kos: []config.Ko{
			{},
		},
	})
	require.ErrorIs(t, Pipe{}.Default(ctx), errNoRepository)
}

func TestDescription(t *testing.T) {
	require.NotEmpty(t, Pipe{}.String())
}

func TestSkip(t *testing.T) {
	t.Run("skip ko set", func(t *testing.T) {
		ctx := testctx.NewWithCfg(config.Project{
			Kos: []config.Ko{{}},
		}, testctx.Skip(skips.Ko))
		require.True(t, Pipe{}.Skip(ctx))
	})
	t.Run("skip no kos", func(t *testing.T) {
		ctx := testctx.New()
		require.True(t, Pipe{}.Skip(ctx))
	})
	t.Run("dont skip", func(t *testing.T) {
		ctx := testctx.NewWithCfg(config.Project{
			Kos: []config.Ko{{}},
		})
		require.False(t, Pipe{}.Skip(ctx))
	})
}

func TestPublishPipeNoMatchingBuild(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		Builds: []config.Build{
			{
				ID: "doesnt matter",
			},
		},
		Kos: []config.Ko{
			{
				ID:    "default",
				Build: "wont match nothing",
			},
		},
	})

	require.EqualError(t, Pipe{}.Default(ctx), `no builds with id "wont match nothing"`)
}

func TestPublishPipeSuccess(t *testing.T) {
	testlib.CheckPath(t, "docker")
	testlib.StartRegistry(t, "ko_registry", registryPort)

	chainguardStaticLabels := map[string]string{
		"org.opencontainers.image.authors": "Chainguard Team https://www.chainguard.dev/",
		"org.opencontainers.image.source":  "https://github.com/chainguard-images/images/tree/main/images/static",
		"org.opencontainers.image.url":     "https://images.chainguard.dev/directory/image/static/overview",
		"org.opencontainers.image.vendor":  "Chainguard",
		"org.opencontainers.image.created": ".*",
	}
	baseImageAnnotations := map[string]string{
		"org.opencontainers.image.base.name":   ".*",
		"org.opencontainers.image.base.digest": ".*",
	}

	table := []struct {
		Name                string
		SBOM                string
		BaseImage           string
		Labels              map[string]string
		ExpectedLabels      map[string]string
		Annotations         map[string]string
		ExpectedAnnotations map[string]string
		User                string
		Platforms           []string
		Tags                []string
		CreationTime        string
		KoDataCreationTime  string
	}{
		{
			// Must be first as others add an SBOM for the same image
			Name: "sbom-none",
			SBOM: "none",
		},
		{
			Name: "sbom-spdx",
			SBOM: "spdx",
		},
		{
			Name:      "base-image-is-not-index",
			BaseImage: "alpine:latest@sha256:c0d488a800e4127c334ad20d61d7bc21b4097540327217dfab52262adc02380c",
		},
		{
			Name:      "multiple-platforms",
			Platforms: []string{"linux/amd64", "linux/arm64"},
		},
		{
			Name:           "labels",
			Labels:         map[string]string{"foo": "bar", "project": "{{.ProjectName}}"},
			ExpectedLabels: map[string]string{"foo": "bar", "project": "test"},
		},
		{
			Name:                "annotations",
			Annotations:         map[string]string{"foo": "bar", "project": "{{.ProjectName}}"},
			ExpectedAnnotations: map[string]string{"foo": "bar", "project": "test"},
		},
		{
			Name: "user",
			User: "1234:1234",
		},
		{
			Name:         "creation-time",
			CreationTime: "1672531200",
		},
		{
			Name:               "kodata-creation-time",
			KoDataCreationTime: "1672531200",
		},
		{
			Name: "tag-templates",
			Tags: []string{
				"{{if not .Prerelease }}{{.Version}}{{ end }}",
				"   ", // empty
			},
		},
		{
			Name: "tag-template-eval-empty",
			Tags: []string{
				"{{.Version}}",
				"{{if .Prerelease }}latest{{ end }}",
			},
		},
	}

	repository := fmt.Sprintf("%sgoreleasertest/testapp", registry)

	for _, table := range table {
		t.Run(table.Name, func(t *testing.T) {
			if len(table.Tags) == 0 {
				table.Tags = []string{table.Name}
			}
			ctx := testctx.NewWithCfg(config.Project{
				ProjectName: "test",
				Builds: []config.Build{
					{
						ID: "foo",
						BuildDetails: config.BuildDetails{
							Ldflags: []string{"-s", "-w"},
							Flags:   []string{"-tags", "netgo"},
							Env:     []string{"GOCACHE=" + t.TempDir()},
						},
					},
				},
				Kos: []config.Ko{
					{
						ID:                 "default",
						Build:              "foo",
						WorkingDir:         "./testdata/app/",
						BaseImage:          table.BaseImage,
						Repository:         repository,
						Labels:             table.Labels,
						Annotations:        table.Annotations,
						User:               table.User,
						Platforms:          table.Platforms,
						Tags:               table.Tags,
						CreationTime:       table.CreationTime,
						KoDataCreationTime: table.KoDataCreationTime,
						SBOM:               table.SBOM,
						Bare:               true,
					},
				},
			}, testctx.WithVersion("1.2.0"))

			if table.BaseImage == "" {
				if table.User == "" {
					table.User = "65532"
				}
				table.ExpectedLabels = mergeMaps(table.ExpectedLabels, chainguardStaticLabels)
			}
			table.ExpectedAnnotations = mergeMaps(table.ExpectedAnnotations, baseImageAnnotations)

			require.NoError(t, Pipe{}.Default(ctx))
			require.NoError(t, Pipe{}.Publish(ctx))

			manifests := ctx.Artifacts.Filter(artifact.ByType(artifact.DockerManifest)).List()
			require.Len(t, manifests, 1)
			require.NotEmpty(t, manifests[0].Name)
			require.Equal(t, manifests[0].Name, manifests[0].Path)
			require.NotEmpty(t, manifests[0].Extra[artifact.ExtraDigest])
			require.Equal(t, "default", manifests[0].Extra[artifact.ExtraID])

			tags, err := applyTemplate(ctx, table.Tags)
			require.NoError(t, err)
			tags = removeEmpty(tags)
			require.Len(t, tags, 1)

			ref, err := name.ParseReference(
				fmt.Sprintf("%s:latest", repository),
				name.Insecure,
			)
			require.NoError(t, err)
			_, err = remote.Index(ref)
			require.Error(t, err) // latest should not exist

			ref, err = name.ParseReference(
				fmt.Sprintf("%s:%s", repository, tags[0]),
				name.Insecure,
			)
			require.NoError(t, err)

			index, err := remote.Index(ref)
			if len(table.Platforms) > 1 {
				require.NoError(t, err)
				imf, err := index.IndexManifest()
				require.NoError(t, err)

				compareMaps(t, table.ExpectedAnnotations, imf.Annotations)

				platforms := make([]string, 0, len(imf.Manifests))
				for _, mf := range imf.Manifests {
					platforms = append(platforms, mf.Platform.String())
				}
				require.ElementsMatch(t, table.Platforms, platforms)
			} else {
				require.Error(t, err)
			}

			image, err := remote.Image(ref)
			require.NoError(t, err)

			digest, err := image.Digest()
			require.NoError(t, err)

			sbomRef, err := name.ParseReference(
				fmt.Sprintf(
					"%s:%s.sbom",
					repository,
					strings.Replace(digest.String(), ":", "-", 1),
				),
				name.Insecure,
			)
			require.NoError(t, err)

			sbom, err := remote.Image(sbomRef)
			if table.SBOM == "none" {
				require.Error(t, err)
			} else {
				require.NoError(t, err)

				layers, err := sbom.Layers()
				require.NoError(t, err)
				require.NotEmpty(t, layers)

				mediaType, err := layers[0].MediaType()
				require.NoError(t, err)

				switch table.SBOM {
				case "spdx", "":
					require.Equal(t, "text/spdx+json", string(mediaType))
				default:
					require.Fail(t, "unknown SBOM type", table.SBOM)
				}
			}

			mf, err := image.Manifest()
			require.NoError(t, err)

			expectedAnnotations := table.ExpectedAnnotations
			if table.BaseImage == "" {
				expectedAnnotations = mergeMaps(
					expectedAnnotations,
					chainguardStaticLabels,
				)
			}
			compareMaps(t, expectedAnnotations, mf.Annotations)

			configFile, err := image.ConfigFile()
			require.NoError(t, err)
			require.GreaterOrEqual(t, len(configFile.History), 3)

			compareMaps(t, table.ExpectedLabels, configFile.Config.Labels)
			require.Equal(t, table.User, configFile.Config.User)

			var creationTime time.Time
			if table.CreationTime != "" {
				ct, err := strconv.ParseInt(table.CreationTime, 10, 64)
				require.NoError(t, err)
				creationTime = time.Unix(ct, 0).UTC()

				require.Equal(t, creationTime, configFile.Created.Time.UTC())
			}
			require.Equal(t, creationTime, configFile.History[len(configFile.History)-1].Created.Time.UTC())

			var koDataCreationTime time.Time
			if table.KoDataCreationTime != "" {
				kdct, err := strconv.ParseInt(table.KoDataCreationTime, 10, 64)
				require.NoError(t, err)
				koDataCreationTime = time.Unix(kdct, 0).UTC()
			}
			require.Equal(t, koDataCreationTime, configFile.History[len(configFile.History)-2].Created.Time.UTC())
		})
	}
}

func TestSnapshot(t *testing.T) {
	ctx := testctx.NewWithCfg(config.Project{
		ProjectName: "test",
		Builds: []config.Build{
			{
				ID: "foo",
				BuildDetails: config.BuildDetails{
					Ldflags: []string{"-s", "-w"},
					Flags:   []string{"-tags", "netgo"},
					Env:     []string{"GOCACHE=" + t.TempDir()},
				},
			},
		},
		Kos: []config.Ko{
			{
				ID:         "default",
				Build:      "foo",
				Repository: "testimage",
				WorkingDir: "./testdata/app/",
				Tags:       []string{"latest"},
			},
		},
	}, testctx.WithVersion("1.2.0"), testctx.Snapshot)

	require.NoError(t, Pipe{}.Default(ctx))
	require.NoError(t, Pipe{}.Run(ctx))

	manifests := ctx.Artifacts.Filter(artifact.ByType(artifact.DockerManifest)).List()
	require.Len(t, manifests, 1)
	require.NotEmpty(t, manifests[0].Name)
	require.Equal(t, manifests[0].Name, manifests[0].Path)
	require.NotEmpty(t, manifests[0].Extra[artifact.ExtraDigest])
	require.Equal(t, "default", manifests[0].Extra[artifact.ExtraID])
}

func TestKoValidateMainPathIssue4382(t *testing.T) {
	// testing the validation of the main path directly to cover many cases
	require.NoError(t, validateMainPath(""))
	require.NoError(t, validateMainPath("."))
	require.NoError(t, validateMainPath("./..."))
	require.NoError(t, validateMainPath("./app"))
	require.NoError(t, validateMainPath("../../../..."))
	require.NoError(t, validateMainPath("../../app/"))
	require.NoError(t, validateMainPath("./testdata/app/main"))
	require.NoError(t, validateMainPath("./testdata/app/folder.with.dots"))

	require.ErrorIs(t, validateMainPath("app/"), errInvalidMainPath)
	require.ErrorIs(t, validateMainPath("/src/"), errInvalidMainPath)
	require.ErrorIs(t, validateMainPath("/src/app"), errInvalidMainPath)
	require.ErrorIs(t, validateMainPath("./testdata/app/main.go"), errInvalidMainGoPath)

	// testing with real context
	ctxOk := testctx.NewWithCfg(config.Project{
		Builds: []config.Build{
			{
				ID:   "foo",
				Main: "./...",
			},
		},
		Kos: []config.Ko{
			{
				ID:         "default",
				Build:      "foo",
				Repository: "fakerepo",
			},
		},
	})
	require.NoError(t, Pipe{}.Default(ctxOk))

	ctxWithInvalidMainPath := testctx.NewWithCfg(config.Project{
		Builds: []config.Build{
			{
				ID:   "foo",
				Main: "/some/non/relative/path",
			},
		},
		Kos: []config.Ko{
			{
				ID:         "default",
				Build:      "foo",
				Repository: "fakerepo",
			},
		},
	})
	require.ErrorIs(t, Pipe{}.Default(ctxWithInvalidMainPath), errInvalidMainPath)
}

func TestPublishPipeError(t *testing.T) {
	makeCtx := func() *context.Context {
		return testctx.NewWithCfg(config.Project{
			Builds: []config.Build{
				{
					ID:   "foo",
					Main: "./...",
				},
			},
			Kos: []config.Ko{
				{
					ID:         "default",
					Build:      "foo",
					WorkingDir: "./testdata/app/",
					Repository: "fakerepo:8080/",
					Tags:       []string{"latest", "{{.Tag}}"},
				},
			},
		}, testctx.WithCurrentTag("v1.0.0"))
	}

	t.Run("invalid base image", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].BaseImage = "not a valid image hopefully"
		require.NoError(t, Pipe{}.Default(ctx))
		require.EqualError(t, Pipe{}.Publish(ctx), `build: fetching base image: could not parse reference: not a valid image hopefully`)
	})

	t.Run("invalid label tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].Labels = map[string]string{"nope": "{{.Nope}}"}
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid sbom", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].SBOM = "nope"
		require.NoError(t, Pipe{}.Default(ctx))
		require.EqualError(t, Pipe{}.Publish(ctx), `makeBuilder: unknown sbom type: "nope"`)
	})

	t.Run("invalid build", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].WorkingDir = t.TempDir()
		require.NoError(t, Pipe{}.Default(ctx))
		require.EqualError(
			t, Pipe{}.Publish(ctx),
			"build: build: go build: exit status 1: pattern ./...: directory prefix . does not contain main module or its selected dependencies\n",
		)
	})

	t.Run("invalid tags tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].Tags = []string{"{{.Nope}}"}
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid creation time", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].CreationTime = "nope"
		require.NoError(t, Pipe{}.Default(ctx))
		err := Pipe{}.Publish(ctx)
		require.ErrorContains(t, err, `strconv.ParseInt: parsing "nope": invalid syntax`)
	})

	t.Run("invalid creation time tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].CreationTime = "{{.Nope}}"
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid kodata creation time", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].KoDataCreationTime = "nope"
		require.NoError(t, Pipe{}.Default(ctx))
		err := Pipe{}.Publish(ctx)
		require.ErrorContains(t, err, `strconv.ParseInt: parsing "nope": invalid syntax`)
	})

	t.Run("invalid kodata creation time tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Kos[0].KoDataCreationTime = "{{.Nope}}"
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid env tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Builds[0].Env = []string{"{{.Nope}}"}
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid ldflags tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Builds[0].Ldflags = []string{"{{.Nope}}"}
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("invalid flags tmpl", func(t *testing.T) {
		ctx := makeCtx()
		ctx.Config.Builds[0].Flags = []string{"{{.Nope}}"}
		require.NoError(t, Pipe{}.Default(ctx))
		testlib.RequireTemplateError(t, Pipe{}.Publish(ctx))
	})

	t.Run("publish fail", func(t *testing.T) {
		ctx := makeCtx()
		require.NoError(t, Pipe{}.Default(ctx))
		err := Pipe{}.Publish(ctx)
		require.Error(t, err)
		require.Contains(t, err.Error(), `Get "https://fakerepo:8080/v2/": dial tcp:`)
	})
}

func TestApplyTemplate(t *testing.T) {
	t.Run("success", func(t *testing.T) {
		foo, err := applyTemplate(testctx.NewWithCfg(config.Project{
			Env: []string{"FOO=bar"},
		}), []string{"{{ .Env.FOO }}"})
		require.NoError(t, err)
		require.Equal(t, []string{"bar"}, foo)
	})
	t.Run("error", func(t *testing.T) {
		_, err := applyTemplate(testctx.New(), []string{"{{ .Nope}}"})
		require.Error(t, err)
	})
}

func mergeMaps(ms ...map[string]string) map[string]string {
	result := map[string]string{}
	for _, m := range ms {
		if m != nil {
			maps.Copy(result, m)
		}
	}
	return result
}

func compareMaps(t *testing.T, expected, actual map[string]string) {
	t.Helper()
	require.Len(t, actual, len(expected), "expected: %v", expected)
	for k, v := range expected {
		got, ok := actual[k]
		require.True(t, ok, "missing key: %s", k)
		require.Regexp(t, v, got, "key: %s", k)
	}
}