package image

import (
	"archive/tar"
	"encoding/json"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"reflect"
	"sort"
	"strings"
	"testing"
	"time"

	cerrdefs "github.com/containerd/errdefs"
	"github.com/cpuguy83/tar2go"
	containertypes "github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/versions"
	"github.com/docker/docker/client"
	"github.com/docker/docker/integration/internal/build"
	"github.com/docker/docker/integration/internal/container"
	"github.com/docker/docker/internal/testutils"
	"github.com/docker/docker/internal/testutils/specialimage"
	"github.com/docker/docker/testutil/fakecontext"
	"github.com/moby/go-archive/compression"
	"github.com/opencontainers/go-digest"
	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
	"gotest.tools/v3/assert"
	is "gotest.tools/v3/assert/cmp"
	"gotest.tools/v3/skip"
)

type imageSaveManifestEntry struct {
	Config   string
	RepoTags []string
	Layers   []string
}

func tarIndexFS(t *testing.T, rdr io.Reader) fs.FS {
	t.Helper()

	dir := t.TempDir()

	f, err := os.Create(filepath.Join(dir, "image.tar"))
	assert.NilError(t, err)

	// Do not close at the end of this function otherwise the indexer won't work
	t.Cleanup(func() { f.Close() })

	_, err = io.Copy(f, rdr)
	assert.NilError(t, err)

	return tar2go.NewIndex(f).FS()
}

func TestSaveCheckTimes(t *testing.T) {
	ctx := setupTest(t)

	t.Parallel()
	client := testEnv.APIClient()

	const repoName = "busybox:latest"
	img, err := client.ImageInspect(ctx, repoName)
	assert.NilError(t, err)

	rdr, err := client.ImageSave(ctx, []string{repoName})
	assert.NilError(t, err)

	tarfs := tarIndexFS(t, rdr)

	dt, err := fs.ReadFile(tarfs, "manifest.json")
	assert.NilError(t, err)

	var ls []imageSaveManifestEntry
	assert.NilError(t, json.Unmarshal(dt, &ls))
	assert.Assert(t, is.Len(ls, 1))

	info, err := fs.Stat(tarfs, ls[0].Config)
	assert.NilError(t, err)

	created, err := time.Parse(time.RFC3339, img.Created)
	assert.NilError(t, err)

	if testEnv.UsingSnapshotter() {
		// containerd archive export sets the mod time to zero.
		assert.Check(t, is.Equal(info.ModTime(), time.Unix(0, 0)))
	} else {
		assert.Check(t, is.Equal(info.ModTime().Format(time.RFC3339), created.Format(time.RFC3339)))
	}
}

