//go:build !vectors

package memorysearch

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

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

// managerHarness wires a Manager against a single in-memory source
// clone, mirroring newBuildTestHarness but constructing a Reconciler
// via the Manager instead of NewReconciler directly.
func managerHarness(t *testing.T, opts ManagerOptions) (*Manager, *buildTestHarness) {
	t.Helper()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ninitial body\n",
	})
	opts.Resolver = h.resolver
	opts.Scan = Scan
	if opts.BuildLimiter == nil && opts.BuildConcurrency == 0 {
		opts.BuildConcurrency = 1
	}
	m, err := NewManager(opts)
	require.NoError(t, err)
	t.Cleanup(func() { _ = m.Close() })
	return m, h
}

// managerMultiHarness wires a Manager against N independent agent
// workdirs. Each agent gets its own source clone, derivative root,
// and binding; the Manager sees them as separate fingerprints. Used
// to exercise cross-binding limiter contention and concurrent
// GetOrCreate races.
func managerMultiHarness(t *testing.T, n int, opts ManagerOptions) (*Manager, []*buildTestHarness) {
	t.Helper()
	if n < 1 {
		require.Greater(t, n, 0)
	}
	tmp := t.TempDir()

	// Multi-agent resolver: agentID "agt_<i>" maps to a per-agent workdir.
	workDirs := make([]string, n)
	for i := 0; i < n; i++ {
		workDirs[i] = filepath.Join(tmp, "agents", agentIDFor(i))
	}
	resolver := AgentWorkDirResolver(func(agentID string) string {
		for i, wd := range workDirs {
			if agentIDFor(i) == agentID {
				return wd
			}
		}
		return ""
	})

	hs := make([]*buildTestHarness, n)
	for i := 0; i < n; i++ {
		workDir := workDirs[i]
		resolvedRoot := filepath.Join(workDir, "memory")
		derivativeRoot := DerivativeRootFromWorkDir(workDir)
		for _, p := range []string{resolvedRoot, derivativeRoot} {
			require.NoError(t, os.MkdirAll(p, 0o755))
		}
		identityPath := filepath.Join(resolvedRoot, "system", "identity.md")
		require.NoError(t, os.MkdirAll(filepath.Dir(identityPath), 0o755))
		require.NoError(t, os.WriteFile(
			identityPath,
			[]byte("# Identity\n\nagent "+agentIDFor(i)+" body\n"), 0o644))
		binding, err := NewBinding(resolver, agentIDFor(i), "mem_1", "main",
			AnalyzerVersion, "goldmark-v1")
		require.NoError(t, err)
		hs[i] = &buildTestHarness{
			tmp:            tmp,
			workDir:        workDir,
			resolvedRoot:   resolvedRoot,
			derivativeRoot: derivativeRoot,
			resolver:       resolver,
			binding:        binding,
		}
	}

	opts.Resolver = resolver
	opts.Scan = Scan
	if opts.BuildLimiter == nil && opts.BuildConcurrency == 0 {
		opts.BuildConcurrency = 1
	}
	m, err := NewManager(opts)
	require.NoError(t, err)
	t.Cleanup(func() { _ = m.Close() })
	return m, hs
}

func agentIDFor(i int) string {
	agents := []string{"agt_x", "agt_y", "agt_z", "agt_w"}
	if i < len(agents) {
		return agents[i]
	}
	return "agt_extra"
}

// TestManagerGetOrCreateDedup: same agent+binding twice returns same Reconciler.
func TestManagerGetOrCreateDedup(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	r1, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	r2, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.Same(t, r1, r2, "second GetOrCreate must return the same Reconciler pointer")
	require.Equal(t, h.binding.Fingerprint(), m.ActiveFingerprint(h.binding.LocalAgentID))
}

func TestManagerActiveReconcilerTracksLifecycle(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.Same(t, r, m.ActiveReconciler(h.binding.LocalAgentID))

	require.NoError(t, m.FenceBinding(context.Background(), h.binding.Fingerprint()))
	require.Nil(t, m.ActiveReconciler(h.binding.LocalAgentID))
}

