//go:build !vectors

package memorysearch

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"testing"

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

// staticSearchSource opens a fresh Generation on every Current() call to
// match the Reconciler contract — Search() closes the Generation it receives,
// so a source that hands out the same pointer twice would return degraded
// on the second call.
//
// binding is exposed via Binding() so Search() can populate SourceRoot on
// degraded responses without needing a live Reconciler (codex3 round 5
// secondary 2). Tests may override binding to nil to exercise the
// no-binding path.
type staticSearchSource struct {
	opener    func(context.Context) (*Generation, error)
	submits   int
	allowOpen bool
	binding   *Binding
	marked    []string
	markErr   error
	state     SearchState
}

func (s *staticSearchSource) Current(ctx context.Context) (*Generation, error) {
	if !s.allowOpen || s.opener == nil {
		return nil, ErrNotReady
	}
	return s.opener(ctx)
}

func (s *staticSearchSource) Submit(ctx context.Context) {
	s.submits++
}

func (s *staticSearchSource) Binding() *Binding {
	return s.binding
}

func (s *staticSearchSource) MarkQuerySuccess(_ context.Context, basename string) error {
	s.marked = append(s.marked, basename)
	return s.markErr
}

func (s *staticSearchSource) SearchState(string) SearchState {
	if s.state == "" {
		return StateReady
	}
	return s.state
}

// searchHarness assembles a source clone with bilingual content and
// builds a generation. The Build-returned Generation has a nil Bleve index
// (Build closes its writer before returning), so the harness does NOT hold
// the bbolt flock — each Search call opens a fresh reader via the opener
// and closes it before returning.
type searchHarness struct {
	harness      *buildTestHarness
	genDir       string
	src          *staticSearchSource
	resolvedRoot string
}

func newSearchHarness(t testing.TB, files map[string]string) *searchHarness {
	t.Helper()
	h := newBuildTestHarness(t, files)
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	require.NoError(t, err)
	// Build's Generation has index=nil — Close is a no-op, but we register
	// cleanup anyway for symmetry. Critically, we do NOT call OpenGeneration
	// here: doing so would hold the bbolt flock and cause the opener's
	// OpenGeneration to hang.
	t.Cleanup(func() { _ = gen.Close() })

	genDir := gen.Dir()
	src := &staticSearchSource{
		opener: func(ctx context.Context) (*Generation, error) {
			return OpenGeneration(ctx, h.binding, h.resolver, genDir)
		},
		allowOpen: true,
		binding:   h.binding,
	}
	return &searchHarness{
		harness:      h,
		genDir:       genDir,
		src:          src,
		resolvedRoot: h.resolvedRoot,
	}
}

func bilingualCorpus() map[string]string {
	return map[string]string{
		"MEMORY.md": "# Memory Index\n\nTop-level index for the agent workspace.\n",
		"system/identity.md": "# Identity\n\nAgent role: backend engineer for the AgentTeam project.\n" +
			"Owner: 龙恒 (@yw1knp7). Primary channel: #engineering.\n",
		"system/goals.md": "# Goals\n\nShip the memory search feature (PR #2731).\n" +
			"中文目标：完善 Memory 搜索的查询层和源验证。\n",
		"knowledge/concepts/migration-audit.md": "# Migration Audit\n\n" +
			"重号问题：PR #2731 和 PR #2732 在 migration audit 中产生冲突。\n" +
			"See knowledge/workflows/pr-and-merge.md for the merge workflow.\n",
		"knowledge/concepts/identifier-extraction.md": "# Identifier Extraction\n\n" +
			"The query layer performs identifier extraction: #2731, @handle, and https://example.com/url " +
			"are extracted via the ASCII-leaning regex set in identifiers.go.\n" +
			"识别符抽取：支持 #编号、@handle、URL 等多种 ASCII 模式。\n",
		"knowledge/workflows/pr-and-merge.md": "# PR Workflow\n\n" +
			"Open the PR, request review from @yw1knp7, and merge after LGTM.\n" +
			"工作流：开 PR → 评审 → 合并。\n",
		"events/2026-07-15-kickoff.md": "# Kickoff 2026-07-15\n\n" +
			"Attendees: 龙恒, @looper-codex3. Decided to scope Task 6 strictly to memorysearch.\n",
		"events/2026-07-16-standup.md": "# Standup 2026-07-16\n\n" +
			"Status: query.go and search.go written. Next: validate.go + snippet.go + tests.\n",
		"reflections/insights.md": "# Insights\n\n" +
			"Single composite BooleanQuery is the only way to keep BM25 scores comparable.\n" +
			"反思：不要把 raw BM25 score 跨查询直接比较。\n",
		"reflections/decisions.md": "# Decisions\n\n" +
			"V1 returns three match categories: exactIdentifier, exactPath, lexical. " +
			"phrase is intentionally absent because body has IncludeTermVectors=false.\n",
		"profiles/user.md":       "# User Profile\n\n龙恒偏好中文沟通，代码、PR、commit 使用英文。\n",
		"archive/old-feature.md": "# Archived\n\nLegacy feature notes.\n",
		"notes/raw.txt":          "Free-form notes. Mention #2731 once.\n",
	}
}

// runSearchReq is a test helper that runs Search and fails on error.
func runSearchReq(t *testing.T, src SearchSource, req SearchRequest) SearchResponse {
	t.Helper()
	resp, err := Search(context.Background(), src, req)
	require.NoError(t, err)
	return resp
}

func resultPaths(resp SearchResponse) []string {
	out := make([]string, 0, len(resp.Results))
	for _, r := range resp.Results {
		out = append(out, r.Path)
	}
	return out
}

func containsPath(paths []string, want string) bool {
	for _, p := range paths {
		if p == want {
			return true
		}
	}
	return false
}

// TestSearchExactIdentifierPreferredOverLexical: a query like "#2731" must
// match the exact identifier route and return the migration audit concept
// that carries that identifier, ahead of any lexical-only hits.
func TestSearchExactIdentifierPreferredOverLexical(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.NotEmpty(t, resp.Results, "expected hits for #2731")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/migration-audit.md"),
		"migration-audit.md must be in results: %v", resultPaths(resp))
	require.Equal(t, StateReady, resp.State)
}

// TestSearchBilingualChineseMatchesBody: a CJK query must match body text
// via the Bleve cjk analyzer's bigram path. The lexical route is the only
// path that can fire for a pure-CJK query.
func TestSearchBilingualChineseMatchesBody(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "重号", Limit: 5})
	require.NotEmpty(t, resp.Results, "expected hits for 重号")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/migration-audit.md"),
		"migration-audit.md must match 重号: %v", resultPaths(resp))
}

