package mfer

import (
	"bytes"
	"context"
	"testing"
	"time"

	"github.com/spf13/afero"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestNewScanner(t *testing.T) {
	s := NewScanner()
	assert.NotNil(t, s)
	assert.Equal(t, FileCount(0), s.FileCount())
	assert.Equal(t, FileSize(0), s.TotalBytes())
}

func TestNewScannerWithOptions(t *testing.T) {
	t.Run("nil options", func(t *testing.T) {
		s := NewScannerWithOptions(nil)
		assert.NotNil(t, s)
	})

	t.Run("with options", func(t *testing.T) {
		fs := afero.NewMemMapFs()
		opts := &ScannerOptions{
			IncludeDotfiles: true,
			FollowSymLinks:  true,
			Fs:              fs,
		}
		s := NewScannerWithOptions(opts)
		assert.NotNil(t, s)
	})
}

func TestScannerEnumerateFile(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumerateFile("/test.txt")
	require.NoError(t, err)

	assert.Equal(t, FileCount(1), s.FileCount())
	assert.Equal(t, FileSize(11), s.TotalBytes())

	files := s.Files()
	require.Len(t, files, 1)
	assert.Equal(t, RelFilePath("test.txt"), files[0].Path)
	assert.Equal(t, FileSize(11), files[0].Size)
}

func TestScannerEnumerateFileMissing(t *testing.T) {
	fs := afero.NewMemMapFs()
	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumerateFile("/nonexistent.txt")
	assert.Error(t, err)
}

func TestScannerEnumeratePath(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumeratePath("/testdir", nil)
	require.NoError(t, err)

	assert.Equal(t, FileCount(3), s.FileCount())
	assert.Equal(t, FileSize(3+3+5), s.TotalBytes())
}

func TestScannerEnumeratePathWithProgress(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	progress := make(chan EnumerateStatus, 10)

	err := s.EnumeratePath("/testdir", progress)
	require.NoError(t, err)

	var updates []EnumerateStatus
	for p := range progress {
		updates = append(updates, p)
	}

	assert.NotEmpty(t, updates)
	// Final update should show all files
	final := updates[len(updates)-1]
	assert.Equal(t, FileCount(2), final.FilesFound)
	assert.Equal(t, FileSize(6), final.BytesFound)
}

func TestScannerEnumeratePaths(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/dir1", 0o755))
	require.NoError(t, fs.MkdirAll("/dir2", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/dir1/a.txt", []byte("aaa"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/dir2/b.txt", []byte("bbb"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumeratePaths(nil, "/dir1", "/dir2")
	require.NoError(t, err)

	assert.Equal(t, FileCount(2), s.FileCount())
}

func TestScannerExcludeDotfiles(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644))

	t.Run("exclude by default", func(t *testing.T) {
		s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false})
		err := s.EnumeratePath("/testdir", nil)
		require.NoError(t, err)

		assert.Equal(t, FileCount(1), s.FileCount())
		files := s.Files()
		assert.Equal(t, RelFilePath("visible.txt"), files[0].Path)
	})

	t.Run("include when enabled", func(t *testing.T) {
		s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true})
		err := s.EnumeratePath("/testdir", nil)
		require.NoError(t, err)

		assert.Equal(t, FileCount(3), s.FileCount())
	})
}

func TestScannerToManifest(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumeratePath("/testdir", nil)
	require.NoError(t, err)

	var buf bytes.Buffer
	err = s.ToManifest(context.Background(), &buf, nil)
	require.NoError(t, err)

	// Manifest should have magic bytes
	assert.True(t, buf.Len() > 0)
	assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
}

func TestScannerToManifestWithProgress(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumeratePath("/testdir", nil)
	require.NoError(t, err)

	var buf bytes.Buffer
	progress := make(chan ScanStatus, 10)

	err = s.ToManifest(context.Background(), &buf, progress)
	require.NoError(t, err)

	var updates []ScanStatus
	for p := range progress {
		updates = append(updates, p)
	}

	assert.NotEmpty(t, updates)
	// Final update should show completion
	final := updates[len(updates)-1]
	assert.Equal(t, FileCount(1), final.TotalFiles)
	assert.Equal(t, FileCount(1), final.ScannedFiles)
	assert.Equal(t, FileSize(1000), final.TotalBytes)
	assert.Equal(t, FileSize(1000), final.ScannedBytes)
}

