package memorysearch

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"sync"
	"time"
)

// ManagerOptions configures NewManager. Every Reconciler the Manager
// constructs shares the resolver, scan, clock, build override, and build
// limiter; per-binding state lives on the Reconciler.
type ManagerOptions struct {
	// Resolver is the agent work-dir resolver. It must be the same
	// resolver callers use to construct bindings. A binding whose
	// ResolvedRoot does not match resolver(binding.LocalAgentID)/memory
	// fails ValidateWith at GetOrCreate time.
	Resolver AgentWorkDirResolver
	// Scan reads the source clone. Production wires the existing
	// memorysearch.Scan; tests inject a fake.
	Scan func(ctx context.Context, root string) (SourceSnapshot, ScanStatus, error)
	// Logger receives lifecycle events. Nil falls back to slog.Default()
	// at Reconciler construction time.
	Logger *slog.Logger
	// Clock returns the current time. Tests inject a fake; production
	// leaves nil for time.Now.
	Clock func() time.Time
	// BuildFunc overrides the package-level Build. Tests inject a fake
	// to observe build-path timing; production leaves nil.
	BuildFunc func(ctx context.Context, opts BuildOptions) (*Generation, error)
	// BuildConcurrency caps how many Reconciler builds may run at once
	// across ALL bindings the Manager owns. Default 2, clamped to [1, 4].
	// A daemon-wide budget prevents N simultaneous agents from each
	// burning a full CPU on a fresh build.
	BuildConcurrency int
	// BuildLimiter overrides the Manager-wide limiter. Tests inject a
	// counting fake to observe in-flight count; production leaves nil
	// and the Manager constructs one from BuildConcurrency.
	BuildLimiter BuildLimiter
	// DiskUsageProvider measures the complete per-agent derivative and its
	// containing volume. Nil uses the platform provider.
	DiskUsageProvider DiskUsageProvider
	// PressurePolicy controls soft retention and hard build admission.
	// Zero fields use the V1 defaults.
	PressurePolicy PressurePolicy
	// MaintenanceInterval controls retention/trash maintenance. Zero uses
	// one hour; negative disables the ticker (tests call RunMaintenance).
	MaintenanceInterval time.Duration
}

// Build concurrency bounds. The default of 2 is a deliberate balance:
// one build at a time serializes recovery after a daemon restart that
// touches multiple agents, while two allows one agent's reconcile pass
// to overlap another's without forcing the second to wait on the
// first's chunk read. The clamp range is small because build is CPU and
// IO heavy; values above 4 have not been validated.
const (
	defaultBuildConcurrency = 2
	minBuildConcurrency     = 1
	maxBuildConcurrency     = 4
)

