package memorywatcher

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

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

// fakeCommitter records every CommitPending call. It returns ErrNothingToCommit
// when the test sets cleanWorktree=true, simulating a debounce round that
// found no actual changes (e.g., fsnotify reported a Chmod that go-git's
// Status considers clean).
type fakeCommitter struct {
	mu            sync.Mutex
	calls         []fakeCommitCall
	lastMsg       string
	cleanWorktree bool
	lastCommitMsg string
	lastCommitErr error
}

type fakeCommitCall struct {
	Msg  string
	Opts CommitOpts
}

func (f *fakeCommitter) CommitPending(ctx context.Context, msg string, opts CommitOpts) (string, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls = append(f.calls, fakeCommitCall{Msg: msg, Opts: opts})
	if f.cleanWorktree {
		return "", ErrNothingToCommit
	}
	f.lastMsg = msg
	f.lastCommitMsg = msg
	return "deadbeef", nil
}

func (f *fakeCommitter) LastCommitMessage(ctx context.Context) (string, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	if f.lastCommitErr != nil {
		return "", f.lastCommitErr
	}
	return f.lastCommitMsg, nil
}

func (f *fakeCommitter) LastCommitTime(ctx context.Context) (time.Time, error) {
	return time.Now(), nil
}

func (f *fakeCommitter) callCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return len(f.calls)
}

func (f *fakeCommitter) snapshotCalls() []fakeCommitCall {
	f.mu.Lock()
	defer f.mu.Unlock()
	out := make([]fakeCommitCall, len(f.calls))
	copy(out, f.calls)
	return out
}

// staticResolver returns the same CommitContext for every call. Useful for
// tests that need predictable commit messages.
func staticResolver(ctx CommitContext) ContextResolver {
	return func(_ context.Context) CommitContext { return ctx }
}

// newStartedWatcher constructs a watcher pointed at a temp memory dir, starts
// it, and returns the committer so the test can inspect calls. Cleanup is
// registered with t.Cleanup.
func newStartedWatcher(t *testing.T, opts Options) (*Watcher, *fakeCommitter) {
	t.Helper()
	if opts.Debounce == 0 {
		opts.Debounce = 20 * time.Millisecond
	}
	committer := &fakeCommitter{}
	if opts.Committer == nil {
		opts.Committer = committer
	}
	if opts.Resolve == nil {
		opts.Resolve = staticResolver(CommitContext{AgentID: "agt_alice"})
	}
	if opts.MemoryDir == "" {
		opts.MemoryDir = t.TempDir()
	}
	w, err := New(opts)
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() {
		_ = w.Close()
	})
	return w, committer
}

// waitForCommits polls until committer has recorded at least n commits or
// the timeout elapses. Returns the final call slice.
func waitForCommits(t *testing.T, committer *fakeCommitter, n int, timeout time.Duration) []fakeCommitCall {
	t.Helper()
	deadline := time.NewTimer(timeout)
	defer deadline.Stop()
	ticker := time.NewTicker(5 * time.Millisecond)
	defer ticker.Stop()
	for {
		if calls := committer.snapshotCalls(); len(calls) >= n {
			return calls
		}
		select {
		case <-deadline.C:
			return committer.snapshotCalls()
		case <-ticker.C:
		}
	}
}

func TestWatcherCommitsAfterDebouncedBurst(t *testing.T) {
	t.Parallel()
	w, committer := newStartedWatcher(t, Options{
		MemoryDir: t.TempDir(),
	})

	// Burst of 5 writes inside the debounce window must produce exactly 1
	// commit, not 5.
	for i := 0; i < 5; i++ {
		require.NoError(t, os.WriteFile(
			filepath.Join(w.opts.MemoryDir, "note-"+string(rune('a'+i))+".md"),
			[]byte("# note\n"),
			0o644,
		))
	}

	calls := waitForCommits(t, committer, 1, 2*time.Second)
	require.Len(t, calls, 1, "burst of writes should collapse to a single commit")
}

