package main

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

func TestWatcher_Create(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "new.txt", "hello")
	assertFileEventually(t, data, "new.txt", "hello", 2*time.Second)
}

func TestWatcher_CreateInSubdir(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "src/existing.go", "old")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "src/new.go", "package main")
	assertFileEventually(t, data, "src/new.go", "package main", 2*time.Second)
}

func TestWatcher_Write(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "file.txt", "original")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	assertFileExists(t, data, "file.txt", "original")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "file.txt", "updated")
	assertFileEventually(t, data, "file.txt", "updated", 2*time.Second)
}

func TestWatcher_Remove(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "file.txt", "content")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	assertFileExists(t, data, "file.txt", "content")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	os.Remove(filepath.Join(host, "file.txt"))
	assertFileRemovedEventually(t, data, "file.txt", 2*time.Second)
}

func TestWatcher_Rename(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "Foo.txt", "content")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	assertFileExists(t, data, "Foo.txt", "content")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	os.Rename(filepath.Join(host, "Foo.txt"), filepath.Join(host, "foo.txt"))
	writeFile(t, host, "foo.txt", "content")

	assertFileRemovedEventually(t, data, "Foo.txt", 2*time.Second)
	assertFileEventually(t, data, "foo.txt", "content", 2*time.Second)
}

func TestWatcher_IgnoredFileEvents(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, ".gitignore", "*.log\nbuild/\n")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "debug.log", "log data")
	writeFile(t, host, "build/output.o", "binary")

	time.Sleep(500 * time.Millisecond)

	assertFileNotExists(t, data, "debug.log")
	assertFileNotExists(t, data, "build/output.o")

	writeFile(t, host, "app.go", "package main")
	assertFileEventually(t, data, "app.go", "package main", 2*time.Second)
}

func TestWatcher_Debouncing(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "file.txt", "v1")
	writeFile(t, host, "file.txt", "v2")
	writeFile(t, host, "file.txt", "v3")
	writeFile(t, host, "file.txt", "final")

	assertFileEventually(t, data, "file.txt", "final", 2*time.Second)

	content, err := os.ReadFile(filepath.Join(data, "file.txt"))
	if err != nil {
		t.Fatal(err)
	}
	if string(content) != "final" {
		t.Errorf("content = %q, want %q", string(content), "final")
	}
}

func TestWatcher_CreateDirectory(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	if err := os.MkdirAll(filepath.Join(host, "newdir"), 0755); err != nil {
		t.Fatal(err)
	}
	time.Sleep(300 * time.Millisecond)
	writeFile(t, host, "newdir/file.txt", "nested")

	assertDirExistsEventually(t, data, "newdir", 2*time.Second)
	assertFileEventually(t, data, "newdir/file.txt", "nested", 2*time.Second)
}

func TestWatcher_RemoveDirectory(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "dir/file.txt", "content")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	assertFileExists(t, data, "dir/file.txt", "content")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	os.RemoveAll(filepath.Join(host, "dir"))
	assertFileRemovedEventually(t, data, "dir/file.txt", 2*time.Second)
}

func TestWatcher_VerifyManifestUpdated(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "file.txt", "v1")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	if manifest.Files["file.txt"].Size != 2 {
		t.Errorf("initial manifest size = %d, want 2", manifest.Files["file.txt"].Size)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "file.txt", "version two")

	time.Sleep(500 * time.Millisecond)

	if manifest.Files["file.txt"].Size != 11 {
		t.Errorf("manifest size after write = %d, want 11", manifest.Files["file.txt"].Size)
	}
}

func TestWatcher_NestedGitignore_CreateIgnored(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "sub/.gitignore", "*.tmp\n")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "sub/cache.tmp", "tmp")
	time.Sleep(500 * time.Millisecond)

	assertFileNotExists(t, data, "sub/cache.tmp")

	writeFile(t, host, "sub/file.go", "code")
	assertFileEventually(t, data, "sub/file.go", "code", 2*time.Second)
}

