package pkg

import (
	"context"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/anchore/syft/syft/cpe"
	"github.com/anchore/syft/syft/file"
)

func TestIDUniqueness(t *testing.T) {
	ctx := context.TODO()
	originalLocation := file.NewVirtualLocationFromCoordinates(
		file.Coordinates{
			RealPath:     "39.0742° N, 21.8243° E",
			FileSystemID: "Earth",
		},
		"/Ancient-Greece",
	)

	originalPkg := Package{
		Name:    "pi",
		Version: "3.14",
		FoundBy: "Archimedes",
		Locations: file.NewLocationSet(
			originalLocation,
		),
		Licenses: NewLicenseSet(
			NewLicenseWithContext(ctx, "MIT"),
			NewLicenseWithContext(ctx, "cc0-1.0"),
		),
		Language: "math",
		Type:     PythonPkg,
		CPEs: []cpe.CPE{
			cpe.Must(`cpe:2.3:a:Archimedes:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource),
		},
		PURL: "pkg:pypi/pi@3.14",
		Metadata: PythonPackage{
			Name:                 "pi",
			Version:              "3.14",
			Author:               "Archimedes",
			AuthorEmail:          "Archimedes@circles.io",
			Platform:             "universe",
			SitePackagesRootPath: "Pi",
		},
	}

	// this is a set of differential tests, ensuring that select mutations are reflected in the fingerprint (or not)
	tests := []struct {
		name                 string
		transform            func(pkg Package) Package
		expectedIDComparison assert.ComparisonAssertionFunc
	}{
		{
			name: "go case (no transform)",
			transform: func(pkg Package) Package {
				// do nothing!
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "same metadata is ignored",
			transform: func(pkg Package) Package {
				// note: this is the same as the original values, just a new allocation
				pkg.Metadata = PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				}
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "licenses order is ignored",
			transform: func(pkg Package) Package {
				// note: same as the original package, only a different order
				pkg.Licenses = NewLicenseSet(
					NewLicenseWithContext(ctx, "cc0-1.0"),
					NewLicenseWithContext(ctx, "MIT"),
				)
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "name is reflected",
			transform: func(pkg Package) Package {
				pkg.Name = "new!"
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "location is reflected",
			transform: func(pkg Package) Package {
				locations := file.NewLocationSet(pkg.Locations.ToSlice()...)
				locations.Add(file.NewLocation("/somewhere/new"))
				pkg.Locations = locations
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "licenses is reflected",
			transform: func(pkg Package) Package {
				pkg.Licenses = NewLicenseSet(NewLicenseWithContext(ctx, "new!"))
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "same path for different filesystem is NOT reflected",
			transform: func(pkg Package) Package {
				newLocation := originalLocation
				newLocation.FileSystemID = "Mars"

				pkg.Locations = file.NewLocationSet(newLocation)
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "multiple equivalent paths for different filesystem is NOT reflected",
			transform: func(pkg Package) Package {
				newLocation := originalLocation
				newLocation.FileSystemID = "Mars"

				locations := file.NewLocationSet(pkg.Locations.ToSlice()...)
				locations.Add(newLocation, originalLocation)

				pkg.Locations = locations
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "version is reflected",
			transform: func(pkg Package) Package {
				pkg.Version = "new!"
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "type is reflected",
			transform: func(pkg Package) Package {
				pkg.Type = RustPkg
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "CPEs is ignored",
			transform: func(pkg Package) Package {
				pkg.CPEs = []cpe.CPE{}
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "pURL is ignored",
			transform: func(pkg Package) Package {
				pkg.PURL = "new!"
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "language is NOT reflected",
			transform: func(pkg Package) Package {
				pkg.Language = Rust
				return pkg
			},
			expectedIDComparison: assert.Equal,
		},
		{
			name: "metadata mutation is reflected",
			transform: func(pkg Package) Package {
				metadata := pkg.Metadata.(PythonPackage)
				metadata.Name = "new!"
				pkg.Metadata = metadata
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "new metadata is reflected",
			transform: func(pkg Package) Package {
				pkg.Metadata = PythonPackage{
					Name: "new!",
				}
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
		{
			name: "nil metadata is reflected",
			transform: func(pkg Package) Package {
				pkg.Metadata = nil
				return pkg
			},
			expectedIDComparison: assert.NotEqual,
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			originalPkg.SetID()
			transformedPkg := test.transform(originalPkg)
			transformedPkg.SetID()

			originalFingerprint := originalPkg.ID()
			assert.NotEmpty(t, originalFingerprint)
			transformedFingerprint := transformedPkg.ID()
			assert.NotEmpty(t, transformedFingerprint)

			test.expectedIDComparison(t, originalFingerprint, transformedFingerprint)
		})
	}
}

func TestPackage_Merge(t *testing.T) {
	originalLocation := file.NewVirtualLocationFromCoordinates(
		file.Coordinates{
			RealPath:     "39.0742° N, 21.8243° E",
			FileSystemID: "Earth",
		},
		"/Ancient-Greece",
	)

	similarLocation := originalLocation
	similarLocation.FileSystemID = "Mars"

	tests := []struct {
		name     string
		subject  Package
		other    Package
		expected *Package
	}{
		{
			name: "merge two packages (different cpes + locations)",
			subject: Package{
				Name:    "pi",
				Version: "3.14",
				FoundBy: "Archimedes",
				Locations: file.NewLocationSet(
					originalLocation,
				),
				Language: "math",
				Type:     PythonPkg,
				CPEs: []cpe.CPE{
					cpe.Must(`cpe:2.3:a:Archimedes:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource),
				},
				PURL: "pkg:pypi/pi@3.14",
				Metadata: PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				},
			},
			other: Package{
				Name:    "pi",
				Version: "3.14",
				FoundBy: "Archimedes",
				Locations: file.NewLocationSet(
					similarLocation, // NOTE: difference; we have a different layer but the same path
				),
				Language: "math",
				Type:     PythonPkg,
				CPEs: []cpe.CPE{
					cpe.Must(`cpe:2.3:a:DIFFERENT:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource), // NOTE: difference
				},
				PURL: "pkg:pypi/pi@3.14",
				Metadata: PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				},
			},
			expected: &Package{
				Name:    "pi",
				Version: "3.14",
				FoundBy: "Archimedes",
				Locations: file.NewLocationSet(
					originalLocation,
					similarLocation, // NOTE: merge!
				),
				Language: "math",
				Type:     PythonPkg,
				CPEs: []cpe.CPE{
					cpe.Must(`cpe:2.3:a:Archimedes:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource),
					cpe.Must(`cpe:2.3:a:DIFFERENT:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource), // NOTE: merge!
				},
				PURL: "pkg:pypi/pi@3.14",
				Metadata: PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				},
			},
		},
		{
			name: "error when there are different IDs",
			subject: Package{
				Name:    "pi",
				Version: "3.14",
				FoundBy: "Archimedes",
				Locations: file.NewLocationSet(
					originalLocation,
				),
				Language: "math",
				Type:     PythonPkg,
				CPEs: []cpe.CPE{
					cpe.Must(`cpe:2.3:a:Archimedes:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource),
				},
				PURL: "pkg:pypi/pi@3.14",
				Metadata: PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				},
			},
			other: Package{
				Name:    "pi-DIFFERENT", // difference
				Version: "3.14",
				FoundBy: "Archimedes",
				Locations: file.NewLocationSet(
					originalLocation,
				),
				Language: "math",
				Type:     PythonPkg,
				CPEs: []cpe.CPE{
					cpe.Must(`cpe:2.3:a:Archimedes:pi:3.14:*:*:*:*:math:*:*`, cpe.NVDDictionaryLookupSource),
				},
				PURL: "pkg:pypi/pi@3.14",
				Metadata: PythonPackage{
					Name:                 "pi",
					Version:              "3.14",
					Author:               "Archimedes",
					AuthorEmail:          "Archimedes@circles.io",
					Platform:             "universe",
					SitePackagesRootPath: "Pi",
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			tt.subject.SetID()
			tt.other.SetID()

			err := tt.subject.merge(tt.other)
			if tt.expected == nil {
				require.Error(t, err)
				return
			}
			require.NoError(t, err)

			tt.expected.SetID()
			require.Equal(t, tt.expected.id, tt.subject.id)

			if diff := cmp.Diff(*tt.expected, tt.subject,
				cmp.AllowUnexported(Package{}),
				cmp.Comparer(
					func(x, y file.LocationSet) bool {
						xs := x.ToSlice()
						ys := y.ToSlice()

						if len(xs) != len(ys) {
							return false
						}
						for i, xe := range xs {
							ye := ys[i]
							if !locationComparer(xe, ye) {
								return false
							}
						}

						return true
					},
				),
				cmp.Comparer(
					func(x, y LicenseSet) bool {
						xs := x.ToSlice()
						ys := y.ToSlice()

						if len(xs) != len(ys) {
							return false
						}
						for i, xe := range xs {
							ye := ys[i]
							if !licenseComparer(xe, ye) {
								return false
							}
						}

						return true
					},
				),
				cmp.Comparer(locationComparer),
			); diff != "" {
				t.Errorf("unexpected result from parsing (-expected +actual)\n%s", diff)
			}
		})
	}
}

func licenseComparer(x, y License) bool {
	return cmp.Equal(x, y, cmp.Comparer(locationComparer))
}

func locationComparer(x, y file.Location) bool {
	return cmp.Equal(x.Coordinates, y.Coordinates) && cmp.Equal(x.AccessPath, y.AccessPath)
}

func TestIsValid(t *testing.T) {
	cases := []struct {
		name  string
		given *Package
		want  bool
	}{
		{
			name:  "nil",
			given: nil,
			want:  false,
		},
		{
			name:  "has-name",
			given: &Package{Name: "paul"},
			want:  true,
		},
		{
			name:  "has-no-name",
			given: &Package{},
			want:  false,
		},
	}

	for _, c := range cases {
		require.Equal(t, c.want, IsValid(c.given), "when package: %s", c.name)
	}
}
