package workspacefs

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"
)

func makeDir(t *testing.T, path string) {
	t.Helper()
	require.NoError(t, os.MkdirAll(path, 0755))
}

func makeFile(t *testing.T, path, content string) {
	t.Helper()
	require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
	require.NoError(t, os.WriteFile(path, []byte(content), 0644))
}

func machineDirectoryPaths(directories []MachineDirectory) []string {
	out := make([]string, 0, len(directories))
	for _, d := range directories {
		out = append(out, d.Path)
	}
	return out
}

func setHome(t *testing.T, home string) {
	t.Helper()
	t.Setenv("HOME", home)
	if runtime.GOOS == "windows" {
		t.Setenv("USERPROFILE", home)
	}
}

// resolved resolves symlinks on platforms (like macOS) where the OS-provided
// temp directory lives behind a symlinked root (e.g. /var -> /private/var).
// The production code returns resolved native paths, so tests must compare
// against the resolved form.
func resolved(t *testing.T, path string) string {
	t.Helper()
	resolved, err := filepath.EvalSymlinks(path)
	require.NoError(t, err)
	return resolved
}

func TestListMachineDirectoriesPageStartsAtHomeAndReturnsDirectoriesOnly(t *testing.T) {
	home := resolved(t, t.TempDir())
	setHome(t, home)
	makeDir(t, filepath.Join(home, "alpha"))
	makeDir(t, filepath.Join(home, ".config"))
	makeDir(t, filepath.Join(home, "beta"))
	makeFile(t, filepath.Join(home, "README.md"), "readme")

	directories, page, err := ListMachineDirectoriesPage("", ListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, home, page.CurrentPath)
	require.Equal(t, filepath.Dir(home), page.ParentPath)
	got := machineDirectoryPaths(directories)
	sort.Strings(got)
	require.Equal(t, []string{
		filepath.Join(home, ".config"),
		filepath.Join(home, "alpha"),
		filepath.Join(home, "beta"),
	}, got)
	require.False(t, page.HasMore)
	require.False(t, page.Truncated)
}

func TestListMachineDirectoriesPagePreservesTrailingSpacePath(t *testing.T) {
	parent := resolved(t, t.TempDir())
	raw := filepath.Join(parent, "repo ")
	require.NoError(t, os.Mkdir(raw, 0755))

	directories, page, err := ListMachineDirectoriesPage(raw, ListOptions{Limit: 10})
	require.NoError(t, err)
	// The trailing-space directory is empty; we only assert that the raw path
	// resolved to that exact directory rather than being trimmed or rejected.
	require.Empty(t, directories)
	require.Equal(t, raw, page.CurrentPath)
}

func TestListMachineDirectoriesPageRejectsRelativeMissingAndFilePaths(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	makeFile(t, filepath.Join(tmp, "file.txt"), "x")

	for _, raw := range []string{
		"relative/path",
		filepath.Join(tmp, "missing"),
		filepath.Join(tmp, "file.txt"),
	} {
		t.Run(raw, func(t *testing.T) {
			_, _, err := ListMachineDirectoriesPage(raw, ListOptions{Limit: 10})
			require.ErrorIs(t, err, ErrMachineDirectoryInvalid)
		})
	}
}

func TestListMachineDirectoriesPageReturnsParent(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	home := filepath.Join(tmp, "home")
	require.NoError(t, os.MkdirAll(filepath.Join(home, "projects", "agentteam"), 0755))

	directories, page, err := ListMachineDirectoriesPage(filepath.Join(home, "projects"), ListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, filepath.Join(home, "projects"), page.CurrentPath)
	require.NotEmpty(t, page.ParentPath)
	require.Equal(t, home, page.ParentPath)
	require.Len(t, directories, 1)
	require.Equal(t, filepath.Join(home, "projects", "agentteam"), directories[0].Path)
}

