//go:build !vectors

package memorysearch

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

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

// reconcileHarness assembles a fresh binding, derivative root, and a
// real Scan over a temp source clone. Tests plant files in resolvedRoot
// to drive reconcile; inject fake scans via ReconcilerOptions.Scan when
// they need drift simulation.
func reconcileHarness(t *testing.T) (*Reconciler, *reconcileFixture) {
	t.Helper()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ninitial body\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	fixture := &reconcileFixture{
		harness:    h,
		reconciler: r,
	}
	t.Cleanup(func() { _ = r.Close() })
	return r, fixture
}

type reconcileFixture struct {
	harness    *buildTestHarness
	reconciler *Reconciler
}

func TestReconcilerSearchStateFirstBuildAndPublishedReady(t *testing.T) {
	r, _ := reconcileHarness(t)
	require.Equal(t, StateReady, r.SearchState(""))

	r.Submit(context.Background())
	require.Equal(t, StateRefreshing, r.SearchState(""))

	r.stateMu.Lock()
	r.ackEpoch = r.dirtyEpoch
	r.currentBasename = "gen-a"
	r.stateMu.Unlock()
	require.Equal(t, StateReady, r.SearchState("gen-a"))
	require.Equal(t, StateRefreshing, r.SearchState("gen-old"))
}

func TestReconcilerDirtyBuildFailureStaysRefreshingUntilPublish(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ninitial body\n",
	})
	var fail atomic.Bool
	r, err := NewReconciler(ReconcilerOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Scan:     Scan,
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			if fail.Load() {
				return nil, errors.New("synthetic build failure")
			}
			return Build(ctx, opts)
		},
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })
	require.NoError(t, r.Start(context.Background()))
	initial := waitForServiceableCurrent(t, r, 5*time.Second)
	require.Eventually(t, func() bool {
		return r.SearchState(initial.GenerationBasename) == StateReady
	}, time.Second, 10*time.Millisecond)

	fail.Store(true)
	require.NoError(t, os.MkdirAll(filepath.Join(h.resolvedRoot, "knowledge"), 0o755))
	require.NoError(t, os.WriteFile(
		filepath.Join(h.resolvedRoot, "knowledge", "new.md"),
		[]byte("# New\n\nunindexed needle\n"), 0o644))
	r.MarkDirty(context.Background())
	require.Eventually(t, func() bool {
		return r.SearchState(initial.GenerationBasename) == StateRefreshing
	}, time.Second, 10*time.Millisecond)
	// A failed pass must not falsely clear the known-dirty state.
	require.Never(t, func() bool {
		return r.SearchState(initial.GenerationBasename) == StateReady
	}, 250*time.Millisecond, 10*time.Millisecond)

	fail.Store(false)
	r.CheckFreshness(context.Background())
	require.Eventually(t, func() bool {
		current, err := loadCurrent(r.derivativeRoot)
		return err == nil &&
			current.GenerationBasename != initial.GenerationBasename &&
			r.SearchState(current.GenerationBasename) == StateReady
	}, 5*time.Second, 20*time.Millisecond)
}

// waitForCurrent polls until loadCurrent succeeds with a matching
// fingerprint, or fails the test after a timeout. Reconcile is
// asynchronous; tests use this to wait for an eventual-consistency
// window.
func waitForCurrent(t *testing.T, r *Reconciler, timeout time.Duration) *Current {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		c, err := loadCurrent(r.derivativeRoot)
		if err == nil && c.BindingFingerprint == r.opts.Binding.Fingerprint() {
			return c
		}
		time.Sleep(10 * time.Millisecond)
	}
	c, err := loadCurrent(r.derivativeRoot)
	require.NoError(t, err, "current pointer never appeared")
	require.Equal(t, r.opts.Binding.Fingerprint(), c.BindingFingerprint)
	return c
}

// waitForServiceableCurrent polls until loadCurrent returns a pointer
// whose referenced generation passes verifyCurrentServiceable. Tests
// that plant a bogus current pointer use this (rather than
// waitForCurrent) because the bogus pointer satisfies waitForCurrent
// immediately without ever being rebuilt.
func waitForServiceableCurrent(t *testing.T, r *Reconciler, timeout time.Duration) *Current {
	t.Helper()
	deadline := time.Now().Add(timeout)
	for time.Now().Before(deadline) {
		c, err := loadCurrent(r.derivativeRoot)
		if err == nil && c.BindingFingerprint == r.opts.Binding.Fingerprint() {
			if err := r.verifyCurrentServiceable(context.Background(), c); err == nil {
				return c
			}
		}
		time.Sleep(20 * time.Millisecond)
	}
	c, err := loadCurrent(r.derivativeRoot)
	require.NoError(t, err, "current pointer never appeared")
	require.NoError(t, r.verifyCurrentServiceable(context.Background(), c),
		"current pointer never became serviceable")
	return c
}

func TestReconcilerPublishesCurrentGeneration(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	ctx := context.Background()
	require.NoError(t, r.Start(ctx))

	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEmpty(t, cur.GenerationBasename, "generation basename must be published")
	require.NotEmpty(t, cur.SourceRevision, "source revision must be recorded")

	// Current() must open the published generation read-only.
	gen, err := r.Current(ctx)
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index(), "opened generation must hold a reader handle")
	require.Equal(t, cur.SourceRevision, gen.Manifest().SourceRevision)

	// BUILD_COMPLETE marker present in the resolved path.
	marker := filepath.Join(gen.Dir(), buildCompleteMarker)
	_, err = os.Stat(marker)
	require.NoError(t, err, "BUILD_COMPLETE must be present in current generation")
}