func TestScannerToManifestContextCancellation(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir", 0o755))
	// Create many files to ensure we have time to cancel
	for i := 0; i < 100; i++ {
		name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt"
		require.NoError(t, afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0o644))
	}

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumeratePath("/testdir", nil)
	require.NoError(t, err)

	ctx, cancel := context.WithCancel(context.Background())
	cancel() // Cancel immediately

	var buf bytes.Buffer
	err = s.ToManifest(ctx, &buf, nil)
	assert.ErrorIs(t, err, context.Canceled)
}

func TestScannerToManifestEmptyScanner(t *testing.T) {
	fs := afero.NewMemMapFs()
	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})

	var buf bytes.Buffer
	err := s.ToManifest(context.Background(), &buf, nil)
	require.NoError(t, err)

	// Should still produce a valid manifest
	assert.True(t, buf.Len() > 0)
	assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
}

func TestScannerFilesCopiesSlice(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	require.NoError(t, s.EnumerateFile("/test.txt"))

	files1 := s.Files()
	files2 := s.Files()

	// Should be different slices
	assert.NotSame(t, &files1[0], &files2[0])
}

func TestScannerEnumerateFS(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755))
	require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644))
	require.NoError(t, afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644))

	// Create a basepath filesystem
	baseFs := afero.NewBasePathFs(fs, "/testdir")

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	err := s.EnumerateFS(baseFs, "/testdir", nil)
	require.NoError(t, err)

	assert.Equal(t, FileCount(2), s.FileCount())
}

func TestSendEnumerateStatusNonBlocking(t *testing.T) {
	// Channel with no buffer - send should not block
	ch := make(chan EnumerateStatus)

	// This should not block
	done := make(chan bool)
	go func() {
		sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
		done <- true
	}()

	select {
	case <-done:
		// Success - did not block
	case <-time.After(100 * time.Millisecond):
		t.Fatal("sendEnumerateStatus blocked on full channel")
	}
}

func TestSendScanStatusNonBlocking(t *testing.T) {
	// Channel with no buffer - send should not block
	ch := make(chan ScanStatus)

	done := make(chan bool)
	go func() {
		sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
		done <- true
	}()

	select {
	case <-done:
		// Success - did not block
	case <-time.After(100 * time.Millisecond):
		t.Fatal("sendScanStatus blocked on full channel")
	}
}

func TestSendStatusNilChannel(t *testing.T) {
	// Should not panic with nil channel
	sendEnumerateStatus(nil, EnumerateStatus{})
	sendScanStatus(nil, ScanStatus{})
}

func TestScannerFileEntryFields(t *testing.T) {
	fs := afero.NewMemMapFs()
	now := time.Now().Truncate(time.Second)
	require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644))
	require.NoError(t, fs.Chtimes("/test.txt", now, now))

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	require.NoError(t, s.EnumerateFile("/test.txt"))

	files := s.Files()
	require.Len(t, files, 1)

	entry := files[0]
	assert.Equal(t, RelFilePath("test.txt"), entry.Path)
	assert.Contains(t, string(entry.AbsPath), "test.txt")
	assert.Equal(t, FileSize(7), entry.Size)
	// Mtime should be set (within a second of now)
	assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
}

func TestScannerLargeFileEnumeration(t *testing.T) {
	fs := afero.NewMemMapFs()
	require.NoError(t, fs.MkdirAll("/testdir", 0o755))

	// Create 100 files
	for i := 0; i < 100; i++ {
		name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt"
		require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644))
	}

	s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
	progress := make(chan EnumerateStatus, 200)

	err := s.EnumeratePath("/testdir", progress)
	require.NoError(t, err)

	// Drain channel
	for range progress {
	}

	assert.Equal(t, FileCount(100), s.FileCount())
	assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes
}

func TestIsHiddenPath(t *testing.T) {
	tests := []struct {
		path   string
		hidden bool
	}{
		{"file.txt", false},
		{".hidden", true},
		{"dir/file.txt", false},
		{"dir/.hidden", true},
		{".dir/file.txt", true},
		{"/absolute/path", false},
		{"/absolute/.hidden", true},
		{"./relative", false}, // path.Clean removes leading ./
		{"a/b/c/.d/e", true},
	}

	for _, tt := range tests {
		t.Run(tt.path, func(t *testing.T) {
			assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path)
		})
	}
}
