package dispatcher

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

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/desktop/internal/agent"
	"github.com/tidbcloud/AgentTeam/desktop/internal/memorysearch"
	"github.com/tidbcloud/AgentTeam/desktop/internal/profile"
	"github.com/tidbcloud/AgentTeam/desktop/internal/vault"
)

// enableMemorySearchForTest wires the production memorysearch registry onto
// a test dispatcher using the production profile.AgentWorkDir resolver. Tests
// pair this with t.Setenv(profile.ConfigDirEnvKey, configDir) so the resolver
// and the dispatcher's sessionWorkDir agree on the per-agent work dir.
func enableMemorySearchForTest(t *testing.T, d *Dispatcher) {
	t.Helper()
	d.EnableMemorySearch(
		memorysearch.AgentWorkDirResolver(profile.AgentWorkDir),
		memorysearch.Scan,
		nil,
	)
}

// requireMemorySearchCurrent polls the registry until it returns a serviceable
// Generation for agentID, or fails the test after a timeout. The Generation is
// closed before return; callers that need the basename should use
// currentBasenameForAgent instead.
func requireMemorySearchCurrent(t *testing.T, d *Dispatcher, agentID string) {
	t.Helper()
	ctx := context.Background()
	require.Eventually(t, func() bool {
		reg, _ := d.snapshotMemorySearch(ctx)
		if reg == nil {
			return false
		}
		gen, err := reg.Current(ctx, agentID)
		if err != nil {
			return false
		}
		basename := filepath.Base(gen.Dir())
		_ = gen.Close()
		rec := reg.manager.ActiveReconciler(agentID)
		return rec != nil && rec.SearchState(basename) == memorysearch.StateReady
	}, 10*time.Second, 25*time.Millisecond, "memory search never became current for %s", agentID)
}

// currentBasenameForAgent returns the GenerationBasename currently published
// for agentID, or "" when no current pointer exists yet.
func currentBasenameForAgent(t *testing.T, d *Dispatcher, agentID string) string {
	t.Helper()
	reg, _ := d.snapshotMemorySearch(context.Background())
	if reg == nil {
		return ""
	}
	gen, err := reg.Current(context.Background(), agentID)
	if err != nil {
		if errors.Is(err, memorysearch.ErrNotReady) {
			return ""
		}
		t.Fatalf("Current for %s: %v", agentID, err)
	}
	defer gen.Close()
	return filepath.Base(gen.Dir())
}

// TestMemorySearchBuildsIndexOnWrite drives the full write→watcher→publish
// path. A real vault clone is created by attachMemoryWatcher; an in-clone
// file write triggers the watcher's commit; the watcher's Notify listener
// (the per-binding reconciler) must Submit + publish a serviceable
// generation that Current() can open.
//
// Regression coverage: a wiring that only registered the reconciler on
// session start but failed to wire it as the watcher's ChangeListener would
// leave the index eternally stale. This test catches that by asserting the
// generation source revision tracks the watcher-driven commit.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchBuildsIndexOnWrite(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_write"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_search_write",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	workDir := sessionWorkDir(agentID)
	sess := &interactiveSession{
		agentID: agentID,
		agent:   agent.Config{Name: "Search Write"},
		binding: conversationBinding{
			channelID:       "#agentteam",
			targetScopeType: "channel",
			conversationKey: "#agentteam",
		},
		workDir: workDir,
	}
	d.sessionManager.sessions[agentID] = sess
	t.Cleanup(func() { stopMemoryWatcher(sess) })

	d.attachMemoryWatcher(context.Background(), sess, agentID, workDir)
	require.NotNil(t, sess.watcher, "precondition: watcher attached")
	require.NotNil(t, sess.memoryClone, "precondition: memory clone attached")

	// Initial publish from the freshly-built reconciler (the daemon's
	// startup Submit) should land even without a file write. Poll for it
	// so the test does not race the goroutine.
	requireMemorySearchCurrent(t, d, agentID)
	initialBasename := currentBasenameForAgent(t, d, agentID)
	require.NotEmpty(t, initialBasename, "precondition: initial generation published")

	// Mutate source content. The watcher's commit + Notify Submit must
	// drive a fresh generation whose revision differs from the initial.
	require.NoError(t, os.WriteFile(
		filepath.Join(sess.memoryClone.Path(), "MEMORY.md"),
		[]byte("# Identity\n\nwatcher-driven update for search.\n"), 0o644,
	))

	var updatedBasename string
	require.Eventually(t, func() bool {
		updatedBasename = currentBasenameForAgent(t, d, agentID)
		return updatedBasename != "" && updatedBasename != initialBasename
	}, 10*time.Second, 25*time.Millisecond,
		"generation basename must change after watcher-driven commit; initial=%s", initialBasename)

	// The published generation must be openable and hold a Bleve reader.
	ctx := context.Background()
	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg, "registry must remain wired after watcher-driven publish")
	gen, err := reg.Current(ctx, agentID)
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index(), "published generation must hold an open Bleve reader")
	require.Equal(t, updatedBasename, filepath.Base(gen.Dir()),
		"Current must return the post-commit generation")
}

// TestMemorySearchPreCloneBuildsIndex covers the no-active-session startup
// path. A binding notification arrives while no interactive session exists;
// refreshAgentMemoryAccess routes to preCloneMemory, which clones the repo
// AND best-effort starts a reconciler. Without the explicit getOrCreate in
// preCloneMemory, an agent that never opens a session would never get a
// search derivative — defeating the daemon-scope ownership contract.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchPreCloneBuildsIndex(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_preclone"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_search_preclone",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// No session is registered on sessionManager, so the refresh path lands
	// in the no-session branch that calls preCloneMemory.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})

	// The clone must exist on disk.
	memoryDir := filepath.Join(sessionWorkDir(agentID), "memory")
	require.FileExists(t, filepath.Join(memoryDir, "MEMORY.md"),
		"preCloneMemory must clone the memory repo")

	// Retention state must be active (not revoked).
	state, err := vault.ReadRetentionState(memoryDir)
	require.NoError(t, err)
	require.Equal(t, "mem_search_preclone", state.MemoryID)
	require.Empty(t, state.RevokedAt, "retention state must be active while bound")

	// And the search derivative must converge to a serviceable generation.
	requireMemorySearchCurrent(t, d, agentID)

	// Sanity: the agent is reachable via fingerprintForAgent and produces
	// the expected binding fingerprint shape (one entry for this agent).
	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg, "registry must remain wired after preCloneMemory")
	fp := reg.fingerprintForAgent(agentID)
	require.NotEmpty(t, fp, "preCloneMemory must register a reconciler for the agent")
}