func TestReconcilerCurrentSharesPoolAndCloseDrainsBorrow(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	require.NoError(t, r.Start(context.Background()))
	cur := waitForServiceableCurrent(t, r, 5*time.Second)

	first, err := r.Current(context.Background())
	require.NoError(t, err)
	second, err := r.Current(context.Background())
	require.NoError(t, err)
	require.Same(t, first.Index(), second.Index(),
		"same published basename must share one Bleve reader")
	require.Equal(t, cur.GenerationBasename, filepath.Base(first.Dir()))

	closeDone := make(chan error, 1)
	go func() { closeDone <- r.Close() }()
	require.Never(t, func() bool {
		select {
		case <-closeDone:
			return true
		default:
			return false
		}
	}, 100*time.Millisecond, 10*time.Millisecond,
		"Reconciler.Close must not close a reader under an active query")

	require.NoError(t, first.Close())
	require.NoError(t, second.Close())
	require.NoError(t, <-closeDone)

	_, err = r.Current(context.Background())
	require.ErrorIs(t, err, ErrClosed)

	probe, err := NewReconciler(ReconcilerOptions{
		Binding:  r.opts.Binding,
		Resolver: r.opts.Resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	require.NoError(t, probe.Start(context.Background()),
		"owner lock must release after all pooled readers drain")
	require.NoError(t, probe.Close())
}

func TestReconcilerCurrentTracksPublishedBasenameAndRetiresOldPool(t *testing.T) {
	t.Parallel()
	r, fixture := reconcileHarness(t)
	require.NoError(t, r.Start(context.Background()))
	firstCurrent := waitForServiceableCurrent(t, r, 5*time.Second)
	first, err := r.Current(context.Background())
	require.NoError(t, err)
	require.Equal(t, firstCurrent.GenerationBasename, filepath.Base(first.Dir()))

	identityPath := filepath.Join(fixture.harness.resolvedRoot, "system", "identity.md")
	require.NoError(t, os.WriteFile(identityPath,
		[]byte("# Identity\n\nupdated body\n"), 0o644))
	r.Submit(context.Background())
	require.Eventually(t, func() bool {
		current, err := loadCurrent(r.derivativeRoot)
		return err == nil && current.GenerationBasename != firstCurrent.GenerationBasename
	}, 5*time.Second, 20*time.Millisecond)

	second, err := r.Current(context.Background())
	require.NoError(t, err)
	require.NotEqual(t, filepath.Base(first.Dir()), filepath.Base(second.Dir()))
	require.NoError(t, first.Close())
	require.Eventually(t, func() bool {
		pooled, state, opening := r.pools.RetireForGC(firstCurrent.GenerationBasename)
		return pooled && !opening && state == poolClosed
	}, time.Second, 10*time.Millisecond)
	require.NoError(t, second.Close())
}

func TestReconcilerIdempotentOnSameRevision(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	ctx := context.Background()
	require.NoError(t, r.Start(ctx))

	first := waitForCurrent(t, r, 5*time.Second)

	// Submit a few more times without changing source content. The
	// current pointer must remain stable (same generation basename) and
	// no extra generation directories accumulate.
	for i := 0; i < 3; i++ {
		r.Submit(ctx)
		time.Sleep(50 * time.Millisecond)
	}

	// No new generations should be sitting around.
	entries, err := os.ReadDir(r.generationsDir)
	require.NoError(t, err)
	// At most: current + previous (rollback). After idempotent reruns,
	// there should be exactly 1.
	count := 0
	for _, e := range entries {
		if e.IsDir() {
			count++
		}
	}
	require.Equal(t, 1, count, "idempotent reruns must not stack generations")

	second := waitForCurrent(t, r, 2*time.Second)
	require.Equal(t, first.GenerationBasename, second.GenerationBasename,
		"current pointer must not change on idempotent rerun")
}

// TestReconcilerDropsStaleCandidateOnDrift exercises codex3's re-verify
// constraint. A fake Scan returns revision A on the pre-build call, then
// revision B on the post-build re-Scan. The reconciler must drop the
// candidate built against A and never publish it.
func TestReconcilerDropsStaleCandidateOnDrift(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ninitial\n",
	})

	// Capture real snapshots by calling the real Scan. The fake returns
	// snapshot A first, then snapshot B for every subsequent call. The
	// re-Scan inside runOnce will return B, drift will be detected, and
	// the candidate built against A will be dropped.
	realA, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)

	// Mutate the source to produce a different revision for snapshot B.
	require.NoError(t, os.WriteFile(
		filepath.Join(h.resolvedRoot, "system/identity.md"),
		[]byte("# Identity\n\nchanged\n"), 0o644))
	realB, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)
	require.NotEqual(t, realA.Revision, realB.Revision,
		"test setup: source mutation must produce different revisions")

	calls := 0
	driftScan := func(_ context.Context, _ string) (SourceSnapshot, ScanStatus, error) {
		calls++
		if calls == 1 {
			return realA, ScanStatus{}, nil
		}
		return realB, ScanStatus{}, nil
	}

	r, err := NewReconciler(ReconcilerOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Scan:     driftScan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	require.NoError(t, r.Start(context.Background()))

	// After convergence the published revision must be B (or a later
	// value), never A. A candidate built against A would have been
	// dropped at the re-verify gate.
	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.Equal(t, realB.Revision, cur.SourceRevision,
		"current must reflect post-drift revision, not the stale one")
}