// Manager is the daemon-scoped domain owner of per-binding Reconcilers.
//
// Commit A scope: Manager is the domain-target owner. Production
// dispatcher keeps using its existing memorySearchRegistry as the sole
// production owner until Task 8 swaps the registry to an eligibility
// adapter and cuts production over. Until then Manager has no production
// wiring; it exists so Commits B–E can layer refcount, lifecycle,
// retention, and pressure on a single owner rather than spreading them
// across the dispatcher package.
//
// Key model:
//   - Primary index: fingerprint → *managedHandle. Each binding has at
//     most one Reconciler.
//   - Secondary index: agentID → activeFP. The same agent has at most
//     one ACTIVE binding at a time. The per-derivative-root owner lock
//     is shared across fingerprints for the same agent, so two
//     concurrent Starts for different fingerprints would race on that
//     lock; the secondary index prevents the race by refusing the
//     second Start with ErrBindingTransitionRequired.
//
// Concurrency model: GetOrCreate's slow path (construct + Start) runs
// OUTSIDE the Manager mutex. Per-agent serialization via a lazily-
// allocated *sync.Mutex ensures only one in-flight Start per agent.
// The Manager mutex is held only for map reads/writes and never during
// Start, which can take seconds doing recovery + the first build.
//
// Lifecycle fencing: a closed flag plus a per-agent epoch counter let
// a concurrent Close interrupt an in-flight Start. The in-flight
// Start observes the fence at its insert gate, closes the freshly-
// Started Reconciler (releasing the per-binding owner lock), and
// returns ErrManagerClosed. The handle never lands in the map and the
// owner lock is not leaked.
type Manager struct {
	opts ManagerOptions

	mu sync.Mutex
	// closed is set by Close and never cleared. Every GetOrCreate
	// observes it at the slow-path re-check and the insert gate.
	closed bool
	// handles is the primary fingerprint → handle index.
	handles map[string]*managedHandle
	// retired retains fenced/unbound/unknown-owner bindings solely for
	// retention maintenance. They are never returned by GetOrCreate.
	retired map[string]*managedHandle
	// agentActiveFP is the secondary agentID → active fingerprint index.
	// Invariant: if agentActiveFP[a] = f then handles[f].agentID == a.
	// The reverse invariant is enforced at GetOrCreate: an agent can
	// only acquire one active fingerprint at a time.
	agentActiveFP map[string]string
	// agentEpoch is bumped on Close BEFORE the handle is removed, so
	// an in-flight GetOrCreate can detect that its captured epoch is
	// stale and refuse to insert.
	agentEpoch map[string]uint64
	// agentSourceTrusted is the authoritative construction/publication gate.
	// Missing means trusted for startup compatibility. constructing exposes a
	// Reconciler from NewReconciler until its Manager insert gate, so a revoke
	// can freeze it even before agentActiveFP/handles become visible.
	agentSourceTrusted map[string]bool
	constructing       map[string]*Reconciler
	// agentMu lazily allocates a per-agent *sync.Mutex serializing the
	// GetOrCreate slow path.
	agentMu sync.Map
	// inflightWG tracks GetOrCreate slow paths that have committed to
	// Starting a Reconciler but have not yet either inserted the handle
	// or self-cancelled at the insert gate. Close waits on this before
	// returning so an in-flight Start does not outlive the Manager and
	// leak its owner lock.
	inflightWG sync.WaitGroup
	// lifecycleWG tracks lifecycle operations admitted under m.mu. Close
	// sets closed under the same mutex before waiting, so Add cannot race
	// Wait and no old lifecycle writer can outlive Manager.Close.
	lifecycleWG sync.WaitGroup

	// limiter is the daemon-wide build limiter, injected into every
	// Reconciler the Manager constructs.
	limiter BuildLimiter

	maintenanceStop chan struct{}
	maintenanceDone chan struct{}

	// testHooks are nil in production. Access goes through testHooksMu so a
	// test can install/remove deterministic gates without racing workers.
	testHooksMu sync.RWMutex
	testHooks   managerTestHooks
}

type managerTestHooks struct {
	afterLifecycleAdmission func(operation, agentID string)
	beforeGetOrCreateInsert func(agentID, fingerprint string)
}

func (m *Manager) snapshotTestHooks() managerTestHooks {
	m.testHooksMu.RLock()
	defer m.testHooksMu.RUnlock()
	return m.testHooks
}

func (m *Manager) setTestHooks(hooks managerTestHooks) {
	m.testHooksMu.Lock()
	m.testHooks = hooks
	m.testHooksMu.Unlock()
}

// managedHandle pairs a binding with its owning Reconciler. Commit A
// has just these three fields; Commit B adds pool/lease state, C adds
// lifecycle state, D adds retire/trash state.
type managedHandle struct {
	fp         string
	agentID    string
	reconciler *Reconciler
}

// ErrBindingTransitionRequired is returned by GetOrCreate when the agent
// already has an active binding with a different fingerprint. The Task 8
// dispatcher adapter handles this by FenceBinding the old binding (after
// Commit D lands) before retrying GetOrCreate on the new fingerprint.
var ErrBindingTransitionRequired = errors.New(
	"memorysearch: agent has an active binding with a different fingerprint; fence the old before creating the new")

// ErrManagerClosed is returned by every Manager method after Close.
// GetOrCreate in flight when Close is called also returns this error so
// the caller can distinguish "the Manager went away" from "this binding
// is invalid".
var ErrManagerClosed = errors.New("memorysearch: manager is closed")

// ErrBindingFenced is returned by an in-flight GetOrCreate when a concurrent
// lifecycle transition invalidates its captured per-agent epoch.
var ErrBindingFenced = errors.New("memorysearch: binding was fenced during creation")