func TestListMachineDirectoriesPageOmitsDirectorySymlinks(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("symlink creation requires elevated privileges on some Windows setups")
	}
	tmp := resolved(t, t.TempDir())
	realDir := filepath.Join(tmp, "real")
	require.NoError(t, os.MkdirAll(realDir, 0755))
	link := filepath.Join(tmp, "link")
	require.NoError(t, os.Symlink(realDir, link))

	directories, _, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, []string{realDir}, machineDirectoryPaths(directories))
}

func TestListMachineDirectoriesPageUsesBoundedIncrementalPages(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	// Native directory order is non-deterministic; interleave files, symlinks,
	// and directories. Every real directory must appear exactly once across the
	// full page walk, while files and symlinks never appear.
	for _, name := range []string{"d1", "f1", "d2", "f2", "d3", "s1", "d4"} {
		full := filepath.Join(tmp, name)
		switch {
		case strings.HasPrefix(name, "d"):
			require.NoError(t, os.Mkdir(full, 0755))
		case strings.HasPrefix(name, "s"):
			require.NoError(t, os.Symlink(tmp, full))
		default:
			require.NoError(t, os.WriteFile(full, []byte("x"), 0644))
		}
	}

	var gathered []MachineDirectory
	cursor := ""
	for i := 0; i < 10; i++ {
		dirs, page, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 2, Cursor: cursor})
		require.NoError(t, err)
		// Bounded page: at most 2 eligible rows even when files/symlinks surround them.
		require.LessOrEqual(t, len(dirs), 2)
		gathered = append(gathered, dirs...)
		if !page.HasMore {
			break
		}
		cursor = page.NextCursor
		require.NotEmpty(t, cursor)
	}

	paths := machineDirectoryPaths(gathered)
	sort.Strings(paths)
	require.Equal(t, []string{
		filepath.Join(tmp, "d1"),
		filepath.Join(tmp, "d2"),
		filepath.Join(tmp, "d3"),
		filepath.Join(tmp, "d4"),
	}, paths)
}

func TestListMachineDirectoriesPageRejectsInvalidAndOversizedCursor(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	require.NoError(t, os.MkdirAll(filepath.Join(tmp, "child"), 0755))

	for _, cursor := range []string{
		"-1", "01", " ", "abc", "1.5", "0x10", "1001", "999999999999999999999999",
	} {
		t.Run(cursor, func(t *testing.T) {
			_, _, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 10, Cursor: cursor})
			require.ErrorIs(t, err, ErrMachineDirectoryInvalid)
		})
	}

	dirs, page, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 10, Cursor: "0"})
	require.NoError(t, err)
	require.Len(t, dirs, 1)
	require.False(t, page.HasMore)
}

func TestListMachineDirectoriesPageStopsAtRawEntryBudget(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	// Create 1_001 raw entries so the 1_000-entry boundary engages. Names are
	// short so the response-byte budget never fires first.
	for i := 0; i < 1_001; i++ {
		require.NoError(t, os.MkdirAll(filepath.Join(tmp, paddedName(i)), 0755))
	}

	dirs, page, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 200})
	require.NoError(t, err)
	require.Len(t, dirs, 200)
	require.True(t, page.HasMore)
	require.False(t, page.Truncated)
	require.Equal(t, "200", page.NextCursor)

	// Walk forward until the truncated boundary.
	cursor := page.NextCursor
	truncatedSeen := false
	for i := 0; i < 20; i++ {
		dirs, page, err = ListMachineDirectoriesPage(tmp, ListOptions{Limit: 200, Cursor: cursor})
		require.NoError(t, err)
		cursor = page.NextCursor
		if page.Truncated {
			require.False(t, page.HasMore)
			require.Empty(t, page.NextCursor)
			truncatedSeen = true
			break
		}
		if !page.HasMore {
			break
		}
	}
	require.True(t, truncatedSeen, "expected to hit the truncated boundary")

	// Cursor at exactly 1_000 reads only the existence probe.
	dirs, page, err = ListMachineDirectoriesPage(tmp, ListOptions{Limit: 200, Cursor: "1000"})
	require.NoError(t, err)
	require.Empty(t, dirs)
	require.True(t, page.Truncated)
	require.False(t, page.HasMore)
	require.Empty(t, page.NextCursor)
}