// TestReconcilerLockRejectsSecondOwner verifies TryAcquireOwnerLock is
// non-blocking. A second Reconciler on the same derivative root must
// fail fast with ErrOwnerLockBusy; it must never wait.
func TestReconcilerLockRejectsSecondOwner(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	require.NoError(t, r.Start(context.Background()))
	waitForCurrent(t, r, 5*time.Second)

	// Second Reconciler over the same derivative root.
	r2, err := NewReconciler(ReconcilerOptions{
		Binding:  r.opts.Binding,
		Resolver: r.opts.Resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	start := time.Now()
	err = r2.Start(context.Background())
	elapsed := time.Since(start)
	require.ErrorIs(t, err, ErrOwnerLockBusy)
	require.Less(t, elapsed, 2*time.Second,
		"second Start must fail fast; got %v", elapsed)
	_ = r2.Close()
}

// TestReconcilerCloseReleasesLock confirms Close frees the second owner
// to acquire.
func TestReconcilerCloseReleasesLock(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	require.NoError(t, r.Start(context.Background()))
	waitForCurrent(t, r, 5*time.Second)
	require.NoError(t, r.Close())

	// Second Reconciler must now acquire the lock and serve.
	r2, err := NewReconciler(ReconcilerOptions{
		Binding:  r.opts.Binding,
		Resolver: r.opts.Resolver,
		Scan:     Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r2.Close() })
	require.NoError(t, r2.Start(context.Background()))
	waitForCurrent(t, r2, 5*time.Second)
}

// --- Recovery tests ---

// plantPending writes a journal by hand to simulate a crashed owner.
func plantPending(t *testing.T, r *Reconciler, p Pending) {
	t.Helper()
	require.NoError(t, writePendingAtomic(r.derivativeRoot, p))
}

// plantCurrent writes a current pointer by hand.
func plantCurrent(t *testing.T, r *Reconciler, c Current) {
	t.Helper()
	require.NoError(t, writeCurrentAtomic(r.derivativeRoot, c))
}

// TestRecoveryScanningPhaseClearsAndKeepsCurrent plants a journal from a
// crashed scanning phase. There is no candidate to clean up and the
// previous current pointer (if any) stays serviceable.
func TestRecoveryScanningPhaseClearsAndKeepsCurrent(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	buildEntered := make(chan struct{}, 1)
	buildRelease := make(chan struct{})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
		BuildFunc: func(ctx context.Context, opts BuildOptions) (*Generation, error) {
			select {
			case buildEntered <- struct{}{}:
			default:
			}
			select {
			case <-buildRelease:
				return Build(ctx, opts)
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		},
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a pre-existing current so we can verify recovery preserves it.
	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "preexisting-gen",
		SourceRevision:     "preexisting-rev",
		PublishedAt:        time.Now(),
	})
	plantPending(t, r, Pending{
		BindingFingerprint: h.binding.Fingerprint(),
		TargetRevision:     "would-have-been",
		Phase:              PendingPhaseScanning,
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	// Start immediately enqueues a fresh reconcile. Hold that pass inside
	// Build so a new journal is definitely present when we verify recovery.
	select {
	case <-buildEntered:
	case <-time.After(5 * time.Second):
		t.Fatal("fresh reconcile did not reach Build")
	}
	pending, loadErr := loadPending(r.derivativeRoot)
	require.NoError(t, loadErr)
	require.NotEqual(t, "would-have-been", pending.TargetRevision,
		"scanning-phase recovery must clear the crashed journal")
	close(buildRelease)

	// Daemon startup will run a fresh reconcile, so eventually a new
	// current pointer lands. The point is that recovery did not crash
	// and did not destroy the previous pointer before reconcile.
	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEmpty(t, cur.GenerationBasename)
}

// TestRecoveryBuildingPhaseDropsPartialCandidate plants a journal from
// a crashed building phase with a half-built candidate directory
// (no BUILD_COMPLETE marker). Recovery must drop the partial directory.
func TestRecoveryBuildingPhaseDropsPartialCandidate(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a partial generation directory (no BUILD_COMPLETE).
	partial := filepath.Join(r.generationsDir, "partial-gen")
	require.NoError(t, os.MkdirAll(filepath.Join(partial, indexDirName), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(partial, manifestFilename),
		[]byte("{}"), 0o644))

	plantPending(t, r, Pending{
		BindingFingerprint: h.binding.Fingerprint(),
		TargetRevision:     "partial-rev",
		Phase:              PendingPhaseBuilding,
		CandidateBasename:  "partial-gen",
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	// The partial directory must be gone.
	_, err = os.Stat(partial)
	require.ErrorIs(t, err, os.ErrNotExist,
		"building-phase recovery must drop the partial candidate")

	// Start immediately enqueues a fresh reconcile, which may create a new
	// pending journal before this assertion runs. Verify that recovery removed
	// the crashed build journal without racing the new pass.
	if pending, loadErr := loadPending(r.derivativeRoot); loadErr == nil {
		require.NotEqual(t, "partial-rev", pending.TargetRevision)
		require.NotEqual(t, "partial-gen", pending.CandidateBasename)
	} else {
		require.ErrorIs(t, loadErr, os.ErrNotExist)
	}

	waitForCurrent(t, r, 5*time.Second)
}

// TestRecoveryPublishingPhaseRepublishesWhenMarkerPresent plants a
// publishing-phase journal whose candidate has BUILD_COMPLETE and a
// matching revision. Recovery must finish the publish.
func TestRecoveryPublishingPhaseRepublishesWhenMarkerPresent(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})

	// Build a real generation by hand so it has BUILD_COMPLETE + a
	// valid manifest with a known revision.
	snap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)
	chunks := mustChunkAll(t, h, snap)
	gen, err := Build(context.Background(), BuildOptions{
		Binding: h.binding, Resolver: h.resolver,
		Snapshot: snap, Chunks: chunks,
	})
	require.NoError(t, err)

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a publishing-phase journal pointing at the built generation.
	plantPending(t, r, Pending{
		BindingFingerprint: h.binding.Fingerprint(),
		TargetRevision:     snap.Revision,
		Phase:              PendingPhasePublishing,
		CandidateBasename:  filepath.Base(gen.Dir()),
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	// Recovery must have re-published immediately (before any goroutine
	// activity). Eventually a current pointer exists.
	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.Equal(t, filepath.Base(gen.Dir()), cur.GenerationBasename,
		"recovery must re-publish the completed candidate")
	require.Equal(t, snap.Revision, cur.SourceRevision)
}

// TestRecoveryPublishingPhaseDropsWhenMarkerAbsent plants a publishing-
// phase journal whose candidate is missing BUILD_COMPLETE. Recovery
// must treat it as a build that crashed before the marker write and
// drop it.
func TestRecoveryPublishingPhaseDropsWhenMarkerAbsent(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant an unmarked candidate directory.
	partial := filepath.Join(r.generationsDir, "unmarked-gen")
	require.NoError(t, os.MkdirAll(filepath.Join(partial, indexDirName), 0o755))

	plantPending(t, r, Pending{
		BindingFingerprint: h.binding.Fingerprint(),
		TargetRevision:     "target-rev",
		Phase:              PendingPhasePublishing,
		CandidateBasename:  "unmarked-gen",
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	_, err = os.Stat(partial)
	require.ErrorIs(t, err, os.ErrNotExist,
		"publishing-phase recovery must drop unmarked candidate")

	waitForCurrent(t, r, 5*time.Second)
}

// TestRecoveryClearingPhaseFinishesGC plants a clearing-phase journal
// with multiple stale generations. Recovery must finish GC.
func TestRecoveryClearingPhaseFinishesGC(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Create the candidate that was being published + 3 stale failed
	// generations. Failed/temp generations are retained for one hour, so age
	// them beyond that frozen retention boundary before recovery.
	require.NoError(t, os.MkdirAll(filepath.Join(r.generationsDir, "current-gen"), 0o755))
	staleAt := time.Now().Add(-failedGenerationRetention - time.Minute)
	for _, name := range []string{"stale-1", "stale-2", "stale-3"} {
		dir := filepath.Join(r.generationsDir, name)
		require.NoError(t, os.MkdirAll(dir, 0o755))
		require.NoError(t, os.Chtimes(dir, staleAt, staleAt))
	}

	plantPending(t, r, Pending{
		BindingFingerprint: h.binding.Fingerprint(),
		TargetRevision:     "final-rev",
		Phase:              PendingPhaseClearing,
		CandidateBasename:  "current-gen",
		PreviousBasename:   "",
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	// Stale generations must be gone.
	for _, name := range []string{"stale-1", "stale-2", "stale-3"} {
		_, err := os.Stat(filepath.Join(r.generationsDir, name))
		require.ErrorIs(t, err, os.ErrNotExist,
			"clearing-phase recovery must remove stale generation %s", name)
	}

	waitForCurrent(t, r, 5*time.Second)
}

// TestRecoveryRejectsJournalFromDifferentBinding plants a journal whose
// fingerprint does not match the current binding. Recovery must refuse
// rather than destroy state belonging to a previous binding.
func TestRecoveryRejectsJournalFromDifferentBinding(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	otherFingerprint := "some-other-binding-fingerprint"
	require.NoError(t, writeJSONAtomic(
		filepath.Join(h.derivativeRoot, lifecycleFileName),
		LifecycleState{
			SchemaVersion:      lifecycleSchemaVersion,
			BindingFingerprint: otherFingerprint,
			BindingState:       BindingLifecycleActive,
			BindingChangedAt:   time.Now(),
			Generations:        map[string]GenerationLifecycle{},
		},
	))
	plantPending(t, r, Pending{
		BindingFingerprint: otherFingerprint,
		TargetRevision:     "x",
		Phase:              PendingPhaseScanning,
		StartedAt:          time.Now(),
		UpdatedAt:          time.Now(),
	})

	err = r.Start(context.Background())
	require.ErrorIs(t, err, ErrRecoveryBindingMismatch,
		"an active lifecycle is not retirement proof for a different binding")
	pending, loadErr := loadPending(h.derivativeRoot)
	require.NoError(t, loadErr)
	require.Equal(t, otherFingerprint, pending.BindingFingerprint,
		"failed recovery must preserve the unexplained journal")
}

// TestRecoveryDiscardsJournalFromDurablyRetiredBinding covers the crash window
// between the durable lifecycle fence and pending.json removal. A replacement
// binding may discard the old journal only when lifecycle.json is valid,
// non-active, and names that exact old fingerprint.
func TestRecoveryDiscardsJournalFromDurablyRetiredBinding(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	oldBinding := h.binding
	replacement, err := NewBinding(
		h.resolver,
		oldBinding.LocalAgentID,
		"mem_replacement",
		oldBinding.Branch,
		oldBinding.AnalyzerVersion,
		oldBinding.ChunkerVersion,
	)
	require.NoError(t, err)
	r, err := NewReconciler(ReconcilerOptions{
		Binding: replacement, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	now := time.Now()
	require.NoError(t, writeJSONAtomic(
		filepath.Join(h.derivativeRoot, lifecycleFileName),
		LifecycleState{
			SchemaVersion:      lifecycleSchemaVersion,
			BindingFingerprint: oldBinding.Fingerprint(),
			BindingState:       BindingLifecycleFenced,
			BindingChangedAt:   now,
			Generations:        map[string]GenerationLifecycle{},
		},
	))
	plantPending(t, r, Pending{
		BindingFingerprint: oldBinding.Fingerprint(),
		TargetRevision:     "abandoned-old-revision",
		Phase:              PendingPhaseScanning,
		StartedAt:          now,
		UpdatedAt:          now,
	})
	retiredPartial := filepath.Join(r.generationsDir, "retired-partial")
	require.NoError(t, os.MkdirAll(retiredPartial, 0o755))
	staleAt := now.Add(-failedGenerationRetention - time.Minute)
	require.NoError(t, os.Chtimes(retiredPartial, staleAt, staleAt))

	require.NoError(t, r.Start(context.Background()),
		"replacement must recover past a journal proven retired")
	require.NoDirExists(t, retiredPartial,
		"retired-journal recovery must retain the normal orphan sweep")
	if pending, loadErr := loadPending(h.derivativeRoot); loadErr == nil {
		require.Equal(t, replacement.Fingerprint(), pending.BindingFingerprint,
			"replacement may create a new journal, but old journal must be gone")
	} else {
		require.ErrorIs(t, loadErr, os.ErrNotExist)
	}
}

// mustChunkAll reads every file in the snapshot and chunks it. Test
// helper for cases that need a real chunk inventory outside the harness.
func mustChunkAll(t *testing.T, h *buildTestHarness, snap SourceSnapshot) []SearchChunk {
	t.Helper()
	var chunks []SearchChunk
	for _, sf := range snap.Files {
		data, err := os.ReadFile(filepath.Join(h.resolvedRoot, sf.Path))
		require.NoError(t, err)
		chunks = append(chunks, Chunk(ChunkInput{
			Path: sf.Path, Kind: sf.Kind, Format: sf.Format,
			Archived: sf.Archived, ContentHash: sf.ContentHash,
			SourceRevision: snap.Revision, Data: data,
		})...)
	}
	return chunks
}

// Compile-time guard: ensure errors import is exercised even when only
// a subset of tests runs.
var _ = errors.Is

// --- Round 6 regression tests ---

// TestRecoveryRejectsTraversalCandidate is the mandatory regression for
// blocker 6. A forged journal with CandidateBasename = "../../memory"
// must not let recovery turn into a delete of the source clone. The
// test plants a real source file, plants a malicious journal, and
// asserts both that Start returns/contains the failure AND that the
// source file is still present with its original content afterwards.
//
// Two phases are exercised: building (which removes the candidate via
// filepath.Join(generationsDir, basename)) and publishing (same). Both
// would resolve to <derivativeRoot>/generations/../../memory = the
// source clone and delete it without the validateBasename gate.
func TestRecoveryRejectsTraversalCandidate(t *testing.T) {
	t.Parallel()
	for _, phase := range []PendingPhase{PendingPhaseBuilding, PendingPhasePublishing} {
		phase := phase
		t.Run(string(phase), func(t *testing.T) {
			t.Parallel()
			h := newBuildTestHarness(t, map[string]string{
				"system/identity.md": "# Identity\n\noriginal body\n",
			})
			// Snapshot the source body so we can prove recovery did
			// not touch it.
			sourceFile := filepath.Join(h.resolvedRoot, "system/identity.md")
			originalBody, err := os.ReadFile(sourceFile)
			require.NoError(t, err)

			// Hand-craft the malicious journal directly on disk so we
			// bypass writePendingAtomic's own validateBasename gate
			// (recovery must defend against journals an older binary or
			// an attacker wrote, not just against our own writer).
			raw := `{"binding_fingerprint":"` + h.binding.Fingerprint() + `",` +
				`"target_revision":"x","phase":"` + string(phase) + `",` +
				`"candidate_basename":"../../memory",` +
				`"started_at":"2026-01-01T00:00:00Z",` +
				`"updated_at":"2026-01-01T00:00:00Z"}`
			require.NoError(t, os.WriteFile(
				filepath.Join(r_derivativeRoot(h), pendingFileName),
				[]byte(raw), 0o644))

			r, err := NewReconciler(ReconcilerOptions{
				Binding: h.binding, Resolver: h.resolver, Scan: Scan,
			})
			require.NoError(t, err)
			t.Cleanup(func() { _ = r.Close() })

			startErr := r.Start(context.Background())
			// Recovery must refuse the journal (no destructive action).
			require.ErrorIs(t, startErr, ErrRecoveryInvalidJournal,
				"phase=%s: recovery must reject a journal with a traversal candidate basename", phase)

			// Source file must still exist, unchanged.
			gotBody, err := os.ReadFile(sourceFile)
			require.NoError(t, err, "source file must survive forged journal")
			require.Equal(t, originalBody, gotBody,
				"source content must be unchanged after forged journal")
		})
	}
}

// r_derivativeRoot extracts the derivative root from the harness. It
// mirrors DerivativeRootFromWorkDir(resolver(agentID)) without forcing
// the test to import build.go helpers.
func r_derivativeRoot(h *buildTestHarness) string {
	return DerivativeRootFromWorkDir(h.resolver(h.binding.LocalAgentID))
}

// TestCurrentBasenameForOpenRejectsTraversal covers blocker 6 for the
// read path. A forged current.json with GenerationBasename = "../../memory"
// must not turn into opening a directory outside generations/. We write
// the JSON directly to disk so the test bypasses writeCurrentAtomic's
// own validateBasename gate — CurrentBasenameForOpen must defend on the
// load side regardless of what wrote the file.
func TestCurrentBasenameForOpenRejectsTraversal(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	derivativeRoot := r_derivativeRoot(h)
	raw := `{"binding_fingerprint":"` + h.binding.Fingerprint() + `",` +
		`"generation_basename":"../../memory",` +
		`"source_revision":"x",` +
		`"published_at":"2026-01-01T00:00:00Z"}`
	require.NoError(t, os.WriteFile(
		filepath.Join(derivativeRoot, currentFileName),
		[]byte(raw), 0o644))

	_, err = CurrentBasenameForOpen(context.Background(), derivativeRoot, h.binding.Fingerprint())
	require.ErrorIs(t, err, ErrNotReady,
		"CurrentBasenameForOpen must reject traversal basename")
}

// TestReconcilerStartIdempotent covers blocker 1. Two Start calls on
// the same Reconciler must both succeed without re-acquiring the lock
// or launching a second goroutine.
func TestReconcilerStartIdempotent(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	ctx := context.Background()
	require.NoError(t, r.Start(ctx))
	waitForCurrent(t, r, 5*time.Second)
	require.NoError(t, r.Start(ctx), "second Start must be a no-op nil")
}

// TestReconcilerStartAfterCloseReturnsErrClosed covers blocker 1.
// Start after Close must return ErrClosed rather than attempting to
// re-acquire the lock on a half-torn-down Reconciler.
func TestReconcilerStartAfterCloseReturnsErrClosed(t *testing.T) {
	t.Parallel()
	r, _ := reconcileHarness(t)
	ctx := context.Background()
	require.NoError(t, r.Start(ctx))
	waitForCurrent(t, r, 5*time.Second)
	require.NoError(t, r.Close())
	require.ErrorIs(t, r.Start(ctx), ErrClosed, "Start after Close must return ErrClosed")
}

// TestReconcilerCloseCancelsInFlightPass covers blocker 1. With a
// blocking Scan, Close must unblock the in-flight pass via the owned
// context, wait for the goroutine to exit, and only then release the
// cross-process lock so the second owner can immediately acquire it.
func TestReconcilerCloseCancelsInFlightPass(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})

	scanStarted := make(chan struct{})
	releaseScan := make(chan struct{})
	blockingScan := func(ctx context.Context, root string) (SourceSnapshot, ScanStatus, error) {
		select {
		case scanStarted <- struct{}{}:
		case <-ctx.Done():
			return SourceSnapshot{}, ScanStatus{}, ctx.Err()
		}
		select {
		case <-releaseScan:
			return Scan(ctx, root)
		case <-ctx.Done():
			return SourceSnapshot{}, ScanStatus{}, ctx.Err()
		}
	}

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: blockingScan,
	})
	require.NoError(t, err)

	require.NoError(t, r.Start(context.Background()))
	// Wait for the in-flight Scan to start so we know Close is
	// cancelling a real pass, not racing an idle goroutine.
	select {
	case <-scanStarted:
	case <-time.After(2 * time.Second):
		t.Fatal("blocking Scan never started")
	}

	closeStart := time.Now()
	require.NoError(t, r.Close())
	closeElapsed := time.Since(closeStart)
	// Close must return promptly — the owned ctx cancellation should
	// unblock the Scan immediately. 2s is generous; the goal is to
	// catch a regression where Close waits on a 10s+ timeout.
	require.Less(t, closeElapsed, 2*time.Second,
		"Close must cancel the in-flight pass, not wait for it to finish")

	// Second owner must now acquire the lock immediately, proving the
	// first owner released it after the writer exited.
	r2, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r2.Close() })
	require.NoError(t, r2.Start(context.Background()))
	// releaseScan so r2's reconcile can progress (the blockingScan is
	// still wired into r; r2 uses real Scan, but releasing is harmless).
	close(releaseScan)
	waitForCurrent(t, r2, 5*time.Second)
}

// TestReconcilerDegradedScanDoesNotPublish covers blocker 4. A scan
// that returns Degraded=true must NOT cause a partial snapshot to be
// published. A pre-existing current pointer must remain serviceable
// so search continues to work against the last good generation.
func TestReconcilerDegradedScanDoesNotPublish(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)
	degradedScan := func(ctx context.Context, _ string) (SourceSnapshot, ScanStatus, error) {
		return realSnap, ScanStatus{Degraded: true}, nil
	}
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: degradedScan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a serviceable previous current pointer so we can prove the
	// reconciler leaves it alone. We do not need a real generation
	// directory: the test only checks the pointer survives, not that
	// OpenGeneration works.
	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "preexisting-serviceable-gen",
		SourceRevision:     "preexisting-rev",
		PublishedAt:        time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))
	// Give the reconciler time to attempt a pass and observe the
	// degraded failure.
	time.Sleep(500 * time.Millisecond)

	cur, err := loadCurrent(r.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, "preexisting-serviceable-gen", cur.GenerationBasename,
		"degraded scan must not overwrite the previous current pointer")
	require.Equal(t, "preexisting-rev", cur.SourceRevision,
		"degraded scan must not overwrite the previous revision")
}

// TestReconcilerLimitedScanDoesNotPublish covers blocker 4. Limited
// alone (Degraded=false) must also fail-closed: a truncated snapshot
// would silently drop content from search results.
func TestReconcilerLimitedScanDoesNotPublish(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)
	limitedScan := func(ctx context.Context, _ string) (SourceSnapshot, ScanStatus, error) {
		return realSnap, ScanStatus{Limited: true}, nil
	}
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: limitedScan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "preexisting-serviceable-gen",
		SourceRevision:     "preexisting-rev",
		PublishedAt:        time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))
	time.Sleep(500 * time.Millisecond)

	cur, err := loadCurrent(r.derivativeRoot)
	require.NoError(t, err)
	require.Equal(t, "preexisting-serviceable-gen", cur.GenerationBasename,
		"limited scan must not overwrite the previous current pointer")
}