// TestManagerBindingTransitionClearsCanceledPending deterministically covers
// the A -> B race that used to fail only under full-package load. Start writes
// pending.json before entering BuildFunc. Fencing A cancels that blocked build,
// so Close leaves an A journal behind; the completed lifecycle transition must
// remove exactly that journal before B starts against the shared per-agent
// derivative root.
func TestManagerBindingTransitionClearsCanceledPending(t *testing.T) {
	t.Parallel()
	buildStarted := make(chan string, 4)
	m, h := managerHarness(t, ManagerOptions{
		MaintenanceInterval: -1,
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			buildStarted <- opts.Binding.Fingerprint()
			<-ctx.Done()
			return nil, ctx.Err()
		},
	})

	rA, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	select {
	case got := <-buildStarted:
		require.Equal(t, h.binding.Fingerprint(), got)
	case <-time.After(5 * time.Second):
		t.Fatal("binding A never reached the forced pending build")
	}
	pending, err := loadPending(h.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, h.binding.Fingerprint(), pending.BindingFingerprint)

	require.NoError(t,
		m.FenceBinding(context.Background(), h.binding.Fingerprint()))
	_, err = loadPending(h.derivativeRoot)
	require.ErrorIs(t, err, os.ErrNotExist,
		"completed A fence must abandon A's cancelled journal")

	bindingB, err := NewBinding(
		h.resolver,
		h.binding.LocalAgentID,
		"mem_binding_b",
		h.binding.Branch,
		h.binding.AnalyzerVersion,
		h.binding.ChunkerVersion,
	)
	require.NoError(t, err)
	rB, err := m.GetOrCreate(context.Background(), bindingB)
	require.NoError(t, err,
		"binding B must not be rejected by A's cancelled pending journal")
	require.NotNil(t, rB)
	select {
	case got := <-buildStarted:
		require.Equal(t, bindingB.Fingerprint(), got)
	case <-time.After(5 * time.Second):
		t.Fatal("binding B never reached its forced pending build")
	}
	pending, err = loadPending(h.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, bindingB.Fingerprint(), pending.BindingFingerprint)

	// A stale finalizer cannot acquire the owner lock while B is live, and
	// therefore cannot overwrite B's lifecycle state or delete B's journal.
	err = rA.finalizeBindingTransition(BindingLifecycleFenced)
	require.ErrorIs(t, err, ErrOwnerLockBusy)
	pending, err = loadPending(h.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, bindingB.Fingerprint(), pending.BindingFingerprint)

	// The fingerprint CAS is an independent fence after B releases the lock:
	// a delayed A finalizer may reacquire ownership, but it still must not
	// remove the newer B journal left by B's cancelled pass.
	require.NoError(t, rB.Close())
	err = rA.finalizeBindingTransition(BindingLifecycleFenced)
	require.ErrorIs(t, err, ErrRecoveryBindingMismatch)
	pending, err = loadPending(h.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, bindingB.Fingerprint(), pending.BindingFingerprint)
}

// TestManagerGetOrCreateValidatesBinding: a nil binding is rejected
// before any map mutation; the secondary index is unchanged.
func TestManagerGetOrCreateValidatesBinding(t *testing.T) {
	t.Parallel()
	m, _ := managerHarness(t, ManagerOptions{})
	_, err := m.GetOrCreate(context.Background(), nil)
	require.ErrorIs(t, err, ErrInvalidBinding)
}