func TestWatcherIgnoresGitDirectory(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	// Pre-create memory/.git so the watcher's initial walk must skip it.
	require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git", "objects"), 0o755))
	w, committer := newStartedWatcher(t, Options{MemoryDir: dir})

	// Write inside .git/ — must NOT trigger a commit.
	require.NoError(t, os.WriteFile(
		filepath.Join(dir, ".git", "index"),
		[]byte("garbage"),
		0o644,
	))

	require.Never(t, func() bool {
		return committer.callCount() > 0
	}, 150*time.Millisecond, 10*time.Millisecond, "writes under memory/.git must be ignored")
	// Also confirm .git was never added to the fsnotify watcher by writing
	// outside it and seeing a normal commit land.
	require.NoError(t, os.WriteFile(filepath.Join(dir, "MEMORY.md"), []byte("# m\n"), 0o644))
	require.NotEmpty(t, waitForCommits(t, committer, 1, 2*time.Second))
	_ = w
}

func TestWatcherCommitsAfterDirectoryCreation(t *testing.T) {
	t.Parallel()
	w, committer := newStartedWatcher(t, Options{MemoryDir: t.TempDir()})

	// Create a new subdirectory and write a file inside it. The watcher must
	// detect the new subdir and observe the file write.
	require.NoError(t, os.MkdirAll(filepath.Join(w.opts.MemoryDir, "kb", "tasks"), 0o755))
	require.NoError(t, os.WriteFile(
		filepath.Join(w.opts.MemoryDir, "kb", "tasks", "t9.md"),
		[]byte("# task 9 notes\n"),
		0o644,
	))

	calls := waitForCommits(t, committer, 1, 2*time.Second)
	require.Len(t, calls, 1)
}