// TestReconcilerSweepsOrphanPartialDir covers blocker 2. A partial
// generation directory (no BUILD_COMPLETE) left from a crashed build
// must be swept after the failed/temp retention window, even when no journal
// references it.
func TestReconcilerSweepsOrphanPartialDir(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	orphan := filepath.Join(r.generationsDir, "orphan-from-crashed-build")
	require.NoError(t, os.MkdirAll(filepath.Join(orphan, indexDirName), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(orphan, manifestFilename),
		[]byte("{}"), 0o644))
	staleAt := time.Now().Add(-failedGenerationRetention - time.Minute)
	require.NoError(t, os.Chtimes(orphan, staleAt, staleAt))

	require.NoError(t, r.Start(context.Background()))

	// Orphan must be gone after recovery sweeps partials. Use a poll
	// because recovery runs before the goroutine launches and Start
	// returns; once Start returns the sweep has completed.
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		if _, err := os.Stat(orphan); errors.Is(err, os.ErrNotExist) {
			return
		}
		time.Sleep(20 * time.Millisecond)
	}
	_, err = os.Stat(orphan)
	require.ErrorIs(t, err, os.ErrNotExist,
		"recovery must sweep orphan partial generation directories")
}

// TestReconcilerChunkReadDetectsHashDrift covers blocker 5. A snapshot
// is taken, then source content is mutated before the chunk read
// runs. The reconciler must detect the hash mismatch and refuse to
// publish a candidate built against stale hashes.
func TestReconcilerChunkReadDetectsHashDrift(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ninitial\n",
	})
	// Lock the snapshot at "initial" content.
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)
	frozenScan := func(ctx context.Context, _ string) (SourceSnapshot, ScanStatus, error) {
		// Always return the initial snapshot, regardless of the live
		// source state. The chunk read will see mutated bytes and
		// the hash check must reject them.
		return realSnap, ScanStatus{}, nil
	}
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: frozenScan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	require.NoError(t, r.Start(context.Background()))

	// Mutate the source so the chunk read hashes do not match the
	// frozen snapshot's recorded hashes.
	require.NoError(t, os.WriteFile(
		filepath.Join(h.resolvedRoot, "system/identity.md"),
		[]byte("# Identity\n\nmutated by attacker\n"), 0o644))

	// Submit a few times and wait long enough for the chunk-read guard
	// to fire. The reconciler must never publish a generation built
	// from mutated bytes against a stale hash.
	for i := 0; i < 3; i++ {
		r.Submit(context.Background())
		time.Sleep(100 * time.Millisecond)
	}

	// Either no current pointer exists (no publish ever landed), or
	// the published pointer is for the original revision. There must
	// NOT be a current pointer claiming the frozen snapshot's revision
	// with chunks built from mutated bytes.
	cur, err := loadCurrent(r.derivativeRoot)
	if err == nil {
		require.NotEqual(t, realSnap.Revision, cur.SourceRevision,
			"hash drift must prevent publishing a candidate built against mutated bytes")
	}
	// Sanity: source content is what we wrote.
	got, err := os.ReadFile(filepath.Join(h.resolvedRoot, "system/identity.md"))
	require.NoError(t, err)
	require.Contains(t, string(got), "mutated by attacker")
}