// TestManagerGetOrCreateValidatesResolver: a binding whose ResolvedRoot
// does not match the Manager's resolver fails ValidateWith at GetOrCreate
// entry and is NOT inserted into the map. The error wraps
// ErrInvalidBinding (NOT ErrInvalidBuildOptions, which is the wrapper
// used by NewReconciler/Build — Manager calls ValidateWith directly to
// fail-closed before the transition gate).
func TestManagerGetOrCreateValidatesResolver(t *testing.T) {
	t.Parallel()
	m, _ := managerHarness(t, ManagerOptions{})
	// Construct a binding against a foreign resolver that returns a
	// different workDir. ValidateWith(Manager resolver) will reject.
	foreignTmp := t.TempDir()
	foreignResolver := AgentWorkDirResolver(func(agentID string) string {
		return filepath.Join(foreignTmp, agentID)
	})
	foreign, err := NewBinding(foreignResolver, "agt_x", "mem_1", "main",
		AnalyzerVersion, "goldmark-v1")
	require.NoError(t, err)
	_, err = m.GetOrCreate(context.Background(), foreign)
	require.ErrorIs(t, err, ErrInvalidBinding,
		"Manager.GetOrCreate must return ValidateWith failure as ErrInvalidBinding")
	require.Equal(t, "", m.ActiveFingerprint("agt_x"))
}

// TestManagerGetOrCreateTransitionRejected: an agent with active=FP_A
// cannot GetOrCreate FP_B; the secondary index is unchanged.
func TestManagerGetOrCreateTransitionRejected(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	r1, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotNil(t, r1)

	// Same agentID, different memoryID => different fingerprint.
	other, err := NewBinding(h.resolver, h.binding.LocalAgentID, "mem_OTHER",
		"main", AnalyzerVersion, "goldmark-v1")
	require.NoError(t, err)
	require.NotEqual(t, h.binding.Fingerprint(), other.Fingerprint())

	_, err = m.GetOrCreate(context.Background(), other)
	require.ErrorIs(t, err, ErrBindingTransitionRequired)
	// Original binding is still the active one.
	require.Equal(t, h.binding.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID))
	r1Again, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.Same(t, r1, r1Again)
}

// TestManagerActiveFingerprintEmptyAfterClose: after Close the secondary
// index reports empty even for previously-active agents.
func TestManagerActiveFingerprintEmptyAfterClose(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	// Override the Close cleanup so we can exercise it explicitly.
	t.Cleanup(func() {}) // managerHarness already registered Close; cancel double-close concern below
	_, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.Equal(t, h.binding.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID))

	// First Close stops Reconcilers; second is idempotent.
	require.NoError(t, m.Close())
	require.NoError(t, m.Close())
	require.Equal(t, "", m.ActiveFingerprint(h.binding.LocalAgentID))

	// GetOrCreate on a closed Manager fails closed.
	_, err = m.GetOrCreate(context.Background(), h.binding)
	require.ErrorIs(t, err, ErrManagerClosed)
}

// TestManagerResolveBinding: secondary index returns the active binding.
func TestManagerResolveBinding(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	require.Nil(t, m.ResolveBinding(h.binding.LocalAgentID))
	_, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	got := m.ResolveBinding(h.binding.LocalAgentID)
	require.NotNil(t, got)
	require.Equal(t, h.binding.Fingerprint(), got.Fingerprint())
}

// TestManagerReconcilerAcquiresLimiterBeforeBuild: the injected
// limiter sees Acquire before Build and Release after Build completes.
// Peak inflight reaches 1; final inflight is 0.
func TestManagerReconcilerAcquiresLimiterBeforeBuild(t *testing.T) {
	t.Parallel()
	counter := newCountingLimiter(NewBuildLimiter(1))
	m, h := managerHarness(t, ManagerOptions{
		BuildLimiter: counter,
	})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotNil(t, r)
	require.Eventually(t, func() bool {
		return waitForCurrent(t, r, 5*time.Second) != nil
	}, 5*time.Second, 20*time.Millisecond, "current pointer never appeared")

	require.LessOrEqual(t, counter.Peak(), int64(1),
		"single reconciler must never exceed limiter capacity 1")
	require.Equal(t, int64(1), counter.Peak(),
		"build must have run at least once")
	require.Equal(t, int64(0), counter.Inflight(),
		"limiter slot must be released after build completes")
}

