package snapsource

import (
	"context"
	"crypto"
	"fmt"
	"os"
	"path/filepath"
	"testing"

	"github.com/spf13/afero"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/mock"
	"github.com/stretchr/testify/require"
	"github.com/wagoodman/go-progress"

	"github.com/anchore/stereoscope/pkg/image"
	"github.com/anchore/syft/internal/file"
)

func TestSnapIdentity_String(t *testing.T) {
	tests := []struct {
		name     string
		identity snapIdentity
		expected string
	}{
		{
			name: "name only",
			identity: snapIdentity{
				Name: "etcd",
			},
			expected: "etcd",
		},
		{
			name: "name with channel",
			identity: snapIdentity{
				Name:    "etcd",
				Channel: "stable",
			},
			expected: "etcd@stable",
		},
		{
			name: "name with architecture",
			identity: snapIdentity{
				Name:         "etcd",
				Architecture: "amd64",
			},
			expected: "etcd (amd64)",
		},
		{
			name: "name with channel and architecture",
			identity: snapIdentity{
				Name:         "etcd",
				Channel:      "beta",
				Architecture: "arm64",
			},
			expected: "etcd@beta (arm64)",
		},
		{
			name: "empty channel with architecture",
			identity: snapIdentity{
				Name:         "mysql",
				Channel:      "",
				Architecture: "amd64",
			},
			expected: "mysql (amd64)",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := tt.identity.String()
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestFileExists(t *testing.T) {
	fs := afero.NewMemMapFs()

	tests := []struct {
		name     string
		setup    func() string
		expected bool
	}{
		{
			name: "file exists",
			setup: func() string {
				path := "/test/file.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			expected: true,
		},
		{
			name: "file does not exist",
			setup: func() string {
				return "/nonexistent/file.snap"
			},
			expected: false,
		},
		{
			name: "path is directory",
			setup: func() string {
				path := "/test/dir"
				require.NoError(t, fs.MkdirAll(path, 0755))
				return path
			},
			expected: false,
		},
		{
			name: "file exists in subdirectory",
			setup: func() string {
				path := "/deep/nested/path/file.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			expected: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			path := tt.setup()
			result := fileExists(fs, path)
			assert.Equal(t, tt.expected, result)
		})
	}
}

func TestNewSnapFromFile(t *testing.T) {
	ctx := context.Background()
	fs := afero.NewMemMapFs()

	tests := []struct {
		name        string
		cfg         Config
		setup       func() string
		expectError bool
		errorMsg    string
	}{
		{
			name: "valid local snap file",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			setup: func() string {
				path := "/test/valid.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			expectError: false,
		},
		{
			name: "architecture specified for local file",
			cfg: Config{
				Platform: &image.Platform{
					Architecture: "arm64",
				},
			},
			setup: func() string {
				path := "/test/valid.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			expectError: true,
			errorMsg:    "architecture cannot be specified for local snap files",
		},
		{
			name: "file does not exist",
			cfg:  Config{},
			setup: func() string {
				return "/nonexistent/file.snap"
			},
			expectError: true,
			errorMsg:    "unable to stat path",
		},
		{
			name: "path is directory",
			cfg:  Config{},
			setup: func() string {
				path := "/test/directory"
				require.NoError(t, fs.MkdirAll(path, 0755))
				return path
			},
			expectError: true,
			errorMsg:    "given path is a directory",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			path := tt.setup()
			tt.cfg.Request = path

			result, err := newSnapFromFile(ctx, fs, tt.cfg)

			if tt.expectError {
				assert.Error(t, err)
				if tt.errorMsg != "" {
					assert.Contains(t, err.Error(), tt.errorMsg)
				}
				assert.Nil(t, result)
			} else {
				assert.NoError(t, err)
				assert.NotNil(t, result)
				assert.Equal(t, path, result.Path)
				assert.NotEmpty(t, result.MimeType)
				assert.NotEmpty(t, result.Digests)
				assert.Nil(t, result.Cleanup) // Local files don't have cleanup
			}
		})
	}
}

func TestNewSnapFileFromRemote(t *testing.T) {
	ctx := context.Background()

	tests := []struct {
		name        string
		cfg         Config
		info        *remoteSnap
		setupMock   func(*mockFileGetter, afero.Fs)
		expectError bool
		errorMsg    string
		validate    func(t *testing.T, result *snapFile, fs afero.Fs)
	}{
		{
			name: "successful remote snap download",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "etcd",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://api.snapcraft.io/download/etcd_123.snap",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.MatchedBy(func(dst string) bool {
					// expect destination to end with etcd_123.snap
					return filepath.Base(dst) == "etcd_123.snap"
				}), "https://api.snapcraft.io/download/etcd_123.snap", mock.Anything).Run(func(args mock.Arguments) {
					// simulate successful download by creating the file
					dst := args.String(0)
					require.NoError(t, createMockSquashfsFile(fs, dst))
				}).Return(nil)
			},
			expectError: false,
			validate: func(t *testing.T, result *snapFile, fs afero.Fs) {
				assert.NotNil(t, result)
				assert.Contains(t, result.Path, "etcd_123.snap")
				assert.NotEmpty(t, result.MimeType)
				assert.NotEmpty(t, result.Digests)
				assert.NotNil(t, result.Cleanup)

				_, err := fs.Stat(result.Path)
				assert.NoError(t, err)

				err = result.Cleanup()
				require.NoError(t, err)

				_, err = fs.Stat(result.Path)
				assert.True(t, os.IsNotExist(err))

				// cleanup owns the whole temp directory, not just the snap file within it
				_, err = fs.Stat(filepath.Dir(result.Path))
				assert.True(t, os.IsNotExist(err), "temp directory outlived the cleanup closure")
			},
		},
		{
			name: "successful download with no digest algorithms",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{}, // no digests requested
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "mysql",
					Channel:      "8.0/stable",
					Architecture: "arm64",
				},
				URL: "https://api.snapcraft.io/download/mysql_456.snap",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.MatchedBy(func(dst string) bool {
					return filepath.Base(dst) == "mysql_456.snap"
				}), "https://api.snapcraft.io/download/mysql_456.snap", mock.Anything).Run(func(args mock.Arguments) {
					dst := args.String(0)
					require.NoError(t, createMockSquashfsFile(fs, dst))
				}).Return(nil)
			},
			expectError: false,
			validate: func(t *testing.T, result *snapFile, fs afero.Fs) {
				assert.NotNil(t, result)
				assert.Contains(t, result.Path, "mysql_456.snap")
				assert.NotEmpty(t, result.MimeType)
				assert.Empty(t, result.Digests) // no digests requested
				assert.NotNil(t, result.Cleanup)
			},
		},
		{
			name: "download fails",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "failing-snap",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://api.snapcraft.io/download/failing_snap.snap",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/failing_snap.snap", mock.Anything).Return(fmt.Errorf("network timeout"))
			},
			expectError: true,
			errorMsg:    "failed to download snap file",
		},
		{
			name: "download writes partial data then fails",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "partial-snap",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://api.snapcraft.io/download/partial_snap.snap",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/partial_snap.snap", mock.Anything).Run(func(args mock.Arguments) {
					// simulate a transfer that writes some bytes before the connection drops
					require.NoError(t, createMockSquashfsFile(fs, args.String(0)))
				}).Return(fmt.Errorf("connection reset by peer"))
			},
			expectError: true,
			errorMsg:    "failed to download snap file",
		},
		{
			// isSquashFSFile trusts the .snap extension, so non-squashfs content under that name is accepted
			name: "downloaded file has a snap extension but non-snap content",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "not-a-snap",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://api.snapcraft.io/download/not_a_snap.snap",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/not_a_snap.snap", mock.Anything).Run(func(args mock.Arguments) {
					require.NoError(t, afero.WriteFile(fs, args.String(0), []byte("not squashfs"), 0644))
				}).Return(nil)
			},
			expectError: false,
			validate: func(t *testing.T, result *snapFile, fs afero.Fs) {
				assert.NotNil(t, result)
				assert.Contains(t, result.Path, "not_a_snap.snap")
				assert.NotNil(t, result.Cleanup)
			},
		},
		{
			name: "downloaded file is not a snap",
			cfg: Config{
				DigestAlgorithms: []crypto.Hash{crypto.SHA256},
			},
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "not-a-snap",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://api.snapcraft.io/download/not_a_snap.tar.gz",
			},
			setupMock: func(mockGetter *mockFileGetter, fs afero.Fs) {
				mockGetter.On("GetFile", mock.AnythingOfType("string"), "https://api.snapcraft.io/download/not_a_snap.tar.gz", mock.Anything).Run(func(args mock.Arguments) {
					require.NoError(t, afero.WriteFile(fs, args.String(0), []byte("not squashfs"), 0644))
				}).Return(nil)
			},
			expectError: true,
			errorMsg:    "not a valid squashfs/snap file",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			fs := afero.NewOsFs()
			mockGetter := &mockFileGetter{}

			if tt.setupMock != nil {
				tt.setupMock(mockGetter, fs)
			}

			result, err := newSnapFileFromRemote(ctx, fs, tt.cfg, mockGetter, tt.info)

			if tt.expectError {
				require.Error(t, err)
				if tt.errorMsg != "" {
					assert.Contains(t, err.Error(), tt.errorMsg)
				}
				assert.Nil(t, result)

				// the temp directory and any partially downloaded payload must not be left behind. The
				// download destination handed to the getter lives directly under the temp directory.
				require.Len(t, mockGetter.Calls, 1)
				tempDir := filepath.Dir(mockGetter.Calls[0].Arguments.String(0))
				_, statErr := fs.Stat(tempDir)
				assert.True(t, os.IsNotExist(statErr), "temp directory %q was not cleaned up after failure", tempDir)
			} else {
				require.NoError(t, err)
				if result.Cleanup != nil {
					t.Cleanup(func() { _ = result.Cleanup() })
				}
				if tt.validate != nil {
					tt.validate(t, result, fs)
				}
			}

			mockGetter.AssertExpectations(t)
		})
	}
}