// TestReconcilerClearingJournalObservable covers blocker 3. The
// durable clearing-phase journal is the recovery contract for crashes
// mid-GC. The contract is already exercised end-to-end by
// TestRecoveryClearingPhaseFinishesGC, which plants a clearing-phase
// journal and asserts recovery finishes the sweep. A happy-path
// observability test would require hooking the GC step (otherwise the
// window between "phase=clearing written" and "clearPending" is too
// narrow to sample reliably); we rely on the recovery test instead.
//
// This stub documents that decision and prevents a future contributor
// from re-adding a brittle timing test.
func TestReconcilerClearingJournalObservable(t *testing.T) {
	t.Skip("contract covered by TestRecoveryClearingPhaseFinishesGC; " +
		"happy-path observation requires a GC hook (see blocker 3 notes)")
}

// TestReconcilerDrainBoundsTwoPasses covers blocker 9. With an
// always-drifting scan, runPassWithDrain must not busy-agentteam. We count
// scan invocations across a single external trigger burst and assert
// the count is bounded.
func TestReconcilerDrainBoundsTwoPasses(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	// Always drift: every scan returns a snapshot with a unique
	// revision that does not match the source, forcing ErrDirtyDuringBuild
	// at the chunk-read stage every pass.
	counter := uint64(0)
	driftScan := func(ctx context.Context, _ string) (SourceSnapshot, ScanStatus, error) {
		n := atomic.AddUint64(&counter, 1)
		// Fabricate a snapshot with a unique revision; the chunk read
		// will hash real source bytes, which will not match the
		// fabricated ContentHash, forcing ErrDirtyDuringBuild.
		snap, _, err := Scan(ctx, h.resolvedRoot)
		if err != nil {
			return snap, ScanStatus{}, err
		}
		snap.Revision = fmt.Sprintf("drift-%d", n)
		// Tamper with one file's hash so snapshotChunks detects drift
		// without depending on real source mutation timing.
		for path, sf := range snap.Files {
			sf.ContentHash = fmt.Sprintf("fake-hash-%d", n)
			snap.Files[path] = sf
		}
		return snap, ScanStatus{}, nil
	}
	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: driftScan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	require.NoError(t, r.Start(context.Background()))
	// Wait for the initial Submit's drain burst to complete.
	time.Sleep(500 * time.Millisecond)
	first := atomic.LoadUint64(&counter)

	// Fire one external trigger; the drain burst should bound scan
	// calls to at most 2 more (pass 1 + drift retry pass 2).
	r.Submit(context.Background())
	time.Sleep(500 * time.Millisecond)
	second := atomic.LoadUint64(&counter)

	delta := second - first
	require.LessOrEqual(t, delta, uint64(3),
		"single external trigger must not cause unbounded scan calls; got %d", delta)
}