// TestManagerReconcilerReleasesLimiterOnBuildError: a Build that fails
// still releases the limiter slot.
func TestManagerReconcilerReleasesLimiterOnBuildError(t *testing.T) {
	t.Parallel()
	counter := newCountingLimiter(NewBuildLimiter(1))
	buildErr := errors.New("synthetic build failure")
	m, h := managerHarness(t, ManagerOptions{
		BuildLimiter: counter,
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			return nil, buildErr
		},
	})
	_, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	// The reconciler retries internally (runPassWithDrain). Eventually
	// it logs build errors; we cannot assert on the error path directly,
	// but we CAN assert that the limiter is left clean: each failed
	// build acquired and released a slot, so peak >= 1 and inflight is 0.
	require.Eventually(t, func() bool {
		return counter.Peak() >= 1 && counter.Inflight() == 0
	}, 3*time.Second, 20*time.Millisecond,
		"build error path must release the limiter slot")
}

// TestManagerReconcilerReleasesLimiterOnCtxCancel: a Build whose ctx
// is cancelled while waiting on the limiter does NOT leak the slot
// (Release is not called when Acquire did not succeed) AND a Build
// cancelled mid-flight does NOT leak the slot (deferred Release runs).
func TestManagerReconcilerReleasesLimiterOnCtxCancel(t *testing.T) {
	t.Parallel()

	// Capacity 1, pre-acquired so the reconciler's Acquire blocks.
	sem := NewBuildLimiter(1)
	counter := newCountingLimiter(sem)

	// Hold the only slot from outside; the reconciler's runBuild will
	// block in Acquire until ctx is cancelled or we release.
	holdCtx := context.Background()
	require.NoError(t, sem.Acquire(holdCtx))
	defer sem.Release()

	m, h := managerHarness(t, ManagerOptions{
		BuildLimiter: counter,
	})
	ctx, cancel := context.WithCancel(context.Background())
	_, err := m.GetOrCreate(ctx, h.binding)
	require.NoError(t, err)
	// Cancel the reconciler's build ctx while it is parked on Acquire.
	cancel()

	// The reconciler's runBuild returns ctx.Err() from Acquire WITHOUT
	// calling Release on counter, so counter.Inflight stays 0. The
	// outer hold is still active, so peak stays 1 (just our hold, which
	// does not go through counter).
	require.Eventually(t, func() bool {
		return counter.Inflight() == 0 && counter.Peak() == 0
	}, 2*time.Second, 10*time.Millisecond,
		"cancelled Acquire must not increment counter")
}

// TestManagerReconcilerNilLimiterBackwardCompat: a nil BuildLimiter
// runs builds without any Acquire/Release calls; existing dispatcher
// wiring that leaves the field nil is unaffected.
func TestManagerReconcilerNilLimiterBackwardCompat(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{
		BuildLimiter: nil,
	})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotNil(t, r)
	require.Eventually(t, func() bool {
		return waitForCurrent(t, r, 5*time.Second) != nil
	}, 5*time.Second, 20*time.Millisecond,
		"current pointer never appeared with nil limiter")
}

// TestManagerConcurrentSameFPOneStart: concurrent GetOrCreate calls for
// the same fingerprint construct at most one Reconciler. Wraps
// NewReconciler with a counter to assert exactly one construction.
func TestManagerConcurrentSameFPOneStart(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})

	const N = 8
	var constructed atomic.Int64
	wrapped := m // Manager already constructed; we instead count via a
	// shim: spawn N concurrent GetOrCreate against the same binding and
	// require they all return the same *Reconciler pointer.
	_ = wrapped
	results := make([]*Reconciler, N)
	errs := make([]error, N)
	var wg sync.WaitGroup
	wg.Add(N)
	start := make(chan struct{})
	for i := 0; i < N; i++ {
		i := i
		go func() {
			defer wg.Done()
			<-start
			r, err := m.GetOrCreate(context.Background(), h.binding)
			results[i] = r
			errs[i] = err
		}()
	}
	close(start)
	wg.Wait()

	for i := 0; i < N; i++ {
		require.NoError(t, errs[i])
		require.NotNil(t, results[i])
		require.Same(t, results[0], results[i],
			"all concurrent GetOrCreate must return the same Reconciler")
	}
	// Sanity: the construction count was at most 1. We cannot directly
	// intercept NewReconciler without a hook, but the same-pointer
	// invariant above is the observable contract.
	constructed.Store(0) // silence unused
}