func TestWatcherNewCommitsWhenScopeChanges(t *testing.T) {
	t.Parallel()

	// Pre-seed the previous commit message so the first round will see an
	// existing HEAD on channel #agentteam with no thread/task. The second round
	// switches context to channel #engineering — must NOT amend.
	prevMsg := BuildCommitMessage(CommitContext{
		AgentID:   "agt_alice",
		ChannelID: "#agentteam",
	})
	committer := &fakeCommitter{lastCommitMsg: prevMsg}
	dir := t.TempDir()

	// Swappable resolver guarded by a mutex. The watcher contract treats
	// Options.Resolve as immutable; tests that need to flip the context
	// between rounds must wrap the swap in a thread-safe closure.
	var resolverMu sync.Mutex
	currentCtx := CommitContext{AgentID: "agt_alice", ChannelID: "#agentteam"}
	resolver := func(_ context.Context) CommitContext {
		resolverMu.Lock()
		defer resolverMu.Unlock()
		return currentCtx
	}

	w, err := New(Options{
		MemoryDir: dir,
		Committer: committer,
		Resolve:   resolver,
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	// First commit on #agentteam — should amend because prev commit is on #agentteam.
	require.NoError(t, os.WriteFile(filepath.Join(dir, "a.md"), []byte("a\n"), 0o644))
	calls := waitForCommits(t, committer, 1, 2*time.Second)
	require.Len(t, calls, 1)
	require.True(t, calls[0].Opts.Amend, "same-channel second commit must amend")

	// Update lastCommitMsg so the watcher sees the just-made commit as HEAD
	// (the fakeCommitter stores lastMsg under lock during CommitPending, but
	// the watcher only reads LastCommitMessage which returns lastCommitMsg;
	// sync the two for this test).
	committer.mu.Lock()
	committer.lastCommitMsg = calls[0].Msg
	committer.mu.Unlock()

	// Switch context to #engineering. Next commit must NOT amend.
	resolverMu.Lock()
	currentCtx = CommitContext{AgentID: "agt_alice", ChannelID: "#engineering"}
	resolverMu.Unlock()

	require.NoError(t, os.WriteFile(filepath.Join(dir, "b.md"), []byte("b\n"), 0o644))
	calls = waitForCommits(t, committer, 2, 2*time.Second)
	require.Len(t, calls, 2)
	require.False(t, calls[1].Opts.Amend, "channel change must not amend")
}

func TestWatcherSwallowsErrNothingToCommit(t *testing.T) {
	t.Parallel()
	dir := t.TempDir()
	committer := &fakeCommitter{cleanWorktree: true}
	w, err := New(Options{
		MemoryDir: dir,
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	require.NoError(t, os.WriteFile(filepath.Join(dir, "x.md"), []byte("x\n"), 0o644))

	require.Eventually(t, func() bool {
		return len(committer.snapshotCalls()) > 0
	}, 2*time.Second, 10*time.Millisecond, "commit must have been attempted")
	calls := committer.snapshotCalls()
	require.NotEmpty(t, calls)
	// Committer returned ErrNothingToCommit; the watcher should not have
	// logged it as a failure or panicked. No way to assert logs here, but
	// the test passing means the watcher handled the error gracefully.
}

func TestWatcherRequiresMandatoryOpts(t *testing.T) {
	t.Parallel()

	_, err := New(Options{})
	require.Error(t, err)
	require.Contains(t, err.Error(), "memory dir")

	_, err = New(Options{MemoryDir: t.TempDir()})
	require.Error(t, err)
	require.Contains(t, err.Error(), "committer")

	_, err = New(Options{
		MemoryDir: t.TempDir(),
		Committer: &fakeCommitter{},
	})
	require.Error(t, err)
	require.Contains(t, err.Error(), "resolver")
}

func TestWatcherStartRejectsMissingDir(t *testing.T) {
	t.Parallel()

	w, err := New(Options{
		MemoryDir: filepath.Join(t.TempDir(), "does-not-exist"),
		Committer: &fakeCommitter{},
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
	})
	require.NoError(t, err)
	err = w.Start(context.Background())
	require.Error(t, err)
	require.Contains(t, err.Error(), "stat memory dir")
}

func TestWatcherStartRejectsFilePath(t *testing.T) {
	t.Parallel()

	dir := t.TempDir()
	file := filepath.Join(dir, "not-a-dir")
	require.NoError(t, os.WriteFile(file, []byte("x"), 0o644))

	w, err := New(Options{
		MemoryDir: file,
		Committer: &fakeCommitter{},
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
	})
	require.NoError(t, err)
	err = w.Start(context.Background())
	require.Error(t, err)
	require.Contains(t, err.Error(), "not a directory")
}

func TestWatcherCloseIsIdempotent(t *testing.T) {
	t.Parallel()
	w, _ := newStartedWatcher(t, Options{MemoryDir: t.TempDir()})

	require.NoError(t, w.Close())
	require.NoError(t, w.Close())
}

func TestWatcherStartAfterCloseReturnsErrClosed(t *testing.T) {
	t.Parallel()
	w, _ := newStartedWatcher(t, Options{MemoryDir: t.TempDir()})

	require.NoError(t, w.Close())
	err := w.Start(context.Background())
	require.Error(t, err)
	require.ErrorIs(t, err, ErrClosed)
}

func TestWatcherStartIsIdempotent(t *testing.T) {
	t.Parallel()
	w, _ := newStartedWatcher(t, Options{MemoryDir: t.TempDir()})

	// Second Start must be a no-op (no error, no double-agentteam).
	require.NoError(t, w.Start(context.Background()))
	require.NoError(t, w.Start(context.Background()))
}

func TestShouldAmendFallsBackWhenPrevIsManualCommit(t *testing.T) {
	t.Parallel()

	// A manually-authored commit without AgentTeam-* trailers must not be
	// amended even if the current context looks related.
	manual := "Initial memory import\n\nHand-authored before the watcher existed.\n"
	require.False(t, shouldAmend(manual, time.Now(), CommitContext{ChannelID: "#agentteam"}))
}

func TestShouldAmendTrueWhenKeysMatch(t *testing.T) {
	t.Parallel()

	prev := BuildCommitMessage(CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	})
	next := CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	}
	require.True(t, shouldAmend(prev, time.Now(), next))
}

func TestShouldAmendFalseWhenTaskDiffers(t *testing.T) {
	t.Parallel()

	prev := BuildCommitMessage(CommitContext{
		ChannelID: "#agentteam",
		TaskScope: "#tsk_aaa",
	})
	next := CommitContext{
		ChannelID: "#agentteam",
		TaskScope: "#tsk_bbb",
	}
	require.False(t, shouldAmend(prev, time.Now(), next))
}

func TestShouldAmendFalseWhenCommitTooOld(t *testing.T) {
	t.Parallel()

	prev := BuildCommitMessage(CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	})
	next := CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	}
	oldTime := time.Now().Add(-7 * time.Hour)
	require.False(t, shouldAmend(prev, oldTime, next))
}

