package manifest

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

	"github.com/opencontainers/go-digest"
	imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"go.podman.io/image/v5/pkg/compression"
	compressionTypes "go.podman.io/image/v5/pkg/compression/types"
	"go.podman.io/image/v5/types"
)

func TestOCI1IndexPublicFromManifest(t *testing.T) {
	validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
	require.NoError(t, err)

	parser := func(m []byte) error {
		_, err := OCI1IndexPublicFromManifest(m)
		return err
	}
	// Schema mismatch is rejected
	testManifestFixturesAreRejected(t, parser, []string{
		"schema2-to-schema1-by-docker.json",
		"v2s2.manifest.json",
		// Not "v2list.manifest.json" yet, without mediaType the two are too similar to tell the difference.
		"ociv1.manifest.json",
	})
	// Extra fields are rejected
	testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
}

func TestOCI1IndexFromManifest(t *testing.T) {
	validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
	require.NoError(t, err)

	parser := func(m []byte) error {
		_, err := OCI1IndexFromManifest(m)
		return err
	}
	// Schema mismatch is rejected
	testManifestFixturesAreRejected(t, parser, []string{
		"schema2-to-schema1-by-docker.json",
		"v2s2.manifest.json",
		// Not "v2list.manifest.json" yet, without mediaType the two are too similar to tell the difference.
		"ociv1.manifest.json",
	})
	// Extra fields are rejected
	testValidManifestWithExtraFieldsIsRejected(t, parser, validManifest, []string{"config", "fsLayers", "history", "layers"})
}