func TestListMachineDirectoriesPageStopsAtByteBudget(t *testing.T) {
	// Use FS injection: a synthetic FS exposes enough eligible directories with
	// long marshaled paths that the 448 KiB page budget engages well below the
	// 1,000 raw-entry boundary. The host filesystem's NAME_MAX would otherwise
	// forbid the long names this fixture needs.
	base := "/synthetic/base"
	const entries = 8
	const nameLen = 1 << 15 // 32 KiB per name
	fs := newSyntheticDirectoryFS(base, entries, nameLen)

	firstDirs, firstPage, err := listMachineDirectoriesPage(fs, base, ListOptions{Limit: 200})
	require.NoError(t, err)
	require.NotEmpty(t, firstDirs)
	require.True(t, firstPage.HasMore)
	require.False(t, firstPage.Truncated)
	require.NotEmpty(t, firstPage.NextCursor, "byte-budget stop must advance cursor to retry the rejected row")

	// The marshaled first-page response must stay within the page budget.
	marshaled, err := json.Marshal(machineDirectoryListEnvelope{
		CurrentPath: firstPage.CurrentPath,
		ParentPath:  firstPage.ParentPath,
		Directories: firstDirs,
		Limit:       firstPage.Limit,
		Cursor:      firstPage.Cursor,
		HasMore:     firstPage.HasMore,
		NextCursor:  firstPage.NextCursor,
		Truncated:   firstPage.Truncated,
	})
	require.NoError(t, err)
	require.LessOrEqual(t, len(marshaled), maxMachineDirectoryPageBytes)

	// The next page must reconsider the rejected row instead of skipping it.
	secondDirs, secondPage, err := listMachineDirectoriesPage(fs, base, ListOptions{Limit: 200, Cursor: firstPage.NextCursor})
	require.NoError(t, err)
	require.NotEmpty(t, secondDirs, "next page must resume from the rejected raw offset")

	totalReturned := len(firstDirs) + len(secondDirs)
	// Continue paging until EOF to confirm every eligible row is reachable.
	cursor := secondPage.NextCursor
	for i := 0; i < 20 && secondPage.HasMore; i++ {
		var dirs []MachineDirectory
		dirs, secondPage, err = listMachineDirectoriesPage(fs, base, ListOptions{Limit: 200, Cursor: cursor})
		require.NoError(t, err)
		totalReturned += len(dirs)
		cursor = secondPage.NextCursor
	}
	require.Equal(t, entries, totalReturned, "all eligible rows must eventually be returned across pages")
}

func TestListMachineDirectoriesPageByteBudgetRejectsOversizedSingleEntry(t *testing.T) {
	// A single entry whose marshaled form alone exceeds the 448 KiB page budget
	// must target as unreadable rather than looping or returning an empty page.
	base := "/synthetic/oversized"
	fs := newSyntheticDirectoryFS(base, 1, maxMachineDirectoryPageBytes)

	_, _, err := listMachineDirectoriesPage(fs, base, ListOptions{Limit: 10})
	require.ErrorIs(t, err, ErrMachineDirectoryUnreadable)
}