func TestShouldAmendTrueWhenCommitWithinDeadline(t *testing.T) {
	t.Parallel()

	prev := BuildCommitMessage(CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	})
	next := CommitContext{
		ChannelID:    "#agentteam",
		ThreadTarget: "#agentteam:abc",
	}
	recentTime := time.Now().Add(-5 * time.Hour)
	require.True(t, shouldAmend(prev, recentTime, next))
}

func TestParseTrailersHandlesBlankInput(t *testing.T) {
	t.Parallel()
	require.Nil(t, parseTrailers(""))
	require.Nil(t, parseTrailers("\n\n"))
}

func TestParseTrailersRejectsBodyInLastParagraph(t *testing.T) {
	t.Parallel()
	// Last paragraph has prose mixed with trailers — not a valid trailer
	// block per our strict parser.
	msg := "Subject\n\nSome prose.\nAgentTeam-Channel: #agentteam\n"
	require.Nil(t, parseTrailers(msg))
}

func TestParseTrailersExtractsAgentTeamTrailers(t *testing.T) {
	t.Parallel()
	msg := strings.Join([]string{
		"Update memory from #agentteam",
		"",
		"Channel: #agentteam",
		"",
		"AgentTeam-Channel: #agentteam",
		"AgentTeam-Task: #tsk_xxx",
	}, "\n")
	got := parseTrailers(msg)
	require.Equal(t, "#agentteam", got["AgentTeam-Channel"])
	require.Equal(t, "#tsk_xxx", got["AgentTeam-Task"])
}

func TestSplitTrailerRejectsMalformed(t *testing.T) {
	t.Parallel()
	cases := []string{
		"",
		"no-colon",
		"-: value",
		"bad key: value",
	}
	for _, c := range cases {
		_, _, ok := splitTrailer(c)
		require.False(t, ok, "input %q must be rejected", c)
	}
}

// Ensure errors import is exercised even when only the package-level
// ErrNothingToCommit check fires.
var _ = errors.Is

// fakeSyncer records Sync invocations in order. Tests inspect order to assert
// pipeline sequencing (sync → commit → push).
type fakeSyncer struct {
	mu       sync.Mutex
	calls    []fakeSyncCall
	result   SyncResult
	err      error
	delay    time.Duration // optional stall to amplify races
	released chan struct{} // closed when first Sync returns; for race tests
}

type fakeSyncCall struct{}

func (s *fakeSyncer) Sync(ctx context.Context) (SyncResult, error) {
	s.mu.Lock()
	s.calls = append(s.calls, fakeSyncCall{})
	release := s.released
	delay := s.delay
	err := s.err
	result := s.result
	s.mu.Unlock()
	if delay > 0 {
		select {
		case <-time.After(delay):
		case <-ctx.Done():
		}
	}
	if release != nil {
		select {
		case release <- struct{}{}:
		default:
		}
	}
	return result, err
}

func (s *fakeSyncer) callCount() int {
	s.mu.Lock()
	defer s.mu.Unlock()
	return len(s.calls)
}

// fakePipelinePusher is a Pusher that records push order relative to other
// pipeline fakes via a shared log slice guarded by logMu.
type fakePipelinePusher struct {
	logMu *sync.Mutex
	log   *[]string
}

func newFakePipelinePusher(logMu *sync.Mutex, log *[]string) *fakePipelinePusher {
	return &fakePipelinePusher{logMu: logMu, log: log}
}

func (p *fakePipelinePusher) Push(ctx context.Context, _ PushOpts) (string, error) {
	p.logMu.Lock()
	*p.log = append(*p.log, "push")
	p.logMu.Unlock()
	return "pushed", nil
}