func TestSearchBilingualEnglishMatchesBody(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "identifier extraction", Limit: 5})
	require.NotEmpty(t, resp.Results, "expected hits for identifier extraction")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/identifier-extraction.md"),
		"identifier-extraction.md must match: %v", resultPaths(resp))
}

// TestSearchHandleExtractsAndMatches: a query "@yw1knp7" must hit the
// identifier route and return chunks mentioning the handle.
func TestSearchHandleExtractsAndMatches(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "@yw1knp7", Limit: 5})
	require.NotEmpty(t, resp.Results, "expected hits for @yw1knp7")
	paths := resultPaths(resp)
	require.True(t,
		containsPath(paths, "system/identity.md") ||
			containsPath(paths, "knowledge/workflows/pr-and-merge.md"),
		"expected handle-bearing chunk in results: %v", paths)
}

// TestSearchNoConfidentMatchReturnsReason: a query with no overlap to the
// corpus must return StateReady with ReasonNoConfidentMatch and no results.
func TestSearchNoConfidentMatchReturnsReason(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "xylophone gazmoopley", Limit: 5})
	require.Equal(t, StateReady, resp.State)
	require.Empty(t, resp.Results)
	require.Equal(t, ReasonNoConfidentMatch, resp.Reason)
}

func TestSearchRefreshingUsesValidatedOldGeneration(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.state = StateRefreshing
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.Equal(t, StateRefreshing, resp.State)
	require.True(t, resp.Stale)
	require.Equal(t, ReasonResultsMayBeIncomplete, resp.Reason)
	require.NotEmpty(t, resp.Results)
	require.Empty(t, h.src.marked, "refreshing results must not establish first-query success")
}

func TestSearchSupersededBorrowReportsRefreshing(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.state = StateRefreshing
	resp := runSearchReq(t, h.src, SearchRequest{Query: "xylophone gazmoopley", Limit: 5})
	require.Equal(t, StateRefreshing, resp.State)
	require.True(t, resp.Stale)
	require.Equal(t, ReasonResultsMayBeIncomplete, resp.Reason)
}

func TestSearchMarksFreshSuccessfulGeneration(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.False(t, resp.Stale)
	require.NotEmpty(t, resp.Results)
	require.Equal(t, []string{filepath.Base(h.genDir)}, h.src.marked)
}

func TestSearchMarksFreshNoConfidentMatch(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query: "definitely-absent-memory-token",
		Limit: 5,
	})
	require.False(t, resp.Stale)
	require.Equal(t, ReasonNoConfidentMatch, resp.Reason)
	require.Equal(t, []string{filepath.Base(h.genDir)}, h.src.marked)
}

func TestSearchDoesNotMarkAllStaleResponse(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	for rel := range bilingualCorpus() {
		require.NoError(t, os.WriteFile(
			filepath.Join(h.resolvedRoot, rel),
			[]byte("# Rewritten\n\nAll new content.\n"),
			0o644,
		))
	}
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.True(t, resp.Stale)
	require.Empty(t, h.src.marked)
}

func TestSearchIgnoresGenerationSupersededDuringSuccessMark(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.markErr = ErrQueryGenerationSuperseded
	resp, err := Search(context.Background(), h.src,
		SearchRequest{Query: "#2731", Limit: 5})
	require.NoError(t, err)
	require.NotEmpty(t, resp.Results)
}

func TestSearchIgnoresLifecycleWriteFailure(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.markErr = errors.New("lifecycle disk failed")
	resp, err := Search(context.Background(), h.src,
		SearchRequest{Query: "#2731", Limit: 5})
	require.NoError(t, err)
	require.NotEmpty(t, resp.Results)
}

// TestSearchShortGenericQueryIsNotConfident: a query of a single short
// generic word ("the") must NOT be confident because lexical confidence
// requires query-term coverage and rejects single short generic terms.
func TestSearchShortGenericQueryIsNotConfident(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	// "workflow" is a real word in the corpus but appears in many chunks;
	// confidence should still be granted because it's not in the stopword
	// set. We verify the stopword path separately with a clearly generic term.
	resp := runSearchReq(t, h.src, SearchRequest{Query: "the", Limit: 5})
	require.Equal(t, StateReady, resp.State)
	require.Empty(t, resp.Results, "single short generic term must not be confident")
	require.Equal(t, ReasonNoConfidentMatch, resp.Reason)
}

// TestSearchDegradedWhenIndexUnavailable: when Current returns ErrNotReady,
// Search returns StateDegraded with ReasonIndexUnavailable.
func TestSearchDegradedWhenIndexUnavailable(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.allowOpen = false
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "anything", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, StateDegraded, resp.State)
	require.True(t, resp.Stale)
	require.Equal(t, ReasonIndexUnavailable, resp.Reason)
}

// TestSearchInvalidQueryRejected: query outside [2, 512] chars or containing
// control chars returns ErrInvalidSearchRequest.
func TestSearchInvalidQueryRejected(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	cases := map[string]SearchRequest{
		"empty":        {Query: "", Limit: 5},
		"single char":  {Query: "x", Limit: 5},
		"nul byte":     {Query: "ab\x00cd", Limit: 5},
		"control char": {Query: "ab\x01cd", Limit: 5},
	}
	for name, req := range cases {
		t.Run(name, func(t *testing.T) {
			_, err := Search(context.Background(), h.src, req)
			require.ErrorIs(t, err, ErrInvalidSearchRequest)
		})
	}
}

// TestSearchLimitClampedToMax: a request with Limit=100 must be clamped to
// maxSearchLimit and still return a valid response.
func TestSearchLimitClampedToMax(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "memory", Limit: 100})
	require.Equal(t, StateReady, resp.State)
	require.LessOrEqual(t, len(resp.Results), maxSearchLimit)
}

// TestSearchPathPrefixSegmentAware: PathPrefix "knowledge/concepts" must
// return only concept chunks, and must NOT return other knowledge paths
// that merely share the leading characters.
func TestSearchPathPrefixSegmentAware(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:      "identifier",
		Limit:      5,
		PathPrefix: "knowledge/concepts",
	})
	for _, r := range resp.Results {
		require.True(t, strings.HasPrefix(r.Path, "knowledge/concepts/"),
			"result %q outside prefix", r.Path)
	}
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/identifier-extraction.md"),
		"identifier-extraction.md expected under concepts prefix: %v", resultPaths(resp))
}