func TestWatcher_NestedGitignore_CreateUnignore(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, ".gitignore", "*.txt\n")
	writeFile(t, host, "sub/.gitignore", "!important.txt\n")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	writeFile(t, host, "sub/important.txt", "keep me")
	assertFileEventually(t, data, "sub/important.txt", "keep me", 2*time.Second)
}

func TestWatcher_CreateSymlink(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "target.txt", "target content")

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil {
		t.Fatal(err)
	}

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		info, err := os.Lstat(filepath.Join(data, "link.txt"))
		if err == nil && info.Mode()&os.ModeSymlink != 0 {
			target, _ := os.Readlink(filepath.Join(data, "link.txt"))
			if target == "target.txt" {
				return
			}
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Error("symlink not created in /data within timeout")
}

func TestWatcher_UpdateSymlinkTarget(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "v1.txt", "version 1")
	writeFile(t, host, "v2.txt", "version 2")
	if err := os.Symlink("v1.txt", filepath.Join(host, "link.txt")); err != nil {
		t.Fatal(err)
	}

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	// Verify initial symlink
	linkInfo, err := os.Lstat(filepath.Join(data, "link.txt"))
	if err != nil || linkInfo.Mode()&os.ModeSymlink == 0 {
		t.Fatal("initial symlink not created")
	}
	target, _ := os.Readlink(filepath.Join(data, "link.txt"))
	if target != "v1.txt" {
		t.Fatalf("initial symlink target = %q, want %q", target, "v1.txt")
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	// Update symlink to point to v2.txt
	os.Remove(filepath.Join(host, "link.txt"))
	os.Symlink("v2.txt", filepath.Join(host, "link.txt"))

	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		info, err := os.Lstat(filepath.Join(data, "link.txt"))
		if err == nil && info.Mode()&os.ModeSymlink != 0 {
			tgt, _ := os.Readlink(filepath.Join(data, "link.txt"))
			if tgt == "v2.txt" {
				return
			}
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Error("symlink target not updated within timeout")
}

func TestWatcher_RemoveSymlink(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "target.txt", "content")
	if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil {
		t.Fatal(err)
	}

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	os.Remove(filepath.Join(host, "link.txt"))

	assertFileRemovedEventually(t, data, "link.txt", 2*time.Second)
}

func TestWatcher_ModifySymlinkTargetFile(t *testing.T) {
	host := t.TempDir()
	data := t.TempDir()

	writeFile(t, host, "target.txt", "original")
	if err := os.Symlink("target.txt", filepath.Join(host, "link.txt")); err != nil {
		t.Fatal(err)
	}

	stack := NewGitignoreStack(host)
	manifest, err := initialSync(host, data, stack, false)
	if err != nil {
		t.Fatal(err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go watchForTests(ctx, host, data, manifest, stack, false)

	time.Sleep(100 * time.Millisecond)

	// Modify the target file, not the symlink itself
	writeFile(t, host, "target.txt", "modified")

	// The symlink should still exist and point to the target
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		info, err := os.Lstat(filepath.Join(data, "link.txt"))
		if err == nil && info.Mode()&os.ModeSymlink != 0 {
			// Symlink still exists, which is correct
			return
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Error("symlink lost after target file modification")
}

// Helpers

func assertFileEventually(t *testing.T, dir, path, expectedContent string, timeout time.Duration) {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		content, err := os.ReadFile(filepath.Join(dir, path))
		if err == nil && string(content) == expectedContent {
			return
		}
		time.Sleep(50 * time.Millisecond)
	}
	content, _ := os.ReadFile(filepath.Join(dir, path))
	t.Errorf("file %s not as expected within %v: got %q, want %q", path, timeout, string(content), expectedContent)
}

func assertFileRemovedEventually(t *testing.T, dir, path string, timeout time.Duration) {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		_, err := os.Stat(filepath.Join(dir, path))
		if os.IsNotExist(err) {
			return
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Errorf("file %s still exists after %v", path, timeout)
}

func assertDirExistsEventually(t *testing.T, dir, path string, timeout time.Duration) {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		info, err := os.Stat(filepath.Join(dir, path))
		if err == nil && info.IsDir() {
			return
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Errorf("directory %s not found within %v", path, timeout)
}
