//go:build integration

package integration

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"sort"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/aquasecurity/trivy/internal/testutil"
	"github.com/aquasecurity/trivy/pkg/cache"
	"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
	"github.com/aquasecurity/trivy/pkg/fanal/applier"
	"github.com/aquasecurity/trivy/pkg/fanal/artifact"
	aimage "github.com/aquasecurity/trivy/pkg/fanal/artifact/image"
	"github.com/aquasecurity/trivy/pkg/fanal/image"
	"github.com/aquasecurity/trivy/pkg/fanal/types"

	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/all"
	_ "github.com/aquasecurity/trivy/pkg/fanal/handler/all"
	_ "modernc.org/sqlite"
)

var update = flag.Bool("update", false, "update golden files")

type testCase struct {
	name                string
	imageTag            string
	imageFile           string
	wantOS              types.OS
	wantPkgsFromCmds    string
	wantApplicationFile string
}

var tests = []testCase{
	{
		name:      "happy path, alpine:3.10",
		imageTag:  "alpine-310",
		imageFile: "../../../../integration/testdata/fixtures/images/alpine-310.tar.gz",
		wantOS: types.OS{
			Name:   "3.10.2",
			Family: "alpine",
		},
	},
	{
		name:      "happy path, amazonlinux:2",
		imageTag:  "amazon-2",
		imageFile: "../../../../integration/testdata/fixtures/images/amazon-2.tar.gz",
		wantOS: types.OS{
			Name:   "2 (Karoo)",
			Family: "amazon",
		},
	},
	{
		name:      "happy path, debian:buster",
		imageTag:  "debian-buster",
		imageFile: "../../../../integration/testdata/fixtures/images/debian-buster.tar.gz",
		wantOS: types.OS{
			Name:   "10.1",
			Family: "debian",
		},
	},
	{
		name:      "happy path, photon:3.0",
		imageTag:  "photon-30",
		imageFile: "../../../../integration/testdata/fixtures/images/photon-30.tar.gz",
		wantOS: types.OS{
			Name:   "3.0",
			Family: "photon",
		},
	},
	{
		name:      "happy path, registry.redhat.io/ubi7",
		imageTag:  "ubi-7",
		imageFile: "../../../../integration/testdata/fixtures/images/ubi-7.tar.gz",
		wantOS: types.OS{
			Name:   "7.7",
			Family: "redhat",
		},
	},
	{
		name:      "happy path, opensuse leap 15.1",
		imageTag:  "opensuse-leap-151",
		imageFile: "../../../../integration/testdata/fixtures/images/opensuse-leap-151.tar.gz",
		wantOS: types.OS{
			Name:   "15.1",
			Family: "opensuse-leap",
		},
	},
	{
		name:      "happy path, opensuse tumbleweed",
		imageTag:  "opensuse-tumbleweed",
		imageFile: "../../../../integration/testdata/fixtures/images/opensuse-tumbleweed.tar.gz",
		wantOS: types.OS{
			Name:   "20240607",
			Family: "opensuse-tumbleweed",
		},
	},
	{
		// from registry.suse.com/suse/sle15:15.3.17.8.16
		name:      "happy path, suse 15.3 (NDB)",
		imageTag:  "suse-15.3_ndb",
		imageFile: "../../../../integration/testdata/fixtures/images/suse-15.3_ndb.tar.gz",
		wantOS: types.OS{
			Name:   "15.3",
			Family: "sles",
		},
	},
	{
		name:      "happy path, Fedora 35",
		imageTag:  "fedora-35",
		imageFile: "../../../../integration/testdata/fixtures/images/fedora-35.tar.gz",
		wantOS: types.OS{
			Name:   "35",
			Family: "fedora",
		},
	},
	{
		name:      "happy path, vulnimage with lock files",
		imageTag:  "vulnimage",
		imageFile: "../../../../integration/testdata/fixtures/images/vulnimage.tar.gz",
		wantOS: types.OS{
			Name:   "3.7.1",
			Family: "alpine",
		},
		wantApplicationFile: "testdata/goldens/vuln-image1.2.3.expectedlibs.golden",
		wantPkgsFromCmds:    "testdata/goldens/vuln-image1.2.3.expectedpkgsfromcmds.golden",
	},
}

func TestFanal_Library_DockerMode(t *testing.T) {
	// Disable updating golden files because local images don't have compressed layer digests,
	// and updating golden files in this function results in incomplete files.
	if *update {
		t.Skipf("This test creates wrong golden file")
	}

	cli := testutil.NewDockerClient(t)
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			ctx := t.Context()
			d := t.TempDir()

			c, err := cache.NewFSCache(d)
			require.NoError(t, err)

			loadedImage := cli.ImageLoad(t, ctx, tt.imageFile)

			// Enable only dockerd scanning
			img, cleanup, err := image.NewContainerImage(ctx, loadedImage, types.ImageOptions{
				ImageSources: types.ImageSources{types.DockerImageSource},
			})
			require.NoError(t, err, tt.name)
			defer cleanup()

			// run tests twice, one without cache and with cache
			for i := 1; i <= 2; i++ {
				ar, err := aimage.NewArtifact(img, c, artifact.Option{
					// disable license checking in the test - in parallel it will fail because of resource requirement
					DisabledAnalyzers: []analyzer.Type{
						analyzer.TypeExecutable,
						analyzer.TypeLicenseFile,
					},
				})
				require.NoError(t, err)

				applier := applier.NewApplier(c)

				runChecks(t, ctx, ar, applier, tt)
			}

			// clear Cache
			require.NoError(t, c.Clear(t.Context()), tt.name)
		})
	}
}