func TestMemorySearchUnknownFreezesBuildAndBoundResumes(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_unknown"
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_search_unknown",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	initial := currentBasenameForAgent(t, d, agentID)

	provider.setUnknown(true)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	memoryDir := filepath.Join(sessionWorkDir(agentID), "memory")
	require.NoError(t, os.WriteFile(filepath.Join(memoryDir, "MEMORY.md"),
		[]byte("# Memory\n\nunknown access mutation must not publish\n"), 0o644))

	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	for i := 0; i < 5; i++ {
		reg.Submit(context.Background(), agentID)
	}
	require.Never(t, func() bool {
		return currentBasenameForAgent(t, d, agentID) != initial
	}, 300*time.Millisecond, 10*time.Millisecond,
		"Unknown access must freeze publication")

	resp, err := d.MemorySearch(context.Background(), agentID,
		memorysearch.SearchRequest{Query: "Memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, memorysearch.StateRefreshing, resp.State)
	require.True(t, resp.Stale)
	require.Equal(t, memorysearch.ReasonResultsMayBeIncomplete, resp.Reason)

	provider.setUnknown(false)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	require.Eventually(t, func() bool {
		return currentBasenameForAgent(t, d, agentID) != initial
	}, 10*time.Second, 25*time.Millisecond,
		"authoritative Bound must resume reconciliation")
}

func TestMemorySearchLookupErrorFreezesOldGeneration(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_lookup_error"
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID: "mem_lookup_error", OriginURL: origin, Branch: "main",
	})
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	initial := currentBasenameForAgent(t, d, agentID)

	provider.setErr(errors.New("transient lookup outage"))
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	require.NoError(t, os.WriteFile(
		filepath.Join(sessionWorkDir(agentID), "memory", "MEMORY.md"),
		[]byte("# Memory\n\noutage mutation\n"), 0o644))
	reg, _ := d.snapshotMemorySearch(context.Background())
	reg.Submit(context.Background(), agentID)
	require.Never(t, func() bool {
		return currentBasenameForAgent(t, d, agentID) != initial
	}, 300*time.Millisecond, 10*time.Millisecond)
	resp, err := d.MemorySearch(context.Background(), agentID,
		memorysearch.SearchRequest{Query: "Memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, memorysearch.StateRefreshing, resp.State)
	require.True(t, resp.Stale)
}

func TestMemorySearchUnknownFencesBeforeRegistryInsert(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_unknown_insert"
	access := MemoryAccess{
		MemoryID: "mem_unknown_insert", OriginURL: origin, Branch: "main",
	}
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, access)
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	reg, _ := d.snapshotMemorySearch(context.Background())
	reg.closeAgent(agentID)

	parked := make(chan struct{})
	release := make(chan struct{})
	reg.testHooks.beforeInsert = func(hookAgentID, _ string) {
		if hookAgentID == agentID {
			close(parked)
			<-release
		}
	}
	done := make(chan error, 1)
	go func() {
		_, _, err := reg.getOrCreate(context.Background(), agentID, access)
		done <- err
	}()
	<-parked
	provider.setUnknown(true)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	close(release)
	require.ErrorIs(t, <-done, ErrAgentRevoked)
	require.Empty(t, reg.fingerprintForAgent(agentID))
	require.Nil(t, reg.manager.ActiveReconciler(agentID),
		"Unknown insert fence must not leave a trusted active writer")
}

func TestMemorySearchUnboundRejectsLateStaleGetOrCreate(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_unbound_late"
	access := MemoryAccess{
		MemoryID: "mem_unbound_late", OriginURL: origin, Branch: "main",
	}
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, access)
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	reg, _ := d.snapshotMemorySearch(context.Background())

	provider.remove(agentID)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	_, _, err := reg.getOrCreate(context.Background(), agentID, access)
	require.ErrorIs(t, err, ErrAgentRevoked)
	require.Empty(t, reg.fingerprintForAgent(agentID))
	require.Nil(t, reg.manager.ActiveReconciler(agentID))
}

func TestMemorySearchUnknownToChangedBindingRecoversInOneRefresh(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	originA := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-a"))
	originB := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-b"))
	agentID := "agt_search_unknown_rebind"
	accessA := MemoryAccess{
		MemoryID: "mem_unknown_a", OriginURL: originA, Branch: "main",
	}
	accessB := MemoryAccess{
		MemoryID: "mem_unknown_b", OriginURL: originB, Branch: "main",
		ReplaceLocalContentOnSwitch: true,
	}
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, accessA)
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	reg, _ := d.snapshotMemorySearch(context.Background())
	fpA := reg.manager.ActiveFingerprint(agentID)

	provider.setUnknown(true)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	provider.set(agentID, accessB)
	provider.setUnknown(false)
	// One Bound refresh must fence A, reopen the gate, clone B, and create B.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	require.Eventually(t, func() bool {
		fpB := reg.manager.ActiveFingerprint(agentID)
		return fpB != "" && fpB != fpA
	}, 10*time.Second, 25*time.Millisecond)
	requireMemorySearchCurrent(t, d, agentID)
}

func TestMemorySearchPeriodicReconcileAndClose(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_periodic"
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_search_periodic",
		OriginURL: origin,
		Branch:    "main",
	})
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	initial := currentBasenameForAgent(t, d, agentID)

	// Simulate a watcher commit failure/external edit: source bytes change
	// but no watcher Notify is delivered. A periodic wake must still converge.
	memoryDir := filepath.Join(sessionWorkDir(agentID), "memory")
	require.NoError(t, os.WriteFile(filepath.Join(memoryDir, "MEMORY.md"),
		[]byte("# Memory\n\nperiodic convergence\n"), 0o644))
	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	reg.periodicWake <- struct{}{}
	require.Eventually(t, func() bool {
		return currentBasenameForAgent(t, d, agentID) != initial
	}, 10*time.Second, 25*time.Millisecond)
	current := currentBasenameForAgent(t, d, agentID)
	require.Eventually(t, func() bool {
		rec := reg.manager.ActiveReconciler(agentID)
		return rec != nil && rec.SearchState(current) == memorysearch.StateReady
	}, 10*time.Second, 25*time.Millisecond,
		"published generation must settle before checking a periodic no-op")
	reg.periodicWake <- struct{}{}
	require.Never(t, func() bool {
		rec := reg.manager.ActiveReconciler(agentID)
		return rec != nil && rec.SearchState(current) != memorysearch.StateReady
	}, 250*time.Millisecond, 10*time.Millisecond,
		"periodic no-op freshness check must not make a healthy index stale")

	d.Close()
	select {
	case <-reg.periodicDone:
	default:
		t.Fatal("registry close returned before periodic agentteam stopped")
	}
	// A stale test wake cannot restart work after Close.
	select {
	case reg.periodicWake <- struct{}{}:
	default:
	}
	time.Sleep(20 * time.Millisecond)
	select {
	case <-reg.periodicDone:
	default:
		t.Fatal("periodic agentteam restarted after close")
	}
}