func TestListMachineDirectoriesPageClassifiesFilesystemErrors(t *testing.T) {
	for _, tc := range []struct {
		name    string
		fs      machineDirectoryFS
		wantErr error
	}{
		{"home failure", brokenHomeFS{}, ErrMachineDirectoryUnreadable},
		{"evalsymlink permission", evalSymlinkFS{err: os.ErrPermission}, ErrMachineDirectoryUnreadable},
		{"evalsymlink not exist", evalSymlinkFS{err: os.ErrNotExist}, ErrMachineDirectoryInvalid},
		{"open failure", openFailureFS{err: os.ErrPermission}, ErrMachineDirectoryUnreadable},
		{"open not exist", openFailureFS{err: os.ErrNotExist}, ErrMachineDirectoryInvalid},
		{"stat failure", statFailureFS{}, ErrMachineDirectoryUnreadable},
		{"readdir failure", readDirFailureFS{}, ErrMachineDirectoryUnreadable},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, _, err := listMachineDirectoriesPage(tc.fs, "/tmp", ListOptions{Limit: 10})
			require.ErrorIs(t, err, tc.wantErr)
		})
	}
}

func TestListMachineDirectoriesPageNeverIncludesPathInSentinel(t *testing.T) {
	tmp := t.TempDir()
	unique := "glm2-marker-9f3a4c2d"
	missing := filepath.Join(tmp, unique)
	_, _, err := ListMachineDirectoriesPage(missing, ListOptions{Limit: 10})
	require.Error(t, err)
	require.NotContains(t, err.Error(), unique)
}

func TestListMachineDirectoriesPageLiteralEmptyResolvesHomeAndWhitespaceIsInvalid(t *testing.T) {
	// Only a literal empty rawPath resolves to the daemon home directory. A
	// whitespace-only path is a raw filesystem value: it is not absolute and
	// must be classified as invalid without being silently rewritten to home.
	home := resolved(t, t.TempDir())
	setHome(t, home)
	require.NoError(t, os.MkdirAll(filepath.Join(home, "child"), 0o755))

	dirs, page, err := ListMachineDirectoriesPage("", ListOptions{Limit: 10})
	require.NoError(t, err)
	require.Equal(t, home, page.CurrentPath)
	require.Equal(t, []string{filepath.Join(home, "child")}, machineDirectoryPaths(dirs))

	for _, raw := range []string{" ", "  ", "\t"} {
		_, _, err := ListMachineDirectoriesPage(raw, ListOptions{Limit: 10})
		require.ErrorIs(t, err, ErrMachineDirectoryInvalid, "rawPath=%q must be invalid, not rewritten to home", raw)
	}
}

func TestListMachineDirectoriesPageEchoesRawRequestCursor(t *testing.T) {
	tmp := resolved(t, t.TempDir())
	require.NoError(t, os.MkdirAll(filepath.Join(tmp, "child"), 0o755))

	// Missing cursor is interpreted as zero for page logic but the response
	// echoes the empty resolved input instead of rewriting it to "0".
	_, page, err := ListMachineDirectoriesPage(tmp, ListOptions{Limit: 10, Cursor: ""})
	require.NoError(t, err)
	require.Equal(t, "", page.Cursor)

	// A literal "0" stays "0"; a non-zero cursor echoes byte-exact.
	_, page, err = ListMachineDirectoriesPage(tmp, ListOptions{Limit: 10, Cursor: "0"})
	require.NoError(t, err)
	require.Equal(t, "0", page.Cursor)
}