// TestSearchPathPrefixRejectsTraversal: path prefix with "..", backslash,
// or absolute path must be rejected as invalid request.
func TestSearchPathPrefixRejectsTraversal(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	for _, prefix := range []string{"/abs", "../escape", "with\\slash", "a/./b"} {
		_, err := Search(context.Background(), h.src, SearchRequest{
			Query:      "x",
			Limit:      5,
			PathPrefix: prefix,
		})
		require.ErrorIs(t, err, ErrInvalidSearchRequest, "prefix %q must be rejected", prefix)
	}
}

// TestSearchKindsFilter narrows results to one Kind.
func TestSearchKindsFilter(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query: "yw1knp7",
		Limit: 8,
		Kinds: []string{SourceKindSystem},
	})
	for _, r := range resp.Results {
		require.Equal(t, SourceKindSystem, r.Kind, "expected only system kind: got %q in %q", r.Kind, r.Path)
	}
}

// TestSearchArchivedExcludedByDefault: archive/* chunks must not appear
// unless IncludeArchived=true.
func TestSearchArchivedExcludedByDefault(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	// "Archived" appears in archive/old-feature.md title.
	resp := runSearchReq(t, h.src, SearchRequest{Query: "Archived Legacy feature", Limit: 5})
	for _, r := range resp.Results {
		require.False(t, r.Archived, "archived chunk acked without IncludeArchived: %q", r.Path)
	}
}

func TestSearchArchivedIncludedWhenRequested(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:           "Archived Legacy feature",
		Limit:           5,
		IncludeArchived: true,
	})
	// Either we got the archived chunk, or no confident match (corpus term
	// overlap may be thin). Assert at least that the call succeeds and the
	// state is ready.
	require.Equal(t, StateReady, resp.State)
}

// TestSearchMaliciousQueryCharsRejected: NUL, control chars, and invalid
// UTF-8 are rejected by validateSearchRequest.
func TestSearchMaliciousQueryCharsRejected(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	invalid := []byte{0xff, 0xfe, 'a', 'b'}
	_ = invalid
	cases := map[string]SearchRequest{
		"nul":            {Query: "ab\x00cd"},
		"bell":           {Query: "ab\x07cd"},
		"line feed only": {Query: "\n"},
	}
	for name, req := range cases {
		t.Run(name, func(t *testing.T) {
			req.Limit = 5
			_, err := Search(context.Background(), h.src, req)
			require.ErrorIs(t, err, ErrInvalidSearchRequest)
		})
	}
}

func TestValidateSearchRequestUsesAgentFocusedDefaults(t *testing.T) {
	req, err := validateSearchRequest(SearchRequest{Query: "memory search"})
	require.NoError(t, err)
	require.Equal(t, 3, req.Limit)
	require.Equal(t, 1600, req.MaxChars)
}

// TestSearchStaleHitDroppedOnModify: when a source file changes AFTER
// the build but BEFORE the search, source validation must drop the stale
// hit and set Stale=true, enqueuing a reconcile.
func TestSearchStaleHitDroppedOnModify(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	// Modify the file post-build. The build-time ContentHash no longer
	// matches the on-disk bytes; source validation must flag stale.
	target := filepath.Join(h.resolvedRoot, "knowledge/concepts/migration-audit.md")
	require.NoError(t, os.WriteFile(target, []byte("# Migration Audit\n\nRewritten body.\n"), 0o644))

	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.True(t, resp.Stale, "response must be flagged stale after source mutation")
	// The dirty enqueue must fire exactly once per request, regardless of
	// how many candidates were stale.
	require.Equal(t, 1, h.src.submits, "expected single coalesced Submit, got %d", h.src.submits)
}

// TestSearchStaleHitDroppedOnDelete: when a source file is deleted after
// the build, source validation fails the confined read and treats the hit
// as stale.
func TestSearchStaleHitDroppedOnDelete(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	target := filepath.Join(h.resolvedRoot, "knowledge/concepts/migration-audit.md")
	require.NoError(t, os.Remove(target))

	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.True(t, resp.Stale, "response must be flagged stale after source delete")
	require.Equal(t, 1, h.src.submits, "expected single coalesced Submit, got %d", h.src.submits)
}

// TestSearchStaleHitDroppedOnSymlinkSwap: when a source file is replaced
// with an outside-pointing symlink, the confined os.Root.Open must reject
// the escape and the hit is flagged stale.
func TestSearchStaleHitDroppedOnSymlinkSwap(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	target := filepath.Join(h.resolvedRoot, "knowledge/concepts/migration-audit.md")
	outside := t.TempDir()
	secret := filepath.Join(outside, "secret.md")
	require.NoError(t, os.WriteFile(secret, []byte("outside body\n"), 0o644))
	require.NoError(t, os.Remove(target))
	require.NoError(t, os.Symlink(secret, target))

	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	// Either the confined read rejected the swap (stale=true, secret not
	// acked) or the response carries no results at all. The secret body
	// must never appear in any snippet.
	require.True(t, resp.Stale, "symlink swap must report stale")
	for _, r := range resp.Results {
		require.NotContains(t, r.Snippet, "outside body",
			"symlink swap escaped confinement in %q", r.Path)
	}
}

// TestSearchBudgetTruncation: when MaxChars is small enough that all
// snippets cannot fit, applyBudget must set Truncated=true and shorten
// at least one snippet.
func TestSearchBudgetTruncation(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "the memory search workflow",
		Limit:    8,
		MaxChars: minSearchMaxChars, // 1000 — forces truncation when results fill up
	})
	if len(resp.Results) == 0 {
		t.Skip("no confident matches; cannot exercise truncation")
	}
	// Truncated must be true IF any snippet was actually clipped. When all
	// snippets fit naturally, Truncated stays false.
	totalChars := 0
	for _, r := range resp.Results {
		totalChars += len([]rune(r.Snippet))
	}
	if totalChars > minSearchMaxChars && !resp.Truncated {
		t.Errorf("expected Truncated=true with totalChars=%d", totalChars)
	}
}

// TestSearchResultSnippetWithinPerResultBudget: a single result's snippet
// must never exceed maxPerResultSnippet runes.
func TestSearchResultSnippetWithinPerResultBudget(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "memory",
		Limit:    5,
		MaxChars: maxSearchMaxChars,
	})
	for i, r := range resp.Results {
		rc := len([]rune(r.Snippet))
		if rc > maxPerResultSnippet {
			t.Errorf("result[%d] %q snippet=%d runes exceeds per-result cap %d",
				i, r.Path, rc, maxPerResultSnippet)
		}
	}
}