func TestMemorySearchClearDeletesOnlyDerivative(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_clear"
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID: "mem_search_clear", OriginURL: origin, Branch: "main",
	})
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	workDir := sessionWorkDir(agentID)
	sourceFile := filepath.Join(workDir, "memory", "MEMORY.md")
	require.FileExists(t, sourceFile)

	require.NoError(t, d.ClearMemorySearch(context.Background(), agentID))
	require.FileExists(t, sourceFile, "Clear must never delete source Memory")
	require.Eventually(t, func() bool {
		return d.MemorySearchAvailable(context.Background(), agentID) &&
			currentBasenameForAgent(t, d, agentID) != ""
	}, 10*time.Second, 25*time.Millisecond,
		"Bound Clear must safely rebuild and restore availability")
}

func TestMemorySearchRebuildKeepsCurrentUntilAtomicSwap(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)
	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_search_rebuild"
	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID: "mem_search_rebuild", OriginURL: origin, Branch: "main",
	})
	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	old := currentBasenameForAgent(t, d, agentID)

	require.NoError(t, d.RebuildMemorySearch(context.Background(), agentID))
	// The old current remains borrowable while the side-by-side rebuild runs.
	reg, _ := d.snapshotMemorySearch(context.Background())
	gen, err := reg.Current(context.Background(), agentID)
	require.NoError(t, err)
	require.Equal(t, old, filepath.Base(gen.Dir()))
	require.NoError(t, gen.Close())
	require.Eventually(t, func() bool {
		current := currentBasenameForAgent(t, d, agentID)
		return current != "" && current != old
	}, 10*time.Second, 25*time.Millisecond)
}

// TestMemorySearchBindingSwitchRevoke exercises the three lifecycle
// transitions codex3's hardening contract requires:
//
//  1. Switch: when the binding changes (memoryID or branch) while a watcher
//     is attached, the old reconciler must close and release its per-binding
//     owner lock BEFORE the new binding constructs its own reconciler, and
//     the new fingerprint's generation must become current.
//  2. Lock release: after closeAgent, a fresh Reconciler for the OLD binding
//     must be able to acquire the owner lock (Start succeeds rather than
//     returning ErrOwnerLockBusy).
//  3. Revoke: revocation closes the active reconciler for the agent, so the
//     registry no longer tracks any fingerprint for it.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchBindingSwitchRevoke(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	originA := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-a"))
	originB := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-b"))
	agentID := "agt_search_switch"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_switch_a",
		OriginURL: originA,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	workDir := sessionWorkDir(agentID)
	sess := &interactiveSession{
		agentID: agentID,
		agent:   agent.Config{Name: "Search Switch"},
		binding: conversationBinding{
			channelID:       "#agentteam",
			targetScopeType: "channel",
			conversationKey: "#agentteam",
		},
		workDir: workDir,
	}
	d.sessionManager.sessions[agentID] = sess
	t.Cleanup(func() { stopMemoryWatcher(sess) })

	d.attachMemoryWatcher(context.Background(), sess, agentID, workDir)
	require.NotNil(t, sess.watcher, "precondition: watcher A attached")
	requireMemorySearchCurrent(t, d, agentID)

	regForFpA, _ := d.snapshotMemorySearch(context.Background())
	fpA := regForFpA.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpA, "precondition: binding A registered")
	basenameA := currentBasenameForAgent(t, d, agentID)
	require.NotEmpty(t, basenameA, "precondition: binding A generation published")

	// Switch the binding. The dispatcher must close A's reconciler and
	// construct B's. The fresh origin (originB) is bare so rebind can push.
	provider.set(agentID, MemoryAccess{
		MemoryID:                    "mem_switch_b",
		OriginURL:                   originB,
		Branch:                      "main",
		ReplaceLocalContentOnSwitch: true,
	})
	d.RefreshMemoryAccess(context.Background(), []string{agentID})

	require.NotNil(t, sess.watcher, "watcher B should reattach after rebind")
	require.NotNil(t, sess.memoryClone, "clone B should be populated after rebind")

	// Wait for B's generation to become current.
	require.Eventually(t, func() bool {
		basenameB := currentBasenameForAgent(t, d, agentID)
		return basenameB != "" && basenameB != basenameA
	}, 10*time.Second, 25*time.Millisecond,
		"generation basename must change from A=%s after binding switch", basenameA)

	regForFpB, _ := d.snapshotMemorySearch(context.Background())
	fpB := regForFpB.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpB, "post-switch: binding B registered")
	require.NotEqual(t, fpA, fpB,
		"binding switch must produce a different registry fingerprint")

	// Lock release contract (indirect verification): the per-binding owner
	// lock is acquired per-derivative-root, which is shared by every
	// fingerprint for the same agent. B's reconciler could only acquire
	// the lock — and therefore could only publish the generation asserted
	// above — if A's Close had already released it. If closeAgent forgot
	// to release the lock, B's Start would have failed with
	// ErrOwnerLockBusy and fpB would never have been registered. The
	// assertions above therefore cover the "A released its lock" branch.

	// Revoke: the refresh path's "revoked while watcher active" branch
	// must call closeAgent before retaining the clone offline, so no
	// reconciler keeps serving hits against a clone the agent no longer
	// owns.
	provider.remove(agentID)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})

	require.Eventually(t, func() bool {
		reg, _ := d.snapshotMemorySearch(context.Background())
		return reg.fingerprintForAgent(agentID) == ""
	}, 5*time.Second, 25*time.Millisecond,
		"revoke must close the agent's reconciler so the registry drops its fingerprint")

	// Current() now returns ErrNotReady because the agent has no
	// registered reconciler.
	regForCurrent, _ := d.snapshotMemorySearch(context.Background())
	_, currentErr := regForCurrent.Current(context.Background(), agentID)
	require.ErrorIs(t, currentErr, memorysearch.ErrNotReady,
		"revoke must leave Current() reporting ErrNotReady for the agent")

	// Post-revoke the per-derivative-root owner lock must be free for a
	// fresh owner to acquire — both A and B have been closed. Construct
	// a bare reconciler for the same agent and confirm Start succeeds.
	resolver := memorysearch.AgentWorkDirResolver(profile.AgentWorkDir)
	probeBinding, err := memorysearch.NewBinding(
		resolver, agentID, "mem_switch_a", "main",
		memorysearch.AnalyzerVersion, memorysearch.ChunkerVersion,
	)
	require.NoError(t, err)
	probe, err := memorysearch.NewReconciler(memorysearch.ReconcilerOptions{
		Binding:  probeBinding,
		Resolver: resolver,
		Scan:     memorysearch.Scan,
	})
	require.NoError(t, err)
	require.NoError(t, probe.Start(context.Background()),
		"post-revoke owner lock must be free for a fresh owner to acquire")
	require.NoError(t, probe.Close())
}

