package testutils

import (
	"bytes"
	"math/rand"
	"strings"
	"testing"
	"time"

	"github.com/sergi/go-diff/diffmatchpatch"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/anchore/go-testutils"
	"github.com/anchore/stereoscope/pkg/filetree"
	"github.com/anchore/stereoscope/pkg/image"
	"github.com/anchore/stereoscope/pkg/imagetest"
	"github.com/anchore/syft/syft/artifact"
	"github.com/anchore/syft/syft/cpe"
	"github.com/anchore/syft/syft/file"
	"github.com/anchore/syft/syft/linux"
	"github.com/anchore/syft/syft/pkg"
	"github.com/anchore/syft/syft/sbom"
	"github.com/anchore/syft/syft/source"
)

type redactor func(s []byte) []byte

type imageCfg struct {
	fromSnapshot bool
}

type ImageOption func(cfg *imageCfg)

func FromSnapshot() ImageOption {
	return func(cfg *imageCfg) {
		cfg.fromSnapshot = true
	}
}

func AssertEncoderAgainstGoldenImageSnapshot(t *testing.T, format sbom.Format, sbom sbom.SBOM, testImage string, updateSnapshot bool, json bool, redactors ...redactor) {
	var buffer bytes.Buffer

	// grab the latest image contents and persist
	if updateSnapshot {
		imagetest.UpdateGoldenFixtureImage(t, testImage)
	}

	err := format.Encode(&buffer, sbom)
	assert.NoError(t, err)
	actual := buffer.Bytes()

	// replace the expected snapshot contents with the current encoder contents
	if updateSnapshot {
		testutils.UpdateGoldenFileContents(t, actual)
	}

	actual = redact(actual, redactors...)
	expected := redact(testutils.GetGoldenFileContents(t), redactors...)

	if json {
		require.JSONEq(t, string(expected), string(actual))
	} else if !bytes.Equal(expected, actual) {
		// assert that the golden file snapshot matches the actual contents
		dmp := diffmatchpatch.New()
		diffs := dmp.DiffMain(string(expected), string(actual), true)
		t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
	}
}

func AssertEncoderAgainstGoldenSnapshot(t *testing.T, format sbom.Format, sbom sbom.SBOM, updateSnapshot bool, json bool, redactors ...redactor) {
	var buffer bytes.Buffer

	err := format.Encode(&buffer, sbom)
	assert.NoError(t, err)
	actual := buffer.Bytes()

	// replace the expected snapshot contents with the current encoder contents
	if updateSnapshot {
		testutils.UpdateGoldenFileContents(t, actual)
	}

	actual = redact(actual, redactors...)
	expected := redact(testutils.GetGoldenFileContents(t), redactors...)

	if json {
		require.JSONEq(t, string(expected), string(actual))
	} else if !bytes.Equal(expected, actual) {
		dmp := diffmatchpatch.New()
		diffs := dmp.DiffMain(string(expected), string(actual), true)
		t.Logf("len: %d\nexpected: %s", len(expected), expected)
		t.Logf("len: %d\nactual: %s", len(actual), actual)
		t.Errorf("mismatched output:\n%s", dmp.DiffPrettyText(diffs))
	}
}

func ImageInput(t testing.TB, testImage string, options ...ImageOption) sbom.SBOM {
	t.Helper()
	catalog := pkg.NewCollection()
	var cfg imageCfg
	var img *image.Image
	for _, opt := range options {
		opt(&cfg)
	}

	switch cfg.fromSnapshot {
	case true:
		img = imagetest.GetGoldenFixtureImage(t, testImage)
	default:
		img = imagetest.GetFixtureImage(t, "docker-archive", testImage)
	}

	populateImageCatalog(catalog, img)

	// this is a hard coded value that is not given by the fixture helper and must be provided manually
	img.Metadata.ManifestDigest = "sha256:2731251dc34951c0e50fcc643b4c5f74922dad1a5d98f302b504cf46cd5d9368"

	src, err := source.NewFromImage(img, "user-image-input")
	assert.NoError(t, err)

	return sbom.SBOM{
		Artifacts: sbom.Artifacts{
			Packages: catalog,
			LinuxDistribution: &linux.Release{
				PrettyName: "debian",
				Name:       "debian",
				ID:         "debian",
				IDLike:     []string{"like!"},
				Version:    "1.2.3",
				VersionID:  "1.2.3",
			},
		},
		Source: src.Metadata,
		Descriptor: sbom.Descriptor{
			Name:    "syft",
			Version: "v0.42.0-bogus",
			// the application configuration should be persisted here, however, we do not want to import
			// the application configuration in this package (it's reserved only for ingestion by the cmd package)
			Configuration: map[string]string{
				"config-key": "config-value",
			},
		},
	}
}