// NewManager validates options and returns a Manager with empty maps.
// The Manager does not start any Reconcilers; callers GetOrCreate on
// demand. BuildConcurrency is clamped to [1, 4] with a default of 2.
func NewManager(opts ManagerOptions) (*Manager, error) {
	if opts.Resolver == nil {
		return nil, fmt.Errorf("%w: Resolver is required", ErrInvalidBuildOptions)
	}
	if opts.Scan == nil {
		return nil, fmt.Errorf("%w: Scan is required", ErrInvalidBuildOptions)
	}
	policy, err := normalizePressurePolicy(opts.PressurePolicy)
	if err != nil {
		return nil, err
	}
	opts.PressurePolicy = policy
	if opts.DiskUsageProvider == nil {
		opts.DiskUsageProvider = osDiskUsageProvider{}
	}
	concurrency := opts.BuildConcurrency
	if concurrency == 0 {
		concurrency = defaultBuildConcurrency
	}
	if concurrency < minBuildConcurrency {
		concurrency = minBuildConcurrency
	}
	if concurrency > maxBuildConcurrency {
		concurrency = maxBuildConcurrency
	}
	limiter := opts.BuildLimiter
	if limiter == nil {
		limiter = NewBuildLimiter(concurrency)
	}
	manager := &Manager{
		opts:               opts,
		handles:            make(map[string]*managedHandle),
		retired:            make(map[string]*managedHandle),
		agentActiveFP:      make(map[string]string),
		agentEpoch:         make(map[string]uint64),
		agentSourceTrusted: make(map[string]bool),
		constructing:       make(map[string]*Reconciler),
		limiter:            limiter,
		maintenanceStop:    make(chan struct{}),
		maintenanceDone:    make(chan struct{}),
	}
	go manager.maintenanceLoop()
	return manager, nil
}