// TestManagerConcurrentDifferentFPTransition: concurrent GetOrCreate
// calls for different fingerprints on the SAME agent end with exactly
// one binding active and the rest rejected as transition.
func TestManagerConcurrentDifferentFPTransition(t *testing.T) {
	t.Parallel()
	// Single-agent harness so all bindings share agentID "agt_x".
	m, h := managerHarness(t, ManagerOptions{})

	// Build N candidate bindings with different memoryIDs. All share
	// agentID "agt_x" so only one can become active.
	const N = 4
	bindings := make([]*Binding, N)
	bindings[0] = h.binding
	for i := 1; i < N; i++ {
		b, err := NewBinding(h.resolver, h.binding.LocalAgentID,
			"mem_"+string(rune('A'+i)), "main",
			AnalyzerVersion, "goldmark-v1")
		require.NoError(t, err)
		bindings[i] = b
		require.NotEqual(t, bindings[0].Fingerprint(), b.Fingerprint())
	}

	type outcome struct {
		fp  string
		err error
	}
	out := make([]outcome, N)
	var wg sync.WaitGroup
	wg.Add(N)
	start := make(chan struct{})
	for i := 0; i < N; i++ {
		i := i
		go func() {
			defer wg.Done()
			<-start
			_, err := m.GetOrCreate(context.Background(), bindings[i])
			out[i] = outcome{fp: bindings[i].Fingerprint(), err: err}
		}()
	}
	close(start)
	wg.Wait()

	var okCount, transitionCount int
	for _, o := range out {
		if o.err == nil {
			okCount++
		} else if errors.Is(o.err, ErrBindingTransitionRequired) {
			transitionCount++
		} else {
			t.Fatalf("unexpected error: %v", o.err)
		}
	}
	require.Equal(t, 1, okCount,
		"exactly one binding must become active; got %d", okCount)
	require.Equal(t, N-1, transitionCount,
		"the other %d bindings must be rejected as transition", N-1)
}

// TestManagerCloseRacingStartNoLatePublish: Close racing an in-flight
// GetOrCreate must either (a) let it land if it finished before Close
// took the mutex, or (b) close its freshly-Started Reconciler and
// return ErrManagerClosed. The handle must NOT leak: after Close,
// ActiveFingerprint is empty and no Reconciler keeps the owner lock.
//
// We assert the no-leak half: after Close returns, the derivative root
// no longer holds the owner lock (a new standalone Reconciler can
// acquire it).
func TestManagerCloseRacingStartNoLatePublish(t *testing.T) {
	t.Parallel()

	// Build a Manager whose BuildFunc blocks until we close publishGate,
	// so the in-flight GetOrCreate is parked at the build step when we
	// call Close.
	publishGate := make(chan struct{})
	buildStarted := make(chan struct{})
	m, h := managerHarness(t, ManagerOptions{
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			select {
			case <-buildStarted:
			default:
				close(buildStarted)
			}
			select {
			case <-publishGate:
			case <-ctx.Done():
				return nil, ctx.Err()
			}
			return Build(ctx, opts)
		},
	})

	// Kick off GetOrCreate but do not wait for it.
	go func() {
		_, _ = m.GetOrCreate(context.Background(), h.binding)
	}()
	<-buildStarted

	// Close racing the in-flight Start. Close must wait for the
	// inflightWG, then close any Reconciler that managed to start.
	closeDone := make(chan error, 1)
	go func() {
		closeDone <- m.Close()
	}()

	// Unblock the build so the in-flight GetOrCreate can proceed to its
	// insert gate. By then Close has set closed=true and bumped the
	// epoch, so the insert gate closes the Reconciler and returns
	// ErrManagerClosed.
	close(publishGate)

	select {
	case err := <-closeDone:
		require.NoError(t, err)
	case <-time.After(5 * time.Second):
		t.Fatal("Close did not return within 5s; inflightWG not draining")
	}

	// ActiveFingerprint is empty after Close.
	require.Equal(t, "", m.ActiveFingerprint(h.binding.LocalAgentID))

	// A standalone Reconciler for the same derivative root must be able
	// to acquire the owner lock — if the closed Manager's Reconciler
	// leaked the lock, Start would return ErrOwnerLockBusy.
	r, err := NewReconciler(ReconcilerOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	require.NoError(t, r.Start(context.Background()))
	require.NoError(t, r.Close())
}