// TestMemorySearchRegistryFenceOnRevoke exercises blocker 2's epoch fence:
// when closeAgent races with an in-flight getOrCreate slow path, the slow
// path must observe the epoch bump at its insert gate, close the freshly-
// Started reconciler, and return ErrAgentRevoked — without landing an entry.
//
// The beforeInsert test seam parks the slow path AFTER Start+Submit succeed
// but BEFORE the insert gate acquires the registry mutex. A parallel test
// (TestMemorySearchRegistryFenceOnShutdown) covers closeAll → ErrRegistryClosed.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchRegistryFenceOnRevoke(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_fence_revoke"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_fence_revoke",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// First refresh establishes the clone + retention marker + reconciler A.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	// Drop A so the next getOrCreate re-enters the slow path (no entry → not
	// fast-path). Retention marker stays on disk so constructBinding succeeds.
	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	reg.closeAgent(agentID)
	require.Empty(t, reg.fingerprintForAgent(agentID), "precondition: A dropped")

	// Inject the test hook. The hook signals "parked" then waits for release.
	parked := make(chan struct{})
	release := make(chan struct{})
	reg.testHooks = memorySearchRegistryTestHooks{
		beforeInsert: func(hookAgentID, fp string) {
			if hookAgentID != agentID {
				return
			}
			parked <- struct{}{}
			<-release
		},
	}
	t.Cleanup(func() { reg.testHooks = memorySearchRegistryTestHooks{} })

	access, _, err := provider.Lookup(context.Background(), agentID)
	require.NoError(t, err)

	type result struct {
		rec *memorysearch.Reconciler
		err error
	}
	done := make(chan result, 1)
	go func() {
		rec, _, err := reg.getOrCreate(context.Background(), agentID, access)
		done <- result{rec: rec, err: err}
	}()

	select {
	case <-parked:
	case <-time.After(5 * time.Second):
		t.Fatalf("getOrCreate never parked at beforeInsert hook")
	}

	// getOrCreate is now parked with a freshly-Started reconciler but no entry
	// yet. Racing closeAgent bumps epoch + (no-op) removes entries.
	reg.closeAgent(agentID)

	// Release the hook so the slow path proceeds to the insert gate.
	close(release)

	select {
	case r := <-done:
		// Must return ErrAgentRevoked, not a reconciler. The freshly-Started
		// reconciler must have been closed by the insert gate.
		require.Nil(t, r.rec, "fenced getOrCreate must not return a reconciler")
		require.ErrorIs(t, r.err, ErrAgentRevoked,
			"getOrCreate must report ErrAgentRevoked when closeAgent races the slow path")
	case <-time.After(5 * time.Second):
		t.Fatalf("fenced getOrCreate never returned")
	}

	// No entry landed in the map for the agent.
	require.Empty(t, reg.fingerprintForAgent(agentID),
		"fenced getOrCreate must not leave an entry in the registry")

	// Registry still usable: clear the hook, a subsequent getOrCreate succeeds.
	// closeAll at t.Cleanup will release the per-derivative-root owner lock
	// held by this reconciler before t.TempDir cleanup removes the directory.
	reg.testHooks = memorySearchRegistryTestHooks{}
	_, _, err = reg.getOrCreate(context.Background(), agentID, access)
	require.NoError(t, err, "registry must remain usable after fence fires")
}