// GetOrCreate returns the Reconciler for binding.Fingerprint(),
// constructing one if none exists for that fingerprint AND no other
// binding is active for binding.LocalAgentID.
//
// Snapshot contract: GetOrCreate value-copies the caller's binding on
// entry. The Manager and the Reconciler it constructs reference only
// the snapshot, never the caller-owned *Binding. A caller that mutates
// the original after GetOrCreate returns cannot affect the Manager's
// fingerprint key, the activeFP index, or the Reconciler's view of its
// own binding. Reconciler.Binding() and Manager.ResolveBinding() also
// return fresh copies, so a caller that mutates the returned *Binding
// cannot reach back into Manager state.
//
// Validation order: the snapshot is ValidateWith'd against the Manager's
// resolver BEFORE the fast-path/transition gate runs. A forged binding
// is rejected with ErrInvalidBinding even when the agent already has an
// active binding with a different fingerprint — fail-closed on invalid
// identity takes precedence over the transition rejection.
//
// Transition case: if the snapshot is valid AND the agent already has
// an active binding with a different fingerprint, GetOrCreate returns
// ErrBindingTransitionRequired with the active fingerprint available
// via ActiveFingerprint. The caller must FenceBinding the old binding
// (after Commit D lands) before retrying.
//
// Concurrency: the slow path (construct + Start) runs OUTSIDE the
// Manager mutex, serialized per-agent via a lazily-allocated mutex so
// two concurrent GetOrCreate calls for the same agent cannot both
// reach Start. The Manager mutex is held only for map operations.
//
// Cancelation: the caller's ctx gates Start (recovery + first build
// honor it). If Start succeeds but the caller has cancelled by the
// insert gate, the freshly-Started Reconciler is closed and the caller
// observes the ctx error.
func (m *Manager) GetOrCreate(ctx context.Context, binding *Binding) (*Reconciler, error) {
	if binding == nil {
		return nil, fmt.Errorf("%w: binding is required", ErrInvalidBinding)
	}
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	// Value-copy snapshot so caller cannot mutate the original and
	// affect Manager state. All downstream code uses snapshotBinding;
	// the caller-owned *Binding is never retained.
	snapshot := *binding
	// Fail-closed on invalid identity BEFORE any transition check. A
	// forged binding must return ErrInvalidBinding even when the agent
	// has an active binding with a different fingerprint.
	if err := snapshot.ValidateWith(m.opts.Resolver); err != nil {
		return nil, err
	}
	fp := snapshot.Fingerprint()
	agentID := snapshot.LocalAgentID

	// Fast path: read-only check under the mutex.
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return nil, ErrManagerClosed
	}
	if h, ok := m.handles[fp]; ok {
		r := h.reconciler
		m.mu.Unlock()
		return r, nil
	}
	m.mu.Unlock()

	// Slow path serialized per-agent.
	agentMu := m.agentMutex(agentID)
	agentMu.Lock()
	defer agentMu.Unlock()

	// Re-check under Manager mutex after acquiring agent lock. This
	// catches both "another GetOrCreate for the same FP landed while we
	// waited on agentMu" and "Close raced while we waited".
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return nil, ErrManagerClosed
	}
	if h, ok := m.handles[fp]; ok {
		r := h.reconciler
		m.mu.Unlock()
		return r, nil
	}
	if active, ok := m.agentActiveFP[agentID]; ok && active != fp {
		m.mu.Unlock()
		return nil, fmt.Errorf("%w: active=%s requested=%s",
			ErrBindingTransitionRequired, active, fp)
	}
	if trusted, known := m.agentSourceTrusted[agentID]; known && !trusted {
		m.mu.Unlock()
		return nil, ErrBindingFenced
	}
	epoch := m.agentEpoch[agentID]
	m.inflightWG.Add(1)
	m.mu.Unlock()
	defer m.inflightWG.Done()

	r, err := m.construct(&snapshot)
	if err != nil {
		return nil, err
	}
	m.mu.Lock()
	m.constructing[agentID] = r
	trusted, known := m.agentSourceTrusted[agentID]
	m.mu.Unlock()
	if known && !trusted {
		r.SetSourceTrusted(false)
		_ = r.Close()
		return nil, ErrBindingFenced
	}
	defer func() {
		m.mu.Lock()
		if m.constructing[agentID] == r {
			delete(m.constructing, agentID)
		}
		m.mu.Unlock()
	}()
	if err := r.Start(ctx); err != nil {
		_ = r.Close()
		return nil, fmt.Errorf("start reconciler: %w", err)
	}
	if hook := m.snapshotTestHooks().beforeGetOrCreateInsert; hook != nil {
		hook(agentID, fp)
	}

	// Insert gate: re-check closed + epoch + dedup + transition + ctx
	// under mutex. Any failure closes the freshly-Started Reconciler
	// so its owner lock does not leak.
	m.mu.Lock()
	defer m.mu.Unlock()
	if err := ctx.Err(); err != nil {
		_ = r.Close()
		return nil, err
	}
	if m.closed {
		_ = r.Close()
		return nil, ErrManagerClosed
	}
	if m.agentEpoch[agentID] != epoch {
		_ = r.Close()
		return nil, ErrBindingFenced
	}
	if trusted, known := m.agentSourceTrusted[agentID]; known && !trusted {
		_ = r.Close()
		return nil, ErrBindingFenced
	}
	if h, ok := m.handles[fp]; ok {
		// Another GetOrCreate raced and landed first. Close ours and
		// return the survivor.
		_ = r.Close()
		return h.reconciler, nil
	}
	if active, ok := m.agentActiveFP[agentID]; ok && active != fp {
		_ = r.Close()
		return nil, fmt.Errorf("%w: active=%s requested=%s (post-Start)",
			ErrBindingTransitionRequired, active, fp)
	}
	m.handles[fp] = &managedHandle{fp: fp, agentID: agentID, reconciler: r}
	delete(m.retired, fp)
	m.agentActiveFP[agentID] = fp
	return r, nil
}

// construct builds a Reconciler from the Manager's shared options. The caller
// publishes it in constructing before Start so authoritative revocation can
// freeze publication throughout the pre-insert window.
func (m *Manager) construct(binding *Binding) (*Reconciler, error) {
	r, err := NewReconciler(ReconcilerOptions{
		Binding:           binding,
		Resolver:          m.opts.Resolver,
		Scan:              m.opts.Scan,
		Logger:            m.opts.Logger,
		Clock:             m.opts.Clock,
		BuildFunc:         m.opts.BuildFunc,
		BuildLimiter:      m.limiter,
		DiskUsageProvider: m.opts.DiskUsageProvider,
		PressurePolicy:    m.opts.PressurePolicy,
	})
	if err != nil {
		return nil, fmt.Errorf("construct reconciler: %w", err)
	}
	return r, nil
}

// agentMutex returns the lazily-allocated per-agent serialization mutex.
// The same *sync.Mutex is returned for the lifetime of the Manager for
// a given agentID.
func (m *Manager) agentMutex(agentID string) *sync.Mutex {
	v, _ := m.agentMu.LoadOrStore(agentID, &sync.Mutex{})
	return v.(*sync.Mutex)
}

// ActiveFingerprint returns the fingerprint of the agent's currently
// active binding, or "" if the agent has no active binding. Cheap;
// does not touch the Reconciler.
func (m *Manager) ActiveFingerprint(agentID string) string {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.closed {
		return ""
	}
	return m.agentActiveFP[agentID]
}