// Regression test for https://github.com/moby/moby/issues/47065
func TestSaveOCI(t *testing.T) {
	skip.If(t, versions.LessThan(testEnv.DaemonAPIVersion(), "1.44"), "OCI layout support was introduced in v25")

	ctx := setupTest(t)
	client := testEnv.APIClient()

	const busybox = "busybox:latest"
	inspectBusybox, err := client.ImageInspect(ctx, busybox)
	assert.NilError(t, err)

	type testCase struct {
		image                 string
		expectedOCIRef        string
		expectedContainerdRef string
	}

	testCases := []testCase{
		// Busybox by tagged name
		{image: busybox, expectedContainerdRef: "docker.io/library/busybox:latest", expectedOCIRef: "latest"},

		// Busybox by ID
		{image: inspectBusybox.ID},
	}

	if testEnv.DaemonInfo.OSType != "windows" {
		multiLayerImage := specialimage.Load(ctx, t, client, specialimage.MultiLayer)
		// Multi-layer image
		testCases = append(testCases, testCase{image: multiLayerImage, expectedContainerdRef: "docker.io/library/multilayer:latest", expectedOCIRef: "latest"})

	}

	// Busybox frozen image will have empty RepoDigests when loaded into the
	// graphdriver image store so we can't use it.
	// This will work with the containerd image store though.
	if len(inspectBusybox.RepoDigests) > 0 {
		// Digested reference
		testCases = append(testCases, testCase{
			image: inspectBusybox.RepoDigests[0],
		})
	}

	for _, tc := range testCases {
		t.Run(tc.image, func(t *testing.T) {
			// Get information about the original image.
			inspect, err := client.ImageInspect(ctx, tc.image)
			assert.NilError(t, err)

			rdr, err := client.ImageSave(ctx, []string{tc.image})
			assert.NilError(t, err)
			defer rdr.Close()

			tarfs := tarIndexFS(t, rdr)

			indexData, err := fs.ReadFile(tarfs, "index.json")
			assert.NilError(t, err, "failed to read index.json")

			var index ocispec.Index
			assert.NilError(t, json.Unmarshal(indexData, &index), "failed to unmarshal index.json")

			// All test images are single-platform, so they should have only one manifest.
			assert.Assert(t, is.Len(index.Manifests, 1))

			manifestData, err := fs.ReadFile(tarfs, "blobs/sha256/"+index.Manifests[0].Digest.Encoded())
			assert.NilError(t, err)

			var manifest ocispec.Manifest
			assert.NilError(t, json.Unmarshal(manifestData, &manifest))

			t.Run("Manifest", func(t *testing.T) {
				assert.Check(t, is.Len(manifest.Layers, len(inspect.RootFS.Layers)))

				var digests []string
				// Check if layers referenced by the manifest exist in the archive
				// and match the layers from the original image.
				for _, l := range manifest.Layers {
					layerPath := "blobs/sha256/" + l.Digest.Encoded()
					stat, err := fs.Stat(tarfs, layerPath)
					assert.NilError(t, err)

					assert.Check(t, is.Equal(l.Size, stat.Size()))

					f, err := tarfs.Open(layerPath)
					assert.NilError(t, err)

					layerDigest, err := testutils.UncompressedTarDigest(f)
					f.Close()

					assert.NilError(t, err)

					digests = append(digests, layerDigest.String())
				}

				assert.Check(t, is.DeepEqual(digests, inspect.RootFS.Layers))
			})

			t.Run("Config", func(t *testing.T) {
				configData, err := fs.ReadFile(tarfs, "blobs/sha256/"+manifest.Config.Digest.Encoded())
				assert.NilError(t, err)

				var config ocispec.Image
				assert.NilError(t, json.Unmarshal(configData, &config))

				var diffIDs []string
				for _, l := range config.RootFS.DiffIDs {
					diffIDs = append(diffIDs, l.String())
				}

				assert.Check(t, is.DeepEqual(diffIDs, inspect.RootFS.Layers))
			})

			t.Run("Containerd image name", func(t *testing.T) {
				assert.Check(t, is.Equal(index.Manifests[0].Annotations["io.containerd.image.name"], tc.expectedContainerdRef))
			})

			t.Run("OCI reference tag", func(t *testing.T) {
				assert.Check(t, is.Equal(index.Manifests[0].Annotations["org.opencontainers.image.ref.name"], tc.expectedOCIRef))
			})
		})
	}
}

// TODO(thaJeztah): this test currently only checks invalid cases; update this test to use a table-test and test both valid and invalid platform options.
func TestSavePlatform(t *testing.T) {
	ctx := setupTest(t)

	t.Parallel()
	apiClient := testEnv.APIClient()

	const repoName = "busybox:latest"
	_, err := apiClient.ImageInspect(ctx, repoName)
	assert.NilError(t, err)

	_, err = apiClient.ImageSave(ctx, []string{repoName}, client.ImageSaveWithPlatforms(
		ocispec.Platform{Architecture: "amd64", OS: "linux"},
		ocispec.Platform{Architecture: "arm64", OS: "linux", Variant: "v8"},
	))
	assert.Check(t, is.ErrorType(err, cerrdefs.IsInvalidArgument))
	assert.Check(t, is.Error(err, "Error response from daemon: multiple platform parameters not supported"))
}