// TestMemorySearchRegistryFenceOnShutdown exercises blocker 2's closed flag:
// when closeAll races with an in-flight getOrCreate, the slow path must
// observe the closed flag at its insert gate, close the freshly-Started
// reconciler, and return ErrRegistryClosed — without landing an entry.
//
// Round 10 final blocker: closeAll now drains inflight via inflightWg.Wait(),
// so it CANNOT be called synchronously while a getOrCreate is parked at
// beforeInsert — the call would deadlock. The test runs closeAll in a
// goroutine, asserts it blocks while inflight is parked, then releases the
// hook so the in-flight getOrCreate self-cancels and unblocks the drain.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchRegistryFenceOnShutdown(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_fence_shutdown"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_fence_shutdown",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)

	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	reg.closeAgent(agentID)
	require.Empty(t, reg.fingerprintForAgent(agentID), "precondition: A dropped")

	parked := make(chan struct{})
	release := make(chan struct{})
	reg.testHooks = memorySearchRegistryTestHooks{
		beforeInsert: func(hookAgentID, fp string) {
			if hookAgentID != agentID {
				return
			}
			parked <- struct{}{}
			<-release
		},
	}
	t.Cleanup(func() { reg.testHooks = memorySearchRegistryTestHooks{} })

	access, _, err := provider.Lookup(context.Background(), agentID)
	require.NoError(t, err)

	type result struct {
		rec *memorysearch.Reconciler
		err error
	}
	done := make(chan result, 1)
	go func() {
		rec, _, err := reg.getOrCreate(context.Background(), agentID, access)
		done <- result{rec: rec, err: err}
	}()

	select {
	case <-parked:
	case <-time.After(5 * time.Second):
		t.Fatalf("getOrCreate never parked at beforeInsert hook")
	}

	// Run closeAll in a goroutine because the inflight drain blocks at
	// inflightWg.Wait() until the parked getOrCreate self-cancels.
	closeAllDone := make(chan struct{})
	go func() {
		reg.closeAll()
		close(closeAllDone)
	}()

	// Wait for closeAll to set closed=true. It will then block at
	// inflightWg.Wait() until the parked getOrCreate's deferred Done() runs.
	require.Eventually(t, func() bool {
		reg.mu.Lock()
		defer reg.mu.Unlock()
		return reg.closed
	}, 5*time.Second, 5*time.Millisecond,
		"closeAll never set closed=true on the registry")

	// closeAll is now blocked at inflightWg.Wait(). Assert it hasn't completed.
	select {
	case <-closeAllDone:
		t.Fatalf("closeAll completed without draining inflight — drain regression")
	default:
	}

	// Release the hook. The parked getOrCreate proceeds to its insert gate,
	// observes closed=true, closes its freshly-Started reconciler (releasing
	// the per-binding owner lock), and returns ErrRegistryClosed. Its
	// deferred cleanup calls inflightWg.Done(), unblocking closeAll.
	close(release)

	select {
	case <-closeAllDone:
	case <-time.After(5 * time.Second):
		t.Fatalf("closeAll never completed after inflight drain")
	}

	select {
	case r := <-done:
		require.Nil(t, r.rec, "fenced getOrCreate must not return a reconciler")
		require.ErrorIs(t, r.err, ErrRegistryClosed,
			"getOrCreate must report ErrRegistryClosed when closeAll races the slow path")
	case <-time.After(5 * time.Second):
		t.Fatalf("fenced getOrCreate never returned")
	}

	// Registry is permanently closed: any subsequent getOrCreate must refuse.
	_, _, err = reg.getOrCreate(context.Background(), agentID, access)
	require.ErrorIs(t, err, ErrRegistryClosed,
		"closed registry must refuse any subsequent getOrCreate")
}

// TestMemorySearchEnableTwiceSwapClose exercises blocker 3: re-calling
// EnableMemorySearch must atomically swap the registry in and closeAll the
// old one OUTSIDE the lock, so reconcilers from the prior wiring deterministically
// release their per-binding owner locks before the new registry's first
// getOrCreate — and concurrent readers never block.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchEnableTwiceSwapClose(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_enable_twice"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_enable_twice",
		OriginURL: origin,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	prev, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, prev)
	fpBefore := prev.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpBefore, "precondition: registry wired and serving")

	// Re-call EnableMemorySearch. The old registry must be closed; the new one
	// must serve subsequent reads. Production never does this, but tests rely
	// on it to rebind resolver/scan onto the same dispatcher safely.
	enableMemorySearchForTest(t, d)

	// Old registry is permanently closed.
	_, _, err := prev.getOrCreate(context.Background(), agentID, MemoryAccess{
		MemoryID: "mem_enable_twice",
		Branch:   "main",
	})
	require.ErrorIs(t, err, ErrRegistryClosed,
		"previous registry must be permanently closed after Enable swaps it out")

	// New registry is wired and serving.
	next, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, next)
	require.NotSame(t, prev, next, "EnableMemorySearch must install a fresh registry")

	// Re-publish under the new registry. The previous owner lock must have
	// been released by the swap-then-closeAll, so the new getOrCreate's Start
	// succeeds rather than returning ErrOwnerLockBusy.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	fpAfter := next.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpAfter, "new registry must serve the agent")
	require.Equal(t, fpBefore, fpAfter,
		"same binding must produce the same fingerprint across registries")
}

// TestMemorySearchNoSessionBindingSwitchIdentityGate exercises blocker 4:
// a Bound→Bound identity switch with NO active session must close A's
// reconciler (releasing the per-derivative-root owner lock) BEFORE preCloneMemory
// mutates the source clone, then publish B's fingerprint. Without the gate,
// B's Start would hit ErrOwnerLockBusy and A would keep serving stale content.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestMemorySearchNoSessionBindingSwitchIdentityGate(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	originA := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-a"))
	originB := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-b"))
	agentID := "agt_identity_gate"

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, MemoryAccess{
		MemoryID:  "mem_identity_a",
		OriginURL: originA,
		Branch:    "main",
	})

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// No session is registered → refresh lands in the no-session preClone path.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	fpA := reg.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpA, "precondition: binding A registered")

	// Switch the binding (different memoryID). Without the identity gate,
	// preCloneMemory(B) would call EnsureClone (mutating the source clone)
	// before A's reconciler is closed; A would keep holding the owner lock.
	provider.set(agentID, MemoryAccess{
		MemoryID:                    "mem_identity_b",
		OriginURL:                   originB,
		Branch:                      "main",
		ReplaceLocalContentOnSwitch: true,
	})
	d.RefreshMemoryAccess(context.Background(), []string{agentID})

	// B's generation must converge.
	require.Eventually(t, func() bool {
		fpB := reg.fingerprintForAgent(agentID)
		return fpB != "" && fpB != fpA
	}, 10*time.Second, 25*time.Millisecond,
		"no-session A→B switch must close A and register B's fingerprint")

	fpB := reg.fingerprintForAgent(agentID)
	require.NotEqual(t, fpA, fpB, "post-switch: B's fingerprint must differ from A's")

	// B must converge to a serviceable generation (polls through any rebuild
	// triggered by A's stale on-disk generation pointer).
	requireMemorySearchCurrent(t, d, agentID)

	// Retention marker on disk reflects B (EnsureClone overwrote A).
	memoryDir := filepath.Join(sessionWorkDir(agentID), "memory")
	state, err := vault.ReadRetentionState(memoryDir)
	require.NoError(t, err)
	require.Equal(t, "mem_identity_b", state.MemoryID,
		"source clone must be mutated to B before B's reconciler starts")
}