// ResolveBinding returns the *Binding for the agent's active fingerprint,
// or nil if the agent has no active binding. Cheap; does not touch the
// Reconciler.
func (m *Manager) ResolveBinding(agentID string) *Binding {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.closed {
		return nil
	}
	fp, ok := m.agentActiveFP[agentID]
	if !ok {
		return nil
	}
	h, ok := m.handles[fp]
	if !ok {
		return nil
	}
	return h.reconciler.Binding()
}

// ActiveReconciler returns the active per-agent source for daemon-local
// adapters. It never creates a handle and is nil after fence/revoke/close.
func (m *Manager) ActiveReconciler(agentID string) *Reconciler {
	m.mu.Lock()
	defer m.mu.Unlock()
	if m.closed {
		return nil
	}
	fp := m.agentActiveFP[agentID]
	if fp == "" {
		return nil
	}
	handle := m.handles[fp]
	if handle == nil {
		return nil
	}
	return handle.reconciler
}

// SetSourceTrusted updates the Manager-level authoritative gate and applies it
// to both landed and pre-insert Reconcilers. Missing gate state defaults true.
// Setting false also bumps the per-agent epoch so every in-flight insert
// self-cancels even if it has not yet exposed an active fingerprint.
func (m *Manager) SetSourceTrusted(agentID string, trusted bool) {
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return
	}
	var reconcilers []*Reconciler
	if r := m.constructing[agentID]; r != nil {
		reconcilers = append(reconcilers, r)
	}
	if fp := m.agentActiveFP[agentID]; fp != "" {
		if handle := m.handles[fp]; handle != nil {
			reconcilers = append(reconcilers, handle.reconciler)
		}
	}
	// Freeze/resume every visible Reconciler while m.mu still excludes the
	// construction and insert gates. Reconciler.SetSourceTrusted(false)
	// linearizes against publish via sourceMu, so after this call returns no
	// pre-insert writer can publish under the old trust decision.
	for _, reconciler := range reconcilers {
		reconciler.SetSourceTrusted(trusted)
	}
	m.agentSourceTrusted[agentID] = trusted
	if !trusted {
		m.agentEpoch[agentID]++
	}
	m.mu.Unlock()
}

func (m *Manager) FenceBinding(ctx context.Context, fingerprint string) error {
	return m.retireBinding(ctx, fingerprint, BindingLifecycleFenced, false)
}

func (m *Manager) MarkUnbound(ctx context.Context, fingerprint string) error {
	return m.retireBinding(ctx, fingerprint, BindingLifecycleUnbound, false)
}

func (m *Manager) MarkUnknownOwner(ctx context.Context, fingerprint string) error {
	return m.retireBinding(ctx, fingerprint, BindingLifecycleUnknownOwner, false)
}

func (m *Manager) PurgeBinding(ctx context.Context, fingerprint string) error {
	return m.retireBinding(ctx, fingerprint, BindingLifecycleFenced, true)
}

func (m *Manager) PurgeAgent(ctx context.Context, agentID string) error {
	if err := ctx.Err(); err != nil {
		return err
	}
	// Register and fence under m.mu. Close sets closed under the same mutex,
	// so it either excludes this purge or waits for its complete barrier.
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return ErrManagerClosed
	}
	m.lifecycleWG.Add(1)
	m.agentEpoch[agentID]++
	m.mu.Unlock()
	defer m.lifecycleWG.Done()
	if hook := m.snapshotTestHooks().afterLifecycleAdmission; hook != nil {
		hook("purge-agent", agentID)
	}

	// GetOrCreate holds agentMu across construct+Start+insert. Waiting here
	// drains every pre-fence slow path; its insert gate observes the epoch
	// bump and closes the freshly-started Reconciler.
	agentMu := m.agentMutex(agentID)
	agentMu.Lock()
	defer agentMu.Unlock()

	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return ErrManagerClosed
	}
	var handles []*managedHandle
	for fingerprint, handle := range m.handles {
		if handle.agentID != agentID {
			continue
		}
		handles = append(handles, handle)
		delete(m.handles, fingerprint)
	}
	for fingerprint, handle := range m.retired {
		if handle.agentID != agentID {
			continue
		}
		handles = append(handles, handle)
		delete(m.retired, fingerprint)
	}
	delete(m.agentActiveFP, agentID)
	m.mu.Unlock()

	var errs []error
	for _, handle := range handles {
		if err := handle.reconciler.Close(); err != nil {
			errs = append(errs, err)
		}
		// Close must precede the lifecycle write so an in-flight publish
		// cannot overwrite fenced back to active.
		if err := handle.reconciler.lifecycle.setBindingState(BindingLifecycleFenced); err != nil {
			errs = append(errs, err)
		}
	}
	workDir := m.opts.Resolver(agentID)
	if workDir != "" {
		if err := purgeDerivativeRoot(DerivativeRootFromWorkDir(workDir)); err != nil {
			errs = append(errs, err)
		}
	}
	return errors.Join(errs...)
}