func TestManagerCloseDrainsAdmittedRetireBeforeReplacement(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{MaintenanceInterval: -1})
	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	waitForServiceableCurrent(t, r, 5*time.Second)

	admitted := make(chan struct{})
	release := make(chan struct{})
	var once sync.Once
	m.setTestHooks(managerTestHooks{afterLifecycleAdmission: func(operation, agentID string) {
		if operation == "retire-binding" && agentID == h.binding.LocalAgentID {
			once.Do(func() { close(admitted) })
			<-release
		}
	}})

	retireDone := make(chan error, 1)
	go func() {
		retireDone <- m.FenceBinding(context.Background(), h.binding.Fingerprint())
	}()
	<-admitted

	closeDone := make(chan error, 1)
	go func() { closeDone <- m.Close() }()
	require.Eventually(t, func() bool {
		m.mu.Lock()
		defer m.mu.Unlock()
		return m.closed
	}, time.Second, 5*time.Millisecond)
	select {
	case err := <-closeDone:
		t.Fatalf("Close returned before admitted retire drained: %v", err)
	default:
	}

	close(release)
	require.ErrorIs(t, <-retireDone, ErrManagerClosed)
	require.NoError(t, <-closeDone)

	// A replacement Manager can acquire the owner lock and publish active
	// lifecycle; the old admitted retire has fully exited and cannot overwrite.
	replacement, err := NewManager(ManagerOptions{
		Resolver:            h.resolver,
		Scan:                Scan,
		MaintenanceInterval: -1,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = replacement.Close() })
	replacementR, err := replacement.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	waitForServiceableCurrent(t, replacementR, 5*time.Second)
	state, err := replacementR.lifecycle.load()
	require.NoError(t, err)
	require.Equal(t, BindingLifecycleActive, state.BindingState)
}