func TestGetSnapFileInfo(t *testing.T) {
	ctx := context.Background()
	fs := afero.NewMemMapFs()

	tests := []struct {
		name        string
		setup       func() string
		hashes      []crypto.Hash
		expectError bool
		errorMsg    string
	}{
		{
			name: "valid squashfs file with hashes",
			setup: func() string {
				path := "/test/valid.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			hashes:      []crypto.Hash{crypto.SHA256, crypto.MD5},
			expectError: false,
		},
		{
			name: "valid squashfs file without hashes",
			setup: func() string {
				path := "/test/valid.snap"
				require.NoError(t, createMockSquashfsFile(fs, path))
				return path
			},
			hashes:      []crypto.Hash{},
			expectError: false,
		},
		{
			name: "file does not exist",
			setup: func() string {
				return "/nonexistent/file.snap"
			},
			expectError: true,
			errorMsg:    "unable to stat path",
		},
		{
			name: "path is directory",
			setup: func() string {
				path := "/test/directory"
				require.NoError(t, fs.MkdirAll(path, 0755))
				return path
			},
			expectError: true,
			errorMsg:    "given path is a directory",
		},
		{
			name: "invalid file format",
			setup: func() string {
				path := "/test/invalid.txt"
				require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0755))
				file, err := fs.Create(path)
				require.NoError(t, err)
				defer file.Close()
				_, err = file.Write([]byte("not a squashfs file"))
				require.NoError(t, err)
				return path
			},
			expectError: true,
			errorMsg:    "not a valid squashfs/snap file",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			path := tt.setup()

			mimeType, digests, err := getSnapFileInfo(ctx, fs, path, tt.hashes)

			if tt.expectError {
				assert.Error(t, err)
				if tt.errorMsg != "" {
					assert.Contains(t, err.Error(), tt.errorMsg)
				}
			} else {
				assert.NoError(t, err)
				assert.NotEmpty(t, mimeType)
				if len(tt.hashes) > 0 {
					assert.Len(t, digests, len(tt.hashes))
				} else {
					assert.Empty(t, digests)
				}
			}
		})
	}
}