func TestListMachineDirectoriesPageBoundedReadDirRawEntryBudget(t *testing.T) {
	// Inject a counting FS to prove the per-request raw-entry read budget is
	// bounded regardless of cursor position. One request may read at most
	// min(cursor + limit + 1, 1_001) raw entries, split across bounded batches
	// of at most maxMachineDirectorySkipBatch each.
	const entries = 1_001
	base := "/synthetic/bounded"
	inner := newSyntheticDirectoryFS(base, entries, 4)
	counter := &countingDirectoryFS{inner: inner}

	assertBoundedReadDir := func(t *testing.T, cursor, limit int) {
		t.Helper()
		var totalReturned int
		var remaining = cursor + limit + 1
		if remaining > maxMachineDirectoryRawEntries+1 {
			remaining = maxMachineDirectoryRawEntries + 1
		}
		for i, record := range counter.records {
			require.Greater(t, record.Requested, 0, "call %d: ReadDir must request a positive batch", i)
			require.LessOrEqual(t, record.Requested, maxMachineDirectorySkipBatch,
				"call %d: each ReadDir request must be bounded by maxMachineDirectorySkipBatch", i)
			require.LessOrEqual(t, record.Returned, record.Requested,
				"call %d: ReadDir must not return more entries than requested", i)
			// Each request must itself fit inside the remaining per-request
			// budget, not just the cumulative returned count.
			require.LessOrEqual(t, record.Requested, remaining-totalReturned,
				"call %d: ReadDir request must not exceed the remaining per-request budget", i)
			totalReturned += record.Returned
			require.LessOrEqual(t, totalReturned, remaining,
				"call %d: cumulative returned entries must stay within min(cursor+limit+1, 1001)", i)
		}
		require.Equal(t, totalReturned, counter.entriesRead,
			"sum of per-call Returned must equal counter.entriesRead")
	}

	// First page: cursor=0, limit=200 -> at most 201 raw entries.
	dirs, page, err := listMachineDirectoriesPage(counter, base, ListOptions{Limit: 200})
	require.NoError(t, err)
	require.Len(t, dirs, 200)
	require.True(t, page.HasMore)
	require.Equal(t, "200", page.NextCursor)
	require.LessOrEqual(t, counter.entriesRead, 200+1, "first page must not read more than limit+1 raw entries")
	require.Greater(t, counter.calls, 0)
	assertBoundedReadDir(t, 0, 200)

	// Deep skip: cursor=700, limit=200 -> at most 700+200+1 = 901 raw entries,
	// and nextOffset=900 stays below the 1_000 truncation boundary.
	counter.reset()
	dirs, page, err = listMachineDirectoriesPage(counter, base, ListOptions{Limit: 200, Cursor: "700"})
	require.NoError(t, err)
	require.Len(t, dirs, 200)
	require.True(t, page.HasMore)
	require.Equal(t, "900", page.NextCursor)
	require.LessOrEqual(t, counter.entriesRead, 700+200+1, "skip phase must stay within min(cursor+limit+1, 1001)")
	assertBoundedReadDir(t, 700, 200)

	// Truncation probe at cursor=1000 returns no rows but still has to skip
	// the 1_000-entry prefix before probing. Total raw entries stay within the
	// min(cursor+limit+1, 1_001) bound = 1_001.
	counter.reset()
	dirs, page, err = listMachineDirectoriesPage(counter, base, ListOptions{Limit: 200, Cursor: "1000"})
	require.NoError(t, err)
	require.Empty(t, dirs)
	require.True(t, page.Truncated)
	require.False(t, page.HasMore)
	require.LessOrEqual(t, counter.entriesRead, maxMachineDirectoryRawEntries+1, "cursor=1000 stays within the bounded prefix re-scan")
	assertBoundedReadDir(t, 1000, 200)
}

func TestListMachineDirectoriesPageByteBudgetMarshalUsesWorstCaseNextCursor(t *testing.T) {
	// Force the byte-budget boundary to land on a row whose probe-path
	// NextCursor (cursor+pageCapacity) is longer than the byte-reject
	// NextCursor (absoluteRawRead-1). This is the digit-growth boundary,
	// e.g. cursor=9, pageCapacity=1 -> reject NextCursor="9", probe
	// NextCursor="10" (one byte longer). The trial marshal must use the
	// longer probe-path value so the final emitted envelope never exceeds
	// the budget by even one byte.
	base := "/synthetic/nextcursor"
	inner := newSyntheticDirectoryFS(base, 32, 1<<14)
	counter := &countingDirectoryFS{inner: inner}

	dirs, page, err := listMachineDirectoriesPage(counter, base, ListOptions{Limit: 1, Cursor: "9"})
	require.NoError(t, err)
	require.True(t, page.HasMore)
	require.NotEmpty(t, dirs)

	// The actually emitted envelope must be within budget. The worst-case
	// trial marshal already includes the longer probe-path NextCursor, so
	// acceptance of the trial guarantees the emitted envelope is in budget.
	marshaled, err := json.Marshal(machineDirectoryListEnvelope{
		CurrentPath: page.CurrentPath,
		ParentPath:  page.ParentPath,
		Directories: dirs,
		Limit:       page.Limit,
		Cursor:      page.Cursor,
		HasMore:     page.HasMore,
		NextCursor:  page.NextCursor,
		Truncated:   page.Truncated,
	})
	require.NoError(t, err)
	require.LessOrEqual(t, len(marshaled), maxMachineDirectoryPageBytes,
		"emitted envelope must stay within the 448 KiB budget even when the probe-path NextCursor is longer than the reject-path one")
}