func TestManagerPurgeAgentFencesInflightGetOrCreateBeforeRootDelete(t *testing.T) {
	m, h := managerHarness(t, ManagerOptions{MaintenanceInterval: -1})
	parked := make(chan struct{})
	releaseInsert := make(chan struct{})
	var once sync.Once
	m.setTestHooks(managerTestHooks{beforeGetOrCreateInsert: func(agentID, fingerprint string) {
		if agentID != h.binding.LocalAgentID {
			return
		}
		once.Do(func() { close(parked) })
		<-releaseInsert
	}})

	getDone := make(chan error, 1)
	go func() {
		_, err := m.GetOrCreate(context.Background(), h.binding)
		getDone <- err
	}()
	<-parked

	purgeDone := make(chan error, 1)
	go func() {
		purgeDone <- m.PurgeAgent(context.Background(), h.binding.LocalAgentID)
	}()
	require.Eventually(t, func() bool {
		m.mu.Lock()
		defer m.mu.Unlock()
		return m.agentEpoch[h.binding.LocalAgentID] > 0
	}, time.Second, 5*time.Millisecond)
	select {
	case err := <-purgeDone:
		t.Fatalf("PurgeAgent returned before in-flight GetOrCreate drained: %v", err)
	default:
	}

	close(releaseInsert)
	require.ErrorIs(t, <-getDone, ErrBindingFenced)
	require.NoError(t, <-purgeDone)
	require.Equal(t, "", m.ActiveFingerprint(h.binding.LocalAgentID))
	require.NoDirExists(t, filepath.Join(h.derivativeRoot, generationsDirName))

	// The fenced slow path closed its Reconciler and released the owner lock.
	probe, err := NewReconciler(ReconcilerOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	require.NoError(t, probe.Start(context.Background()))
	require.NoError(t, probe.Close())
}

// TestManagerBuildLimiterSerializesAcrossBindings: with BuildConcurrency=1,
// two Reconcilers sharing the Manager's limiter cannot build
// concurrently. Peak observed in-flight count across both builds is 1.
//
// The test must NOT assume which agent wins the first slot. With
// capacity 1 and two goroutines competing, either may enter BuildFunc
// first; pinning the test to "A first" deadlocks if B wins. The gate
// fires on ANY first BuildFunc entry; the assertion only checks that
// at most one is in-flight while the first holds the slot.
func TestManagerBuildLimiterSerializesAcrossBindings(t *testing.T) {
	t.Parallel()
	counter := newCountingLimiter(NewBuildLimiter(1))

	// Single gate: fires when any build enters BuildFunc. The test
	// does not care WHICH agent wins the first slot.
	firstBuildStarted := make(chan struct{})
	buildGate := make(chan struct{})
	buildsStarted := atomic.Int64{}
	buildFn := func(ctx context.Context, opts BuildOptions) (*Generation, error) {
		select {
		case <-firstBuildStarted:
		default:
			close(firstBuildStarted)
		}
		buildsStarted.Add(1)
		select {
		case <-buildGate:
		case <-ctx.Done():
			return nil, ctx.Err()
		}
		return Build(ctx, opts)
	}

	m, hs := managerMultiHarness(t, 2, ManagerOptions{
		BuildLimiter: counter,
		BuildFunc:    buildFn,
	})

	// Trigger both GetOrCreate concurrently.
	for _, h := range hs {
		_, err := m.GetOrCreate(context.Background(), h.binding)
		require.NoError(t, err)
	}

	// Wait for the first build to park inside BuildFunc; the second
	// is queued on the limiter (capacity 1 held).
	<-firstBuildStarted
	require.Eventually(t, func() bool {
		return counter.Inflight() == 1 && counter.Peak() == 1
	}, 2*time.Second, 10*time.Millisecond,
		"only one build may be in-flight while capacity is held")

	// Release the gate: first completes, second acquires and completes.
	close(buildGate)
	require.Eventually(t, func() bool {
		return buildsStarted.Load() == 2
	}, 5*time.Second, 20*time.Millisecond,
		"both builds must run sequentially")

	// Wait for both builds to fully RELEASE their limiter slots.
	// buildsStarted counts BuildFunc entries, but Build itself runs
	// after the gate and takes time to chunk + index. Inflight reaches
	// 0 only after Build returns and the deferred Release fires.
	require.Eventually(t, func() bool {
		return counter.Inflight() == 0
	}, 5*time.Second, 20*time.Millisecond,
		"both builds must release their limiter slots")
	require.Equal(t, int64(1), counter.Peak(),
		"peak must respect capacity=1 across two bindings")
}

// TestManagerGetOrCreateSnapshotsBinding: GetOrCreate value-copies the
// caller's binding on entry. After GetOrCreate returns, mutating the
// caller's binding (MemoryID, ResolvedRoot, Branch) must NOT drift
// ActiveFingerprint or Reconciler.Binding().Fingerprint().
func TestManagerGetOrCreateSnapshotsBinding(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})
	orig := *h.binding // capture original identity for assertions

	r, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.NotNil(t, r)

	// Mutate the caller-owned binding AFTER registration.
	h.binding.MemoryID = "mem_MUTATED"
	h.binding.Branch = "branch_MUTATED"

	// Manager state reflects the snapshot, not the mutated original.
	require.Equal(t, orig.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID),
		"ActiveFingerprint must reflect snapshot, not mutated binding")

	// Reconciler.Binding() returns a fresh copy of the snapshot.
	rb := r.Binding()
	require.NotNil(t, rb)
	require.Equal(t, orig.Fingerprint(), rb.Fingerprint(),
		"Reconciler.Binding().Fingerprint() must reflect snapshot")
	require.Equal(t, orig.MemoryID, rb.MemoryID,
		"Reconciler.Binding().MemoryID must reflect snapshot")
	require.Equal(t, orig.Branch, rb.Branch,
		"Reconciler.Binding().Branch must reflect snapshot")

	// ResolveBinding returns a copy of the snapshot too.
	resolved := m.ResolveBinding(h.binding.LocalAgentID)
	require.NotNil(t, resolved)
	require.Equal(t, orig.Fingerprint(), resolved.Fingerprint(),
		"ResolveBinding must reflect snapshot")
	require.Equal(t, orig.MemoryID, resolved.MemoryID)

	// Mutating the returned *Binding must not leak back into Manager.
	rb.MemoryID = "mem_LEAK"
	require.Equal(t, orig.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID),
		"mutating Reconciler.Binding() return must not affect Manager state")
	resolved.MemoryID = "mem_LEAK2"
	require.Equal(t, orig.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID),
		"mutating ResolveBinding return must not affect Manager state")
}