func TestFanal_Library_TarMode(t *testing.T) {
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			t.Parallel()
			ctx := t.Context()
			d := t.TempDir()

			c, err := cache.NewFSCache(d)
			require.NoError(t, err)

			img, err := image.NewArchiveImage(tt.imageFile)
			require.NoError(t, err, tt.name)

			ar, err := aimage.NewArtifact(img, c, artifact.Option{
				DisabledAnalyzers: []analyzer.Type{
					analyzer.TypeExecutable,
					analyzer.TypeLicenseFile,
				},
			})
			require.NoError(t, err)

			applier := applier.NewApplier(c)

			runChecks(t, ctx, ar, applier, tt)

			// clear Cache
			require.NoError(t, c.Close())
		})
	}
}

func runChecks(t *testing.T, ctx context.Context, ar artifact.Artifact, applier applier.Applier, tc testCase) {
	imageInfo, err := ar.Inspect(ctx)
	require.NoError(t, err, tc.name)
	imageDetail, err := applier.ApplyLayers(ctx, imageInfo.ID, imageInfo.BlobIDs)
	require.NoError(t, err, tc.name)
	commonChecks(t, imageDetail, tc)
}

func commonChecks(t *testing.T, detail types.ArtifactDetail, tc testCase) {
	assert.Equal(t, tc.wantOS, detail.OS, tc.name)
	checkOSPackages(t, detail, tc)
	checkPackageFromCommands(t, detail, tc)
	checkLangPkgs(detail, t, tc)
}

// clearPackageDetailFields clears package detail fields to keep golden files manageable.
// Fields cleared: Identifier (UID, PURL, BOMRef), Layer, InstalledFiles, DependsOn, Digest
// Fields kept for comparison: ID, Name, Version, Epoch, Release, Arch, SrcName, SrcEpoch, SrcVersion, SrcRelease, Licenses, Maintainer, Modularitylabel, Indirect
func clearPackageDetailFields(packages []types.Package) {
	for i := range packages {
		packages[i].Identifier = types.PkgIdentifier{} // Clear entire Identifier (UID, PURL, BOMRef)
		packages[i].Layer = types.Layer{}
		packages[i].InstalledFiles = nil
		packages[i].DependsOn = nil
		packages[i].Digest = ""
	}
}

func checkOSPackages(t *testing.T, detail types.ArtifactDetail, tc testCase) {
	// Sort OS packages for consistency
	sort.Sort(detail.Packages)

	// Clear package detail fields to keep golden files manageable in size.
	// Cleared fields: Identifier.UID, Layer, InstalledFiles, DependsOn, Digest
	// These fields are either too large (InstalledFiles, Layer) or not critical for comparison (UID, Digest, DependsOn).
	// All other fields including ID, Name, Version, Licenses, Maintainer, etc. are compared.
	clearPackageDetailFields(detail.Packages)

	goldenFile := fmt.Sprintf("testdata/goldens/packages/%s.json.golden", tc.imageTag)

	if *update {
		b, err := json.MarshalIndent(detail.Packages, "", "  ")
		require.NoError(t, err)
		err = os.WriteFile(goldenFile, b, 0o666)
		require.NoError(t, err)
		return
	}
	data, err := os.ReadFile(goldenFile)
	require.NoError(t, err, tc.name)

	var expectedPkgs types.Packages
	err = json.Unmarshal(data, &expectedPkgs)
	require.NoError(t, err)

	sort.Sort(detail.Packages)
	sort.Sort(expectedPkgs)

	assert.Equal(t, expectedPkgs, detail.Packages, tc.name)
}

func checkLangPkgs(detail types.ArtifactDetail, t *testing.T, tc testCase) {
	if tc.wantApplicationFile != "" {
		// Sort applications for consistency
		sort.Slice(detail.Applications, func(i, j int) bool {
			if detail.Applications[i].Type != detail.Applications[j].Type {
				return detail.Applications[i].Type < detail.Applications[j].Type
			}
			return detail.Applications[i].FilePath < detail.Applications[j].FilePath
		})

		for _, app := range detail.Applications {
			sort.Sort(app.Packages)
			for i := range app.Packages {
				sort.Strings(app.Packages[i].DependsOn)
			}
		}

		// Do not compare layers
		for _, app := range detail.Applications {
			for i := range app.Packages {
				app.Packages[i].Layer = types.Layer{}
			}
		}

		if *update {
			b, err := json.MarshalIndent(detail.Applications, "", "  ")
			require.NoError(t, err)
			err = os.WriteFile(tc.wantApplicationFile, b, 0o666)
			require.NoError(t, err)
			return
		}

		var wantApps types.Applications
		data, err := os.ReadFile(tc.wantApplicationFile)
		require.NoError(t, err)
		err = json.Unmarshal(data, &wantApps)
		require.NoError(t, err)

		require.Equal(t, wantApps, detail.Applications, tc.name)
	} else {
		assert.Nil(t, detail.Applications, tc.name)
	}
}

func checkPackageFromCommands(t *testing.T, detail types.ArtifactDetail, tc testCase) {
	if tc.wantPkgsFromCmds != "" {
		if *update {
			sort.Sort(types.Packages(detail.ImageConfig.Packages))
			b, err := json.MarshalIndent(detail.ImageConfig.Packages, "", "  ")
			require.NoError(t, err)
			err = os.WriteFile(tc.wantPkgsFromCmds, b, 0o666)
			require.NoError(t, err)
			return
		}
		data, _ := os.ReadFile(tc.wantPkgsFromCmds)
		var expectedPkgsFromCmds []types.Package

		err := json.Unmarshal(data, &expectedPkgsFromCmds)
		require.NoError(t, err)
		assert.ElementsMatch(t, expectedPkgsFromCmds, detail.ImageConfig.Packages, tc.name)
	} else {
		assert.Equal(t, []types.Package(nil), detail.ImageConfig.Packages, tc.name)
	}
}