func TestListMachineDirectoriesPageByteBudgetHoldsUnderJSONEscapeAmplification(t *testing.T) {
	// JSON escapes control characters as \uXXXX (6 bytes per source byte) and
	// quotes/backslashes as \\ (2 bytes per source byte). A directory whose
	// names are mostly control chars amplifies the marshaled payload ~6x, so
	// the byte budget must engage well below the raw-entry boundary.
	base := "/synthetic/escape"
	const entries = 64
	const nameLen = 1 << 14 // 16 KiB of control chars per name -> ~96 KiB JSON-escaped per row
	fs := newEscapeAmplifiedDirectoryFS(base, entries, nameLen)

	firstDirs, firstPage, err := listMachineDirectoriesPage(fs, base, ListOptions{Limit: 200})
	require.NoError(t, err)
	require.NotEmpty(t, firstDirs)
	require.True(t, firstPage.HasMore, "byte budget must engage before all entries fit")
	require.False(t, firstPage.Truncated)
	require.NotEmpty(t, firstPage.NextCursor)

	marshaled, err := json.Marshal(machineDirectoryListEnvelope{
		CurrentPath: firstPage.CurrentPath,
		ParentPath:  firstPage.ParentPath,
		Directories: firstDirs,
		Limit:       firstPage.Limit,
		Cursor:      firstPage.Cursor,
		HasMore:     firstPage.HasMore,
		NextCursor:  firstPage.NextCursor,
		Truncated:   firstPage.Truncated,
	})
	require.NoError(t, err)
	require.LessOrEqual(t, len(marshaled), maxMachineDirectoryPageBytes, "first page must stay inside the 448 KiB transport budget")

	// Every JSON-escaped row eventually appears across pages without skipping.
	gathered := make(map[string]struct{}, entries)
	for _, d := range firstDirs {
		gathered[d.Name] = struct{}{}
	}
	cursor := firstPage.NextCursor
	page := firstPage
	for i := 0; i < 32 && page.HasMore; i++ {
		var dirs []MachineDirectory
		dirs, page, err = listMachineDirectoriesPage(fs, base, ListOptions{Limit: 200, Cursor: cursor})
		require.NoError(t, err)
		for _, d := range dirs {
			gathered[d.Name] = struct{}{}
		}
		cursor = page.NextCursor
	}
	require.Len(t, gathered, entries, "all escape-amplified rows must be reachable across pages")
}

// paddedName returns a zero-padded fixed-width numeric name so 1_001 entries
// sort deterministically without depending on creation order.
func paddedName(i int) string {
	return fmt.Sprintf("d%04d", i)
}

// --- synthetic FS doubles ---

type syntheticDirEntry struct {
	name string
}

func (e syntheticDirEntry) Name() string             { return e.name }
func (syntheticDirEntry) IsDir() bool                { return true }
func (syntheticDirEntry) Type() os.FileMode          { return os.ModeDir }
func (syntheticDirEntry) Info() (os.FileInfo, error) { return fakeDirInfo{}, nil }

