//go:build !vectors

package memorysearch

import (
	"context"
	"encoding/json"
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/blevesearch/bleve/v2"
	index "github.com/blevesearch/bleve_index_api"
)

// buildTestHarness assembles an in-memory source clone under a temp dir,
// runs Scan + Chunk, and returns the ingredients Build needs. Tests reuse
// the harness to exercise Build without re-scanning each time.
type buildTestHarness struct {
	tmp            string
	workDir        string
	resolvedRoot   string
	derivativeRoot string
	resolver       AgentWorkDirResolver
	binding        *Binding
}

func newBuildTestHarness(t testing.TB, files map[string]string) *buildTestHarness {
	t.Helper()
	tmp := t.TempDir()
	workDir := filepath.Join(tmp, "agents", "agt_x")
	resolvedRoot := filepath.Join(workDir, "memory")
	derivativeRoot := DerivativeRootFromWorkDir(workDir)
	for _, p := range []string{resolvedRoot, derivativeRoot} {
		if err := os.MkdirAll(p, 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", p, err)
		}
	}
	for rel, body := range files {
		full := filepath.Join(resolvedRoot, rel)
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", filepath.Dir(full), err)
		}
		if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
			t.Fatalf("write %s: %v", full, err)
		}
	}
	resolver := AgentWorkDirResolver(func(agentID string) string {
		if agentID != "agt_x" {
			return ""
		}
		return workDir
	})
	binding, err := NewBinding(resolver, "agt_x", "mem_1", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	return &buildTestHarness{
		tmp:            tmp,
		workDir:        workDir,
		resolvedRoot:   resolvedRoot,
		derivativeRoot: derivativeRoot,
		resolver:       resolver,
		binding:        binding,
	}
}