func TestOCI1EditInstances(t *testing.T) {
	validManifest, err := os.ReadFile(filepath.Join("testdata", "ociv1.image.index.json"))
	require.NoError(t, err)
	list, err := ListFromBlob(validManifest, GuessMIMEType(validManifest))
	require.NoError(t, err)

	expectedDigests := list.Instances()
	editInstances := []ListEdit{}
	editInstances = append(editInstances, ListEdit{
		UpdateOldDigest: list.Instances()[0],
		UpdateDigest:    "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
		UpdateSize:      32,
		UpdateMediaType: "something",
		ListOperation:   ListOpUpdate,
	})
	err = list.EditInstances(editInstances, false)
	require.NoError(t, err)

	expectedDigests[0] = editInstances[0].UpdateDigest
	// order of old elements must remain same.
	assert.Equal(t, list.Instances(), expectedDigests)

	instance, err := list.Instance(list.Instances()[0])
	require.NoError(t, err)
	assert.Equal(t, "something", instance.MediaType)
	assert.Equal(t, int64(32), instance.Size)
	// platform must match with what was set in `ociv1.image.index.json` for the first instance
	assert.Equal(t, &imgspecv1.Platform{Architecture: "ppc64le", OS: "linux", OSVersion: "", OSFeatures: []string(nil), Variant: ""}, instance.ReadOnly.Platform)
	assert.Equal(t, []string{compressionTypes.GzipAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)

	// Create a fresh list
	list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
	require.NoError(t, err)

	// Verify correct zstd sorting
	editInstances = []ListEdit{}
	annotations := map[string]string{"io.github.containers.compression.zstd": "true"}
	// without zstd
	editInstances = append(editInstances, ListEdit{
		AddDigest:     "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
		AddSize:       32,
		AddMediaType:  "application/vnd.oci.image.manifest.v1+json",
		AddPlatform:   &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
		ListOperation: ListOpAdd,
	})
	// with zstd
	editInstances = append(editInstances, ListEdit{
		AddDigest:       "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
		AddSize:         32,
		AddMediaType:    "application/vnd.oci.image.manifest.v1+json",
		AddArtifactType: "application/x-tar",
		AddPlatform:     &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
		AddAnnotations:  annotations,
		ListOperation:   ListOpAdd,
	})
	// with zstd but with compression, annotation must be added automatically
	editInstances = append(editInstances, ListEdit{
		AddDigest:                "sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh",
		AddSize:                  32,
		AddMediaType:             "application/vnd.oci.image.manifest.v1+json",
		AddPlatform:              &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
		AddCompressionAlgorithms: []compression.Algorithm{compression.Zstd},
		AddAnnotations:           map[string]string{},
		ListOperation:            ListOpAdd,
	})
	// with zstd but with compression, annotation must be added automatically and AddAnnotations is unset
	editInstances = append(editInstances, ListEdit{
		AddDigest:                "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
		AddSize:                  32,
		AddMediaType:             "application/vnd.oci.image.manifest.v1+json",
		AddPlatform:              &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
		AddCompressionAlgorithms: []compression.Algorithm{compression.Zstd},
		ListOperation:            ListOpAdd,
	})
	// without zstd
	editInstances = append(editInstances, ListEdit{
		AddDigest:     "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
		AddSize:       32,
		AddMediaType:  "application/vnd.oci.image.manifest.v1+json",
		AddPlatform:   &imgspecv1.Platform{Architecture: "amd64", OS: "linux", OSFeatures: []string{"sse4"}},
		ListOperation: ListOpAdd,
	})
	err = list.EditInstances(editInstances, false)
	require.NoError(t, err)

	// Zstd should be kept on lowest priority as compared to the default gzip ones and order of prior elements must be preserved.
	assert.Equal(t, list.Instances(), []digest.Digest{digest.Digest("sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"), digest.Digest("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270"), digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), digest.Digest("sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh"), digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")})

	instance, err = list.Instance(digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
	require.NoError(t, err)
	// Verify if annotations are preserved and correctly set in ReadOnly field.
	assert.Equal(t, annotations, instance.ReadOnly.Annotations)
	// Verify artifactType is preserved.
	assert.Equal(t, "application/x-tar", instance.ReadOnly.ArtifactType)
	// Verify compression of an instance is added to the ReadOnly CompressionAlgorithmNames where compression name
	// is internally derived from the appropriate annotations.
	assert.Equal(t, []string{compressionTypes.ZstdAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)

	// Update list and remove zstd annotation from existing instance, and verify if resorting works
	editInstances = []ListEdit{}
	editInstances = append(editInstances, ListEdit{
		UpdateOldDigest:         digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"),
		UpdateDigest:            "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
		UpdateSize:              32,
		UpdateMediaType:         "application/vnd.oci.image.manifest.v1+json",
		UpdateAffectAnnotations: true,
		UpdateAnnotations:       map[string]string{},
		ListOperation:           ListOpUpdate,
	})
	err = list.EditInstances(editInstances, false)
	require.NoError(t, err)
	// Digest `ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff` should be re-ordered on update.
	assert.Equal(t, list.Instances(), []digest.Digest{digest.Digest("sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"), digest.Digest("sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270"), digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), digest.Digest("sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"), digest.Digest("sha256:hhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhhh")})

	instance, err = list.Instance(digest.Digest("sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"))
	require.NoError(t, err)
	// Verify if annotations are preserved and correctly set in ReadOnly field.
	assert.Equal(t, annotations, instance.ReadOnly.Annotations)

	// Create a fresh list
	artifactBearingManifest, err := os.ReadFile(filepath.Join("testdata", "oci1index.json"))
	require.NoError(t, err)
	list, err = ListFromBlob(artifactBearingManifest, GuessMIMEType(artifactBearingManifest))
	require.NoError(t, err)
	// Edit the parts of the instance that we can change.
	editInstances = []ListEdit{{
		ListOperation:           ListOpUpdate,
		UpdateOldDigest:         digest.Digest("sha256:615e8db5ae085b39e9cd24e5bc887d03fbd30ee4f55f5fedf9b697fafea4fbdd"),
		UpdateDigest:            "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
		UpdateSize:              32,
		UpdateMediaType:         "application/vnd.oci.image.manifest.v1+json",
		UpdateAffectAnnotations: true,
		UpdateAnnotations:       map[string]string{},
	}}
	err = list.EditInstances(editInstances, false)
	require.NoError(t, err)
	// Verify that the artifactType wasn't lost.
	instance, err = list.Instance(digest.Digest("sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"))
	require.NoError(t, err)
	assert.Equal(t, "application/x-tar", instance.ReadOnly.ArtifactType)

	// Updating the zstd annotation for an existing instance works
	for _, cannotModifyManifest := range []bool{false, true} {
		list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
		require.NoError(t, err)
		originalInstance, err := list.Instance(list.Instances()[0])
		require.NoError(t, err)
		editInstances = []ListEdit{{
			ListOperation:               ListOpUpdate,
			UpdateOldDigest:             originalInstance.Digest,
			UpdateDigest:                originalInstance.Digest,
			UpdateSize:                  originalInstance.Size,
			UpdateMediaType:             originalInstance.MediaType,
			UpdateAffectAnnotations:     false,
			UpdateAnnotations:           nil,
			UpdateCompressionAlgorithms: []compression.Algorithm{compression.Zstd},
		}}
		err = list.EditInstances(editInstances, cannotModifyManifest)
		require.NoError(t, err)
		instance, err = list.Instance(list.Instances()[0])
		require.NoError(t, err)

		if cannotModifyManifest {
			assert.Equal(t, originalInstance, instance) // No changes
		} else {
			assert.Equal(t, "true", instance.ReadOnly.Annotations["io.github.containers.compression.zstd"])
			assert.Equal(t, []string{compressionTypes.ZstdAlgorithmName}, instance.ReadOnly.CompressionAlgorithmNames)
			// These are the only changes:
			delete(instance.ReadOnly.Annotations, "io.github.containers.compression.zstd")
			require.Empty(t, instance.ReadOnly.Annotations)
			instance.ReadOnly.Annotations = nil
			instance.ReadOnly.CompressionAlgorithmNames = []string{compressionTypes.GzipAlgorithmName}
			assert.Equal(t, originalInstance, instance)
		}
	}

	// Create a fresh list for delete tests
	list, err = ListFromBlob(validManifest, GuessMIMEType(validManifest))
	require.NoError(t, err)
	originalInstances := list.Instances()
	require.GreaterOrEqual(t, len(originalInstances), 1)

	// Test successful deletion of first instance
	err = list.EditInstances([]ListEdit{{
		ListOperation: ListOpDelete,
		DeleteIndex:   0,
	}}, false)
	require.NoError(t, err)
	assert.Equal(t, originalInstances[1:], list.Instances())

	// Test error on invalid index
	err = list.EditInstances([]ListEdit{{
		ListOperation: ListOpDelete,
		DeleteIndex:   999,
	}}, false)
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "invalid delete index")
}

func TestOCI1IndexChooseInstanceByCompression(t *testing.T) {
	type expectedMatch struct {
		arch, variant  string
		instanceDigest digest.Digest
		preferGzip     bool
	}
	for _, manifestList := range []struct {
		listFile           string
		matchedInstances   []expectedMatch
		unmatchedInstances []string
	}{
		{
			listFile: "oci1.index.zstd-selection.json",
			matchedInstances: []expectedMatch{
				// out of gzip and zstd in amd64 select the first zstd image
				{"amd64", "", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", false},
				// out of multiple gzip in arm64 select the first one to ensure original logic is prevented
				{"arm64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", false},
				// select a single gzip s390x image
				{"s390x", "", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", false},
				// out of gzip and zstd in amd64 select the first gzip image
				{"amd64", "", "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
				// out of multiple gzip in arm64 select the first one to ensure original logic is prevented
				{"arm64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", true},
				// select a single gzip s390x image
				{"s390x", "", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", true},
			},
			unmatchedInstances: []string{
				"unmatched",
			},
		},
		{ // Focus on ARM variant field testing
			listFile: "ocilist-variants.json",
			matchedInstances: []expectedMatch{
				{"amd64", "", "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610", false},
				{"arm", "v7", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", false},
				{"arm", "v6", "sha256:f365626a556e58189fc21d099fc64603db0f440bff07f77c740989515c544a39", false},
				{"arm", "v5", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
				{"arm", "", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
				{"arm", "unrecognized-present", "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990", false},
				{"arm", "unrecognized-not-present", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", false},
				// preferGzip true
				{"amd64", "", "sha256:59eec8837a4d942cc19a52b8c09ea75121acc38114a2c68b98983ce9356b8610", true},
				{"arm", "v7", "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", true},
				{"arm", "v6", "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", true},
				{"arm", "v5", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
				{"arm", "", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
				{"arm", "unrecognized-present", "sha256:bcf9771c0b505e68c65440474179592ffdfa98790eb54ffbf129969c5e429990", true},
				{"arm", "unrecognized-not-present", "sha256:c84b0a3a07b628bc4d62e5047d0f8dff80f7c00979e1e28a821a033ecda8fe53", true},
			},
			unmatchedInstances: []string{
				"unmatched",
			},
		},
		{
			listFile: "oci1.index.zstd-selection2.json",
			// out of list where first instance is gzip , select the first occurrence of zstd out of many
			matchedInstances: []expectedMatch{
				{"amd64", "", "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", false},
				{"amd64", "", "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
				// must return first gzip even if the first entry is zstd
				{"arm64", "", "sha256:6dc14a60d2ba724646cfbf5fccbb9a618a5978a64a352e060b17caf5e005da9d", true},
				// must return first zstd even if the first entry for same platform is gzip
				{"arm64", "", "sha256:1c98002b30a71b08ab175915ce7c8fb8da9e9b502ae082d6f0c572bac9dee324", false},
				// must return first zstd instance with no platform
				{"matchesImageWithNoPlatform", "", "sha256:f2f5f52a2cf2c51d4cac6df0545f751c0adc3f3427eb47c59fcb32894503e18f", false},
				// must return first gzip instance with no platform
				{"matchesImageWithNoPlatform", "", "sha256:c76757bb6006babdd8464dbf2f1157fdfa6fead0bc6f84f15816a32d6f68f706", true},
			},
		},
		{
			listFile: "oci1index.json",
			matchedInstances: []expectedMatch{
				{"amd64", "", "sha256:5b0bcabd1ed22e9fb1310cf6c2dec7cdef19f0ad69efa1f392e94a4333501270", false},
				{"ppc64le", "", "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f", false},
			},
			unmatchedInstances: []string{
				"unmatched",
			},
		},
	} {
		rawManifest, err := os.ReadFile(filepath.Join("testdata", manifestList.listFile))
		require.NoError(t, err)
		list, err := ListFromBlob(rawManifest, GuessMIMEType(rawManifest))
		require.NoError(t, err)
		for _, match := range manifestList.matchedInstances {
			testName := fmt.Sprintf("%s %q+%q", manifestList.listFile, match.arch, match.variant)
			digest, err := list.ChooseInstanceByCompression(&types.SystemContext{
				ArchitectureChoice: match.arch,
				VariantChoice:      match.variant,
				OSChoice:           "linux",
			}, types.NewOptionalBool(match.preferGzip))
			require.NoError(t, err, testName)
			assert.Equal(t, match.instanceDigest, digest, testName)
		}
		for _, arch := range manifestList.unmatchedInstances {
			_, err := list.ChooseInstanceByCompression(&types.SystemContext{
				ArchitectureChoice: arch,
				OSChoice:           "linux",
			}, types.NewOptionalBool(false))
			assert.Error(t, err)
		}
	}
}

func TestOCI1IndexCloneInternal(t *testing.T) {
	// This fixture should be kept updated to have all known fields set to non-empty values
	blob, err := os.ReadFile(filepath.Join("testdata", "oci1.index.everything.json"))
	require.NoError(t, err)
	m, err := OCI1IndexFromManifest(blob)
	require.NoError(t, err)
	clone_ := m.CloneInternal()
	clone, ok := clone_.(*OCI1Index)
	require.True(t, ok)
	assert.Equal(t, m.OCI1IndexPublic.Index, clone.OCI1IndexPublic.Index)
}