type fakeDirInfo struct{}

func (fakeDirInfo) Name() string       { return "resolved" }
func (fakeDirInfo) Size() int64        { return 0 }
func (fakeDirInfo) Mode() os.FileMode  { return os.ModeDir | 0755 }
func (fakeDirInfo) ModTime() time.Time { return time.Time{} }
func (fakeDirInfo) IsDir() bool        { return true }
func (fakeDirInfo) Sys() any           { return nil }

type syntheticDirectoryFS struct {
	base    string
	entries []syntheticDirEntry
}

func newSyntheticDirectoryFS(base string, count, nameLen int) syntheticDirectoryFS {
	entries := make([]syntheticDirEntry, count)
	marker := []byte("abcdefghijklmnopqrstuvwxyz0123456789")
	name := make([]byte, nameLen)
	for i := 0; i < count; i++ {
		for j := range name {
			name[j] = marker[(i+j)%len(marker)]
		}
		entries[i] = syntheticDirEntry{name: fmt.Sprintf("%s-%d", string(name), i)}
	}
	return syntheticDirectoryFS{base: base, entries: entries}
}

func (f syntheticDirectoryFS) UserHomeDir() (string, error) { return f.base, nil }

func (f syntheticDirectoryFS) EvalSymlinks(p string) (string, error) {
	if p == "" {
		return f.base, nil
	}
	return p, nil
}

func (f syntheticDirectoryFS) Open(string) (machineDirectoryHandle, error) {
	return &syntheticHandle{entries: f.entries}, nil
}

type syntheticHandle struct {
	entries []syntheticDirEntry
	offset  int
}

func (h *syntheticHandle) Stat() (os.FileInfo, error) { return fakeDirInfo{}, nil }
func (h *syntheticHandle) Close() error               { return nil }

func (h *syntheticHandle) ReadDir(n int) ([]os.DirEntry, error) {
	if h.offset >= len(h.entries) {
		return nil, io.EOF
	}
	batch := n
	if batch <= 0 {
		batch = len(h.entries)
	}
	end := h.offset + batch
	if end > len(h.entries) {
		end = len(h.entries)
	}
	out := make([]os.DirEntry, 0, end-h.offset)
	for i := h.offset; i < end; i++ {
		out = append(out, h.entries[i])
	}
	h.offset = end
	if end >= len(h.entries) {
		return out, io.EOF
	}
	return out, nil
}

// --- failure FS doubles for classification tests ---

type brokenHomeFS struct{}

func (brokenHomeFS) UserHomeDir() (string, error) { return "", errors.New("home unavailable") }
func (brokenHomeFS) EvalSymlinks(string) (string, error) {
	return "", os.ErrPermission
}
func (brokenHomeFS) Open(string) (machineDirectoryHandle, error) {
	return nil, errors.New("unreachable")
}

type evalSymlinkFS struct{ err error }

func (evalSymlinkFS) UserHomeDir() (string, error) { return "/home/x", nil }
func (f evalSymlinkFS) EvalSymlinks(string) (string, error) {
	return "", f.err
}
func (evalSymlinkFS) Open(string) (machineDirectoryHandle, error) {
	return nil, errors.New("unreachable")
}

type openFailureFS struct{ err error }

func (openFailureFS) UserHomeDir() (string, error)        { return "/home/x", nil }
func (openFailureFS) EvalSymlinks(string) (string, error) { return "/resolved", nil }
func (f openFailureFS) Open(string) (machineDirectoryHandle, error) {
	return nil, f.err
}

type statFailureFS struct{}

func (statFailureFS) UserHomeDir() (string, error)        { return "/home/x", nil }
func (statFailureFS) EvalSymlinks(string) (string, error) { return "/resolved", nil }
func (statFailureFS) Open(string) (machineDirectoryHandle, error) {
	return brokenHandle{statErr: errors.New("stat failed")}, nil
}