func carriageRedactor(s []byte) []byte {
	msg := strings.ReplaceAll(string(s), "\r\n", "\n")
	return []byte(msg)
}

func populateImageCatalog(catalog *pkg.Collection, img *image.Image) {
	_, ref1, _ := img.SquashedTree().File("/somefile-1.txt", filetree.FollowBasenameLinks)
	_, ref2, _ := img.SquashedTree().File("/somefile-2.txt", filetree.FollowBasenameLinks)

	// populate catalog with test data
	catalog.Add(pkg.Package{
		Name:    "package-1",
		Version: "1.0.1",
		Locations: file.NewLocationSet(
			file.NewLocationFromImage(string(ref1.RealPath), *ref1.Reference, img),
		),
		Type:         pkg.PythonPkg,
		FoundBy:      "the-cataloger-1",
		Language:     pkg.Python,
		MetadataType: pkg.PythonPackageMetadataType,
		Licenses: pkg.NewLicenseSet(
			pkg.NewLicense("MIT"),
		),
		Metadata: pkg.PythonPackageMetadata{
			Name:    "package-1",
			Version: "1.0.1",
		},
		PURL: "a-purl-1", // intentionally a bad pURL for test fixtures
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:1:*:*:*:*:*:*:*"),
		},
	})
	catalog.Add(pkg.Package{
		Name:    "package-2",
		Version: "2.0.1",
		Locations: file.NewLocationSet(
			file.NewLocationFromImage(string(ref2.RealPath), *ref2.Reference, img),
		),
		Type:         pkg.DebPkg,
		FoundBy:      "the-cataloger-2",
		MetadataType: pkg.DpkgMetadataType,
		Metadata: pkg.DpkgMetadata{
			Package: "package-2",
			Version: "2.0.1",
		},
		PURL: "pkg:deb/debian/package-2@2.0.1",
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"),
		},
	})
}

func DirectoryInput(t testing.TB) sbom.SBOM {
	catalog := newDirectoryCatalog()

	src, err := source.NewFromDirectory("/some/path")
	assert.NoError(t, err)

	return sbom.SBOM{
		Artifacts: sbom.Artifacts{
			Packages: catalog,
			LinuxDistribution: &linux.Release{
				PrettyName: "debian",
				Name:       "debian",
				ID:         "debian",
				IDLike:     []string{"like!"},
				Version:    "1.2.3",
				VersionID:  "1.2.3",
			},
		},
		Source: src.Metadata,
		Descriptor: sbom.Descriptor{
			Name:    "syft",
			Version: "v0.42.0-bogus",
			// the application configuration should be persisted here, however, we do not want to import
			// the application configuration in this package (it's reserved only for ingestion by the cmd package)
			Configuration: map[string]string{
				"config-key": "config-value",
			},
		},
	}
}

func DirectoryInputWithAuthorField(t testing.TB) sbom.SBOM {
	catalog := newDirectoryCatalogWithAuthorField()

	src, err := source.NewFromDirectory("/some/path")
	assert.NoError(t, err)

	return sbom.SBOM{
		Artifacts: sbom.Artifacts{
			Packages: catalog,
			LinuxDistribution: &linux.Release{
				PrettyName: "debian",
				Name:       "debian",
				ID:         "debian",
				IDLike:     []string{"like!"},
				Version:    "1.2.3",
				VersionID:  "1.2.3",
			},
		},
		Source: src.Metadata,
		Descriptor: sbom.Descriptor{
			Name:    "syft",
			Version: "v0.42.0-bogus",
			// the application configuration should be persisted here, however, we do not want to import
			// the application configuration in this package (it's reserved only for ingestion by the cmd package)
			Configuration: map[string]string{
				"config-key": "config-value",
			},
		},
	}
}

