package binary

import (
	"path"
	"strings"
	"testing"

	"github.com/google/go-cmp/cmp"
	"github.com/google/go-cmp/cmp/cmpopts"

	"github.com/anchore/syft/internal/sbomsync"
	"github.com/anchore/syft/syft/artifact"
	"github.com/anchore/syft/syft/file"
	"github.com/anchore/syft/syft/pkg"
	"github.com/anchore/syft/syft/sbom"
)

func TestPackagesToRemove(t *testing.T) {
	glibcCoordinate := file.NewCoordinates("/usr/lib64/libc.so.6", "")
	glibCPackage := pkg.Package{
		Name:    "glibc",
		Version: "2.28-236.el8_9.12",
		Locations: file.NewLocationSet(
			file.NewLocation("path/to/rpmdb"),
		),
		Type: pkg.RpmPkg,
		Metadata: pkg.RpmDBEntry{
			Files: []pkg.RpmFileRecord{
				{
					Path: glibcCoordinate.RealPath,
				},
			},
		},
	}
	glibCPackage.SetID()

	glibCBinaryELFPackage := pkg.Package{
		Name: "glibc",
		Locations: file.NewLocationSet(
			file.NewLocation(glibcCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
		),
		Type: pkg.BinaryPkg,
		Metadata: pkg.ELFBinaryPackageNoteJSONPayload{
			Type:       "testfixture",
			Vendor:     "syft",
			System:     "syftsys",
			SourceRepo: "https://github.com/someone/somewhere.git",
			Commit:     "5534c38d0ffef9a3f83154f0b7a7fb6ab0ab6dbb",
		},
	}
	glibCBinaryELFPackage.SetID()

	glibCBinaryELFPackageAsRPM := pkg.Package{
		Name: "glibc",
		Locations: file.NewLocationSet(
			file.NewLocation(glibcCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
		),
		Type: pkg.RpmPkg, // note: the elf package claims it is a RPM, not binary
		Metadata: pkg.ELFBinaryPackageNoteJSONPayload{
			Type:       "rpm",
			Vendor:     "syft",
			System:     "syftsys",
			SourceRepo: "https://github.com/someone/somewhere.git",
			Commit:     "5534c38d0ffef9a3f83154f0b7a7fb6ab0ab6dbb",
		},
	}
	glibCBinaryELFPackageAsRPM.SetID()

	glibCBinaryClassifierPackage := pkg.Package{
		Name: "glibc",
		Locations: file.NewLocationSet(
			file.NewLocation(glibcCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation),
		),
		Type:     pkg.BinaryPkg,
		Metadata: pkg.BinarySignature{},
	}
	glibCBinaryClassifierPackage.SetID()

	libCBinaryClassifierPackage := pkg.Package{
		Name: "libc",
		Locations: file.NewLocationSet(
			file.NewLocation(glibcCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
		),
		Type:     pkg.BinaryPkg,
		Metadata: pkg.BinarySignature{},
	}
	libCBinaryClassifierPackage.SetID()

	tests := []struct {
		name     string
		accessor sbomsync.Accessor
		want     []artifact.ID
	}{
		{
			name:     "remove packages that are overlapping rpm --> binary",
			accessor: newAccessor([]pkg.Package{glibCPackage, glibCBinaryELFPackage}, map[file.Coordinates]file.Executable{}, nil),
			// this is surprising, right? the calling function reasons about if any generic binary package (regardless of it being an ELF package or not)
			// should be deleted or kept based on the user configuration to do so.
			want: []artifact.ID{},
		},
		{
			name:     "keep packages that are overlapping rpm --> binary when the binary self identifies as an RPM",
			accessor: newAccessor([]pkg.Package{glibCPackage, glibCBinaryELFPackageAsRPM}, map[file.Coordinates]file.Executable{}, nil),
			want:     []artifact.ID{},
		},
		{
			name:     "remove no packages when there is a single binary package (or self identifying RPM)",
			accessor: newAccessor([]pkg.Package{glibCBinaryELFPackage, glibCBinaryELFPackageAsRPM}, map[file.Coordinates]file.Executable{}, nil),
			want:     []artifact.ID{},
		},
		{
			name:     "remove packages when there is a single binary package and a classifier package",
			accessor: newAccessor([]pkg.Package{glibCBinaryELFPackage, glibCBinaryClassifierPackage}, map[file.Coordinates]file.Executable{}, nil),
			want:     []artifact.ID{glibCBinaryClassifierPackage.ID()},
		},
		{
			name:     "ensure we're considering ELF packages, not just binary packages (supporting evidence)",
			accessor: newAccessor([]pkg.Package{glibCBinaryClassifierPackage}, map[file.Coordinates]file.Executable{}, nil),
			want:     []artifact.ID{},
		},
		{
			name:     "ensure we're considering ELF packages, not just binary packages (primary evidence)",
			accessor: newAccessor([]pkg.Package{libCBinaryClassifierPackage}, map[file.Coordinates]file.Executable{}, nil),
			want:     []artifact.ID{},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			pkgsToDelete := PackagesToRemove(tt.accessor)
			if diff := cmp.Diff(tt.want, pkgsToDelete); diff != "" {
				t.Errorf("unexpected packages to delete (-want, +got): %s", diff)
			}
		})
	}
}

func TestNewDependencyRelationships(t *testing.T) {
	// coordinates for the files under test
	glibcCoordinate := file.NewCoordinates("/usr/lib64/libc.so.6", "")
	secondGlibcCoordinate := file.NewCoordinates("/usr/local/lib64/libc.so.6", "")
	nestedLibCoordinate := file.NewCoordinates("/usr/local/bin/elftests/elfbinwithnestedlib/bin/elfbinwithnestedlib", "")
	parallelLibCoordinate := file.NewCoordinates("/usr/local/bin/elftests/elfbinwithsisterlib/bin/elfwithparallellibbin1", "")

	// rpm package that was discovered in linked section of the ELF binary package
	glibCPackage := pkg.Package{
		Name:    "glibc",
		Version: "2.28-236.el8_9.12",
		Locations: file.NewLocationSet(
			file.NewLocation(glibcCoordinate.RealPath),
			file.NewLocation("some/other/path"),
		),
		Type: pkg.RpmPkg,
		Metadata: pkg.RpmDBEntry{
			Files: []pkg.RpmFileRecord{
				{
					Path: glibcCoordinate.RealPath,
				},
				{
					Path: "some/other/path",
				},
			},
		},
	}
	glibCPackage.SetID()

	// second rpm package that could be discovered in linked section of the ELF binary package (same base path as above)
	glibCustomPackage := pkg.Package{
		Name:      "glibc",
		Version:   "2.28-236.el8_9.12",
		Locations: file.NewLocationSet(file.NewLocation(secondGlibcCoordinate.RealPath)),
		Type:      pkg.RpmPkg,
		Metadata: pkg.RpmDBEntry{
			Files: []pkg.RpmFileRecord{
				{
					Path: secondGlibcCoordinate.RealPath,
				},
			},
		},
	}
	glibCustomPackage.SetID()

	// binary package that is an executable that can link against above rpm packages
	syftTestFixturePackage := pkg.Package{
		Name:    "syfttestfixture",
		Version: "0.01",
		PURL:    "pkg:generic/syftsys/syfttestfixture@0.01",
		FoundBy: "",
		Locations: file.NewLocationSet(
			file.NewLocation(nestedLibCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.PrimaryEvidenceAnnotation),
			file.NewLocation(parallelLibCoordinate.RealPath).WithAnnotation(pkg.EvidenceAnnotationKey, pkg.SupportingEvidenceAnnotation),
		),
		Language: "",
		Type:     pkg.RpmPkg,
		Metadata: pkg.ELFBinaryPackageNoteJSONPayload{
			Type:       "rpm",
			Vendor:     "syft",
			System:     "syftsys",
			SourceRepo: "https://github.com/someone/somewhere.git",
			Commit:     "5534c38d0ffef9a3f83154f0b7a7fb6ab0ab6dbb",
		},
	}
	syftTestFixturePackage.SetID()

	// dummy executable representation of glibc
	glibcExecutable := file.Executable{
		Format:            "elf",
		HasExports:        true,
		HasEntrypoint:     true,
		ImportedLibraries: []string{},
	}

	// executable representation of the syftTestFixturePackage
	syftTestFixtureExecutable := file.Executable{
		Format:        "elf",
		HasExports:    true,
		HasEntrypoint: true,
		ImportedLibraries: []string{
			path.Base(glibcCoordinate.RealPath),
		},
	}

	// second executable representation that has no parent package
	syftTestFixtureExecutable2 := file.Executable{
		Format:        "elf",
		HasExports:    true,
		HasEntrypoint: true,
		ImportedLibraries: []string{
			// this should not be a relationship because it is not a coordinate
			"foo.so.6",
		},
	}

	tests := []struct {
		name                    string
		resolver                file.Resolver
		coordinateIndex         map[file.Coordinates]file.Executable
		packages                []pkg.Package
		prexistingRelationships []artifact.Relationship
		want                    []artifact.Relationship
	}{
		{
			name:            "blank sbom and accessor returns empty relationships",
			resolver:        nil,
			coordinateIndex: map[file.Coordinates]file.Executable{},
			packages:        []pkg.Package{},
		},
		{
			name: "given a package that imports glibc, expect a relationship between the two packages when the package is an executable",
			resolver: file.NewMockResolverForPaths(
				glibcCoordinate.RealPath,
				nestedLibCoordinate.RealPath,
				parallelLibCoordinate.RealPath,
			),
			// path -> executable (above mock resolver needs to be able to resolve to paths in this map)
			coordinateIndex: map[file.Coordinates]file.Executable{
				glibcCoordinate:       glibcExecutable,
				nestedLibCoordinate:   syftTestFixtureExecutable,
				parallelLibCoordinate: syftTestFixtureExecutable2,
			},
			packages: []pkg.Package{glibCPackage, syftTestFixturePackage},
			want: []artifact.Relationship{
				{
					From: glibCPackage,
					To:   syftTestFixturePackage,
					Type: artifact.DependencyOfRelationship,
				},
			},
		},
		{
			name: "given an executable maps to one base path represented by two RPM we make two relationships",
			resolver: file.NewMockResolverForPaths(
				glibcCoordinate.RealPath,
				secondGlibcCoordinate.RealPath,
				nestedLibCoordinate.RealPath,
				parallelLibCoordinate.RealPath,
			),
			coordinateIndex: map[file.Coordinates]file.Executable{
				glibcCoordinate:       glibcExecutable,
				secondGlibcCoordinate: glibcExecutable,
				nestedLibCoordinate:   syftTestFixtureExecutable,
				parallelLibCoordinate: syftTestFixtureExecutable2,
			},
			packages: []pkg.Package{glibCPackage, glibCustomPackage, syftTestFixturePackage},
			want: []artifact.Relationship{
				{
					From: glibCPackage,
					To:   syftTestFixturePackage,
					Type: artifact.DependencyOfRelationship,
				},
				{
					From: glibCustomPackage,
					To:   syftTestFixturePackage,
					Type: artifact.DependencyOfRelationship,
				},
			},
		},
		{
			name: "given some dependency relationships already exist, expect no duplicate relationships to be created",
			resolver: file.NewMockResolverForPaths(
				glibcCoordinate.RealPath,
				nestedLibCoordinate.RealPath,
				parallelLibCoordinate.RealPath,
			),
			coordinateIndex: map[file.Coordinates]file.Executable{
				glibcCoordinate:       glibcExecutable,
				nestedLibCoordinate:   syftTestFixtureExecutable,
				parallelLibCoordinate: syftTestFixtureExecutable2,
			},
			packages: []pkg.Package{glibCPackage, glibCustomPackage, syftTestFixturePackage},
			prexistingRelationships: []artifact.Relationship{
				{
					From: glibCPackage,
					To:   syftTestFixturePackage,
					Type: artifact.DependencyOfRelationship,
				},
			},
		},
		{
			name:     "given a package that imports a library that is not tracked by the resolver, expect no relationships to be created",
			resolver: file.NewMockResolverForPaths(),
			coordinateIndex: map[file.Coordinates]file.Executable{
				glibcCoordinate:       glibcExecutable,
				nestedLibCoordinate:   syftTestFixtureExecutable,
				parallelLibCoordinate: syftTestFixtureExecutable2,
			},
			packages: []pkg.Package{glibCPackage, syftTestFixturePackage},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			accessor := newAccessor(tt.packages, tt.coordinateIndex, tt.prexistingRelationships)
			// given a resolver that knows about the paths of the packages and executables,
			// and given an SBOM accessor that knows about the packages and executables,
			// we should be able to create a set of relationships between the packages and executables
			relationships := NewDependencyRelationships(tt.resolver, accessor)
			if diff := relationshipComparer(tt.want, relationships); diff != "" {
				t.Errorf("unexpected relationships (-want, +got): %s", diff)
			}
		})
	}
}

func relationshipComparer(x, y []artifact.Relationship) string {
	return cmp.Diff(x, y, cmpopts.IgnoreUnexported(
		pkg.Package{},
		artifact.Relationship{},
		file.LocationSet{},
		pkg.LicenseSet{},
	), cmpopts.SortSlices(lessRelationships))
}

func lessRelationships(r1, r2 artifact.Relationship) bool {
	c := strings.Compare(string(r1.Type), string(r2.Type))
	if c != 0 {
		return c < 0
	}
	c = strings.Compare(string(r1.From.ID()), string(r2.From.ID()))
	if c != 0 {
		return c < 0
	}
	c = strings.Compare(string(r1.To.ID()), string(r2.To.ID()))
	return c < 0
}

func newAccessor(pkgs []pkg.Package, coordinateIndex map[file.Coordinates]file.Executable, preexistingRelationships []artifact.Relationship) sbomsync.Accessor {
	sb := sbom.SBOM{
		Artifacts: sbom.Artifacts{
			Packages: pkg.NewCollection(),
		},
	}

	builder := sbomsync.NewBuilder(&sb)
	builder.AddPackages(pkgs...)

	accessor := builder.(sbomsync.Accessor)
	accessor.WriteToSBOM(func(s *sbom.SBOM) {
		s.Artifacts.Executables = coordinateIndex
		if preexistingRelationships != nil {
			s.Relationships = preexistingRelationships
		}
	})
	return accessor
}