// --- Round 7 readiness regression tests ---

// TestReconcilerRebuildsWhenCurrentDirMissing covers the round-7
// readiness blocker. A current pointer references a generation that
// does not exist on disk. Without the serviceability check the
// reconciler would no-op on every pass because the SourceRevision
// matches the source, leaving Current() permanently ErrNotReady. The
// serviceability gate must detect the missing dir and force a rebuild.
func TestReconcilerRebuildsWhenCurrentDirMissing(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a current pointer whose GenerationBasename does not exist
	// under generations/, but whose SourceRevision matches the source.
	// The reconciler must rebuild rather than no-op.
	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "missing-generation",
		SourceRevision:     realSnap.Revision,
		PublishedAt:        time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	// The reconciler must publish a real generation whose basename is
	// NOT "missing-generation" (i.e., it rebuilt) and whose revision
	// matches the source.
	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEqual(t, "missing-generation", cur.GenerationBasename,
		"missing generation dir must force a rebuild, not a no-op")
	require.Equal(t, realSnap.Revision, cur.SourceRevision)

	// And the rebuilt generation must be openable (the rebuild produced
	// a serviceable generation, not another broken pointer).
	gen, err := r.Current(context.Background())
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index())
}

// TestReconcilerRebuildsWhenMarkerMissing covers the round-7 readiness
// blocker for the partial-build case. A current pointer references a
// generation directory that exists but lacks BUILD_COMPLETE. The
// reconciler must rebuild rather than trust the partial dir.
func TestReconcilerRebuildsWhenMarkerMissing(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a generation directory WITHOUT BUILD_COMPLETE and a current
	// pointer at it with a matching revision. The reconciler must
	// detect the missing marker and rebuild.
	partial := filepath.Join(r.generationsDir, "markerless-gen")
	require.NoError(t, os.MkdirAll(filepath.Join(partial, indexDirName), 0o755))
	staleAt := time.Now().Add(-failedGenerationRetention - time.Minute)
	require.NoError(t, os.Chtimes(partial, staleAt, staleAt))
	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "markerless-gen",
		SourceRevision:     realSnap.Revision,
		PublishedAt:        time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEqual(t, "markerless-gen", cur.GenerationBasename,
		"missing BUILD_COMPLETE marker must force a rebuild")

	// The partial dir must eventually be reclaimed by GC after the
	// rebuild publishes a replacement.
	deadline := time.Now().Add(3 * time.Second)
	for time.Now().Before(deadline) {
		if _, err := os.Stat(partial); errors.Is(err, os.ErrNotExist) {
			break
		}
		time.Sleep(50 * time.Millisecond)
	}
	_, err = os.Stat(partial)
	require.ErrorIs(t, err, os.ErrNotExist,
		"partial markerless generation must be GC'd after rebuild publishes a replacement")
}

