package analyzer_test

import (
	"os"
	"slices"
	"sync"
	"testing"

	"github.com/google/go-containerregistry/pkg/name"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"golang.org/x/sync/errgroup"
	"golang.org/x/sync/semaphore"
	"golang.org/x/xerrors"

	"github.com/aquasecurity/trivy/pkg/fanal/analyzer"
	"github.com/aquasecurity/trivy/pkg/fanal/types"
	"github.com/aquasecurity/trivy/pkg/javadb"
	"github.com/aquasecurity/trivy/pkg/mapfs"
	xio "github.com/aquasecurity/trivy/pkg/x/io"

	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/imgconf/apk"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/java/jar"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/python/poetry"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/ruby/bundler"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os/alpine"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os/ubuntu"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/pkg/apk"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/pkg/dpkg"
	_ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/repo/apk"
	_ "github.com/aquasecurity/trivy/pkg/fanal/handler/all"
	_ "modernc.org/sqlite"
)

func TestAnalysisResult_Merge(t *testing.T) {
	type fields struct {
		m            sync.Mutex
		OS           types.OS
		PackageInfos []types.PackageInfo
		Applications []types.Application
	}
	type args struct {
		new *analyzer.AnalysisResult
	}
	tests := []struct {
		name   string
		fields fields
		args   args
		want   analyzer.AnalysisResult
	}{
		{
			name: "happy path",
			fields: fields{
				OS: types.OS{
					Family: types.Debian,
					Name:   "9.8",
				},
				PackageInfos: []types.PackageInfo{
					{
						FilePath: "var/lib/dpkg/status.d/libc",
						Packages: types.Packages{
							{
								Name:    "libc",
								Version: "1.2.3",
							},
						},
					},
				},
				Applications: []types.Application{
					{
						Type:     "bundler",
						FilePath: "app/Gemfile.lock",
						Packages: types.Packages{
							{
								Name:    "rails",
								Version: "5.0.0",
							},
						},
					},
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					PackageInfos: []types.PackageInfo{
						{
							FilePath: "var/lib/dpkg/status.d/openssl",
							Packages: types.Packages{
								{
									Name:    "openssl",
									Version: "1.1.1",
								},
							},
						},
					},
					Applications: []types.Application{
						{
							Type:     "bundler",
							FilePath: "app2/Gemfile.lock",
							Packages: types.Packages{
								{
									Name:    "nokogiri",
									Version: "1.0.0",
								},
							},
						},
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family: types.Debian,
					Name:   "9.8",
				},
				PackageInfos: []types.PackageInfo{
					{
						FilePath: "var/lib/dpkg/status.d/libc",
						Packages: types.Packages{
							{
								Name:    "libc",
								Version: "1.2.3",
							},
						},
					},
					{
						FilePath: "var/lib/dpkg/status.d/openssl",
						Packages: types.Packages{
							{
								Name:    "openssl",
								Version: "1.1.1",
							},
						},
					},
				},
				Applications: []types.Application{
					{
						Type:     "bundler",
						FilePath: "app/Gemfile.lock",
						Packages: types.Packages{
							{
								Name:    "rails",
								Version: "5.0.0",
							},
						},
					},
					{
						Type:     "bundler",
						FilePath: "app2/Gemfile.lock",
						Packages: types.Packages{
							{
								Name:    "nokogiri",
								Version: "1.0.0",
							},
						},
					},
				},
			},
		},
		{
			name: "redhat must be replaced with oracle",
			fields: fields{
				OS: types.OS{
					Family: types.RedHat, // this must be overwritten
					Name:   "8.0",
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					OS: types.OS{
						Family: types.Oracle,
						Name:   "8.0",
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family: types.Oracle,
					Name:   "8.0",
				},
			},
		},
		{
			name: "debian must be replaced with ubuntu",
			fields: fields{
				OS: types.OS{
					Family: types.Debian, // this must be overwritten
					Name:   "9.0",
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					OS: types.OS{
						Family: types.Ubuntu,
						Name:   "18.04",
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family: types.Ubuntu,
					Name:   "18.04",
				},
			},
		},
		{
			name: "merge extended flag",
			fields: fields{
				// This must be overwritten
				OS: types.OS{
					Family: types.Ubuntu,
					Name:   "16.04",
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					OS: types.OS{
						Family:   types.Ubuntu,
						Extended: true,
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family:   types.Ubuntu,
					Name:     "16.04",
					Extended: true,
				},
			},
		},
		{
			name: "alpine OS needs to be extended with apk repositories",
			fields: fields{
				OS: types.OS{
					Family: types.Alpine,
					Name:   "3.15.3",
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					Repository: &types.Repository{
						Family:  types.Alpine,
						Release: "edge",
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family: types.Alpine,
					Name:   "3.15.3",
				},
				Repository: &types.Repository{
					Family:  types.Alpine,
					Release: "edge",
				},
			},
		},
		{
			name: "alpine must not be replaced with oracle",
			fields: fields{
				OS: types.OS{
					Family: types.Alpine, // this must not be overwritten
					Name:   "3.11",
				},
			},
			args: args{
				new: &analyzer.AnalysisResult{
					OS: types.OS{
						Family: types.Oracle,
						Name:   "8.0",
					},
				},
			},
			want: analyzer.AnalysisResult{
				OS: types.OS{
					Family: types.Alpine, // this must not be overwritten
					Name:   "3.11",
				},
			},
		},
		{
			name: "normalize licenses for PackageInfos and Applications",
			args: args{
				new: &analyzer.AnalysisResult{
					Applications: []types.Application{
						{
							Type:     "gomod",
							FilePath: "go.mod",
							Packages: types.Packages{
								{
									Name:    "github.com/example/package",
									Version: "v1.0.0",
									Licenses: []string{
										"",
										"BSD",
										"GPL-2",
										"GPL-2.0",
									},
								},
							},
						},
						{
							Type:     "gomod",
							FilePath: "empty-license/go.mod",
							Packages: types.Packages{
								{
									Name:    "github.com/empty/license",
									Version: "v1.0.0",
									Licenses: []string{
										"",
									},
								},
							},
						},
					},
				},
			},
			want: analyzer.AnalysisResult{
				Applications: []types.Application{
					{
						Type:     "gomod",
						FilePath: "go.mod",
						Packages: types.Packages{
							{
								Name:    "github.com/example/package",
								Version: "v1.0.0",
								Licenses: []string{
									"BSD-3-Clause",
									"GPL-2.0-only",
								},
							},
						},
					},
					{
						Type:     "gomod",
						FilePath: "empty-license/go.mod",
						Packages: types.Packages{
							{
								Name:     "github.com/empty/license",
								Version:  "v1.0.0",
								Licenses: nil,
							},
						},
					},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			r := analyzer.AnalysisResult{
				OS:           tt.fields.OS,
				PackageInfos: tt.fields.PackageInfos,
				Applications: tt.fields.Applications,
			}
			r.Merge(tt.args.new)
			assert.Equal(t, tt.want, r)
		})
	}
}

func TestAnalyzerGroup_AnalyzeFile(t *testing.T) {
	type args struct {
		filePath          string
		testFilePath      string
		disabledAnalyzers []analyzer.Type
		filePatterns      []string
	}
	tests := []struct {
		name    string
		args    args
		want    *analyzer.AnalysisResult
		wantErr string
	}{
		{
			name: "happy path with os analyzer",
			args: args{
				filePath:     "/etc/alpine-release",
				testFilePath: "testdata/etc/alpine-release",
			},
			want: &analyzer.AnalysisResult{
				OS: types.OS{
					Family: "alpine",
					Name:   "3.11.6",
				},
			},
		},
		{
			name: "happy path with disabled os analyzer",
			args: args{
				filePath:          "/etc/alpine-release",
				testFilePath:      "testdata/etc/alpine-release",
				disabledAnalyzers: []analyzer.Type{analyzer.TypeAlpine},
			},
			want: &analyzer.AnalysisResult{},
		},
		{
			name: "happy path with package analyzer",
			args: args{
				filePath:     "/lib/apk/db/installed",
				testFilePath: "testdata/lib/apk/db/installed",
			},
			want: &analyzer.AnalysisResult{
				PackageInfos: []types.PackageInfo{
					{
						FilePath: "/lib/apk/db/installed",
						Packages: types.Packages{
							{
								ID:         "musl@1.1.24-r2",
								Name:       "musl",
								Version:    "1.1.24-r2",
								SrcName:    "musl",
								SrcVersion: "1.1.24-r2",
								Licenses:   []string{"MIT"},
								Maintainer: "Timo Teräs <timo.teras@iki.fi>",
								Arch:       "x86_64",
								Digest:     "sha1:cb2316a189ebee5282c4a9bd98794cc2477a74c6",
								InstalledFiles: []string{
									"lib/libc.musl-x86_64.so.1",
									"lib/ld-musl-x86_64.so.1",
								},
								AnalyzedBy: analyzer.TypeApk,
							},
						},
					},
				},
				SystemInstalledFiles: []string{
					"lib/libc.musl-x86_64.so.1",
					"lib/ld-musl-x86_64.so.1",
				},
			},
		},
		{
			name: "happy path with disabled package analyzer",
			args: args{
				filePath:          "/lib/apk/db/installed",
				testFilePath:      "testdata/lib/apk/db/installed",
				disabledAnalyzers: []analyzer.Type{analyzer.TypeApk},
			},
			want: &analyzer.AnalysisResult{},
		},
		{
			name: "happy path with library analyzer",
			args: args{
				filePath:     "/app/Gemfile.lock",
				testFilePath: "testdata/app/Gemfile.lock",
			},
			want: &analyzer.AnalysisResult{
				Applications: []types.Application{
					{
						Type:     "bundler",
						FilePath: "/app/Gemfile.lock",
						Packages: types.Packages{
							{
								ID:           "actioncable@5.2.3",
								Name:         "actioncable",
								Version:      "5.2.3",
								Indirect:     false,
								Relationship: types.RelationshipDirect,
								DependsOn: []string{
									"actionpack@5.2.3",
								},
								Locations: []types.Location{
									{
										StartLine: 4,
										EndLine:   4,
									},
								},
								AnalyzedBy: analyzer.TypeBundler,
							},
							{
								ID:           "actionpack@5.2.3",
								Name:         "actionpack",
								Version:      "5.2.3",
								Indirect:     true,
								Relationship: types.RelationshipIndirect,
								Locations: []types.Location{
									{
										StartLine: 6,
										EndLine:   6,
									},
								},
								AnalyzedBy: analyzer.TypeBundler,
							},
						},
					},
				},
			},
		},
		{
			name: "happy path with invalid os information",
			args: args{
				filePath:     "/etc/lsb-release",
				testFilePath: "testdata/etc/hostname",
			},
			want: &analyzer.AnalysisResult{},
		},
		{
			name: "happy path with a directory",
			args: args{
				filePath:     "/etc/lsb-release",
				testFilePath: "testdata/etc",
			},
			want: &analyzer.AnalysisResult{},
		},
		{
			name: "happy path with library analyzer file pattern regex",
			args: args{
				filePath:     "/app/Gemfile-dev.lock",
				testFilePath: "testdata/app/Gemfile.lock",
				filePatterns: []string{"bundler:Gemfile(-.*)?\\.lock"},
			},
			want: &analyzer.AnalysisResult{
				Applications: []types.Application{
					{
						Type:     "bundler",
						FilePath: "/app/Gemfile-dev.lock",
						Packages: types.Packages{
							{
								ID:           "actioncable@5.2.3",
								Name:         "actioncable",
								Version:      "5.2.3",
								Indirect:     false,
								Relationship: types.RelationshipDirect,
								DependsOn: []string{
									"actionpack@5.2.3",
								},
								Locations: []types.Location{
									{
										StartLine: 4,
										EndLine:   4,
									},
								},
								AnalyzedBy: analyzer.TypeBundler,
							},
							{
								ID:           "actionpack@5.2.3",
								Name:         "actionpack",
								Version:      "5.2.3",
								Indirect:     true,
								Relationship: types.RelationshipIndirect,
								Locations: []types.Location{
									{
										StartLine: 6,
										EndLine:   6,
									},
								},
								AnalyzedBy: analyzer.TypeBundler,
							},
						},
					},
				},
			},
		},
		{
			name: "ignore permission error",
			args: args{
				filePath:     "/etc/alpine-release",
				testFilePath: "testdata/no-permission",
			},
			want: &analyzer.AnalysisResult{},
		},
		{
			name: "sad path with opener error",
			args: args{
				filePath:     "/lib/apk/db/installed",
				testFilePath: "testdata/error",
			},
			wantErr: "unable to open /lib/apk/db/installed",
		},
		{
			name: "sad path with broken file pattern regex",
			args: args{
				filePath:     "/app/Gemfile-dev.lock",
				testFilePath: "testdata/app/Gemfile.lock",
				filePatterns: []string{"bundler:Gemfile(-.*?\\.lock"},
			},
			wantErr: "error parsing regexp",
		},
		{
			name: "sad path with broken file pattern",
			args: args{
				filePath:     "/app/Gemfile-dev.lock",
				testFilePath: "testdata/app/Gemfile.lock",
				filePatterns: []string{"Gemfile(-.*)?\\.lock"},
			},
			wantErr: "invalid file pattern",
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			eg, ctx := errgroup.WithContext(t.Context())
			limit := semaphore.NewWeighted(3)

			got := new(analyzer.AnalysisResult)
			a, err := analyzer.NewAnalyzerGroup(analyzer.AnalyzerOptions{
				FilePatterns:      tt.args.filePatterns,
				DisabledAnalyzers: tt.args.disabledAnalyzers,
			})
			if err != nil && tt.wantErr != "" {
				require.ErrorContains(t, err, tt.wantErr)
				return
			}
			require.NoError(t, err)

			info, err := os.Stat(tt.args.testFilePath)
			require.NoError(t, err)

			err = a.AnalyzeFile(ctx, eg, limit, got, "", tt.args.filePath, info,
				func() (xio.ReadSeekCloserAt, error) {
					switch tt.args.testFilePath {
					case "testdata/error":
						return nil, xerrors.New("error")
					case "testdata/no-permission":
						os.Chmod(tt.args.testFilePath, 0o000)
						t.Cleanup(func() {
							os.Chmod(tt.args.testFilePath, 0o644)
						})
					}
					return os.Open(tt.args.testFilePath)
				},
				nil, analyzer.AnalysisOptions{},
			)

			egErr := eg.Wait()
			require.NoError(t, egErr)

			if tt.wantErr != "" {
				require.ErrorContains(t, err, tt.wantErr)
				return
			}

			require.NoError(t, err)
			assert.Equal(t, tt.want, got)
		})
	}
}

func TestAnalyzerGroup_PostAnalyze(t *testing.T) {
	tests := []struct {
		name         string
		dir          string
		analyzerType analyzer.Type
		filePatterns []string
		want         *analyzer.AnalysisResult
	}{
		{
			name:         "jars with invalid jar",
			dir:          "testdata/post-apps/jar/",
			analyzerType: analyzer.TypeJar,
			want: &analyzer.AnalysisResult{
				Applications: []types.Application{
					{
						Type:     types.Jar,
						FilePath: "testdata/post-apps/jar/jackson-annotations-2.15.0-rc2.jar",
						Packages: types.Packages{
							{
								Name:       "com.fasterxml.jackson.core:jackson-annotations",
								Version:    "2.15.0-rc2",
								FilePath:   "testdata/post-apps/jar/jackson-annotations-2.15.0-rc2.jar",
								AnalyzedBy: analyzer.TypeJar,
							},
						},
					},
				},
			},
		},
		{
			name: "poetry files with file from pattern and invalid file",
			dir:  "testdata/post-apps/poetry/",
			filePatterns: []string{
				"poetry:poetry-pattern.lock",
			},
			analyzerType: analyzer.TypePoetry,
			want: &analyzer.AnalysisResult{
				Applications: []types.Application{
					{
						Type:     types.Poetry,
						FilePath: "testdata/post-apps/poetry/happy/poetry-pattern.lock",
						Packages: types.Packages{
							{
								ID:         "certifi@2022.12.7",
								Name:       "certifi",
								Version:    "2022.12.7",
								AnalyzedBy: analyzer.TypePoetry,
							},
						},
					},
					{
						Type:     types.Poetry,
						FilePath: "testdata/post-apps/poetry/happy/poetry.lock",
						Packages: types.Packages{
							{
								ID:         "certifi@2022.12.7",
								Name:       "certifi",
								Version:    "2022.12.7",
								AnalyzedBy: analyzer.TypePoetry,
							},
						},
					},
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			a, err := analyzer.NewAnalyzerGroup(analyzer.AnalyzerOptions{
				FilePatterns: tt.filePatterns,
			})
			require.NoError(t, err)

			// Create a virtual filesystem
			composite, err := analyzer.NewCompositeFS()
			require.NoError(t, err)

			mfs := mapfs.New()
			require.NoError(t, mfs.CopyFilesUnder(tt.dir))
			composite.Set(tt.analyzerType, mfs)

			if tt.analyzerType == analyzer.TypeJar {
				// init java-trivy-db with skip update
				repo, err := name.NewTag(javadb.DefaultGHCRRepository)
				require.NoError(t, err)
				javadb.Init("./language/java/jar/testdata", []name.Reference{repo}, true, false, types.RegistryOptions{Insecure: false})
			}

			ctx := t.Context()
			got := new(analyzer.AnalysisResult)
			err = a.PostAnalyze(ctx, composite, got, analyzer.AnalysisOptions{})
			require.NoError(t, err)
			assert.Equal(t, tt.want, got)
		})
	}
}

func TestAnalyzerGroup_AnalyzerVersions(t *testing.T) {
	tests := []struct {
		name     string
		disabled []analyzer.Type
		want     analyzer.Versions
	}{
		{
			name:     "happy path",
			disabled: []analyzer.Type{},
			want: analyzer.Versions{
				Analyzers: map[string]int{
					"alpine":       1,
					"apk-repo":     1,
					"apk":          3,
					"bundler":      1,
					"dpkg-license": 1,
					"ubuntu":       1,
					"ubuntu-esm":   1,
				},
				PostAnalyzers: map[string]int{
					"dpkg":   6,
					"jar":    1,
					"poetry": 1,
				},
			},
		},
		{
			name: "disable analyzers",
			disabled: []analyzer.Type{
				analyzer.TypeAlpine,
				analyzer.TypeApkRepo,
				analyzer.TypeDpkg,
				analyzer.TypeDpkgLicense,
				analyzer.TypeUbuntu,
				analyzer.TypeUbuntuESM,
				analyzer.TypeJar,
			},
			want: analyzer.Versions{
				Analyzers: map[string]int{
					"apk":     3,
					"bundler": 1,
				},
				PostAnalyzers: map[string]int{
					"poetry": 1,
				},
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			a, err := analyzer.NewAnalyzerGroup(analyzer.AnalyzerOptions{
				DisabledAnalyzers: tt.disabled,
			})
			require.NoError(t, err)
			got := a.AnalyzerVersions()
			assert.Equal(t, tt.want, got)
		})
	}
}

// TestAnalyzerGroup_StaticPaths tests the StaticPaths method of AnalyzerGroup
func TestAnalyzerGroup_StaticPaths(t *testing.T) {
	tests := []struct {
		name              string
		disabledAnalyzers []analyzer.Type
		filePatterns      []string
		want              []string
		wantAllStatic     bool
	}{
		{
			name: "all analyzers including post-analyzers implement StaticPathAnalyzer",
			disabledAnalyzers: []analyzer.Type{
				analyzer.TypeApkCommand, analyzer.TypeJar, analyzer.TypePoetry, analyzer.TypeBundler,
			},
			want: []string{
				"etc/apk/repositories",
				"etc/lsb-release",
				"lib/apk/db/installed",
				"usr/lib/apk/db/installed",
				"etc/alpine-release",

				"usr/share/doc/",
				"var/lib/dpkg/status",
				"var/lib/dpkg/status.d/",
				"var/lib/dpkg/available",
				"var/lib/dpkg/info/",
				"var/lib/ubuntu-advantage/status.json",
			},
			wantAllStatic: true,
		},
		{
			name: "all analyzers implement StaticPathAnalyzer, but there is file pattern",
			disabledAnalyzers: []analyzer.Type{
				analyzer.TypeApkCommand, analyzer.TypeJar, analyzer.TypePoetry, analyzer.TypeBundler,
			},
			filePatterns: []string{
				"alpine:etc/alpine-release-custom",
			},
			want:          []string{},
			wantAllStatic: false,
		},
		{
			name:          "some analyzers don't implement StaticPathAnalyzer",
			want:          []string{},
			wantAllStatic: false,
		},
		{
			name:              "disable all analyzers",
			disabledAnalyzers: slices.Concat(analyzer.TypeOSes, analyzer.TypeLanguages),
			want:              []string{},
			wantAllStatic:     true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// Create a new analyzer group
			a, err := analyzer.NewAnalyzerGroup(analyzer.AnalyzerOptions{
				FilePatterns: tt.filePatterns,
			})
			require.NoError(t, err)

			// Get static paths
			gotPaths, gotAllStatic := a.StaticPaths(tt.disabledAnalyzers)

			// Check if all analyzers implement StaticPathAnalyzer
			assert.Equal(t, tt.wantAllStatic, gotAllStatic)

			// Check paths
			assert.ElementsMatch(t, tt.want, gotPaths)
		})
	}
}