// TestEnableMemorySearchAtomicSwapNoLockBusy exercises Round 10 blocker 1:
// when a concurrent Refresh arrives during EnableMemorySearch's replacement
// window, the SAME Refresh call must converge on the new registry without
// hitting ErrOwnerLockBusy.
//
// The previous swap-then-close ordering published `next` BEFORE closeAll(prev)
// released prev's per-binding owner locks; a Refresh that snapshotted `next`
// in that window would call getOrCreate on next whose Start failed with
// ErrOwnerLockBusy because prev's reconciler still held the lock. The fix
// serializes the full Enable transaction under memorySearchReplace: the
// second Enable blocks until the first has run closeAll + published next,
// guaranteeing prev's owner locks are released before next's first getOrCreate.
//
// Timeline enforced by this test:
//  1. E1 parks at prev's beforeCloseAll hook (prev's owner lock still held)
//  2. E2 blocks on memorySearchReplace (waiting for E1)
//  3. Refresh bounded-waits on memorySearchSwap chan
//  4. Release E1 → E1 finishes closeAll(prev) → publishes next1 → closes swap1
//  5. Refresh wakes → snapshot returns next1 → getOrCreate on next1 MUST NOT
//     return ErrOwnerLockBusy (prev's lock was released by closeAll).
//  6. E2 acquires replaceMu → closeAll(next1) → publishes next2.
//  7. Final state: only next2 services the agent.
//
// Per codex3's refinement: the test does NOT require Refresh to publish a
// generation on next1 (E2 may closeAll(next1) before Refresh observes it).
// It only requires no ErrOwnerLockBusy and eventual convergence on next2.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestEnableMemorySearchAtomicSwapNoLockBusy(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_enable_swap"

	provider := &fakeMemoryAccessProvider{}
	access := MemoryAccess{
		MemoryID:  "mem_swap",
		OriginURL: origin,
		Branch:    "main",
	}
	provider.set(agentID, access)

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// Phase 1: establish entry under prev registry (prev holds owner lock).
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	prev, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, prev)
	require.NotEmpty(t, prev.fingerprintForAgent(agentID),
		"precondition: prev registry holds entry for agent")

	// Install beforeCloseAll hook on prev. E1's Enable will park here so the
	// test can observe the replacement window.
	parked := make(chan struct{})
	release := make(chan struct{})
	prev.testHooks = memorySearchRegistryTestHooks{
		beforeCloseAll: func() {
			close(parked)
			<-release
		},
	}
	t.Cleanup(func() { prev.testHooks = memorySearchRegistryTestHooks{} })

	// Phase 2: start E1 in goroutine — parks at beforeCloseAll.
	go enableMemorySearchForTest(t, d)
	select {
	case <-parked:
	case <-time.After(5 * time.Second):
		t.Fatalf("E1 never parked at beforeCloseAll")
	}

	// Phase 3: start E2 in goroutine — blocks on memorySearchReplace
	// (held by E1).
	e2Done := make(chan struct{})
	go func() {
		enableMemorySearchForTest(t, d)
		close(e2Done)
	}()
	// Give E2 a moment to block on replaceMu.
	time.Sleep(100 * time.Millisecond)

	// Phase 4: snapshotMemorySearch in goroutine — bounded-waits on swap1.
	// Use a ctx with deadline so the test fails fast if the swap is never
	// closed (e.g., deadlock between E1/E2/shutdown).
	snapshotCtx, cancelSnapshot := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancelSnapshot()

	type snapshotResult struct {
		reg *memorySearchRegistry
		err error
	}
	snapshotDone := make(chan snapshotResult, 1)
	go func() {
		reg, err := d.snapshotMemorySearch(snapshotCtx)
		snapshotDone <- snapshotResult{reg: reg, err: err}
	}()

	// Phase 5: release E1's closeAll hook.
	close(release)

	// Phase 6: snapshotMemorySearch must wake up with a non-nil registry
	// (either next1 or next2) and no ctx-cancel error.
	var servingReg *memorySearchRegistry
	select {
	case r := <-snapshotDone:
		require.NoError(t, r.err, "snapshotMemorySearch must not be cancelled by ctx")
		require.NotNil(t, r.reg, "snapshotMemorySearch must return a registry after swap completes")
		servingReg = r.reg
	case <-time.After(15 * time.Second):
		t.Fatalf("snapshotMemorySearch never returned after swap1 close")
	}

	// Phase 7: getOrCreate on the observed registry MUST NOT return
	// ErrOwnerLockBusy. This is the core assertion: prev's owner locks were
	// released before any new registry's first getOrCreate, so Start must
	// not contend with prev's stale locks.
	_, _, err := servingReg.getOrCreate(context.Background(), agentID, access)
	if err != nil {
		require.NotErrorIs(t, err, memorysearch.ErrOwnerLockBusy,
			"getOrCreate after replacement must not contend with prev's owner lock")
	}

	// Phase 8: E2 must complete (it ran closeAll(next1) → publish next2).
	select {
	case <-e2Done:
	case <-time.After(15 * time.Second):
		t.Fatalf("E2 never completed")
	}

	// Phase 9: final state — next2 is the active registry and serves the agent.
	finalReg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, finalReg)
	require.NotSame(t, prev, finalReg, "final registry must differ from prev")

	// prev is permanently closed (E1 closeAll'd it).
	_, _, err = prev.getOrCreate(context.Background(), agentID, access)
	require.ErrorIs(t, err, ErrRegistryClosed,
		"prev registry must be permanently closed after E1's swap")

	// Re-establish entry under next2 via Refresh; converges to a serviceable
	// generation. This proves the agent is ultimately served only by next2.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)
	require.NotEmpty(t, finalReg.fingerprintForAgent(agentID),
		"final registry must serve the agent")
}