// TestReconcilerRebuildsWhenManifestCorrupt covers the round-7
// readiness blocker for the corrupt-manifest case. A current pointer
// references a generation directory that has BUILD_COMPLETE but whose
// manifest.json is unparseable. The reconciler must rebuild rather
// than trust the corrupt manifest.
func TestReconcilerRebuildsWhenManifestCorrupt(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})
	realSnap, _, err := Scan(context.Background(), h.resolvedRoot)
	require.NoError(t, err)

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// Plant a complete-looking generation with a corrupt manifest.
	// BUILD_COMPLETE is present, index dir is non-empty, but
	// manifest.json is unparseable JSON.
	corrupt := filepath.Join(r.generationsDir, "corrupt-manifest-gen")
	require.NoError(t, os.MkdirAll(filepath.Join(corrupt, indexDirName), 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(corrupt, indexDirName, "segment"),
		[]byte("fake segment"), 0o644))
	require.NoError(t, os.WriteFile(filepath.Join(corrupt, manifestFilename),
		[]byte("{not valid json"), 0o644))
	// Hand-write BUILD_COMPLETE the same way Build does.
	require.NoError(t, os.WriteFile(filepath.Join(corrupt, buildCompleteMarker),
		[]byte(buildCompleteMarker), 0o644))
	plantCurrent(t, r, Current{
		BindingFingerprint: h.binding.Fingerprint(),
		GenerationBasename: "corrupt-manifest-gen",
		SourceRevision:     realSnap.Revision,
		PublishedAt:        time.Now(),
	})

	require.NoError(t, r.Start(context.Background()))

	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEqual(t, "corrupt-manifest-gen", cur.GenerationBasename,
		"corrupt manifest must force a rebuild")

	// Sanity: the rebuilt current pointer really is serviceable.
	gen, err := r.Current(context.Background())
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index())
}