// TestSearchResultHasLocatorFields: every result must carry non-empty Path,
// Kind, MatchType, MatchLines, and valid ReadLines (End >= Start, Start >= 1).
func TestSearchResultHasLocatorFields(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "PR workflow", Limit: 5})
	require.NotEmpty(t, resp.Results)
	for i, r := range resp.Results {
		require.NotEmpty(t, r.Path, "result[%d] missing path", i)
		require.NotEmpty(t, r.Kind, "result[%d] missing kind", i)
		require.NotEmpty(t, r.MatchType, "result[%d] missing matchType", i)
		require.GreaterOrEqual(t, r.MatchLines.Start, 1, "result[%d] matchLines.Start", i)
		require.GreaterOrEqual(t, r.MatchLines.End, r.MatchLines.Start, "result[%d] matchLines inverted", i)
		require.GreaterOrEqual(t, r.ReadLines.Start, 1, "result[%d] readLines.Start", i)
		require.GreaterOrEqual(t, r.ReadLines.End, r.ReadLines.Start, "result[%d] readLines inverted", i)
		require.NotEmpty(t, r.Snippet, "result[%d] missing snippet", i)
	}
}

// TestSearchSourceRootExposed: the response exposes a SourceRoot pointing
// at the resolved Memory root so the caller can do bounded reads after
// selecting hits.
func TestSearchSourceRootExposed(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Equal(t, sourceRootKindCloudMemory, resp.SourceRoot.Kind)
	require.Equal(t, h.resolvedRoot, resp.SourceRoot.AbsolutePath)
}

// TestSearchContextCancelReturnsCanceled: when the caller's context is
// already canceled, Search returns ctx.Err() wrapped.
func TestSearchContextCancelReturnsCanceled(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err := Search(ctx, h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Error(t, err)
}

// TestSearchQueryShapeIsPath: when the query itself is a path-shaped
// identifier, the path-exact route may fire and the top result should be
// the matching file.
func TestSearchQueryShapeIsPath(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{
		Query: "knowledge/workflows/pr-and-merge.md",
		Limit: 5,
	})
	// Path exact may or may not produce a hit depending on the index, but
	// the response state must be ready and the call must succeed.
	require.Equal(t, StateReady, resp.State)
}

// TestSearchMaxCharsClampedToValidRange: a caller-supplied MaxChars of 0
// or out-of-range must be clamped, not rejected.
func TestSearchMaxCharsClampedToValidRange(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	for _, mc := range []int{0, 10, 100, 999, 100000} {
		resp := runSearchReq(t, h.src, SearchRequest{
			Query:    "memory",
			Limit:    3,
			MaxChars: mc,
		})
		require.Equal(t, StateReady, resp.State, "MaxChars=%d broke state", mc)
	}
}

// TestSearchKindsDeduplicated: duplicate kinds in the request are collapsed
// without changing the result set. We use a query that has a single clear
// hit under the system kind so the result set is deterministic.
func TestSearchKindsDeduplicated(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	respA := runSearchReq(t, h.src, SearchRequest{
		Query: "backend engineer", Limit: 5, Kinds: []string{"system"},
	})
	respB := runSearchReq(t, h.src, SearchRequest{
		Query: "backend engineer", Limit: 5, Kinds: []string{"system", "SYSTEM", " system "},
	})
	require.ElementsMatch(t, resultPaths(respA), resultPaths(respB))
}

// TestSearchKindsLimitEnforced: >maxKindsFilter kinds must be rejected.
func TestSearchKindsLimitEnforced(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	kinds := make([]string, 0, maxKindsFilter+1)
	for i := 0; i <= maxKindsFilter; i++ {
		kinds = append(kinds, "k"+string(rune('a'+i)))
	}
	_, err := Search(context.Background(), h.src, SearchRequest{
		Query: "memory", Limit: 5, Kinds: kinds,
	})
	require.ErrorIs(t, err, ErrInvalidSearchRequest)
}

// TestSearchWorksOnEmptyCorpus: with an empty source clone, Search
// returns StateReady with ReasonNoConfidentMatch.
func TestSearchWorksOnEmptyCorpus(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"MEMORY.md": "# Empty\n\nNo real content.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "anything substantive", Limit: 5})
	require.Equal(t, StateReady, resp.State)
	require.Empty(t, resp.Results)
}

// TestSearchResultMatchTypeCategorization: verify that the three V1 match
// types are the only ones returned.
func TestSearchResultMatchTypeCategorization(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	queries := []string{"#2731", "@yw1knp7", "memory search", "workflow"}
	for _, q := range queries {
		resp := runSearchReq(t, h.src, SearchRequest{Query: q, Limit: 5})
		for _, r := range resp.Results {
			switch r.MatchType {
			case MatchTypeExactIdentifier, MatchTypeExactPath, MatchTypeLexical:
				// ok
			default:
				t.Errorf("query %q produced unknown matchType %q on %q",
					q, r.MatchType, r.Path)
			}
		}
	}
}

// TestSearchReadinessIsTheOnlyErrorPath verifies the codex3 round 5 error
// model:
//   - Request validation errors → ErrInvalidSearchRequest
//   - Context cancellation → ctx error returned as-is
//   - Readiness sentinels (ErrNotReady, ErrGenerationPartial,
//     ErrGenerationOutsideRoot) → StateDegraded, no error
//   - Unexpected Current errors → wrapped non-nil error returned
//
// Previously the API silently degraded on ANY Current error, hiding forged
// manifests / IO / permission failures from the caller. Only the three
// readiness sentinels may degrade; everything else is returned.
func TestSearchReadinessIsTheOnlyErrorPath(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())

	// Baseline: ready path returns no error.
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, StateReady, resp.State)

	// Readiness sentinel → degraded, no error.
	h.src.allowOpen = false
	resp, err = Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, StateDegraded, resp.State)
	require.Equal(t, ReasonIndexUnavailable, resp.Reason)

	// Unexpected error → returned as wrapped non-nil error, NOT degraded.
	h.src.allowOpen = true
	h.src.opener = func(ctx context.Context) (*Generation, error) {
		return nil, errors.New("synthetic forged manifest")
	}
	resp, err = Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Error(t, err)
	require.Equal(t, SearchResponse{}, resp, "unexpected errors must return zero response, not degraded")
}

// TestSearchRechunkCacheHitsOncePerFile: when multiple candidates reference
// the same file, rechunk happens at most once.
func TestSearchRechunkCacheHitsOncePerFile(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	// Multiple chunks under one file would exercise the cache. The external
	// observable is just that the search succeeds; correctness of the cache
	// is a performance assertion we verify by not observing redundant reads.
	resp := runSearchReq(t, h.src, SearchRequest{Query: "memory search workflow", Limit: 8})
	require.Equal(t, StateReady, resp.State)
}