// orderingCommitter is a fakeCommitter variant that appends to a shared log
// so the test can assert call ordering across pipeline stages.
type orderingCommitter struct {
	fakeCommitter
	logMu *sync.Mutex
	log   *[]string
}

func newOrderingCommitter(logMu *sync.Mutex, log *[]string) *orderingCommitter {
	return &orderingCommitter{logMu: logMu, log: log}
}

func (o *orderingCommitter) CommitPending(ctx context.Context, msg string, opts CommitOpts) (string, error) {
	o.logMu.Lock()
	*o.log = append(*o.log, "commit")
	o.logMu.Unlock()
	return o.fakeCommitter.CommitPending(ctx, msg, opts)
}

// orderingSyncer wraps fakeSyncer to log into the shared slice.
type orderingSyncer struct {
	fakeSyncer
	logMu *sync.Mutex
	log   *[]string
}

func newOrderingSyncer(logMu *sync.Mutex, log *[]string) *orderingSyncer {
	return &orderingSyncer{logMu: logMu, log: log}
}

func (o *orderingSyncer) Sync(ctx context.Context) (SyncResult, error) {
	o.logMu.Lock()
	*o.log = append(*o.log, "sync-start")
	o.logMu.Unlock()
	r, err := o.fakeSyncer.Sync(ctx)
	o.logMu.Lock()
	*o.log = append(*o.log, "sync-end")
	o.logMu.Unlock()
	return r, err
}

// snapshotLog copies log under logMu for assertions.
func snapshotLog(t *testing.T, logMu *sync.Mutex, log *[]string) []string {
	t.Helper()
	logMu.Lock()
	defer logMu.Unlock()
	out := make([]string, len(*log))
	copy(out, *log)
	return out
}

// TestPipelineRunsSyncCommitPushInOrder asserts the ordering inside a single
// debounce flush when amending: sync must precede commit, commit precede push.
// Sync only runs when the watcher intends to amend (same context as previous
// commit), so the test triggers two writes with the same context.
func TestPipelineRunsSyncCommitPushInOrder(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	logPtr := &log
	committer := newOrderingCommitter(&logMu, logPtr)
	syncer := newOrderingSyncer(&logMu, logPtr)
	syncer.result = SyncResult{State: SyncLocalAhead}
	pusher := newFakePipelinePusher(&logMu, logPtr)

	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice", ChannelID: "#agentteam"}),
		Push:      pusher,
		Sync:      syncer,
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	// First write: no previous commit → amend=false → no sync.
	require.NoError(t, os.WriteFile(filepath.Join(w.opts.MemoryDir, "note.md"), []byte("x\n"), 0o644))
	require.Eventually(t, func() bool {
		return len(snapshotLog(t, &logMu, logPtr)) >= 2
	}, 2*time.Second, 10*time.Millisecond)

	// Second write with same context: amend=true → sync → commit → push.
	logMu.Lock()
	*logPtr = (*logPtr)[:0]
	logMu.Unlock()
	require.NoError(t, os.WriteFile(filepath.Join(w.opts.MemoryDir, "note.md"), []byte("y\n"), 0o644))

	require.Eventually(t, func() bool {
		return len(snapshotLog(t, &logMu, logPtr)) >= 4
	}, 3*time.Second, 5*time.Millisecond)

	got := snapshotLog(t, &logMu, logPtr)
	first := got
	if len(first) >= 4 {
		first = first[:4]
	}
	require.Equal(t, []string{"sync-start", "sync-end", "commit", "push"}, first,
		"pipeline must run sync → commit → push in order; got %v", got)
}