// TestReconcilerRebuildsWhenIndexIsJunk covers the round-8 serviceability
// blocker. A non-empty index directory is NOT proof that the Bleve index
// can be opened: an attacker (or a torn write that left the directory
// populated but missing root metadata) can leave junk files behind. The
// previous "index dir non-empty" heuristic would no-op on this state
// while Current() returns bleve open failed. The reconciler must actually
// call bleve.Open (via OpenGeneration) and rebuild on failure.
func TestReconcilerRebuildsWhenIndexIsJunk(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// First start: let the reconciler build a real, serviceable
	// generation. We then replace its index directory with junk and
	// Submit to verify the next pass detects bleve.Open failure.
	require.NoError(t, r.Start(context.Background()))
	first := waitForServiceableCurrent(t, r, 5*time.Second)
	genDir := filepath.Join(r.generationsDir, first.GenerationBasename)
	// waitForServiceableCurrent now warms a pooled reader. Retire it before
	// simulating out-of-process disk corruption so the next reconcile smoke
	// performs a fresh open of the corrupted index.
	r.pools.MarkRetire(first.GenerationBasename)

	// Replace the index with a non-empty junk directory: same shape
	// codex3 reproduced. The marker and manifest remain valid, so the
	// only the bleve.Open smoke test catches this.
	idxDir := filepath.Join(genDir, indexDirName)
	require.NoError(t, os.RemoveAll(idxDir))
	require.NoError(t, os.MkdirAll(idxDir, 0o755))
	require.NoError(t, os.WriteFile(filepath.Join(idxDir, "fake-segment"),
		[]byte("not a bleve index"), 0o644))

	// Sanity: bleve.Open now fails on this directory.
	probe, perr := OpenGeneration(context.Background(), r.opts.Binding, r.opts.Resolver, genDir)
	if perr == nil {
		_ = probe.Close()
		t.Fatalf("precondition failed: bleve.Open still succeeds after junking the index directory")
	}

	r.Submit(context.Background())

	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEqual(t, first.GenerationBasename, cur.GenerationBasename,
		"junk-only index directory must force a rebuild, not a no-op")

	gen, err := r.Current(context.Background())
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index())
}

// TestReconcilerRebuildsWhenIndexMetadataTorn covers the round-8
// "metadata missing" variant. The Bleve index directory retains its
// segment file but loses its root metadata (index_meta.json + the
// scorch store root). This simulates a torn write or an external
// truncation; bleve.Open fails closed, so the reconciler must rebuild.
func TestReconcilerRebuildsWhenIndexMetadataTorn(t *testing.T) {
	t.Parallel()
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\nx\n",
	})

	r, err := NewReconciler(ReconcilerOptions{
		Binding: h.binding, Resolver: h.resolver, Scan: Scan,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = r.Close() })

	// First start: let the reconciler build a real, serviceable
	// generation. We then corrupt it in place and Submit to verify the
	// next pass detects the torn metadata.
	require.NoError(t, r.Start(context.Background()))
	first := waitForServiceableCurrent(t, r, 5*time.Second)
	genDir := filepath.Join(r.generationsDir, first.GenerationBasename)
	r.pools.MarkRetire(first.GenerationBasename)

	// Tear the index: drop index_meta.json and the scorch store root
	// metadata, but leave the segment zap so the directory is non-empty
	// (defeating the old heuristic) and obviously-populated.
	require.NoError(t, os.Remove(filepath.Join(genDir, indexDirName, "index_meta.json")))
	storeRoot := filepath.Join(genDir, indexDirName, "store", "root.bolt")
	require.NoError(t, os.Remove(storeRoot), "expected scorch store root metadata at %s", storeRoot)

	// Sanity: the corruption really does make bleve.Open fail before we
	// rely on the reconciler to notice.
	probe, perr := OpenGeneration(context.Background(), r.opts.Binding, r.opts.Resolver, genDir)
	if perr == nil {
		_ = probe.Close()
		t.Fatalf("precondition failed: bleve.Open still succeeds after tearing index metadata")
	}

	// Re-trigger reconcile. The next pass must detect the index is no
	// longer openable and rebuild.
	r.Submit(context.Background())

	cur := waitForServiceableCurrent(t, r, 5*time.Second)
	require.NotEqual(t, first.GenerationBasename, cur.GenerationBasename,
		"torn index metadata must force a rebuild, not a no-op")

	gen, err := r.Current(context.Background())
	require.NoError(t, err)
	defer gen.Close()
	require.NotNil(t, gen.Index())
}