// TestSearchEmptyGeneration: when the generation has zero chunks (empty
// source clone with no admitted files), Search must return degraded or
// ready-no-match rather than crash.
func TestSearchEmptyGeneration(t *testing.T) {
	// Plant a source clone with only excluded files so Scan admits none.
	h := newSearchHarness(t, map[string]string{
		".git/HEAD": "ref: refs/heads/main\n",
		"README":    "no extension\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "anything", Limit: 5})
	// No indexed chunks means no candidates; the response is ready with no
	// confident match.
	require.Equal(t, StateReady, resp.State)
	require.Empty(t, resp.Results)
}

// TestSearchGenerationWithNilIndex is a defensive check that a Generation
// with nil Index() (e.g. produced by Build without OpenGeneration) returns
// as degraded rather than panicking.
func TestSearchGenerationWithNilIndex(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	ctx := context.Background()
	snap, chunks := h.harness.scanAndChunk(ctx, t)
	buildOnly, err := Build(ctx, BuildOptions{
		Binding:  h.harness.binding,
		Resolver: h.harness.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	require.NoError(t, err)
	t.Cleanup(func() { _ = buildOnly.Close() })
	// Override the opener to return a Build-only Generation (Index() == nil).
	h.src.opener = func(ctx context.Context) (*Generation, error) {
		return buildOnly, nil
	}
	resp := runSearchReq(t, h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Equal(t, StateDegraded, resp.State)
	require.Equal(t, ReasonIndexUnavailable, resp.Reason)
}

// TestSearchStaleReasonIsResultsMayBeIncomplete: when every candidate is
// stale, the reason must be ReasonResultsMayBeIncomplete (not noConfidentMatch).
func TestSearchStaleReasonIsResultsMayBeIncomplete(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	// Rewrite every file the index knows about so every candidate is stale.
	stamp := []byte("# Rewritten\n\nAll new content.\n")
	for rel := range bilingualCorpus() {
		_ = os.WriteFile(filepath.Join(h.resolvedRoot, rel), stamp, 0o644)
	}
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	if len(resp.Results) > 0 {
		t.Skip("some candidates survived; cannot assert all-stale reason")
	}
	require.True(t, resp.Stale)
	require.Equal(t, ReasonResultsMayBeIncomplete, resp.Reason)
}

// TestSearchContextCancelInsideRun is hard to deterministically trigger,
// so we assert only the pre-check path: an already-canceled context
// returns the ctx error.
func TestSearchContextCancelInsideRun(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, err := Search(ctx, h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Error(t, err)
	require.True(t, errors.Is(err, context.Canceled) || err != nil)
}

// TestSearchQueryWithUnicodePunctuation: queries that carry CJK punctuation
// (，。！) must be normalized by lexicalTerms without crashing.
func TestSearchQueryWithUnicodePunctuation(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	resp := runSearchReq(t, h.src, SearchRequest{Query: "重号，冲突！", Limit: 5})
	require.Equal(t, StateReady, resp.State)
}

// =============================================================================
// codex3 round 4 adversarial regressions. Each test targets one blocker from
// the review on commit 103ca7ce0 and would stably fail against the
// pre-fix implementation.
// =============================================================================

// TestSearchTitleOnlyHitIsConfident (Blocker 1): a query whose only overlap
// with the chunk is via the title must still be confident. Previously
// confident() checked body alone and rejected, returning 0 results.
func TestSearchTitleOnlyHitIsConfident(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/quasar-nebula.md": "# Quasar Nebula\n\nUnrelated prose only.\n",
		"MEMORY.md":                           "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "quasar nebula", Limit: 5})
	require.NotEmpty(t, resp.Results, "title-only hit must be returned")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/quasar-nebula.md"),
		"quasar-nebula.md expected in results: %v", resultPaths(resp))
	for _, r := range resp.Results {
		if r.Path == "knowledge/concepts/quasar-nebula.md" {
			require.Equal(t, MatchTypeLexical, r.MatchType)
			require.NotContains(t, r.MatchedFields, fieldBody,
				"body must not be in matchedFields when body has no term overlap")
			require.Contains(t, r.MatchedFields, fieldTitle,
				"title must be in matchedFields when it carried the score")
		}
	}
}

// TestSearchHeadingOnlyHitIsConfident (Blocker 1 variant): a query whose
// only overlap is via heading_text (a sub-section heading, not the title).
func TestSearchHeadingOnlyHitIsConfident(t *testing.T) {
	body := "# Top\n\n## Pulsar Wind\n\nUnrelated prose only.\n"
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/pulsar.md": body,
		"MEMORY.md":                    "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "pulsar wind", Limit: 5})
	require.NotEmpty(t, resp.Results, "heading-only hit must be returned")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/pulsar.md"),
		"pulsar.md expected in results: %v", resultPaths(resp))
}

// TestSearchBudgetHardCapHonored (Blocker 2): sum(runeCount(snippet)) must
// be <= MaxChars. Previously applyBudget's "keep snippet anyway" branch
// could push total past MaxChars by up to maxPerResultSnippet per result.
//
// MaxChars=1500 lands in the default per-result tier (360 runes); 8 results
// × 360 = 2880 > 1500 forces real truncation.
func TestSearchBudgetHardCapHonored(t *testing.T) {
	files := map[string]string{"MEMORY.md": "# Memory Index\n\ntop.\n"}
	for i := 0; i < 8; i++ {
		path := fmt.Sprintf("knowledge/concepts/widgit-%d.md", i)
		body := fmt.Sprintf("# Widgit %d\n\n%s\n", i, strings.Repeat("widgit depth probe ", 120))
		files[path] = body
	}
	h := newSearchHarness(t, files)
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "widgit depth probe",
		Limit:    8,
		MaxChars: 1500,
	})
	if len(resp.Results) == 0 {
		t.Skip("no confident matches; cannot exercise budget")
	}
	total := 0
	for _, r := range resp.Results {
		total += len([]rune(r.Snippet))
	}
	require.LessOrEqual(t, total, 1500,
		"sum(runeCount(snippet))=%d must be <= MaxChars=1500 (results=%d)",
		total, len(resp.Results))
	require.True(t, resp.Truncated, "Truncated must be true when snippets were clipped/emptied")
}

// TestSearchBudgetEmptySnippetStillReturnsLocators (Blocker 2): when the
// budget is exhausted mid-result-list, later results keep their locator
// fields (Path, MatchLines, ReadLines) even though Snippet is empty.
func TestSearchBudgetEmptySnippetStillReturnsLocators(t *testing.T) {
	files := map[string]string{"MEMORY.md": "# Memory Index\n\ntop.\n"}
	for i := 0; i < 8; i++ {
		path := fmt.Sprintf("knowledge/concepts/gadget-%d.md", i)
		body := fmt.Sprintf("# Gadget %d\n\n%s\n", i, strings.Repeat("gadget telemetry stream ", 120))
		files[path] = body
	}
	h := newSearchHarness(t, files)
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "gadget telemetry stream",
		Limit:    8,
		MaxChars: 1500,
	})
	if len(resp.Results) < 2 {
		t.Skip("not enough results to exercise exhaustion")
	}
	// At least one result must have an empty snippet once budget ran out.
	anyEmpty := false
	for _, r := range resp.Results {
		if r.Snippet == "" {
			anyEmpty = true
			require.NotEmpty(t, r.Path, "locator Path must remain when snippet is empty")
			require.GreaterOrEqual(t, r.MatchLines.Start, 1)
			require.GreaterOrEqual(t, r.ReadLines.Start, 1)
		}
	}
	require.True(t, anyEmpty, "expected at least one result with empty snippet once budget exhausted")
}