// scanAndChunk runs Scan + Chunk over the source clone and returns the
// snapshot and chunk inventory Build consumes.
func (h *buildTestHarness) scanAndChunk(ctx context.Context, t testing.TB) (SourceSnapshot, []SearchChunk) {
	t.Helper()
	snap, status, err := Scan(ctx, h.resolvedRoot)
	if err != nil {
		t.Fatalf("Scan: %v", err)
	}
	if status.Degraded || status.Limited {
		t.Fatalf("Scan degraded=%v limited=%v", status.Degraded, status.Limited)
	}
	var chunks []SearchChunk
	for _, sf := range snap.Files {
		data, err := os.ReadFile(filepath.Join(h.resolvedRoot, sf.Path))
		if err != nil {
			t.Fatalf("read %s: %v", sf.Path, 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 snap, chunks
}

func TestBuildIndexMappingProducesBM25Mapping(t *testing.T) {
	t.Parallel()
	m, err := BuildIndexMapping()
	if err != nil {
		t.Fatalf("BuildIndexMapping: %v", err)
	}
	if m.ScoringModel != index.BM25Scoring {
		t.Errorf("ScoringModel = %q, want %q", m.ScoringModel, index.BM25Scoring)
	}
	if m.DefaultAnalyzer != "cjk" {
		t.Errorf("DefaultAnalyzer = %q, want %q", m.DefaultAnalyzer, "cjk")
	}
	if m.IndexDynamic || m.StoreDynamic || m.DocValuesDynamic {
		t.Errorf("dynamic mapping must be disabled: index=%v store=%v docvalues=%v",
			m.IndexDynamic, m.StoreDynamic, m.DocValuesDynamic)
	}
	if m.DefaultMapping == nil {
		t.Fatalf("DefaultMapping must not be nil")
	}
	// Every field the document schema emits must have a declared mapping.
	// Otherwise the field is silently dropped because IndexDynamic is false.
	// AddFieldMappingsAt(property, fm) places the field under
	// DefaultMapping.Properties[property].Fields, so traversal must walk
	// Properties — iterating only DefaultMapping.Fields would silently miss
	// every declared field.
	wantFields := []string{
		fieldPath, fieldTitle, fieldHeadingPath, fieldHeadingText, fieldHeadingAnchor,
		fieldBody, fieldKind, fieldFormat, fieldArchived,
		fieldIdentifiers, fieldIdentifierParts,
		fieldStartLine, fieldEndLine, fieldOrdinal,
		fieldContentHash, fieldSourceRevision,
	}
	haveFields := map[string]bool{}
	for name, sub := range m.DefaultMapping.Properties {
		if sub == nil {
			continue
		}
		for _, fm := range sub.Fields {
			if fm != nil {
				haveFields[name] = true
				break
			}
		}
	}
	for _, w := range wantFields {
		if !haveFields[w] {
			t.Errorf("mapping missing declared field %q (would be dropped at index time)", w)
		}
	}
	// The mapping must be acceptable to Bleve at construction time. Build a
	// throwaway scorch index so bleve.New actually validates the mapping
	// rather than just our own field assignment.
	dir := t.TempDir()
	idx, err := bleve.New(filepath.Join(dir, "idx"), m)
	if err != nil {
		t.Fatalf("bleve.New with produced mapping: %v", err)
	}
	defer idx.Close()
}

func TestManifestRoundTrip(t *testing.T) {
	t.Parallel()
	tmp := t.TempDir()
	workDir := filepath.Join(tmp, "agents", "agt_x")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	resolver := AgentWorkDirResolver(func(string) string { return workDir })
	binding, err := NewBinding(resolver, "agt_x", "mem_1", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	snap := SourceSnapshot{
		Revision: "rev-1",
		Files: map[string]SourceFile{
			"system/identity.md": {
				Path:        "system/identity.md",
				Size:        42,
				MTimeNS:     1_700_000_000_000_000_000,
				ContentHash: "deadbeef",
				Kind:        SourceKindSystem,
				Format:      SourceFormatMarkdown,
				Archived:    false,
			},
		},
	}
	chunks := []SearchChunk{
		{
			ID: "chunk-1", Path: "system/identity.md", Title: "Identity",
			Body: "hello", Kind: SourceKindSystem, Format: SourceFormatMarkdown,
			StartLine: 1, EndLine: 3, ContentHash: "deadbeef", SourceRevision: "rev-1",
		},
	}
	started := time.Date(2026, 7, 15, 10, 0, 0, 0, time.UTC)
	completed := started.Add(time.Second)
	m, err := newManifestFromSnapshot(binding, snap, chunks, started, completed)
	if err != nil {
		t.Fatalf("newManifestFromSnapshot: %v", err)
	}

	manifestPath := filepath.Join(workDir, "manifest.json")
	if err := m.SaveAtomic(manifestPath); err != nil {
		t.Fatalf("SaveAtomic: %v", err)
	}
	if m.Checksum() == "" {
		t.Fatalf("Checksum empty after SaveAtomic")
	}

	loaded, err := LoadManifest(context.Background(), manifestPath, resolver)
	if err != nil {
		t.Fatalf("LoadManifest: %v", err)
	}
	if loaded.SchemaVersion != ManifestSchemaVersion {
		t.Errorf("SchemaVersion = %d, want %d", loaded.SchemaVersion, ManifestSchemaVersion)
	}
	if loaded.BindingFingerprint != binding.Fingerprint() {
		t.Errorf("BindingFingerprint drift")
	}
	if loaded.SourceRevision != "rev-1" {
		t.Errorf("SourceRevision = %q", loaded.SourceRevision)
	}
	if got := len(loaded.Files); got != 1 {
		t.Fatalf("Files len = %d, want 1", got)
	}
	fe, ok := loaded.FileForPath("system/identity.md")
	if !ok {
		t.Fatalf("file missing after round-trip")
	}
	if fe.ContentHash != "deadbeef" {
		t.Errorf("fe.ContentHash = %q", fe.ContentHash)
	}
	if len(fe.ChunkIDs) != 1 || fe.ChunkIDs[0] != "chunk-1" {
		t.Errorf("fe.ChunkIDs = %v, want [chunk-1]", fe.ChunkIDs)
	}
	ce, ok := loaded.FindChunk("chunk-1")
	if !ok {
		t.Fatalf("chunk missing after round-trip")
	}
	if ce.Path != "system/identity.md" {
		t.Errorf("ce.Path = %q", ce.Path)
	}
}

func TestManifestRejectsUnknownSchema(t *testing.T) {
	t.Parallel()
	tmp := t.TempDir()
	path := filepath.Join(tmp, "manifest.json")
	workDir := filepath.Join(tmp, "agents", "agt_x")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	resolver := AgentWorkDirResolver(func(string) string { return workDir })
	binding, err := NewBinding(resolver, "agt_x", "mem_1", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	// Hand-craft a manifest with an unknown schema to simulate a future
	// reader encountering an older derivative (or vice versa).
	payload := map[string]interface{}{
		"schema_version":      999,
		"analyzer_version":    AnalyzerVersion,
		"chunker_version":     "goldmark-v1",
		"binding_fingerprint": binding.Fingerprint(),
		"binding":             binding,
		"source_revision":     "rev-1",
		"build_started_at":    time.Now().UTC(),
		"build_completed_at":  time.Now().UTC(),
		"files":               map[string]FileEntry{},
		"chunks":              map[string]ChunkEntry{},
	}
	raw, _ := json.Marshal(payload)
	if err := os.WriteFile(path, raw, 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	if _, err := LoadManifest(context.Background(), path, resolver); !errors.Is(err, ErrManifestSchemaUnknown) {
		t.Fatalf("expected ErrManifestSchemaUnknown, got %v", err)
	}
}

func TestManifestRejectsDanglingChunkRefs(t *testing.T) {
	t.Parallel()
	tmp := t.TempDir()
	workDir := filepath.Join(tmp, "agents", "agt_x")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	resolver := AgentWorkDirResolver(func(string) string { return workDir })
	binding, err := NewBinding(resolver, "agt_x", "mem_1", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	m := &Manifest{
		SchemaVersion:      ManifestSchemaVersion,
		AnalyzerVersion:    AnalyzerVersion,
		ChunkerVersion:     "goldmark-v1",
		BindingFingerprint: binding.Fingerprint(),
		Binding:            *binding,
		SourceRevision:     "rev-1",
		BuildStartedAt:     time.Now().UTC(),
		BuildCompletedAt:   time.Now().UTC(),
		Files: map[string]FileEntry{
			"system/identity.md": {
				Path:        "system/identity.md",
				ContentHash: "deadbeef",
				ChunkIDs:    []string{"missing-chunk"},
			},
		},
		Chunks: map[string]ChunkEntry{},
	}
	if err := m.Validate(resolver); err == nil ||
		!strings.Contains(err.Error(), "unknown chunk") {
		t.Fatalf("expected dangling-chunk error, got %v", err)
	}
}

func TestManifestRejectsTamperedInventory(t *testing.T) {
	t.Parallel()
	tmp := t.TempDir()
	workDir := filepath.Join(tmp, "agents", "agt_x")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	resolver := AgentWorkDirResolver(func(string) string { return workDir })
	binding, err := NewBinding(resolver, "agt_x", "mem_1", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	base := func() *Manifest {
		return &Manifest{
			SchemaVersion:      ManifestSchemaVersion,
			AnalyzerVersion:    AnalyzerVersion,
			ChunkerVersion:     "goldmark-v1",
			BindingFingerprint: binding.Fingerprint(),
			Binding:            *binding,
			SourceRevision:     "rev-1",
			BuildStartedAt:     time.Now().UTC(),
			BuildCompletedAt:   time.Now().UTC(),
			Files: map[string]FileEntry{
				"system/identity.md": {
					Path:        "system/identity.md",
					ContentHash: "deadbeef",
					ChunkIDs:    []string{"chunk-1"},
				},
			},
			Chunks: map[string]ChunkEntry{
				"chunk-1": {
					ID:          "chunk-1",
					Path:        "system/identity.md",
					ContentHash: "deadbeef",
				},
			},
		}
	}

	t.Run("orphan_chunk_not_owned_by_any_file", func(t *testing.T) {
		m := base()
		m.Chunks["chunk-orphan"] = ChunkEntry{
			ID:          "chunk-orphan",
			Path:        "system/identity.md",
			ContentHash: "deadbeef",
		}
		if err := m.Validate(resolver); err == nil ||
			!strings.Contains(err.Error(), "not owned by any file") {
			t.Fatalf("expected orphan-chunk error, got %v", err)
		}
	})

	t.Run("chunk_hash_diverges_from_file_hash", func(t *testing.T) {
		m := base()
		ce := m.Chunks["chunk-1"]
		ce.ContentHash = "evil"
		m.Chunks["chunk-1"] = ce
		if err := m.Validate(resolver); err == nil ||
			!strings.Contains(err.Error(), "does not match file") {
			t.Fatalf("expected hash-divergence error, got %v", err)
		}
	})

	t.Run("chunk_owned_by_two_files", func(t *testing.T) {
		m := base()
		// Both files claim chunk-1. The forward pass detects this when
		// iterating the second file's ChunkIDs because ce.Path still
		// points at the first file. Either "points at X but listed
		// under Y" or "owned by both" is an acceptable rejection.
		m.Files["system/other.md"] = FileEntry{
			Path:        "system/other.md",
			ContentHash: "deadbeef",
			ChunkIDs:    []string{"chunk-1"},
		}
		if err := m.Validate(resolver); err == nil {
			t.Fatalf("expected double-own rejection, got nil")
		} else {
			msg := err.Error()
			if !strings.Contains(msg, "owned by both") &&
				!strings.Contains(msg, "points at") {
				t.Fatalf("expected double-own error, got %v", err)
			}
		}
	})

	t.Run("chunk_map_key_diverges_from_id", func(t *testing.T) {
		m := base()
		// Duplicate under a different key, with the same ID inside.
		m.Chunks["chunk-renamed"] = ChunkEntry{
			ID:          "chunk-1",
			Path:        "system/identity.md",
			ContentHash: "deadbeef",
		}
		// Drop the original so owner[id] still resolves.
		delete(m.Chunks, "chunk-1")
		// Update the file to point at the new key so the forward pass
		// succeeds; the reverse pass must catch the key/ID divergence.
		fe := m.Files["system/identity.md"]
		fe.ChunkIDs = []string{"chunk-renamed"}
		m.Files["system/identity.md"] = fe
		if err := m.Validate(resolver); err == nil ||
			!strings.Contains(err.Error(), "does not match entry ID") {
			t.Fatalf("expected key/ID divergence error, got %v", err)
		}
	})
}

func TestBuildProducesValidGeneration(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md":      "# Identity\n\nAgent identity goes here.\n",
		"knowledge/concepts/x.md": "# Concept X\n\nBody about concept X with searchable text.\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	if len(chunks) == 0 {
		t.Fatalf("expected chunks, got 0")
	}
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	defer gen.Close()

	if !filepath.IsAbs(gen.Dir()) {
		t.Errorf("gen.Dir is not absolute: %q", gen.Dir())
	}
	if gen.Manifest() == nil {
		t.Fatalf("manifest nil after Build")
	}
	if got := gen.Manifest().SourceRevision; got != snap.Revision {
		t.Errorf("manifest SourceRevision = %q, want %q", got, snap.Revision)
	}
	if gen.Manifest().BindingFingerprint != h.binding.Fingerprint() {
		t.Errorf("manifest fingerprint drift")
	}
	if _, err := os.Stat(filepath.Join(gen.Dir(), buildCompleteMarker)); err != nil {
		t.Errorf("BUILD_COMPLETE missing: %v", err)
	}
	if _, err := os.Stat(filepath.Join(gen.Dir(), indexDirName)); err != nil {
		t.Errorf("index dir missing: %v", err)
	}
	if _, err := os.Stat(filepath.Join(gen.Dir(), manifestFilename)); err != nil {
		t.Errorf("manifest file missing: %v", err)
	}

	// Reopen via OpenGeneration to confirm the marker gate works.
	gen2, err := OpenGeneration(ctx, h.binding, h.resolver, gen.Dir())
	if err != nil {
		t.Fatalf("OpenGeneration: %v", err)
	}
	defer gen2.Close()
	// Manifest.Checksum is set by SaveAtomic and never serialized, so a
	// loaded manifest has an empty checksum by design. The query path does
	// not need it: the checksum only lets the build pipeline detect tamper
	// between SaveAtomic and publish.
	if gen2.Manifest().SourceRevision != snap.Revision {
		t.Errorf("reopened manifest SourceRevision = %q, want %q",
			gen2.Manifest().SourceRevision, snap.Revision)
	}
}

func TestBuildEmptySnapshotIsValid(t *testing.T) {
	h := newBuildTestHarness(t, nil)
	ctx := context.Background()
	snap, status, err := Scan(ctx, h.resolvedRoot)
	if err != nil {
		t.Fatalf("Scan: %v", err)
	}
	if status.Degraded || status.Limited {
		t.Fatalf("Scan degraded=%v limited=%v", status.Degraded, status.Limited)
	}
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   nil,
	})
	if err != nil {
		t.Fatalf("Build over empty snapshot: %v", err)
	}
	// Build closes its writer before returning; the returned Generation
	// must not hold an open index. The publish path opens the index
	// separately via OpenGeneration.
	if gen.Index() != nil {
		gen.Close()
		t.Fatalf("Build returned a Generation with an open Bleve writer")
	}
	// Reopen read-only and confirm the empty generation is queryable.
	gen2, err := OpenGeneration(ctx, h.binding, h.resolver, gen.Dir())
	if err != nil {
		t.Fatalf("OpenGeneration on empty build: %v", err)
	}
	defer gen2.Close()
	if got, err := gen2.Index().DocCount(); err != nil || got != 0 {
		t.Errorf("DocCount = %d err=%v, want 0", got, err)
	}
}

func TestBuildRejectsResolverMismatch(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	// Pass a resolver that maps agt_x somewhere else entirely. The binding
	// under h.binding was built against h.resolver, so ValidateWith must
	// reject it on Build's first step.
	otherResolver := AgentWorkDirResolver(func(string) string {
		return filepath.Join(h.tmp, "elsewhere")
	})
	_, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: otherResolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if !errors.Is(err, ErrInvalidBuildOptions) {
		t.Fatalf("expected ErrInvalidBuildOptions, got %v", err)
	}
	// No partial generation directory should be left behind. The
	// generations directory may not exist at all if Build rejected the
	// binding before allocating any gen dir; treat both as success.
	genParent := filepath.Join(h.derivativeRoot, generationsDirName)
	entries, err := os.ReadDir(genParent)
	if err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			t.Fatalf("readdir generations: %v", err)
		}
		entries = nil
	}
	if len(entries) != 0 {
		t.Errorf("expected 0 partial generations, got %d: %v", len(entries), entries)
	}
}

func TestBuildRefusesDerivativeInsideSourceRoot(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	// Forge a resolver that derives a derivative path INSIDE the source
	// clone. Build must reject this because the spec forbids nesting
	// derivative state inside the source clone.
	forgedResolver := AgentWorkDirResolver(func(agentID string) string {
		if agentID != h.binding.LocalAgentID {
			return ""
		}
		// Place workDir so that workDir/memory_search is inside workDir/memory.
		// memoryCloneDir is "memory"; DerivativeDirName is "memory_search".
		// Setting workDir to "<source>/.." makes memory_search a sibling,
		// so we instead set workDir so that memory is the parent of memory_search.
		return filepath.Join(h.resolvedRoot, "memory_search", "sub")
	})
	_, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: forgedResolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	// ValidateWith will reject this resolver because the binding was built
	// against the source h.resolver, so we expect the build to fail with
	// either ErrInvalidBinding or ErrInvalidBuildOptions.
	if err == nil {
		t.Fatalf("expected Build to reject forged resolver, got nil")
	}
}

func TestBuildRejectsArbitraryDerivativeRoot(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	// A resolver that returns a workDir unrelated to the binding's resolved
	// root is rejected by ValidateWith already; the deeper check is that
	// even a "valid-looking" workDir that doesn't match the binding's
	// source root is rejected. Construct a binding against a fresh
	// workDir, then call Build with a resolver pointing at h.workDir (which
	// would derive a different source root than the binding expects).
	otherTmp := t.TempDir()
	otherWorkDir := filepath.Join(otherTmp, "agents", "agt_other")
	if err := os.MkdirAll(otherWorkDir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	otherResolver := AgentWorkDirResolver(func(string) string { return otherWorkDir })
	_, err := Build(ctx, BuildOptions{
		Binding:  h.binding, // binding built against h.resolver
		Resolver: otherResolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if !errors.Is(err, ErrInvalidBuildOptions) {
		t.Fatalf("expected ErrInvalidBuildOptions for arbitrary resolver, got %v", err)
	}
}

func TestOpenGenerationRejectsPartialDir(t *testing.T) {
	h := newBuildTestHarness(t, nil)
	ctx := context.Background()
	// Allocate a generation directory manually but never write the marker.
	genDir := filepath.Join(h.derivativeRoot, generationsDirName, "abc-1700000000")
	if err := os.MkdirAll(filepath.Join(genDir, indexDirName), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	_, err := OpenGeneration(ctx, h.binding, h.resolver, genDir)
	if !errors.Is(err, ErrGenerationPartial) {
		t.Fatalf("expected ErrGenerationPartial, got %v", err)
	}
}

// TestOpenGenerationRejectsCopiedOutsideRoot exercises the genDir containment
// gate. A generation built for one agent, then copied verbatim to a path
// outside that agent's resolver-derived generations parent, must NOT be
// openable. Without the containment check, an attacker (or a misconfigured
// current-pointer) could plant a valid marker + manifest + index anywhere on
// disk and have the query path open it.
func TestOpenGenerationRejectsCopiedOutsideRoot(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	defer gen.Close()

	// Copy the entire generation directory verbatim to a temp dir that is
	// NOT under h.derivativeRoot/generations.
	outsiderParent := t.TempDir()
	outsiderGen := filepath.Join(outsiderParent, "forged")
	if err := copyDir(gen.Dir(), outsiderGen); err != nil {
		t.Fatalf("copyDir: %v", err)
	}
	_, err = OpenGeneration(ctx, h.binding, h.resolver, outsiderGen)
	if !errors.Is(err, ErrGenerationOutsideRoot) {
		t.Fatalf("expected ErrGenerationOutsideRoot for copied generation, got %v", err)
	}

	// Sanity: opening the original (under expected parent) still works so
	// the containment check is not over-restrictive.
	gen2, err := OpenGeneration(ctx, h.binding, h.resolver, gen.Dir())
	if err != nil {
		t.Fatalf("OpenGeneration on in-root dir: %v", err)
	}
	defer gen2.Close()
}

// TestOpenGenerationRejectsSymlinkInsideRoot is the symlink-escape regression
// for verifyGenerationContained. A symlink planted inside the resolver-derived
// generations parent — pointing at a forged generation outside — must NOT
// be openable, even though the link's lexical path is contained.
func TestOpenGenerationRejectsSymlinkInsideRoot(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	defer gen.Close()

	// Copy the entire generation directory verbatim to a temp dir that is
	// NOT under h.derivativeRoot/generations.
	outsiderParent := t.TempDir()
	outsiderGen := filepath.Join(outsiderParent, "forged")
	if err := copyDir(gen.Dir(), outsiderGen); err != nil {
		t.Fatalf("copyDir: %v", err)
	}

	// Plant a symlink INSIDE the resolver-derived generations parent that
	// points at the outside forged generation. The lexical path of the link
	// is contained; without EvalSymlinks the gate would let it through.
	genParent := filepath.Join(h.derivativeRoot, generationsDirName)
	linkPath := filepath.Join(genParent, "link")
	if err := os.Symlink(outsiderGen, linkPath); err != nil {
		// Some CI sandboxes reject symlinks; skip rather than fail in that case.
		t.Skipf("symlink unsupported: %v", err)
	}
	_, err = OpenGeneration(ctx, h.binding, h.resolver, linkPath)
	if !errors.Is(err, ErrGenerationOutsideRoot) {
		t.Fatalf("expected ErrGenerationOutsideRoot for in-root symlink to outside gen, got %v", err)
	}
}

// TestOpenGenerationRejectsOtherBindingSameAgent is the cross-binding fence
// regression (codex3 round 5 final gate). Two bindings that share agent +
// resolver-derived ResolvedRoot but differ on MemoryID both pass
// ValidateWith; without a caller-vs-manifest fingerprint check, one binding
// could open another's generation. The query path would then read source
// files via manifest.Binding while SearchSource.Binding() reports the
// caller's, silently mixing data and serving root across bindings.
func TestOpenGenerationRejectsOtherBindingSameAgent(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	gen, err := Build(ctx, BuildOptions{
		Binding:  h.binding,
		Resolver: h.resolver,
		Snapshot: snap,
		Chunks:   chunks,
	})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	defer gen.Close()

	// Construct a second binding with the same agent + branch + versions but
	// a different MemoryID. The resolver derives the same ResolvedRoot, so
	// both bindings pass ValidateWith; only Fingerprint() differs.
	bindingB, err := NewBinding(h.resolver, "agt_x", "mem_OTHER", "main", AnalyzerVersion, "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding B: %v", err)
	}
	if bindingB.Fingerprint() == h.binding.Fingerprint() {
		t.Fatalf("test premise broken: binding B must have a different fingerprint")
	}

	// Opening genA with bindingB must fail with ErrManifestBindingMismatch.
	_, err = OpenGeneration(ctx, bindingB, h.resolver, gen.Dir())
	if !errors.Is(err, ErrManifestBindingMismatch) {
		t.Fatalf("expected ErrManifestBindingMismatch cross-binding, got %v", err)
	}

	// Sanity: opening genA with the original binding still succeeds — the
	// gate is not over-restrictive for the matching case.
	gen2, err := OpenGeneration(ctx, h.binding, h.resolver, gen.Dir())
	if err != nil {
		t.Fatalf("OpenGeneration with matching binding: %v", err)
	}
	defer gen2.Close()
}

// copyDir copies src into dst recursively. Tests use it to forge a generation
// outside the resolver-derived root.
func copyDir(src, dst string) error {
	info, err := os.Stat(src)
	if err != nil {
		return err
	}
	if !info.IsDir() {
		return os.MkdirAll(filepath.Dir(dst), 0o755)
	}
	if err := os.MkdirAll(dst, 0o755); err != nil {
		return err
	}
	entries, err := os.ReadDir(src)
	if err != nil {
		return err
	}
	for _, e := range entries {
		s := filepath.Join(src, e.Name())
		d := filepath.Join(dst, e.Name())
		if e.IsDir() {
			if err := copyDir(s, d); err != nil {
				return err
			}
		} else {
			data, err := os.ReadFile(s)
			if err != nil {
				return err
			}
			if err := os.WriteFile(d, data, 0o644); err != nil {
				return err
			}
		}
	}
	return nil
}

func TestBuildSameTimestampDoesNotCollide(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	// Inject a constant clock so both builds see the same timestamp. Without
	// the MkdirTemp-based generation allocator this would collide on the
	// second Build with `bleve.New: path already exists`.
	frozen := time.Date(2026, 7, 15, 10, 0, 0, 0, time.UTC)
	gen1, err := Build(ctx, BuildOptions{
		Binding: h.binding, Resolver: h.resolver,
		Snapshot: snap, Chunks: chunks,
		Clock: func() time.Time { return frozen },
	})
	if err != nil {
		t.Fatalf("Build 1: %v", err)
	}
	defer gen1.Close()
	gen2, err := Build(ctx, BuildOptions{
		Binding: h.binding, Resolver: h.resolver,
		Snapshot: snap, Chunks: chunks,
		Clock: func() time.Time { return frozen },
	})
	if err != nil {
		t.Fatalf("Build 2 (same-timestamp retry): %v", err)
	}
	defer gen2.Close()
	if gen1.Dir() == gen2.Dir() {
		t.Errorf("same-timestamp builds reused generation dir: %s", gen1.Dir())
	}
	// Both generations must carry the BUILD_COMPLETE marker.
	for _, dir := range []string{gen1.Dir(), gen2.Dir()} {
		if _, err := os.Stat(filepath.Join(dir, buildCompleteMarker)); err != nil {
			t.Errorf("BUILD_COMPLETE missing in %s: %v", dir, err)
		}
	}
}

func TestBuildRejectsChunksInconsistentWithSnapshot(t *testing.T) {
	h := newBuildTestHarness(t, map[string]string{
		"system/identity.md": "# Identity\n\ntext\n",
	})
	ctx := context.Background()
	snap, chunks := h.scanAndChunk(ctx, t)
	if len(chunks) == 0 {
		t.Fatalf("expected at least one chunk to forge")
	}

	t.Run("missing_path", func(t *testing.T) {
		// Forge a chunk whose Path is not in the snapshot.
		forged := chunks[0]
		forged.ID = "forged-missing"
		forged.Path = "missing.md"
		_, err := Build(ctx, BuildOptions{
			Binding: h.binding, Resolver: h.resolver,
			Snapshot: snap, Chunks: append(chunks, forged),
		})
		if !errors.Is(err, ErrInvalidChunks) {
			t.Fatalf("expected ErrInvalidChunks for missing path, got %v", err)
		}
	})

	t.Run("mismatched_hash", func(t *testing.T) {
		forged := chunks[0]
		forged.ID = "forged-hash"
		forged.ContentHash = "deadbeef"
		_, err := Build(ctx, BuildOptions{
			Binding: h.binding, Resolver: h.resolver,
			Snapshot: snap, Chunks: append(chunks, forged),
		})
		if !errors.Is(err, ErrInvalidChunks) {
			t.Fatalf("expected ErrInvalidChunks for hash mismatch, got %v", err)
		}
	})

	t.Run("mismatched_kind", func(t *testing.T) {
		forged := chunks[0]
		forged.ID = "forged-kind"
		forged.Kind = SourceKindOther
		_, err := Build(ctx, BuildOptions{
			Binding: h.binding, Resolver: h.resolver,
			Snapshot: snap, Chunks: append(chunks, forged),
		})
		if !errors.Is(err, ErrInvalidChunks) {
			t.Fatalf("expected ErrInvalidChunks for kind mismatch, got %v", err)
		}
	})

	t.Run("duplicate_id", func(t *testing.T) {
		dup := chunks[0]
		_, err := Build(ctx, BuildOptions{
			Binding: h.binding, Resolver: h.resolver,
			Snapshot: snap, Chunks: append(chunks, dup),
		})
		if !errors.Is(err, ErrInvalidChunks) {
			t.Fatalf("expected ErrInvalidChunks for duplicate ID, got %v", err)
		}
	})

	t.Run("empty_id", func(t *testing.T) {
		forged := chunks[0]
		forged.ID = ""
		_, err := Build(ctx, BuildOptions{
			Binding: h.binding, Resolver: h.resolver,
			Snapshot: snap, Chunks: append(chunks, forged),
		})
		if !errors.Is(err, ErrInvalidChunks) {
			t.Fatalf("expected ErrInvalidChunks for empty ID, got %v", err)
		}
	})

	// No partial generation should remain after any of the failed builds
	// above. Each failure path runs before allocateGenerationDir, so the
	// generations directory may legitimately not exist.
	genParent := filepath.Join(h.derivativeRoot, generationsDirName)
	entries, err := os.ReadDir(genParent)
	if err != nil {
		if !errors.Is(err, os.ErrNotExist) {
			t.Fatalf("readdir generations: %v", err)
		}
		entries = nil
	}
	if len(entries) != 0 {
		t.Errorf("expected 0 partial generations after rejected builds, got %d: %v",
			len(entries), entries)
	}
}

func TestBuildMappingDoesNotStoreBody(t *testing.T) {
	t.Parallel()
	m, err := BuildIndexMapping()
	if err != nil {
		t.Fatalf("BuildIndexMapping: %v", err)
	}
	// Locate the body field mapping. AddFieldMappingsAt puts it under
	// Properties[fieldBody].Fields, so traverse Properties.
	bodyProp := m.DefaultMapping.Properties[fieldBody]
	if bodyProp == nil || len(bodyProp.Fields) == 0 {
		t.Fatalf("body field mapping not found under Properties[%q]", fieldBody)
	}
	bodyFM := bodyProp.Fields[0]
	if bodyFM.Store {
		t.Errorf("body field must have Store=false (response contract forbids returning body)")
	}
	if bodyFM.IncludeTermVectors {
		t.Errorf("body field must have IncludeTermVectors=false (no phrase queries in V1)")
	}

	// Build a real index and confirm that asking for body via Fields does
	// not return body bytes. Use Fields=["*"] to ask Bleve for every
	// stored field; an unstored body cannot leak this way. Then verify
	// the body IS in fact searchable: with AddFieldMappingsAt, the body
	// field's analyzer runs and BM25 returns a hit. Without property
	// association (AddFieldMapping at root), the body is silently dropped
	// at index time and the query returns zero hits — which would make
	// the "body not stored" check above trivially green and hide the
	// regression.
	dir := t.TempDir()
	idx, err := bleve.New(filepath.Join(dir, "idx"), m)
	if err != nil {
		t.Fatalf("bleve.New: %v", err)
	}
	defer idx.Close()
	doc := map[string]interface{}{
		fieldBody: "secret body content that must never come back",
		fieldPath: "system/identity.md",
	}
	if err := idx.Index("c1", doc); err != nil {
		t.Fatalf("Index: %v", err)
	}

	// Body must be searchable (analyzer ran at index time).
	bodyQ := bleve.NewMatchQuery("secret")
	bodyQ.SetField(fieldBody)
	bodyReq := bleve.NewSearchRequest(bodyQ)
	bodyRes, err := idx.Search(bodyReq)
	if err != nil {
		t.Fatalf("Search body: %v", err)
	}
	if len(bodyRes.Hits) == 0 {
		t.Fatalf("body query returned 0 hits — field mapping lost property path; " +
			"AddFieldMappingsAt must be used or body is silently dropped")
	}

	// Stored locator (path) must come back when requested.
	req := bleve.NewSearchRequest(bleve.NewMatchAllQuery())
	req.Fields = []string{"*"}
	res, err := idx.Search(req)
	if err != nil {
		t.Fatalf("Search: %v", err)
	}
	if len(res.Hits) != 1 {
		t.Fatalf("expected 1 hit, got %d", len(res.Hits))
	}
	gotPath, ok := res.Hits[0].Fields[fieldPath].(string)
	if !ok || gotPath != "system/identity.md" {
		t.Errorf("path locator = %#v, want %q", res.Hits[0].Fields[fieldPath],
			"system/identity.md")
	}
	for name, val := range res.Hits[0].Fields {
		if name == fieldBody {
			t.Errorf("body leaked via SearchRequest.Fields=[*]: %#v", val)
		}
	}
}