// TestPipelineContinuesPushWhenSyncErrors verifies that a Syncer returning an
// error does not block push. Sync only runs when amending, so the test
// triggers two writes with the same context to enable amend.
func TestPipelineContinuesPushWhenSyncErrors(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	logPtr2 := &log
	committer := newOrderingCommitter(&logMu, logPtr2)
	syncer := newOrderingSyncer(&logMu, logPtr2)
	syncer.err = errors.New("simulated sync failure")
	pusher := newFakePipelinePusher(&logMu, logPtr2)

	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice", ChannelID: "#agentteam"}),
		Push:      pusher,
		Sync:      syncer,
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	// First write: no previous commit → amend=false → no sync.
	require.NoError(t, os.WriteFile(filepath.Join(w.opts.MemoryDir, "note.md"), []byte("x\n"), 0o644))
	require.Eventually(t, func() bool {
		return len(snapshotLog(t, &logMu, logPtr2)) >= 2
	}, 2*time.Second, 10*time.Millisecond)

	// Second write with same context: amend=true → sync fails → still commits and pushes.
	logMu.Lock()
	*logPtr2 = (*logPtr2)[:0]
	logMu.Unlock()
	require.NoError(t, os.WriteFile(filepath.Join(w.opts.MemoryDir, "note.md"), []byte("y\n"), 0o644))

	require.Eventually(t, func() bool {
		got := snapshotLog(t, &logMu, logPtr2)
		hasCommit := false
		hasPush := false
		for _, entry := range got {
			hasCommit = hasCommit || entry == "commit"
			hasPush = hasPush || entry == "push"
		}
		return hasCommit && hasPush
	}, 3*time.Second, 5*time.Millisecond)

	got := snapshotLog(t, &logMu, logPtr2)
	require.Contains(t, got, "commit", "commit must run despite sync error")
	require.Contains(t, got, "push", "push must run despite sync error")
}

// TestFlushAndCloseDrainsPendingDebounce asserts that a write arriving inside
// the debounce window but before the timer fires is committed and pushed by
// FlushAndClose. Regression guard for the R4 review item.
func TestFlushAndCloseDrainsPendingDebounce(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	committer := newOrderingCommitter(&logMu, &log)
	pusher := newFakePipelinePusher(&logMu, &log)

	// Large debounce so the write does not flush on its own before we call
	// FlushAndClose.
	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Push:      pusher,
		Debounce:  10 * time.Second,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(t.Context()))

	require.NoError(t, os.WriteFile(filepath.Join(w.opts.MemoryDir, "note.md"), []byte("late write\n"), 0o644))

	require.Eventually(t, func() bool {
		w.mu.Lock()
		defer w.mu.Unlock()
		return w.hasPending
	}, 2*time.Second, 10*time.Millisecond, "fsnotify event should mark the watcher pending")

	require.NoError(t, w.FlushAndClose())

	got := snapshotLog(t, &logMu, &log)
	require.Contains(t, got, "commit", "FlushAndClose must commit pending writes")
	require.Contains(t, got, "push", "FlushAndClose must push after commit")

	// Subsequent Close is a no-op (idempotent).
	require.NoError(t, w.Close())
}

func TestApplyAndFlushSerializesMutationWithoutClosingWatcher(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: newOrderingCommitter(&logMu, &log),
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Push:      newFakePipelinePusher(&logMu, &log),
		Debounce:  10 * time.Second,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { require.NoError(t, w.Close()) })

	for _, name := range []string{"cutover-one.md", "cutover-two.md"} {
		name := name
		require.NoError(t, w.ApplyAndFlush(func() error {
			return os.WriteFile(filepath.Join(w.opts.MemoryDir, name), []byte("cutover\n"), 0o644)
		}))
	}

	got := snapshotLog(t, &logMu, &log)
	require.Equal(t, []string{"commit", "push", "commit", "push"}, got)
	require.Equal(t, StatusReady, w.Status().State)
}

// fakeListener is a ChangeListener that records every OnMemoryChanged call.
// Tests inspect calls to assert the watcher fires the signal at the right
// pipeline stage.
type fakeListener struct {
	mu    sync.Mutex
	calls int
}

func (f *fakeListener) OnMemoryChanged(context.Context) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls++
}

func (f *fakeListener) callCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.calls
}

// orderingListener wraps fakeListener to log into a shared slice so the test
// can assert ordering against commit/push.
type orderingListener struct {
	fakeListener
	logMu *sync.Mutex
	log   *[]string
}

func newOrderingListener(logMu *sync.Mutex, log *[]string) *orderingListener {
	return &orderingListener{logMu: logMu, log: log}
}