func TestDownloadSnap(t *testing.T) {
	mockGetter := &mockFileGetter{}

	tests := []struct {
		name        string
		info        *remoteSnap
		dest        string
		setupMock   func()
		expectError bool
		errorMsg    string
	}{
		{
			name: "successful download",
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "etcd",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://example.com/etcd.snap",
			},
			dest: "/tmp/etcd.snap",
			setupMock: func() {
				mockGetter.On("GetFile", "/tmp/etcd.snap", "https://example.com/etcd.snap", mock.AnythingOfType("[]*progress.Manual")).Return(nil)
			},
			expectError: false,
		},
		{
			name: "download fails",
			info: &remoteSnap{
				snapIdentity: snapIdentity{
					Name:         "etcd",
					Channel:      "stable",
					Architecture: "amd64",
				},
				URL: "https://example.com/etcd.snap",
			},
			dest: "/tmp/etcd.snap",
			setupMock: func() {
				mockGetter.On("GetFile", "/tmp/etcd.snap", "https://example.com/etcd.snap", mock.AnythingOfType("[]*progress.Manual")).Return(fmt.Errorf("network error"))
			},
			expectError: true,
			errorMsg:    "failed to download snap file",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			// reset mock for each test
			mockGetter.ExpectedCalls = nil
			if tt.setupMock != nil {
				tt.setupMock()
			}

			err := downloadSnap(mockGetter, tt.info, tt.dest)

			if tt.expectError {
				assert.Error(t, err)
				if tt.errorMsg != "" {
					assert.Contains(t, err.Error(), tt.errorMsg)
				}
			} else {
				assert.NoError(t, err)
			}

			mockGetter.AssertExpectations(t)
		})
	}
}