func newDirectoryCatalog() *pkg.Collection {
	catalog := pkg.NewCollection()

	// populate catalog with test data
	catalog.Add(pkg.Package{
		Name:    "package-1",
		Version: "1.0.1",
		Type:    pkg.PythonPkg,
		FoundBy: "the-cataloger-1",
		Locations: file.NewLocationSet(
			file.NewLocation("/some/path/pkg1"),
		),
		Language:     pkg.Python,
		MetadataType: pkg.PythonPackageMetadataType,
		Licenses: pkg.NewLicenseSet(
			pkg.NewLicense("MIT"),
		),
		Metadata: pkg.PythonPackageMetadata{
			Name:    "package-1",
			Version: "1.0.1",
			Files: []pkg.PythonFileRecord{
				{
					Path: "/some/path/pkg1/dependencies/foo",
				},
			},
		},
		PURL: "a-purl-2", // intentionally a bad pURL for test fixtures
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"),
		},
	})
	catalog.Add(pkg.Package{
		Name:    "package-2",
		Version: "2.0.1",
		Type:    pkg.DebPkg,
		FoundBy: "the-cataloger-2",
		Locations: file.NewLocationSet(
			file.NewLocation("/some/path/pkg1"),
		),
		MetadataType: pkg.DpkgMetadataType,
		Metadata: pkg.DpkgMetadata{
			Package: "package-2",
			Version: "2.0.1",
		},
		PURL: "pkg:deb/debian/package-2@2.0.1",
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"),
		},
	})

	return catalog
}

func newDirectoryCatalogWithAuthorField() *pkg.Collection {
	catalog := pkg.NewCollection()

	// populate catalog with test data
	catalog.Add(pkg.Package{
		Name:    "package-1",
		Version: "1.0.1",
		Type:    pkg.PythonPkg,
		FoundBy: "the-cataloger-1",
		Locations: file.NewLocationSet(
			file.NewLocation("/some/path/pkg1"),
		),
		Language:     pkg.Python,
		MetadataType: pkg.PythonPackageMetadataType,
		Licenses: pkg.NewLicenseSet(
			pkg.NewLicense("MIT"),
		),
		Metadata: pkg.PythonPackageMetadata{
			Name:    "package-1",
			Version: "1.0.1",
			Author:  "test-author",
			Files: []pkg.PythonFileRecord{
				{
					Path: "/some/path/pkg1/dependencies/foo",
				},
			},
		},
		PURL: "a-purl-2", // intentionally a bad pURL for test fixtures
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"),
		},
	})
	catalog.Add(pkg.Package{
		Name:    "package-2",
		Version: "2.0.1",
		Type:    pkg.DebPkg,
		FoundBy: "the-cataloger-2",
		Locations: file.NewLocationSet(
			file.NewLocation("/some/path/pkg1"),
		),
		MetadataType: pkg.DpkgMetadataType,
		Metadata: pkg.DpkgMetadata{
			Package: "package-2",
			Version: "2.0.1",
		},
		PURL: "pkg:deb/debian/package-2@2.0.1",
		CPEs: []cpe.CPE{
			cpe.Must("cpe:2.3:*:some:package:2:*:*:*:*:*:*:*"),
		},
	})

	return catalog
}

//nolint:gosec
func AddSampleFileRelationships(s *sbom.SBOM) {
	catalog := s.Artifacts.Packages.Sorted()
	s.Artifacts.FileMetadata = map[file.Coordinates]file.Metadata{}

	files := []string{"/f1", "/f2", "/d1/f3", "/d2/f4", "/z1/f5", "/a1/f6"}
	rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
	rnd.Shuffle(len(files), func(i, j int) { files[i], files[j] = files[j], files[i] })

	for _, f := range files {
		meta := file.Metadata{}
		coords := file.Coordinates{RealPath: f}
		s.Artifacts.FileMetadata[coords] = meta

		s.Relationships = append(s.Relationships, artifact.Relationship{
			From: catalog[0],
			To:   coords,
			Type: artifact.ContainsRelationship,
		})
	}
}

// remove dynamic values, which should be tested independently
func redact(b []byte, redactors ...redactor) []byte {
	redactors = append(redactors, carriageRedactor)
	for _, r := range redactors {
		b = r(b)
	}
	return b
}