func TestSaveRepoWithMultipleImages(t *testing.T) {
	ctx := setupTest(t)
	client := testEnv.APIClient()

	makeImage := func(from string, tag string) string {
		id := container.Create(ctx, t, client, func(cfg *container.TestContainerConfig) {
			cfg.Config.Image = from
			cfg.Config.Cmd = []string{"true"}
		})

		res, err := client.ContainerCommit(ctx, id, containertypes.CommitOptions{Reference: tag})
		assert.NilError(t, err)

		err = client.ContainerRemove(ctx, id, containertypes.RemoveOptions{Force: true})
		assert.NilError(t, err)

		return res.ID
	}

	busyboxImg, err := client.ImageInspect(ctx, "busybox:latest")
	assert.NilError(t, err)

	const repoName = "foobar-save-multi-images-test"
	const tagFoo = repoName + ":foo"
	const tagBar = repoName + ":bar"

	idFoo := makeImage("busybox:latest", tagFoo)
	idBar := makeImage("busybox:latest", tagBar)
	idBusybox := busyboxImg.ID

	rdr, err := client.ImageSave(ctx, []string{repoName, "busybox:latest"})
	assert.NilError(t, err)
	defer rdr.Close()

	tarfs := tarIndexFS(t, rdr)

	dt, err := fs.ReadFile(tarfs, "manifest.json")
	assert.NilError(t, err)

	var mfstLs []imageSaveManifestEntry
	assert.NilError(t, json.Unmarshal(dt, &mfstLs))

	actual := make([]string, 0, len(mfstLs))
	for _, m := range mfstLs {
		actual = append(actual, strings.TrimPrefix(m.Config, "blobs/sha256/"))
		// make sure the blob actually exists
		_, err = fs.Stat(tarfs, m.Config)
		assert.Check(t, err)
	}

	expected := []string{idBusybox, idFoo, idBar}
	// prefixes are not in tar
	for i := range expected {
		expected[i] = digest.Digest(expected[i]).Encoded()
	}

	// With snapshotters, ID of the image is the ID of the manifest/index
	// With graphdrivers, ID of the image is the ID of the image config
	if testEnv.UsingSnapshotter() {
		// ID of image won't match the Config ID from manifest.json
		// Just check if manifests exist in blobs
		for _, blob := range expected {
			_, err = fs.Stat(tarfs, "blobs/sha256/"+blob)
			assert.Check(t, err)
		}
	} else {
		sort.Strings(actual)
		sort.Strings(expected)
		assert.Assert(t, is.DeepEqual(actual, expected), "archive does not contains the right layers: got %v, expected %v", actual, expected)
	}
}

func TestSaveDirectoryPermissions(t *testing.T) {
	skip.If(t, testEnv.DaemonInfo.OSType == "windows", "Test is looking at linux specific details")

	ctx := setupTest(t)
	client := testEnv.APIClient()

	layerEntries := []string{"opt/", "opt/a/", "opt/a/b/", "opt/a/b/c"}
	layerEntriesAUFS := []string{"./", ".wh..wh.aufs", ".wh..wh.orph/", ".wh..wh.plnk/", "opt/", "opt/a/", "opt/a/b/", "opt/a/b/c"}

	dockerfile := `FROM busybox
RUN adduser -D user && mkdir -p /opt/a/b && chown -R user:user /opt/a
RUN touch /opt/a/b/c && chown user:user /opt/a/b/c`

	imgID := build.Do(ctx, t, client, fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile)))

	rdr, err := client.ImageSave(ctx, []string{imgID})
	assert.NilError(t, err)
	defer rdr.Close()

	tarfs := tarIndexFS(t, rdr)

	dt, err := fs.ReadFile(tarfs, "manifest.json")
	assert.NilError(t, err)

	var mfstLs []imageSaveManifestEntry
	assert.NilError(t, json.Unmarshal(dt, &mfstLs))

	var found bool

	for _, p := range mfstLs[0].Layers {
		var entriesSansDev []string

		f, err := tarfs.Open(p)
		assert.NilError(t, err)

		entries, err := listTar(f)
		f.Close()
		assert.NilError(t, err)

		for _, e := range entries {
			if !strings.Contains(e, "dev/") {
				entriesSansDev = append(entriesSansDev, e)
			}
		}
		assert.NilError(t, err, "encountered error while listing tar entries: %s", err)

		if reflect.DeepEqual(entriesSansDev, layerEntries) || reflect.DeepEqual(entriesSansDev, layerEntriesAUFS) {
			found = true
			break
		}
	}

	assert.Assert(t, found, "failed to find the layer with the right content listing")
}

func listTar(f io.Reader) ([]string, error) {
	// If using the containerd snapshotter, the tar file may be compressed
	dec, err := compression.DecompressStream(f)
	if err != nil {
		return nil, err
	}
	defer dec.Close()

	tr := tar.NewReader(dec)
	var entries []string

	for {
		th, err := tr.Next()
		if err == io.EOF {
			// end of tar archive
			return entries, nil
		}
		if err != nil {
			return entries, err
		}
		entries = append(entries, th.Name)
	}
}