// TestSearchPathPrefixAcceptsFileExtensionBoundary (Blocker 3): prefix
// "knowledge/a" must match "knowledge/a.md" per the public docstring.
// The previous implementation only accepted '/' as the boundary char.
func TestSearchPathPrefixAcceptsFileExtensionBoundary(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"knowledge/a.md":      "# A Stem\n\na file at the segment root.\n",
		"knowledge/ab/foo.md": "# AB Dir\n\na file in a sibling directory.\n",
		"MEMORY.md":           "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:      "file",
		Limit:      5,
		PathPrefix: "knowledge/a",
	})
	paths := resultPaths(resp)
	require.True(t, containsPath(paths, "knowledge/a.md"),
		"prefix 'knowledge/a' must match 'knowledge/a.md': %v", paths)
	require.False(t, containsPath(paths, "knowledge/ab/foo.md"),
		"prefix 'knowledge/a' must NOT match 'knowledge/ab/foo.md': %v", paths)
}

// TestSearchReadLinesClampedToFileLength (Blocker 4): ReadLines.End must
// never exceed the source file's actual line count. Previously
// expandReadLines had no upper clamp, so a 4-line file could return End=11.
func TestSearchReadLinesClampedToFileLength(t *testing.T) {
	// 4-line file with no trailing newline (the harder case for line counts).
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/short.md": "# Short File\n\nalpha bravo charlie.\nNo trailing newline",
		"MEMORY.md":                   "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "alpha bravo", Limit: 5})
	require.NotEmpty(t, resp.Results)
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/short.md" {
			continue
		}
		require.Equal(t, 4, r.ReadLines.End,
			"ReadLines.End=%d must clamp to file length 4", r.ReadLines.End)
		require.LessOrEqual(t, r.ReadLines.Start, r.ReadLines.End)
	}
}

// TestSearchReadLinesClampedWithTrailingNewline (Blocker 4 variant): same
// as above but the file ends with a newline; the line count semantics must
// still hold (4 lines for a 4-newline file).
func TestSearchReadLinesClampedWithTrailingNewline(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/short.md": "# Short File\n\nalpha bravo charlie.\ndelta\n",
		"MEMORY.md":                   "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "alpha bravo", Limit: 5})
	require.NotEmpty(t, resp.Results)
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/short.md" {
			continue
		}
		require.Equal(t, 4, r.ReadLines.End,
			"ReadLines.End=%d must clamp to file length 4", r.ReadLines.End)
	}
}

// TestSearchExactIdentifierCaseInsensitive (Secondary 1): a query for
// "@camelcase" must match a chunk that carries "@CamelCase" via the
// exact-identifier route, not just the lexical fallback.
func TestSearchExactIdentifierCaseInsensitive(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/case.md": "# Case\n\nPing @CamelCase for context.\n",
		"MEMORY.md":                  "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "@camelcase", Limit: 5})
	require.NotEmpty(t, resp.Results, "case-insensitive identifier must hit")
	require.True(t, containsPath(resultPaths(resp), "knowledge/concepts/case.md"),
		"case.md expected in results: %v", resultPaths(resp))
	for _, r := range resp.Results {
		if r.Path == "knowledge/concepts/case.md" {
			require.Equal(t, MatchTypeExactIdentifier, r.MatchType,
				"query '@camelcase' vs indexed '@CamelCase' must hit via exact-identifier route, got %q",
				r.MatchType)
		}
	}
}

// TestSearchRejectsUnknownKind (Secondary 2): the Kinds filter is a closed
// enum; unknown values are rejected with ErrInvalidSearchRequest rather
// than silently returning 0 hits. Note that dedupKinds lowercases and trims
// first, so "System" is accepted as "system" — only normalized values
// outside the enum are rejected.
func TestSearchRejectsUnknownKind(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	for _, bad := range []string{"systemic", "profile-x", "random", "systems"} {
		_, err := Search(context.Background(), h.src, SearchRequest{
			Query: "memory",
			Limit: 5,
			Kinds: []string{bad},
		})
		require.ErrorIs(t, err, ErrInvalidSearchRequest,
			"unknown kind %q must be rejected", bad)
	}
}

// =============================================================================
// codex3 round 5 adversarial regressions. Each test targets one blocker from
// the review on commit 949aa78e0 and would stably fail against the
// pre-fix implementation.
// =============================================================================

// TestSearchCJKSnippetContainsMatch (Blocker 1): a CJK body of ~1000 runes
// with the term in the middle must produce a snippet that CONTAINS the match.
// Previously the byte-based snap-to-whitespace walked start all the way back
// to byte 0 because the body had no ASCII whitespace near the target window,
// pushing the match out of the snippet.
func TestSearchCJKSnippetContainsMatch(t *testing.T) {
	// 500 CJK chars + " 重号 " + 500 CJK chars. The match sits at rune offset
	// ~501, well past the per-result snippet budget (720 max runes), so the
	// snippet window must be positioned to include it.
	leading := strings.Repeat("在", 500)
	trailing := strings.Repeat("在", 500)
	body := "# 标题\n\n" + leading + " 重号 " + trailing + "\n"
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/cjk-late.md": body,
		"MEMORY.md":                      "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "重号",
		Limit:    5,
		MaxChars: maxSearchMaxChars,
	})
	require.NotEmpty(t, resp.Results, "CJK match must be returned")
	found := false
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/cjk-late.md" {
			continue
		}
		found = true
		require.Contains(t, r.Snippet, "重号",
			"snippet must contain the matched term, got %q", r.Snippet)
	}
	require.True(t, found, "cjk-late.md expected in results: %v", resultPaths(resp))
}