func (m *Manager) retireBinding(
	ctx context.Context,
	fingerprint string,
	state BindingLifecycleState,
	purge bool,
) error {
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return ErrManagerClosed
	}
	handle := m.handles[fingerprint]
	if handle == nil {
		handle = m.retired[fingerprint]
	}
	if handle == nil {
		m.mu.Unlock()
		return nil
	}
	m.lifecycleWG.Add(1)
	agentMu := m.agentMutex(handle.agentID)
	m.mu.Unlock()
	defer m.lifecycleWG.Done()
	if hook := m.snapshotTestHooks().afterLifecycleAdmission; hook != nil {
		hook("retire-binding", handle.agentID)
	}
	agentMu.Lock()
	defer agentMu.Unlock()
	if err := ctx.Err(); err != nil {
		return err
	}

	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return ErrManagerClosed
	}
	handle = m.handles[fingerprint]
	if handle == nil {
		handle = m.retired[fingerprint]
	}
	if handle == nil {
		m.mu.Unlock()
		return nil
	}
	delete(m.handles, fingerprint)
	if m.agentActiveFP[handle.agentID] == fingerprint {
		delete(m.agentActiveFP, handle.agentID)
	}
	m.agentEpoch[handle.agentID]++
	if purge {
		delete(m.retired, fingerprint)
	} else {
		m.retired[fingerprint] = handle
	}
	m.mu.Unlock()

	var errs []error
	// Closing a writer/query owner is deliberately not cancellable: after the
	// handle is removed, returning while it still owns locks/readers would let
	// a new binding race the old owner. ctx was checked before the mutation;
	// from here the safety barrier must complete.
	if err := handle.reconciler.Close(); err != nil {
		errs = append(errs, err)
	}
	// Persist the lifecycle transition only after the reconciler stopped, then
	// abandon this binding's cancelled pending transaction under a fresh
	// cross-process owner lock. The fingerprint-CAS cleanup prevents a stale A
	// retirement from deleting a newer B journal after a binding switch.
	if err := handle.reconciler.finalizeBindingTransition(state); err != nil {
		errs = append(errs, err)
	}
	if len(errs) > 0 {
		return errors.Join(errs...)
	}
	if !purge {
		return nil
	}
	if err := purgeDerivativeRoot(handle.reconciler.derivativeRoot); err != nil {
		return err
	}
	return nil
}

func (m *Manager) maintenanceLoop() {
	defer close(m.maintenanceDone)
	interval := m.opts.MaintenanceInterval
	if interval < 0 {
		<-m.maintenanceStop
		return
	}
	if interval == 0 {
		interval = time.Hour
	}
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-m.maintenanceStop:
			return
		case <-ticker.C:
			if err := m.RunMaintenance(context.Background()); err != nil {
				logger := m.opts.Logger
				if logger == nil {
					logger = slog.Default()
				}
				logger.Warn("memorysearch: maintenance failed", "error", err)
			}
		}
	}
}