type readDirFailureFS struct{}

func (readDirFailureFS) UserHomeDir() (string, error)        { return "/home/x", nil }
func (readDirFailureFS) EvalSymlinks(string) (string, error) { return "/resolved", nil }
func (readDirFailureFS) Open(string) (machineDirectoryHandle, error) {
	return brokenHandle{readErr: errors.New("readdir failed")}, nil
}

type brokenHandle struct {
	statErr error
	readErr error
}

func (h brokenHandle) Stat() (os.FileInfo, error) {
	if h.statErr != nil {
		return nil, h.statErr
	}
	return fakeDirInfo{}, nil
}

func (h brokenHandle) ReadDir(int) ([]os.DirEntry, error) {
	if h.readErr != nil {
		return nil, h.readErr
	}
	return nil, nil
}

func (brokenHandle) Close() error { return nil }

// --- counting FS double for raw-entry budget tests ---

type readDirCall struct {
	Requested int
	Returned  int
	EOF       bool
}

type countingDirectoryFS struct {
	inner       machineDirectoryFS
	calls       int
	entriesRead int
	records     []readDirCall
}

func (c *countingDirectoryFS) UserHomeDir() (string, error) { return c.inner.UserHomeDir() }
func (c *countingDirectoryFS) EvalSymlinks(p string) (string, error) {
	return c.inner.EvalSymlinks(p)
}
func (c *countingDirectoryFS) Open(p string) (machineDirectoryHandle, error) {
	handle, err := c.inner.Open(p)
	if err != nil {
		return nil, err
	}
	return &countingHandle{inner: handle, parent: c}, nil
}

func (c *countingDirectoryFS) reset() {
	c.calls = 0
	c.entriesRead = 0
	c.records = nil
}

type countingHandle struct {
	inner  machineDirectoryHandle
	parent *countingDirectoryFS
}

func (h *countingHandle) Stat() (os.FileInfo, error) { return h.inner.Stat() }
func (h *countingHandle) Close() error               { return h.inner.Close() }

func (h *countingHandle) ReadDir(n int) ([]os.DirEntry, error) {
	h.parent.calls++
	entries, err := h.inner.ReadDir(n)
	h.parent.entriesRead += len(entries)
	h.parent.records = append(h.parent.records, readDirCall{
		Requested: n,
		Returned:  len(entries),
		EOF:       errors.Is(err, io.EOF),
	})
	return entries, err
}

// --- escape-amplified FS double for JSON amplification tests ---

type escapeAmplifiedDirectoryFS struct {
	base    string
	entries []syntheticDirEntry
}

func newEscapeAmplifiedDirectoryFS(base string, count, nameLen int) escapeAmplifiedDirectoryFS {
	entries := make([]syntheticDirEntry, count)
	for i := 0; i < count; i++ {
		name := make([]byte, nameLen)
		for j := range name {
			// Cycle through JSON-escaped byte values: 0x00 (control), 0x22
			// (quote), 0x5C (backslash). JSON emits \uXXXX (6 bytes) for
			// control chars and \\ (2 bytes) for quotes/backslashes.
			switch (i + j) % 3 {
			case 0:
				name[j] = 0x01
			case 1:
				name[j] = '"'
			case 2:
				name[j] = '\\'
			}
		}
		entries[i] = syntheticDirEntry{name: fmt.Sprintf("%s-%d", string(name), i)}
	}
	return escapeAmplifiedDirectoryFS{base: base, entries: entries}
}

func (f escapeAmplifiedDirectoryFS) UserHomeDir() (string, error) { return f.base, nil }

func (f escapeAmplifiedDirectoryFS) EvalSymlinks(p string) (string, error) {
	if p == "" {
		return f.base, nil
	}
	return p, nil
}

func (f escapeAmplifiedDirectoryFS) Open(string) (machineDirectoryHandle, error) {
	return &syntheticHandle{entries: f.entries}, nil
}