// TestSearchEmojiAndMultibyteSnippetSafe (Blocker 1 variant): snippet edges
// must never split a multibyte rune. Emoji are 4-byte UTF-8 sequences; the
// byte-based clip could previously slice one in half and emit U+FFFD.
func TestSearchEmojiAndMultibyteSnippetSafe(t *testing.T) {
	body := "# 🚀 Emoji Doc\n\n" + strings.Repeat("🎉", 250) +
		" alpha needle bravo " + strings.Repeat("🎉", 250) + "\n"
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/emoji.md": body,
		"MEMORY.md":                   "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{
		Query:    "alpha needle",
		Limit:    5,
		MaxChars: maxSearchMaxChars,
	})
	require.NotEmpty(t, resp.Results)
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/emoji.md" {
			continue
		}
		require.Contains(t, r.Snippet, "alpha",
			"snippet must contain the matched term")
		require.NotContains(t, r.Snippet, "�",
			"snippet must not contain replacement chars (split rune)")
	}
}

// TestSearchMatchLinesNarrowLateLine (Blocker 2): when the match sits near
// the END of a long chunk, MatchLines must be the NARROW range around the
// match, not the chunk's full [StartLine, EndLine]. Previously MatchLines
// was set to the chunk range, which collapsed a 100-line chunk to a single
// misleading range even when the match was at line 92.
func TestSearchMatchLinesNarrowLateLine(t *testing.T) {
	// File layout:
	//   line 1: # Search Target
	//   line 2: (blank)
	//   lines 3..93: filler line 0 .. filler line 90  (91 lines)
	//   line 94: alpha needle bravo
	//   lines 95..100: trailing line 0 .. trailing line 5  (6 lines)
	// Total: 100 lines. The chunker admits this as one chunk because the
	// body fits well under chunkMaxTokens.
	var lines []string
	for i := 0; i < 91; i++ {
		lines = append(lines, fmt.Sprintf("filler line %d", i))
	}
	lines = append(lines, "alpha needle bravo")
	for i := 0; i < 6; i++ {
		lines = append(lines, fmt.Sprintf("trailing line %d", i))
	}
	body := "# Search Target\n\n" + strings.Join(lines, "\n") + "\n"
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/late-match.md": body,
		"MEMORY.md":                        "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "alpha needle bravo", Limit: 5})
	require.NotEmpty(t, resp.Results)
	var late *SearchResult
	for i := range resp.Results {
		if resp.Results[i].Path == "knowledge/concepts/late-match.md" {
			late = &resp.Results[i]
			break
		}
	}
	require.NotNil(t, late, "late-match.md expected in results")
	// The match line is file line 94 (heading=1, blank=2, 91 filler=3..93).
	require.Equal(t, 94, late.MatchLines.Start,
		"MatchLines.Start must point at the matched line, got %d", late.MatchLines.Start)
	require.LessOrEqual(t, late.MatchLines.End-late.MatchLines.Start, 1,
		"MatchLines must be narrow (≤2 lines), got [%d, %d]",
		late.MatchLines.Start, late.MatchLines.End)
	// ReadLines must be expanded but clamp to file length.
	require.LessOrEqual(t, late.ReadLines.Start, late.MatchLines.Start,
		"ReadLines.Start must lead MatchLines.Start")
	require.GreaterOrEqual(t, late.ReadLines.End, late.MatchLines.End,
		"ReadLines.End must trail MatchLines.End")
	require.LessOrEqual(t, late.ReadLines.End, 100,
		"ReadLines.End must clamp to file length 100, got %d", late.ReadLines.End)
}

// TestSearchContextCancelFromCurrent (Blocker 3): when src.Current returns
// context.Canceled, Search must propagate ctx.Canceled as-is. Previously
// every Current error was silently degraded, hiding ctx cancellation from
// the caller.
func TestSearchContextCancelFromCurrent(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.opener = func(ctx context.Context) (*Generation, error) {
		return nil, context.Canceled
	}
	_, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.ErrorIs(t, err, context.Canceled,
		"ctx.Canceled from Current must propagate as ctx error")
}

// TestSearchContextCancelFromCollectCandidates (Blocker 3): ctx cancellation
// arriving AFTER Current returns (during collectCandidates / bleve search)
// must propagate as ctx error, not be wrapped as "candidate collection
// failed". This catches the case where bleve.SearchInContext itself returns
// ctx.Err() mid-flight.
func TestSearchContextCancelFromCollectCandidates(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	ctx, cancel := context.WithCancel(context.Background())
	h.src.opener = func(openerCtx context.Context) (*Generation, error) {
		// Open succeeds, then we cancel so collectCandidates observes a
		// cancelled ctx.
		defer cancel()
		return OpenGeneration(openerCtx, h.harness.binding, h.harness.resolver, h.genDir)
	}
	_, err := Search(ctx, h.src, SearchRequest{Query: "memory", Limit: 5})
	require.ErrorIs(t, err, context.Canceled,
		"ctx.Canceled during candidate collection must propagate as ctx error")
}

// TestSearchUnexpectedCurrentErrorReturned (Secondary 1): a non-sentinel
// Current error (forged manifest, IO failure, permission denied) must
// propagate as a wrapped non-nil error, NOT silently degrade. Only the
// three readiness sentinels (ErrNotReady, ErrGenerationPartial,
// ErrGenerationOutsideRoot) map to StateDegraded.
func TestSearchUnexpectedCurrentErrorReturned(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	unexpected := errors.New("synthetic forged manifest signature")
	h.src.opener = func(ctx context.Context) (*Generation, error) {
		return nil, unexpected
	}
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.Error(t, err, "unexpected Current error must propagate")
	require.ErrorIs(t, err, unexpected,
		"unexpected error must be wrapped with %w so caller can errors.Is it")
	require.Equal(t, SearchResponse{}, resp,
		"unexpected errors must NOT silently degrade — degraded is reserved for readiness sentinels")
}