// TestManagerGetOrCreateValidatesBeforeTransition: when an agent has
// an active binding with fingerprint A, a subsequent GetOrCreate for a
// different binding with a FORGED ResolvedRoot must be rejected as
// ErrInvalidBinding (fail-closed on identity) — NOT as
// ErrBindingTransitionRequired.
func TestManagerGetOrCreateValidatesBeforeTransition(t *testing.T) {
	t.Parallel()
	m, h := managerHarness(t, ManagerOptions{})

	// Register the legitimate binding first.
	_, err := m.GetOrCreate(context.Background(), h.binding)
	require.NoError(t, err)
	require.Equal(t, h.binding.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID))

	// Construct a forged binding: same agentID + different memoryID
	// (so it would have a different fingerprint, triggering transition)
	// AND a ResolvedRoot that does not match the Manager's resolver
	// (so it must fail ValidateWith). Build it via a foreign resolver.
	foreignTmp := t.TempDir()
	foreignResolver := AgentWorkDirResolver(func(agentID string) string {
		return filepath.Join(foreignTmp, agentID)
	})
	forged, err := NewBinding(foreignResolver, h.binding.LocalAgentID,
		"mem_OTHER", "main", AnalyzerVersion, "goldmark-v1")
	require.NoError(t, err)
	require.NotEqual(t, h.binding.Fingerprint(), forged.Fingerprint(),
		"sanity: forged must have a different fingerprint to trigger transition path")

	// ValidateWith(Manager resolver) must reject before transition check.
	_, err = m.GetOrCreate(context.Background(), forged)
	require.ErrorIs(t, err, ErrInvalidBinding,
		"invalid binding must fail-closed before transition rejection")
	require.NotErrorIs(t, err, ErrBindingTransitionRequired,
		"transition must not mask invalid binding")

	// Original binding still active.
	require.Equal(t, h.binding.Fingerprint(),
		m.ActiveFingerprint(h.binding.LocalAgentID))
}

// reconcilerIfRegistered is a no-op helper retained for future test
// expansions that want to look up the Reconciler the Manager registered
// for a given binding. Currently tests call GetOrCreate directly.
func (h *buildTestHarness) reconcilerIfRegistered(m *Manager) *Reconciler {
	r, err := m.GetOrCreate(context.Background(), h.binding)
	if err != nil {
		return nil
	}
	return r
}