func (m *Manager) RunMaintenance(ctx context.Context) error {
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return ErrManagerClosed
	}
	m.lifecycleWG.Add(1)
	handles := make([]*managedHandle, 0, len(m.handles)+len(m.retired))
	for _, handle := range m.handles {
		handles = append(handles, handle)
	}
	for _, handle := range m.retired {
		handles = append(handles, handle)
	}
	m.mu.Unlock()
	defer m.lifecycleWG.Done()

	var errs []error
	for _, handle := range handles {
		agentMu := m.agentMutex(handle.agentID)
		agentMu.Lock()
		state, stateErr := handle.reconciler.lifecycle.load()
		if stateErr == nil && state.BindingState != BindingLifecycleActive &&
			bindingRetentionExpired(state, handle.reconciler.clock()) {
			if err := purgeDerivativeRoot(handle.reconciler.derivativeRoot); err != nil {
				errs = append(errs, fmt.Errorf("binding %s purge: %w", handle.fp, err))
			} else {
				m.mu.Lock()
				delete(m.retired, handle.fp)
				m.mu.Unlock()
			}
			agentMu.Unlock()
			continue
		}
		if err := handle.reconciler.RunRetentionGC(ctx); err != nil {
			errs = append(errs, fmt.Errorf("binding %s retention: %w", handle.fp, err))
		}
		if err := sweepTrash(handle.reconciler.derivativeRoot); err != nil {
			errs = append(errs, fmt.Errorf("binding %s trash: %w", handle.fp, err))
		}
		if hard, err := handle.reconciler.RelieveDiskPressure(ctx); err != nil {
			errs = append(errs, fmt.Errorf("binding %s pressure: %w", handle.fp, err))
		} else if hard {
			handle.reconciler.logger.WarnContext(ctx,
				"memorysearch: hard disk pressure remains after emergency reclaim",
				"binding", handle.fp)
		}
		agentMu.Unlock()
	}
	return errors.Join(errs...)
}

func bindingRetentionExpired(state *LifecycleState, now time.Time) bool {
	switch state.BindingState {
	case BindingLifecycleFenced:
		return now.Sub(state.BindingChangedAt) >= fencedBindingRetention
	case BindingLifecycleUnbound:
		return now.Sub(state.BindingChangedAt) >= unboundBindingRetention
	case BindingLifecycleUnknownOwner:
		return now.Sub(state.BindingChangedAt) >= unknownOwnerRetention
	default:
		return false
	}
}

func purgeDerivativeRoot(derivativeRoot string) error {
	for _, name := range []string{
		generationsDirName,
		trashDirName,
		currentFileName,
		pendingFileName,
		lifecycleFileName,
	} {
		if err := os.RemoveAll(filepath.Join(derivativeRoot, name)); err != nil {
			return err
		}
	}
	if err := syncDir(derivativeRoot); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	return nil
}

// Close stops every Reconciler the Manager owns and marks the Manager
// closed. It is idempotent and aggregates close errors via errors.Join.
//
// Close races with in-flight GetOrCreate: any slow path that has
// committed to Start but not yet reached the insert gate observes the
// closed flag (or the per-agent epoch bump) at the gate, closes its
// freshly-Started Reconciler, and returns ErrManagerClosed. Close
// waits on inflightWG before closing each handle so it observes a
// stable snapshot — without this wait a slow Start could complete and
// insert AFTER Close returns, leaking the Reconciler and the owner
// lock it holds.
func (m *Manager) Close() error {
	m.mu.Lock()
	if m.closed {
		m.mu.Unlock()
		return nil
	}
	m.closed = true
	close(m.maintenanceStop)
	handles := make([]*managedHandle, 0, len(m.handles))
	for _, h := range m.handles {
		handles = append(handles, h)
	}
	for _, h := range m.retired {
		handles = append(handles, h)
	}
	// Bump every agent's epoch so any in-flight GetOrCreate observes
	// the bump at its insert gate and self-cancels.
	for agentID := range m.agentActiveFP {
		m.agentEpoch[agentID]++
	}
	m.mu.Unlock()
	<-m.maintenanceDone

	// Wait for in-flight GetOrCreate slow paths. After closed=true and
	// the epoch bumps, each one observes the fence at its insert gate,
	// closes its freshly-Started reconciler, and Done()s. Without this
	// wait the handle close agentteam below could miss a late insertion.
	m.inflightWG.Wait()
	// Every lifecycle operation admitted before closed=true must finish (or
	// observe the second closed gate) before Close returns. This prevents an
	// old Manager from writing lifecycle.json after a replacement starts.
	m.lifecycleWG.Wait()

	var errs []error
	for _, h := range handles {
		if err := h.reconciler.Close(); err != nil {
			errs = append(errs, fmt.Errorf("agent %s binding %s: %w",
				h.agentID, h.fp, err))
		}
	}
	if m.opts.Logger != nil {
		m.opts.Logger.Info("memorysearch: manager closed",
			"handles", len(handles), "errors", len(errs))
	} else {
		slog.Default().Info("memorysearch: manager closed",
			"handles", len(handles), "errors", len(errs))
	}
	return errors.Join(errs...)
}