// TestSearchDegradedResponseHasSourceRoot (Secondary 2): the degraded
// response must still carry SourceRoot pointing at the resolved Memory root
// so the skill's indexUnavailable fallback can direct the caller at the
// source tree for bounded reads.
func TestSearchDegradedResponseHasSourceRoot(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.allowOpen = false
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, StateDegraded, resp.State)
	require.Equal(t, sourceRootKindCloudMemory, resp.SourceRoot.Kind)
	require.Equal(t, h.resolvedRoot, resp.SourceRoot.AbsolutePath,
		"degraded response must expose binding resolved root")
}

// TestSearchDegradedResponseWithoutBindingIsSafe: a nil binding (e.g.
// Reconciler not yet started) must not panic; SourceRoot.AbsolutePath is
// empty and the state is still degraded.
func TestSearchDegradedResponseWithoutBindingIsSafe(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.allowOpen = false
	h.src.binding = nil
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.NoError(t, err)
	require.Equal(t, StateDegraded, resp.State)
	require.Equal(t, "", resp.SourceRoot.AbsolutePath,
		"nil binding yields empty AbsolutePath, not a crash")
}

// TestSearchTitleOnlySnippetFromTitleNotBody (Round 5 refinement): when
// only the title carried the match (body has zero query-term overlap),
// the snippet must come from the title text, NOT an unrelated body preview.
// Previously the code always clipped a body window even when body had no
// match, surfacing irrelevant prose next to a title hit.
func TestSearchTitleOnlySnippetFromTitleNotBody(t *testing.T) {
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/title-only.md": "# Quasar Nebula Hunt\n\nUnrelated prose only about daily weather.\n",
		"MEMORY.md":                        "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "quasar nebula hunt", Limit: 5})
	require.NotEmpty(t, resp.Results)
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/title-only.md" {
			continue
		}
		require.Contains(t, r.Snippet, "Quasar",
			"snippet must come from title text")
		require.NotContains(t, r.Snippet, "weather",
			"body prose must not appear in title-only snippet")
	}
}

// TestSearchExactIdentifierSnippetAnchoredOnIdentifier (Round 5 refinement):
// for an exactIdentifier hit, the snippet must be centered on the FULL
// identifier text (e.g. "#2731"), not on a lexical sub-term fragment.
// Previously the snippet fell back to lexical centering and could miss the
// identifier entirely when the body was long.
func TestSearchExactIdentifierSnippetAnchoredOnIdentifier(t *testing.T) {
	// ~7 leading + 7 trailing padding paragraphs, identifier buried in the
	// middle paragraph. The lexical centering code would still find it via
	// "2731" as a body token, but the goal here is to verify the exact-id
	// route centers on the full "#2731" text.
	leading := strings.Repeat("padding paragraph here. ", 30) // ~720 chars
	trailing := strings.Repeat("padding paragraph here. ", 30)
	body := "# Identifier Doc\n\n" + leading + " See #2731 for context. " + trailing + "\n"
	h := newSearchHarness(t, map[string]string{
		"knowledge/concepts/identifier-doc.md": body,
		"MEMORY.md":                            "# Memory Index\n\ntop.\n",
	})
	resp := runSearchReq(t, h.src, SearchRequest{Query: "#2731", Limit: 5})
	require.NotEmpty(t, resp.Results)
	for _, r := range resp.Results {
		if r.Path != "knowledge/concepts/identifier-doc.md" {
			continue
		}
		require.Equal(t, MatchTypeExactIdentifier, r.MatchType,
			"this corpus must hit via exact-identifier route")
		require.Contains(t, r.Snippet, "#2731",
			"snippet must be anchored on the full identifier text")
	}
}

// =============================================================================
// codex3 round 5 final-gate regressions: binding isolation. Search must
// never serve data from one binding's generation while reporting SourceRoot
// from another. OpenGeneration now rejects cross-binding opens; this section
// locks the Search-side fence as defense-in-depth for adapters that might
// construct a Generation by other means.
// =============================================================================

// TestSearchFailsClosedOnBindingMismatch (final gate): when SearchSource
// reports binding B but the underlying generation's manifest carries binding
// A, Search must return ErrManifestBindingMismatch rather than serve the
// mismatched pair. Without this fence the source read would use A while
// SourceRoot points at B — exactly the cross-binding leak codex3 reproduced.
func TestSearchFailsClosedOnBindingMismatch(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())

	// Construct a second binding with the same agent + ResolvedRoot but a
	// different MemoryID. The opener still opens the original generation
	// (whose manifest fingerprint is binding A); we lie to Search by swapping
	// the source's Binding() to binding B.
	bindingB, err := NewBinding(h.harness.resolver, "agt_x", "mem_OTHER", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding B: %v", err)
	}
	require.NotEqual(t, h.harness.binding.Fingerprint(), bindingB.Fingerprint(),
		"test premise broken: binding B must differ from A")
	h.src.binding = bindingB

	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.ErrorIs(t, err, ErrManifestBindingMismatch,
		"cross-binding mismatch must return ErrManifestBindingMismatch")
	require.Equal(t, SearchResponse{}, resp,
		"cross-binding mismatch must fail closed — no degraded, no results")
}

// TestSearchFailsClosedOnNilBindingAfterGeneration (final gate): when
// SearchSource returns a generation but reports a nil Binding, Search must
// fail closed. A source that hands out data without naming whose data it is
// has lost track of binding identity — exactly the case the fence exists
// to refuse, not silently degrade.
func TestSearchFailsClosedOnNilBindingAfterGeneration(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.binding = nil // generation opener still succeeds; Binding() lies.

	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.ErrorIs(t, err, ErrManifestBindingMismatch,
		"nil binding after a generation must fail closed")
	require.Equal(t, SearchResponse{}, resp,
		"do not degrade when a generation is in hand but the binding is missing")
}

// TestSearchDegradedAllowsNilBindingWithoutGeneration: the fail-closed
// rule applies only AFTER a generation is in hand. When Current fails and
// no generation is available, Search falls back to degraded with whatever
// (possibly empty) SourceRoot the source reports. This re-asserts the
// existing TestSearchDegradedResponseWithoutBindingIsSafe semantics under
// the new fence.
func TestSearchDegradedAllowsNilBindingWithoutGeneration(t *testing.T) {
	h := newSearchHarness(t, bilingualCorpus())
	h.src.allowOpen = false
	h.src.binding = nil
	resp, err := Search(context.Background(), h.src, SearchRequest{Query: "memory", Limit: 5})
	require.NoError(t, err, "degraded path allows nil binding without generation")
	require.Equal(t, StateDegraded, resp.State)
	require.Equal(t, ReasonIndexUnavailable, resp.Reason)
}