func (o *orderingListener) OnMemoryChanged(ctx context.Context) {
	o.logMu.Lock()
	*o.log = append(*o.log, "notify")
	o.logMu.Unlock()
	o.fakeListener.OnMemoryChanged(ctx)
}

// TestNotifyFiresAfterCommitBeforePush asserts the ChangeListener signal
// lands AFTER commit succeeds and BEFORE push is attempted. Push may block
// up to 60s on a slow network, so a delayed notify would starve downstream
// reconcilers; the listener contract requires the prompt slot.
func TestNotifyFiresAfterCommitBeforePush(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	committer := newOrderingCommitter(&logMu, &log)
	pusher := newFakePipelinePusher(&logMu, &log)
	listener := newOrderingListener(&logMu, &log)

	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Push:      pusher,
		Notify:    listener,
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	require.NoError(t, os.WriteFile(
		filepath.Join(w.opts.MemoryDir, "note.md"), []byte("x\n"), 0o644))

	require.Eventually(t, func() bool {
		got := snapshotLog(t, &logMu, &log)
		return len(got) >= 3
	}, 2*time.Second, 5*time.Millisecond)

	got := snapshotLog(t, &logMu, &log)
	first := got
	if len(first) >= 3 {
		first = first[:3]
	}
	require.Equal(t, []string{"commit", "notify", "push"}, first,
		"notify must land between commit and push; got %v", got)
}

// TestNotifyNotFiredWhenNothingToCommit verifies that the ErrNothingToCommit
// path skips ChangeListener. The canonical clone content did not advance to a
// new HEAD, so downstream reconcilers have nothing to converge on. (If the
// clone did change but commit failed for other reasons, the listener still
// misses the event — reconcilers must rely on startup/periodic/tool-time
// triggers for that case, by design.)
func TestNotifyNotFiredWhenNothingToCommit(t *testing.T) {
	t.Parallel()
	listener := &fakeListener{}
	committer := &fakeCommitter{cleanWorktree: true}

	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Notify:    listener,
		Debounce:  20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	require.NoError(t, os.WriteFile(
		filepath.Join(w.opts.MemoryDir, "note.md"), []byte("x\n"), 0o644))

	// Wait long enough for the debounce flush to run and the watcher to
	// observe the cleanWorktree return. The committer must have been called
	// at least once, and the listener must never have been called.
	require.Eventually(t, func() bool {
		return committer.callCount() >= 1
	}, 2*time.Second, 5*time.Millisecond)

	require.Never(t, func() bool {
		return listener.callCount() > 0
	}, 200*time.Millisecond, 20*time.Millisecond,
		"notify must not fire when CommitPending returns ErrNothingToCommit")
}

// TestNotifyNilDoesNotBlockCommit confirms that a nil Notify leaves commit
// and push behavior intact. The Notify field is optional by contract; the
// watcher must not dereference a nil listener.
func TestNotifyNilDoesNotBlockCommit(t *testing.T) {
	t.Parallel()
	var log []string
	var logMu sync.Mutex
	committer := newOrderingCommitter(&logMu, &log)
	pusher := newFakePipelinePusher(&logMu, &log)

	w, err := New(Options{
		MemoryDir: t.TempDir(),
		Committer: committer,
		Resolve:   staticResolver(CommitContext{AgentID: "agt_alice"}),
		Push:      pusher,
		// Notify intentionally nil.
		Debounce: 20 * time.Millisecond,
	})
	require.NoError(t, err)
	require.NoError(t, w.Start(context.Background()))
	t.Cleanup(func() { _ = w.Close() })

	require.NoError(t, os.WriteFile(
		filepath.Join(w.opts.MemoryDir, "note.md"), []byte("x\n"), 0o644))

	require.Eventually(t, func() bool {
		got := snapshotLog(t, &logMu, &log)
		hasCommit := false
		hasPush := false
		for _, entry := range got {
			hasCommit = hasCommit || entry == "commit"
			hasPush = hasPush || entry == "push"
		}
		return hasCommit && hasPush
	}, 2*time.Second, 5*time.Millisecond,
		"commit and push must run with Notify == nil")
}
