package filesystem

import (
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"google.golang.org/protobuf/types/known/anypb"

	trContext "github.com/trufflesecurity/trufflehog/v3/pkg/context"
	"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
	"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
	"github.com/trufflesecurity/trufflehog/v3/pkg/sourcestest"
)

func probeSymlinkSupport(t *testing.T, baseDir string) {
	probe := filepath.Join(baseDir, "symlink-probe")
	if err := os.Symlink("x", probe); err != nil {
		t.Skip("symlinks not supported")
	}
	_ = os.Remove(probe)
}

func TestScanDir_VisitedPath_PreventInfiniteRecursion(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()
	// Skip if symlinks unsupported
	probeSymlinkSupport(t, baseDir)

	dirA := filepath.Join(baseDir, "A")
	dirB := filepath.Join(baseDir, "B")
	err = os.Mkdir(dirA, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory A %v", err)
	}
	err = os.Mkdir(dirB, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory B %v", err)
	}

	// We create
	// A/linkToB -> /B
	// B/linkToA -> /A
	err = os.Symlink(dirB, filepath.Join(dirA, "linkToB"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(dirA, filepath.Join(dirB, "linkToA"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	src := &Source{
		concurrency:     1,
		maxSymlinkDepth: 20,
	}
	chunks := make(chan *sources.Chunk, 10)
	go func() {
		err := src.Chunks(ctx, chunks)
		require.NoError(t, err)
		close(chunks)
	}()
	var chunkCount int
	for range chunks {
		chunkCount++
	}
	// Assert no chunks were emitted due to the infinite symlink loop
	require.Equal(t, 0, chunkCount, "No chunks should be processed due to infinite symlink loop")
}

func TestChunks_DirectorySymlinkLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	// We Create
	// /A->/B
	// /B->/A
	err = os.Symlink(filepath.Join(baseDir, "B"), filepath.Join(baseDir, "A"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "A"), filepath.Join(baseDir, "B"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	src := &Source{
		maxSymlinkDepth: 20,
		concurrency:     1,
		paths:           []string{filepath.Join(baseDir, "B")},
	}

	chunks := make(chan *sources.Chunk, 10)
	// Run the scan
	go func() {
		err := src.Chunks(ctx, chunks)
		require.NoError(t, err)
		close(chunks)
	}()
	var chunkCount int
	for range chunks {
		chunkCount++
	}
	// Assert no chunks were emitted due to the infinite symlink loop
	require.Equal(t, 0, chunkCount, "No chunks should be processed due to infinite symlink loop")
}

func TestChunkUnit_DirectorySymlinkLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	// We Create
	// /A->/B
	// /B->/A
	err = os.Symlink(filepath.Join(baseDir, "B"), filepath.Join(baseDir, "A"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "A"), filepath.Join(baseDir, "B"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	conn, err := anypb.New(&sourcespb.Filesystem{
		MaxSymlinkDepth: 20,
	})
	assert.NoError(t, err)

	// Initialize the source.
	s := Source{}
	err = s.Init(ctx, "test chunk unit", 0, 0, true, conn, 1)
	assert.NoError(t, err)

	reporter := sourcestest.TestReporter{}
	err = s.ChunkUnit(ctx, sources.CommonSourceUnit{
		ID: filepath.Join(baseDir, "B"),
	}, &reporter)
	assert.NoError(t, err)
	// Assert no chunks were emitted due to the infinite symlink loop
	assert.Equal(t, 0, len(reporter.Chunks))
}

func TestChunks_FileSymlinkLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	// We Create
	// /fileA->/fileB
	// /fileB->/fileA
	fileA := filepath.Join(baseDir, "fileA.txt")
	fileB := filepath.Join(baseDir, "fileB.txt")
	err = os.Symlink(fileA, fileB)
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(fileB, fileA)
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	src := &Source{
		maxSymlinkDepth: 20,
		concurrency:     1,
		paths:           []string{fileA},
	}

	chunks := make(chan *sources.Chunk, 10)
	// Run the scan
	go func() {
		err := src.Chunks(ctx, chunks)
		require.NoError(t, err)
		close(chunks)
	}()
	var chunkCount int
	for range chunks {
		chunkCount++
	}
	// Assert no chunks were emitted due to the infinite symlink loop
	require.Equal(t, 0, chunkCount, "No chunks should be processed due to infinite symlink loop")
}

func TestChunkUnit_FileSymlinkLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	// Probe symlink support
	probeSymlinkSupport(t, baseDir)

	// Create two files that are symlinks to each other
	fileA := filepath.Join(baseDir, "fileA.txt")
	fileB := filepath.Join(baseDir, "fileB.txt")

	if err := os.Symlink(fileB, fileA); err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	if err := os.Symlink(fileA, fileB); err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	conn, err := anypb.New(&sourcespb.Filesystem{
		MaxSymlinkDepth: 20,
	})
	require.NoError(t, err)

	s := Source{}
	err = s.Init(ctx, "test chunk unit", 0, 0, true, conn, 1)
	require.NoError(t, err)

	reporter := sourcestest.TestReporter{}
	err = s.ChunkUnit(ctx, sources.CommonSourceUnit{
		ID: fileA,
	}, &reporter)
	require.NoError(t, err)

	// Assert no chunks were emitted due to the infinite symlink loop
	assert.Equal(t, 0, len(reporter.Chunks), "No chunks should be processed due to infinite symlink loop")
}

func TestChunks_ValidDirectorySymlink(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	// Create a temporary base directory
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	// Probe symlink support
	probeSymlinkSupport(t, baseDir)

	// Create a real file
	dirA := filepath.Join(baseDir, "A")
	err = os.Mkdir(dirA, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory A %v", err)
	}
	dirB := filepath.Join(baseDir, "B")
	err = os.Mkdir(dirB, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory B %v", err)
	}

	data := "Hello world!"
	file, cleanupFile, err := createTempFile(dirA, data)
	assert.NoError(t, err)
	defer cleanupFile()

	// we create
	// /B/link.txt->/A/trufflehogtest*
	linkFile := filepath.Join(dirB, "link.txt")
	if err := os.Symlink(file.Name(), linkFile); err != nil {
		t.Fatalf("failed to create symlink: %v", err)
	}

	src := &Source{
		concurrency:     1,
		paths:           []string{dirB},
		maxSymlinkDepth: 20,
	}

	chunksCh := make(chan *sources.Chunk, 1)
	go func() {
		defer close(chunksCh)
		err = src.Chunks(ctx, chunksCh)
		require.NoError(t, err)
	}()
	if err != nil {
		t.Fatalf("unexpected error scanning symlink: %v", err)
	}

	for chunk := range chunksCh {
		if string(chunk.Data) != data {
			t.Fatalf("expected chunk.Data: %v to be equal to %v", string(chunk.Data), data)
		}
	}
}

func TestChunkUnit_ValidDirectorySymlink(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	// Create a temporary base directory
	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	// Probe symlink support
	probeSymlinkSupport(t, baseDir)

	// Create directories
	dirA := filepath.Join(baseDir, "A")
	dirB := filepath.Join(baseDir, "B")
	require.NoError(t, os.Mkdir(dirA, 0755))
	require.NoError(t, os.Mkdir(dirB, 0755))

	// Create a file in dirA
	data := "Hello world!"
	file, cleanupFile, err := createTempFile(dirA, data)
	require.NoError(t, err)
	defer cleanupFile()

	// Create symlink: /B/link.txt -> /A/trufflehogtest*
	linkFile := filepath.Join(dirB, "link.txt")
	require.NoError(t, os.Symlink(file.Name(), linkFile))

	// Prepare Source
	conn, err := anypb.New(&sourcespb.Filesystem{
		MaxSymlinkDepth: 20,
	})
	require.NoError(t, err)

	src := Source{}
	require.NoError(t, src.Init(ctx, "test chunk unit", 0, 0, true, conn, 1))

	reporter := sourcestest.TestReporter{}
	err = src.ChunkUnit(ctx, sources.CommonSourceUnit{
		ID: dirB,
	}, &reporter)
	require.NoError(t, err)

	// Assert exactly 1 chunk is scanned and data matches
	require.Len(t, reporter.Chunks, 1, "Expected exactly 1 chunk from symlinked file")
	require.Equal(t, data, string(reporter.Chunks[0].Data))
}

func TestChunks_ValidFileSymlink(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	// Create a temporary base directory
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	// Probe symlink support
	probeSymlinkSupport(t, baseDir)

	// Create a real file
	dirA := filepath.Join(baseDir, "A")
	err = os.Mkdir(dirA, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory A %v", err)
	}
	dirB := filepath.Join(baseDir, "B")
	err = os.Mkdir(dirB, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory B %v", err)
	}

	data := "Hello world!"
	file, cleanupFile, err := createTempFile(dirA, data)
	assert.NoError(t, err)
	defer cleanupFile()

	// we create
	// /B/link.txt->/A/trufflehogtest*
	linkFile := filepath.Join(dirB, "link.txt")
	if err := os.Symlink(file.Name(), linkFile); err != nil {
		t.Fatalf("failed to create symlink: %v", err)
	}

	src := &Source{
		concurrency:     1,
		maxSymlinkDepth: 20,
		paths:           []string{linkFile},
	}

	chunksCh := make(chan *sources.Chunk, 1)

	go func() {
		defer close(chunksCh)
		err = src.Chunks(ctx, chunksCh)
		if err != nil {
			t.Errorf("src.scanFile() error=%v", err)
		}
	}()

	if err != nil {
		t.Fatalf("unexpected error scanning symlink: %v", err)
	}

	for chunk := range chunksCh {
		if string(chunk.Data) != data {
			t.Fatalf("expected chunk.Data: %v to be equal to %v", string(chunk.Data), data)
		}
	}
}

func TestChunkUnit_ValidFileSymlink(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	// Create a temporary base directory
	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	dirA := filepath.Join(baseDir, "A")
	dirB := filepath.Join(baseDir, "B")
	require.NoError(t, os.Mkdir(dirA, 0755))
	require.NoError(t, os.Mkdir(dirB, 0755))

	data := "Hello world!"
	file, cleanupFile, err := createTempFile(dirA, data)
	require.NoError(t, err)
	defer cleanupFile()

	// Create symlink: /B/link.txt -> /A/trufflehogtest*
	linkFile := filepath.Join(dirB, "link.txt")
	require.NoError(t, os.Symlink(file.Name(), linkFile))

	conn, err := anypb.New(&sourcespb.Filesystem{
		MaxSymlinkDepth: 20,
	})
	require.NoError(t, err)

	src := Source{}
	require.NoError(t, src.Init(ctx, "test chunk unit", 0, 0, true, conn, 1))

	reporter := sourcestest.TestReporter{}
	err = src.ChunkUnit(ctx, sources.CommonSourceUnit{
		ID: linkFile,
	}, &reporter)
	require.NoError(t, err)

	// Assert exactly 1 chunk scanned and data matches
	require.Len(t, reporter.Chunks, 1, "Expected exactly 1 chunk from symlinked file")
	require.Equal(t, data, string(reporter.Chunks[0].Data))
}

func TestScanSymlink_NoError(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	dirD := filepath.Join(baseDir, "D")
	err = os.Mkdir(dirD, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory D %v", err)
	}

	err = os.Symlink(filepath.Join(baseDir, "B"), filepath.Join(baseDir, "A"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "C"), filepath.Join(baseDir, "B"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "D"), filepath.Join(baseDir, "C"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	src := &Source{
		concurrency:     1,
		maxSymlinkDepth: 20,
	}
	chunks := make(chan *sources.Chunk, 10)
	go func() {
		path := filepath.Join(baseDir, "A")
		err := src.scanSymlink(ctx, chunks, path, 0, path)
		require.NoError(t, err)
		close(chunks)
	}()
	var chunkCount int
	for range chunks {
		chunkCount++
	}
	require.Equal(t, 0, chunkCount, "No chunks should be because dir D has no file")
}

func TestScanSymlink_MaxDepthExceeded(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), time.Second*3)
	defer cancel()
	baseDir, cleanup, err := createTempDir("")
	if err != nil {
		t.Fatal(err)
	}
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	dirD := filepath.Join(baseDir, "D")
	err = os.Mkdir(dirD, 0755)
	if err != nil {
		t.Fatalf("Unable to create directory D %v", err)
	}

	err = os.Symlink(filepath.Join(baseDir, "B"), filepath.Join(baseDir, "A"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "C"), filepath.Join(baseDir, "B"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}
	err = os.Symlink(filepath.Join(baseDir, "D"), filepath.Join(baseDir, "C"))
	if err != nil {
		t.Fatalf("Unable to create symlink %v", err)
	}

	src := &Source{
		concurrency:     1,
		maxSymlinkDepth: 2,
	}
	chunks := make(chan *sources.Chunk, 10)

	path := filepath.Join(baseDir, "A")
	err = src.scanSymlink(ctx, chunks, path, 0, path)
	close(chunks)

	require.Error(t, err)
	require.EqualError(t, err, "max symlink depth reached")
}

func TestScanSymlink_FileTarget(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	probeSymlinkSupport(t, baseDir)
	// Create a file
	filePath := filepath.Join(baseDir, "file.txt")
	err = os.WriteFile(filePath, []byte("data"), 0644)
	require.NoError(t, err)

	// Create a symlink pointing to the file
	symlinkPath := filepath.Join(baseDir, "link.txt")
	err = os.Symlink(filePath, symlinkPath)
	require.NoError(t, err)

	src := &Source{
		maxSymlinkDepth: 5,
		concurrency:     1,
	}

	chunks := make(chan *sources.Chunk, 10)

	err = src.scanSymlink(ctx, chunks, symlinkPath, 0, symlinkPath)
	require.NoError(t, err)
	close(chunks)
	var chunkCount int
	for chunk := range chunks {
		require.Equal(t, "data", string(chunk.Data))
		chunkCount++
	}
	require.Equal(t, 1, chunkCount, "Expected 1 chunk")

}

func TestScanSymlink_SelfLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	symlinkPath := filepath.Join(baseDir, "loop.txt")
	err = os.Symlink(symlinkPath, symlinkPath)
	require.NoError(t, err)

	src := &Source{
		maxSymlinkDepth: 5,
	}

	chunks := make(chan *sources.Chunk, 10)

	err = src.scanSymlink(ctx, chunks, symlinkPath, 0, symlinkPath)
	close(chunks)
	require.Error(t, err)
	require.EqualError(t, err, "max symlink depth reached")
}

func TestScanSymlink_BrokenSymlink(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	symlinkPath := filepath.Join(baseDir, "broken")
	err = os.Symlink(filepath.Join(baseDir, "nonexistent.txt"), symlinkPath)
	require.NoError(t, err)

	src := &Source{
		maxSymlinkDepth: 5,
	}

	chunks := make(chan *sources.Chunk, 10)

	err = src.scanSymlink(ctx, chunks, symlinkPath, 0, symlinkPath)
	close(chunks)
	require.Error(t, err)
	require.Contains(t, err.Error(), "lstat error")
}

func TestScanSymlink_TwoFileLoop(t *testing.T) {
	ctx, cancel := trContext.WithTimeout(trContext.Background(), 3*time.Second)
	defer cancel()

	baseDir, cleanup, err := createTempDir("")
	require.NoError(t, err)
	defer cleanup()

	probeSymlinkSupport(t, baseDir)

	fileA := filepath.Join(baseDir, "fileA.txt")
	fileB := filepath.Join(baseDir, "fileB.txt")

	// A -> B, B -> A
	require.NoError(t, os.Symlink(fileB, fileA))
	require.NoError(t, os.Symlink(fileA, fileB))

	src := &Source{
		maxSymlinkDepth: 5,
	}

	chunks := make(chan *sources.Chunk, 10)

	err = src.scanSymlink(ctx, chunks, fileA, 0, fileA)
	close(chunks)
	require.Error(t, err)
	require.EqualError(t, err, "max symlink depth reached")
}