func TestParseSnapRequest(t *testing.T) {
	tests := []struct {
		name             string
		request          string
		expectedName     string
		expectedRevision int
		expectedChannel  string
		expectedError    require.ErrorAssertionFunc
	}{
		{
			name:            "snap name only - uses default channel",
			request:         "etcd",
			expectedName:    "etcd",
			expectedChannel: "stable",
			expectedError:   require.NoError,
		},
		{
			name:            "snap with beta channel",
			request:         "etcd@beta",
			expectedName:    "etcd",
			expectedChannel: "beta",
			expectedError:   require.NoError,
		},
		{
			name:            "snap with edge channel",
			request:         "etcd@edge",
			expectedName:    "etcd",
			expectedChannel: "edge",
			expectedError:   require.NoError,
		},
		{
			name:            "snap with version track",
			request:         "etcd@2.3/stable",
			expectedName:    "etcd",
			expectedChannel: "2.3/stable",
			expectedError:   require.NoError,
		},
		{
			name:            "snap with complex channel path",
			request:         "mysql@8.0/candidate",
			expectedName:    "mysql",
			expectedChannel: "8.0/candidate",
			expectedError:   require.NoError,
		},
		{
			name:            "snap with multiple @ symbols - only first is delimiter",
			request:         "app@beta@test",
			expectedName:    "app",
			expectedChannel: "beta@test",
			expectedError:   require.NoError,
		},
		{
			name:             "snap with revision",
			request:          "etcd:249",
			expectedName:     "etcd",
			expectedRevision: 249,
			expectedError:    require.NoError,
		},
		{
			name:             "snap with revision so the channel doesn't work",
			request:          "etcd:249@2.3/beta",
			expectedName:     "etcd",
			expectedRevision: 249,
			expectedError:    require.NoError,
		},
		{
			name:            "empty snap name with channel",
			request:         "@stable",
			expectedName:    "",
			expectedChannel: "stable",
			expectedError:   require.NoError,
		},
		{
			name:            "snap name with empty channel - uses default",
			request:         "etcd@",
			expectedName:    "etcd",
			expectedChannel: "stable",
			expectedError:   require.NoError,
		},
		{
			name:            "hyphenated snap name",
			request:         "hello-world@stable",
			expectedName:    "hello-world",
			expectedChannel: "stable",
			expectedError:   require.NoError,
		},
		{
			name:            "snap name with numbers",
			request:         "app123",
			expectedName:    "app123",
			expectedChannel: "stable",
			expectedError:   require.NoError,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			name, revision, channel, err := parseSnapRequest(tt.request)
			assert.Equal(t, tt.expectedName, name)
			if tt.expectedRevision != NotSpecifiedRevision {
				assert.Equal(t, tt.expectedRevision, revision)
			} else {
				assert.Equal(t, tt.expectedChannel, channel)
			}
			require.NoError(t, err)
		})
	}
}

type mockFileGetter struct {
	mock.Mock
	file.Getter
}

func (m *mockFileGetter) GetFile(dst, src string, monitor ...*progress.Manual) error {
	args := m.Called(dst, src, monitor)
	return args.Error(0)
}

func createMockSquashfsFile(fs afero.Fs, path string) error {
	dir := filepath.Dir(path)
	if err := fs.MkdirAll(dir, 0755); err != nil {
		return err
	}

	file, err := fs.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()

	// write squashfs magic header
	_, err = file.Write([]byte("hsqs"))
	return err
}