// TestConcurrentDifferentFingerprintGetOrCreateSerialized exercises Round 10
// blocker 2: when two different-fingerprint getOrCreate calls for the same
// agent race, the per-agent mutex serializes them, and closeAgentIfBindingChanged
// detects the in-flight identity drift (even when no entry has landed yet)
// and bumps the agent's epoch so the in-flight getOrCreate self-cancels at
// its insert gate rather than landing a stale entry that holds the owner lock.
//
// Timeline:
//  1. Establish A's entry, then drop it (closeAgent) but keep A's clone +
//     retention marker so a fresh getOrCreate(A) can re-enter the slow path.
//  2. Install beforeInsert hook that parks A's slow path AFTER Start+Submit
//     succeed but BEFORE the insert gate. A holds agentMu + owner lock.
//  3. Start getOrCreate(A) in goroutine — parks at beforeInsert.
//  4. Call closeAgentIfBindingChanged(B) directly. Without inflight tracking
//     the entries-only scan would miss A (no entry has landed); with inflight
//     tracking the gate detects A's identity differs from B, bumps epoch,
//     and returns true.
//  5. Release A's hook. A's insert gate observes the epoch bump, closes its
//     freshly-Started reconciler (releasing the owner lock), and returns
//     ErrAgentRevoked.
//  6. Mutate the clone to B and call getOrCreate(B). A's owner lock has been
//     released; B's Start acquires it cleanly and publishes fpB.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestConcurrentDifferentFingerprintGetOrCreateSerialized(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	originA := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-a"))
	originB := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin-b"))
	agentID := "agt_concurrent_fp"

	accessA := MemoryAccess{MemoryID: "mem_fp_a", OriginURL: originA, Branch: "main"}
	accessB := MemoryAccess{
		MemoryID:                    "mem_fp_b",
		OriginURL:                   originB,
		Branch:                      "main",
		ReplaceLocalContentOnSwitch: true,
	}

	provider := &fakeMemoryAccessProvider{}
	provider.set(agentID, accessA)

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// Phase 1: establish A's entry via Refresh (provider still set to A).
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	reg, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, reg)
	fpA := reg.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpA, "precondition: binding A registered")

	// Phase 2: drop A's entry but keep clone + retention marker so a fresh
	// getOrCreate(A) can re-enter the slow path. closeAgent bumps epoch;
	// constructBinding(A) will still succeed because retention marker on
	// disk still reflects A.
	reg.closeAgent(agentID)
	require.Empty(t, reg.fingerprintForAgent(agentID), "precondition: A entry dropped")

	// Phase 3: install beforeInsert hook that parks A's slow path.
	parked := make(chan struct{})
	release := make(chan struct{})
	reg.testHooks = memorySearchRegistryTestHooks{
		beforeInsert: func(hookAgentID, fp string) {
			if hookAgentID != agentID {
				return
			}
			close(parked)
			<-release
		},
	}
	t.Cleanup(func() { reg.testHooks = memorySearchRegistryTestHooks{} })

	// Phase 4: start A's getOrCreate in goroutine — parks at beforeInsert.
	type result struct {
		rec *memorysearch.Reconciler
		err error
	}
	doneA := make(chan result, 1)
	go func() {
		rec, _, err := reg.getOrCreate(context.Background(), agentID, accessA)
		doneA <- result{rec: rec, err: err}
	}()
	select {
	case <-parked:
	case <-time.After(5 * time.Second):
		t.Fatalf("A's getOrCreate never parked at beforeInsert")
	}

	// Phase 5: closeAgentIfBindingChanged(B) directly — bypasses Refresh so
	// preCloneMemory doesn't block on agentMu (A still holds it). Must
	// detect inflight drift and bump epoch.
	closed := reg.closeAgentIfBindingChanged(agentID, accessB)
	require.True(t, closed,
		"closeAgentIfBindingChanged must detect inflight drift and bump epoch")

	// Sanity: no entries to close (A hasn't inserted); inflight[A] should
	// still be set (A is parked, hasn't reached its deferred cleanup).
	reg.mu.Lock()
	require.Contains(t, reg.inflight, agentID,
		"inflight[A] must be set while A is parked")
	reg.mu.Unlock()

	// Phase 6: release A's hook. A's insert gate observes the epoch bump,
	// closes its freshly-Started reconciler (releasing owner lock), and
	// returns ErrAgentRevoked.
	close(release)
	select {
	case r := <-doneA:
		require.Nil(t, r.rec, "fenced A must not return a reconciler")
		require.ErrorIs(t, r.err, ErrAgentRevoked,
			"A must report ErrAgentRevoked after inflight drift fence")
	case <-time.After(5 * time.Second):
		t.Fatalf("A's getOrCreate never returned after release")
	}

	// A's deferred inflight cleanup must have run.
	reg.mu.Lock()
	require.NotContains(t, reg.inflight, agentID,
		"inflight[A] must be cleared after A returns")
	reg.mu.Unlock()

	// No entry landed for A.
	require.Empty(t, reg.fingerprintForAgent(agentID),
		"A must not leave an entry after self-cancellation")

	// Phase 7: mutate clone to B and call getOrCreate(B). A's owner lock was
	// released when A's reconciler was closed at the insert gate; B's Start
	// must acquire it cleanly and publish fpB.
	//
	// Clear the beforeInsert hook first so B's getOrCreate doesn't re-park.
	// A has already returned; the hook is no longer needed.
	reg.testHooks = memorySearchRegistryTestHooks{}

	target := filepath.Join(sessionWorkDir(agentID), "memory")
	_, err := vault.EnsureClone(context.Background(), vault.EnsureCloneOpts{
		MemoryID:   accessB.MemoryID,
		AgentID:    agentID,
		RepoURL:    accessB.OriginURL,
		Branch:     accessB.Branch,
		TargetPath: target,
		Auth:       accessB.Auth,
	})
	require.NoError(t, err, "setup: re-clone source memory for B")

	rec, _, err := reg.getOrCreate(context.Background(), agentID, accessB)
	require.NoError(t, err, "B's getOrCreate must succeed after A's owner lock release")
	require.NotNil(t, rec)

	fpB := reg.fingerprintForAgent(agentID)
	require.NotEmpty(t, fpB, "B's fingerprint must be registered")
	require.NotEqual(t, fpA, fpB, "B's fingerprint must differ from A's")
}

// TestEnableMemorySearchDrainsInflightOnCloseAll exercises the Round 10 final
// blocker: closeAll must wait for in-flight getOrCreate slow paths to drain at
// the insert fence before returning, so EnableMemorySearch cannot publish
// `next` while prev's in-flight Start still holds the per-binding owner lock.
//
// Hazard without the drain: EnableMemorySearch publishes `next` immediately
// after prev.closeAll() returns. If prev.closeAll() only closes entries (not
// inflight), and a prev getOrCreate is parked at beforeInsert (Start succeeded,
// owner lock held, Add(1) done but entry not yet inserted), then closeAll
// returns with the owner lock still held. next's first getOrCreate's Start
// then races the owner lock and fails with ErrOwnerLockBusy.
//
// Timeline enforced by this test:
//  1. Establish clone + retention marker + entry under prev via Refresh.
//  2. Drop entry via closeAgent (keep clone + retention marker so a fresh
//     getOrCreate can re-enter the slow path).
//  3. Install beforeInsert hook on prev that parks the slow path.
//  4. Start A's prev.getOrCreate in goroutine — parks at beforeInsert,
//     holding the per-binding owner lock with inflightWg.Add(1) done.
//  5. Start EnableMemorySearch in goroutine — it acquires memorySearchReplace,
//     sets swap, calls prev.closeAll() → sets closed=true → blocks at
//     inflightWg.Wait() because A's Done() hasn't run.
//  6. Assert Enable hasn't completed (drain is blocking).
//  7. Release A's hook → A self-cancels at insert gate (closed=true), closes
//     its reconciler (releasing owner lock), returns ErrRegistryClosed, Done()
//     runs.
//  8. closeAll's Wait() returns → Enable publishes next, closes swap.
//  9. getOrCreate on next MUST NOT return ErrOwnerLockBusy — prev's owner
//     lock was released before next was published.
//
// Not parallel: mutates the process-wide profile.ConfigDir via t.Setenv.
func TestEnableMemorySearchDrainsInflightOnCloseAll(t *testing.T) {
	configDir := t.TempDir()
	t.Setenv(profile.ConfigDirEnvKey, configDir)

	origin := makeBareOriginRepoForMemory(t, filepath.Join(t.TempDir(), "origin"))
	agentID := "agt_enable_drain"

	provider := &fakeMemoryAccessProvider{}
	access := MemoryAccess{
		MemoryID:  "mem_enable_drain",
		OriginURL: origin,
		Branch:    "main",
	}
	provider.set(agentID, access)

	d := newDispatcherWithMemoryProvider(t, provider)
	enableMemorySearchForTest(t, d)
	t.Cleanup(d.Close)

	// Phase 1: establish clone + retention marker + entry under prev.
	d.RefreshMemoryAccess(context.Background(), []string{agentID})
	requireMemorySearchCurrent(t, d, agentID)

	prev, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, prev)
	require.NotEmpty(t, prev.fingerprintForAgent(agentID),
		"precondition: prev registry holds entry for agent")

	// Phase 2: drop entry but keep clone + retention marker so a fresh
	// getOrCreate can re-enter the slow path. closeAgent bumps epoch, so
	// the parked getOrCreate would self-cancel with ErrAgentRevoked at its
	// insert gate — but the closed=true set by closeAll takes precedence
	// (checked first in the insert gate's switch), so the test observes
	// ErrRegistryClosed instead. Either way the owner lock is released.
	prev.closeAgent(agentID)
	require.Empty(t, prev.fingerprintForAgent(agentID), "precondition: prev entry dropped")

	// Phase 3: install beforeInsert hook that parks A's slow path.
	parked := make(chan struct{})
	release := make(chan struct{})
	prev.testHooks = memorySearchRegistryTestHooks{
		beforeInsert: func(hookAgentID, fp string) {
			if hookAgentID != agentID {
				return
			}
			close(parked)
			<-release
		},
	}
	t.Cleanup(func() { prev.testHooks = memorySearchRegistryTestHooks{} })

	// Phase 4: start A's getOrCreate in goroutine — parks at beforeInsert.
	type result struct {
		rec *memorysearch.Reconciler
		err error
	}
	doneA := make(chan result, 1)
	go func() {
		rec, _, err := prev.getOrCreate(context.Background(), agentID, access)
		doneA <- result{rec: rec, err: err}
	}()
	select {
	case <-parked:
	case <-time.After(5 * time.Second):
		t.Fatalf("A's getOrCreate never parked at beforeInsert")
	}

	// A is now parked with owner lock held and inflightWg.Add(1) done.
	prev.mu.Lock()
	require.Contains(t, prev.inflight, agentID,
		"precondition: A tracked in prev.inflight while parked")
	prev.mu.Unlock()

	// Phase 5: start EnableMemorySearch in goroutine. It will:
	//   - Acquire memorySearchReplace
	//   - Set memorySearchSwap, clear memorySearch
	//   - Call prev.closeAll() → set closed=true → block at inflightWg.Wait()
	enableDone := make(chan struct{})
	go func() {
		enableMemorySearchForTest(t, d)
		close(enableDone)
	}()

	// Wait for Enable to set prev.closed=true (it's about to block at the drain).
	require.Eventually(t, func() bool {
		prev.mu.Lock()
		defer prev.mu.Unlock()
		return prev.closed
	}, 5*time.Second, 5*time.Millisecond,
		"Enable never set prev.closed=true (closeAll not entered?)")

	// Phase 6: Enable must be blocked at closeAll's inflightWg.Wait().
	select {
	case <-enableDone:
		t.Fatalf("Enable completed without draining prev's inflight — drain regression")
	default:
	}

	// Phase 7: release A. A self-cancels (closed=true observed first at
	// insert gate), closes its reconciler (releasing owner lock), returns
	// ErrRegistryClosed, Done() runs, closeAll's Wait() returns.
	close(release)

	select {
	case <-enableDone:
	case <-time.After(15 * time.Second):
		t.Fatalf("Enable never completed after inflight drain")
	}

	select {
	case r := <-doneA:
		require.Nil(t, r.rec, "A must not return a reconciler")
		require.ErrorIs(t, r.err, ErrRegistryClosed,
			"A must report ErrRegistryClosed after closeAll drained")
	case <-time.After(5 * time.Second):
		t.Fatalf("A's getOrCreate never returned after release")
	}

	// Phase 8: next registry is published. A fresh getOrCreate on next MUST
	// NOT return ErrOwnerLockBusy — prev's owner lock was released by A's
	// self-cancellation BEFORE Enable published next.
	next, _ := d.snapshotMemorySearch(context.Background())
	require.NotNil(t, next)
	require.NotSame(t, prev, next, "next registry must differ from prev")

	prev.testHooks = memorySearchRegistryTestHooks{} // clear hook before next.getOrCreate
	_, _, err := next.getOrCreate(context.Background(), agentID, access)
	if err != nil {
		require.NotErrorIs(t, err, memorysearch.ErrOwnerLockBusy,
			"next getOrCreate must not contend with prev's owner lock — drain regression")
	}

	// Sanity: next serves the agent (entry registered, generation current).
	require.NotEmpty(t, next.fingerprintForAgent(agentID),
		"next registry must serve the agent after drain-completed Enable")
	requireMemorySearchCurrent(t, d, agentID)
}
