package memorysearch

import (
	"context"
	"errors"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"testing"
)

func writeTestFile(t *testing.T, path, content string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
	}
	if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

func sourceFixture(t *testing.T) string {
	t.Helper()
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "MEMORY.md"), "# Project Memory\n\nAuthoritative index.\n")
	writeTestFile(t, filepath.Join(root, "system/identity.md"), "# Identity\n\nAgent role notes.\n")
	writeTestFile(t, filepath.Join(root, "system/goals.md"), "# Goals\n\nShip the feature.\n")
	writeTestFile(t, filepath.Join(root, "knowledge/concepts/migration.md"), "# Migration\n\nPR #2731 重号 audit.\n")
	writeTestFile(t, filepath.Join(root, "knowledge/workflows/pr-and-merge.md"), "# PR Workflow\n\nOpen, review, merge.\n")
	writeTestFile(t, filepath.Join(root, "events/2026-07-15-kickoff.md"), "# Kickoff\n\nSync notes.\n")
	writeTestFile(t, filepath.Join(root, "reflections/insights.md"), "# Insights\n\nLessons learned.\n")
	writeTestFile(t, filepath.Join(root, "profiles/user.md"), "# User\n\nPreferences.\n")
	writeTestFile(t, filepath.Join(root, "data/config.json"), `{"version":1,"feature":"search"}`+"\n")
	writeTestFile(t, filepath.Join(root, "data/policy.yaml"), "version: 1\nfeature: search\n")
	writeTestFile(t, filepath.Join(root, "notes/raw.txt"), "Free-form text notes.\n")
	return root
}

func TestScanDeterministicRevision(t *testing.T) {
	root := sourceFixture(t)
	snap1, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan 1: %v", err)
	}
	snap2, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan 2: %v", err)
	}
	if snap1.Revision != snap2.Revision {
		t.Fatalf("same tree produced different revisions: %q vs %q", snap1.Revision, snap2.Revision)
	}
	if len(snap1.Files) == 0 {
		t.Fatalf("scan admitted no files")
	}
}

func TestScanRevisionChangesOnContentChange(t *testing.T) {
	root := sourceFixture(t)
	before, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan before: %v", err)
	}
	target := filepath.Join(root, "system/identity.md")
	if err := os.WriteFile(target, []byte("# Identity\n\nCompletely new content.\n"), 0o644); err != nil {
		t.Fatalf("rewrite: %v", err)
	}
	after, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan after: %v", err)
	}
	if before.Revision == after.Revision {
		t.Fatalf("revision did not change after content edit")
	}
}

func TestScanRevisionChangesOnAddDelete(t *testing.T) {
	root := sourceFixture(t)
	before, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan before: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "knowledge/concepts/new-concept.md"), "# New Concept\n\nFresh.\n")
	mid, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan mid: %v", err)
	}
	if before.Revision == mid.Revision {
		t.Fatalf("revision did not change after add")
	}
	if err := os.Remove(filepath.Join(root, "knowledge/concepts/new-concept.md")); err != nil {
		t.Fatalf("remove: %v", err)
	}
	after, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan after: %v", err)
	}
	if before.Revision != after.Revision {
		t.Fatalf("add/remove did not round-trip: before=%q after=%q", before.Revision, after.Revision)
	}
}

func TestScanAdmitsOnlyAllowedExtensions(t *testing.T) {
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "doc.md"), "markdown\n")
	writeTestFile(t, filepath.Join(root, "doc.markdown"), "markdown-long-ext\n")
	writeTestFile(t, filepath.Join(root, "doc.txt"), "text\n")
	writeTestFile(t, filepath.Join(root, "doc.json"), "{}\n")
	writeTestFile(t, filepath.Join(root, "doc.yaml"), "k: v\n")
	writeTestFile(t, filepath.Join(root, "doc.yml"), "k: v\n")
	writeTestFile(t, filepath.Join(root, "image.png"), "fake-png-bytes\n")
	writeTestFile(t, filepath.Join(root, "binary.bin"), "random\n")
	writeTestFile(t, filepath.Join(root, "noext"), "plain\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	got := make(map[string]string, len(snap.Files))
	for path, f := range snap.Files {
		got[path] = f.Format
	}
	want := map[string]string{
		"doc.md":       SourceFormatMarkdown,
		"doc.markdown": SourceFormatMarkdown,
		"doc.txt":      SourceFormatText,
		"doc.json":     SourceFormatJSON,
		"doc.yaml":     SourceFormatYAML,
		"doc.yml":      SourceFormatYAML,
	}
	for path, format := range want {
		if got[path] != format {
			t.Errorf("admitted %q format = %q, want %q", path, got[path], format)
		}
	}
	for _, rejected := range []string{"image.png", "binary.bin", "noext"} {
		if _, ok := snap.Files[rejected]; ok {
			t.Errorf("unexpected admission of %q", rejected)
		}
	}
}

func TestScanAssignsPathDomainKind(t *testing.T) {
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "MEMORY.md"), "# Index\n")
	writeTestFile(t, filepath.Join(root, "system/identity.md"), "id\n")
	writeTestFile(t, filepath.Join(root, "system/sub/deep.md"), "deep\n")
	writeTestFile(t, filepath.Join(root, "profiles/user.md"), "user\n")
	writeTestFile(t, filepath.Join(root, "profile/solo.md"), "solo\n")
	writeTestFile(t, filepath.Join(root, "knowledge/concepts/x.md"), "x\n")
	writeTestFile(t, filepath.Join(root, "knowledge/workflows/y.md"), "y\n")
	writeTestFile(t, filepath.Join(root, "knowledge/other/z.md"), "z\n")
	writeTestFile(t, filepath.Join(root, "events/2026-01-01-kick.md"), "kick\n")
	writeTestFile(t, filepath.Join(root, "reflections/insights.md"), "insights\n")
	writeTestFile(t, filepath.Join(root, "random/place/file.md"), "random\n")
	writeTestFile(t, filepath.Join(root, "README.md"), "readme\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	cases := map[string]string{
		"MEMORY.md":                 SourceKindRoot,
		"README.md":                 SourceKindRoot,
		"system/identity.md":        SourceKindSystem,
		"system/sub/deep.md":        SourceKindSystem,
		"profiles/user.md":          SourceKindProfile,
		"profile/solo.md":           SourceKindProfile,
		"knowledge/concepts/x.md":   SourceKindConcept,
		"knowledge/workflows/y.md":  SourceKindWorkflow,
		"knowledge/other/z.md":      SourceKindOther,
		"events/2026-01-01-kick.md": SourceKindEvent,
		"reflections/insights.md":   SourceKindReflection,
		"random/place/file.md":      SourceKindOther,
	}
	for path, want := range cases {
		got := snap.Files[path].Kind
		if got != want {
			t.Errorf("Kind(%q) = %q, want %q", path, got, want)
		}
	}
}

func TestScanFilterExcludesDotGitDerivedAndDotDirs(t *testing.T) {
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "ok.md"), "ok\n")
	writeTestFile(t, filepath.Join(root, ".git/HEAD"), "ref: refs/heads/main\n")
	writeTestFile(t, filepath.Join(root, ".git/refs/heads/main"), "abc123\n")
	writeTestFile(t, filepath.Join(root, ".derived/memory-search/current.json"), "{}\n")
	writeTestFile(t, filepath.Join(root, ".cache/meta.json"), "{}\n")
	writeTestFile(t, filepath.Join(root, "sub/.hidden/nested.md"), "hidden\n")
	writeTestFile(t, filepath.Join(root, "sub/visible.md"), "visible\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	for path := range snap.Files {
		if strings.Contains(path, ".git/") || strings.Contains(path, ".derived/") || strings.HasPrefix(path, ".") {
			t.Errorf("dot path admitted: %q", path)
		}
	}
	if _, ok := snap.Files["ok.md"]; !ok {
		t.Errorf("ok.md missing from snapshot")
	}
	if _, ok := snap.Files["sub/visible.md"]; !ok {
		t.Errorf("sub/visible.md missing from snapshot")
	}
}

func TestScanFilterExcludesRetentionMarkerAndTempNames(t *testing.T) {
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "ok.md"), "ok\n")
	writeTestFile(t, filepath.Join(root, retentionMarkerName), `{"memoryId":"m"}`+"\n")
	writeTestFile(t, filepath.Join(root, "draft.md~"), "draft backup\n")
	writeTestFile(t, filepath.Join(root, "~$wip.md"), "office lock\n")
	writeTestFile(t, filepath.Join(root, "scratch.md.tmp"), "tmp\n")
	writeTestFile(t, filepath.Join(root, "snapshot.md.bak"), "bak\n")
	writeTestFile(t, filepath.Join(root, "legacy.md.old"), "old\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	for _, rejected := range []string{retentionMarkerName, "draft.md~", "~$wip.md", "scratch.md.tmp", "snapshot.md.bak", "legacy.md.old"} {
		if _, ok := snap.Files[rejected]; ok {
			t.Errorf("temp/retention file admitted: %q", rejected)
		}
	}
	if _, ok := snap.Files["ok.md"]; !ok {
		t.Errorf("ok.md missing from snapshot")
	}
}

func TestScanRejectsSymlinks(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("symlink semantics differ on windows; covered separately")
	}
	root := t.TempDir()
	outside := t.TempDir()
	if err := os.WriteFile(filepath.Join(outside, "secret.md"), []byte("escape attempt\n"), 0o644); err != nil {
		t.Fatalf("seed outside: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "real.md"), "real content\n")
	if err := os.Symlink(filepath.Join(outside, "secret.md"), filepath.Join(root, "link-out.md")); err != nil {
		t.Fatalf("symlink out: %v", err)
	}
	if err := os.Symlink(filepath.Join(root, "real.md"), filepath.Join(root, "link-in.md")); err != nil {
		t.Fatalf("symlink in: %v", err)
	}
	if err := os.Symlink(filepath.Join(root, "subdir"), filepath.Join(root, "linkdir")); err != nil {
		t.Fatalf("symlink dir: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "subdir/inside.md"), "inside\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	for _, rejected := range []string{"link-out.md", "link-in.md"} {
		if _, ok := snap.Files[rejected]; ok {
			t.Errorf("symlink admitted: %q", rejected)
		}
	}
	if _, ok := snap.Files["real.md"]; !ok {
		t.Errorf("real.md missing from snapshot")
	}
	for path := range snap.Files {
		if strings.HasPrefix(path, "linkdir/") {
			t.Errorf("symlinked directory was followed: %q", path)
		}
	}
}

func TestScanRejectsRelativeRoot(t *testing.T) {
	_, _, err := Scan(context.Background(), "relative/path")
	if !errors.Is(err, ErrInvalidRoot) {
		t.Fatalf("expected ErrInvalidRoot, got %v", err)
	}
}

func TestScanRejectsMissingRoot(t *testing.T) {
	missing := filepath.Join(t.TempDir(), "does-not-exist")
	_, _, err := Scan(context.Background(), missing)
	if err == nil || errors.Is(err, ErrInvalidRoot) {
		t.Fatalf("expected fs error, got %v", err)
	}
}

func TestScanRejectsFileRoot(t *testing.T) {
	file := filepath.Join(t.TempDir(), "file.txt")
	if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}
	_, _, err := Scan(context.Background(), file)
	if !errors.Is(err, ErrInvalidRoot) {
		t.Fatalf("expected ErrInvalidRoot, got %v", err)
	}
}

func TestScanRejectsBinaryContent(t *testing.T) {
	root := t.TempDir()
	bin := make([]byte, 256)
	for i := range bin {
		bin[i] = byte(i)
	}
	if err := os.WriteFile(filepath.Join(root, "payload.md"), bin, 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "text.md"), "plain text\n")
	snap, status, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if _, ok := snap.Files["payload.md"]; ok {
		t.Errorf("binary payload admitted as text")
	}
	if status.Skipped == 0 {
		t.Errorf("expected binary file to be skipped, got status %+v", status)
	}
	if _, ok := snap.Files["text.md"]; !ok {
		t.Errorf("text.md missing from snapshot")
	}
}

func TestScanRejectsInvalidUTF8(t *testing.T) {
	root := t.TempDir()
	// Invalid UTF-8: 0xFF is not a valid lead byte.
	invalid := []byte("valid prefix \xff\xfe invalid tail\n")
	if err := os.WriteFile(filepath.Join(root, "broken.md"), invalid, 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "clean.md"), "clean text\n")
	snap, status, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if _, ok := snap.Files["broken.md"]; ok {
		t.Errorf("invalid UTF-8 payload admitted")
	}
	if status.Skipped == 0 {
		t.Errorf("expected invalid UTF-8 file to be skipped, got status %+v", status)
	}
	if _, ok := snap.Files["clean.md"]; !ok {
		t.Errorf("clean.md missing from snapshot")
	}
}

func TestScanRejectsLargeFile(t *testing.T) {
	root := t.TempDir()
	big := make([]byte, MaxFileBytes+1)
	for i := range big {
		big[i] = 'a'
	}
	if err := os.WriteFile(filepath.Join(root, "big.md"), big, 0o644); err != nil {
		t.Fatalf("seed big: %v", err)
	}
	writeTestFile(t, filepath.Join(root, "ok.md"), "ok\n")
	snap, status, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if _, ok := snap.Files["big.md"]; ok {
		t.Errorf("file larger than MaxFileBytes admitted")
	}
	if status.Skipped == 0 {
		t.Errorf("expected large file to be skipped, got status %+v", status)
	}
}

// TestScanRejectsFileGrowthDuringRead exercises the bounded-read path with a
// deterministic beforeHashFile seam. The file is small at Lstat time and
// passes the pre-open size check, but the seam grows it past maxFileBytes
// before the confined Open+read runs. The post-read size check must catch
// the concurrent growth and skip the file.
func TestScanRejectsFileGrowthDuringRead(t *testing.T) {
	root := t.TempDir()
	target := filepath.Join(root, "system", "doc.md")
	writeTestFile(t, target, "small\n")
	snap, status, err := scanWithConfig(context.Background(), root, scanConfig{
		maxFiles:      MaxFiles,
		maxTotalBytes: MaxTotalBytes,
		maxFileBytes:  10,
		beforeHashFile: func(rel string) {
			if rel != "system/doc.md" {
				return
			}
			// Grow the file past the cap. The post-read stat will detect the
			// mismatch between pre-open size (6 bytes) and current size.
			if err := os.WriteFile(target, []byte(strings.Repeat("x", 200)), 0o644); err != nil {
				t.Errorf("grow: %v", err)
			}
		},
	})
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if _, ok := snap.Files["system/doc.md"]; ok {
		t.Errorf("file that grew past maxFileBytes was admitted")
	}
	if status.Skipped == 0 {
		t.Errorf("expected the grown file to be skipped, got status %+v", status)
	}
}

// TestScanRechecksTotalByteCapWithActualDataSize exercises the post-read
// total-byte re-check. The pre-read check uses fi.Size() from the walk-time
// DirEntry, but the file may grow between that stat and the open+read seam.
// readAndHash only guarantees the inode is unchanged (SameFile) and the size
// is stable between preStat and postStat; it does not require preStat ==
// fi.Size(). The post-read re-check must use the real len(data) so a
// growth-via-read race cannot push TotalBytes past the cap silently.
func TestScanRechecksTotalByteCapWithActualDataSize(t *testing.T) {
	root := t.TempDir()
	// a.md is walked first and admitted normally (11 bytes).
	writeTestFile(t, filepath.Join(root, "a.md"), "0123456789\n")
	// b.md is walked second; pre-read it is small, but the seam grows it
	// past the remaining total-byte budget. The growth happens before
	// readAndHash opens the file, so SameFile(walkStat, preStat) is true
	// (same inode) even though the size differs.
	target := filepath.Join(root, "b.md")
	writeTestFile(t, target, "abc\n")
	const grownSize = 50
	snap, status, err := scanWithConfig(context.Background(), root, scanConfig{
		maxFiles:      MaxFiles,
		maxTotalBytes: 30, // a.md (11) + b.md pre-read (4) = 15 <= 30; a.md (11) + grown (50) = 61 > 30
		maxFileBytes:  MaxFileBytes,
		beforeHashFile: func(rel string) {
			if rel != "b.md" {
				return
			}
			if err := os.WriteFile(target, []byte(strings.Repeat("y", grownSize)), 0o644); err != nil {
				t.Errorf("grow b.md: %v", err)
			}
		},
	})
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if _, ok := snap.Files["a.md"]; !ok {
		t.Errorf("a.md should have been admitted before the cap re-check, got files %v", snap.Files)
	}
	if _, ok := snap.Files["b.md"]; ok {
		t.Errorf("b.md that grew past the total-byte cap was admitted")
	}
	if !status.Limited {
		t.Errorf("expected Limited flag set when post-read total-byte cap is exceeded, got status %+v", status)
	}
	if status.Skipped == 0 {
		t.Errorf("expected the grown b.md to be skipped, got status %+v", status)
	}
	if status.TotalBytes > 30 {
		t.Errorf("TotalBytes %d exceeded maxTotalBytes 30 after cap re-check", status.TotalBytes)
	}
}

// TestScanRejectsSymlinkSwapDuringRead exercises the confinement defense. The
// scanner Lstat's a regular file, then the seam replaces the file with a
// symlink to an outside secret. The confined root.Open must refuse to follow
// the swap (or the SameFile identity check must catch the inode change), and
// the scan must mark degraded rather than indexing the outside secret's body.
func TestScanRejectsSymlinkSwapDuringRead(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("openat-root confinement semantics differ on windows")
	}
	root := t.TempDir()
	outside := t.TempDir()
	secret := filepath.Join(outside, "secret.md")
	if err := os.WriteFile(secret, []byte("outside secret body\n"), 0o644); err != nil {
		t.Fatalf("seed outside: %v", err)
	}
	target := filepath.Join(root, "system", "doc.md")
	writeTestFile(t, target, "inside\n")
	snap, status, err := scanWithConfig(context.Background(), root, scanConfig{
		maxFiles:      MaxFiles,
		maxTotalBytes: MaxTotalBytes,
		maxFileBytes:  MaxFileBytes,
		beforeHashFile: func(rel string) {
			if rel != "system/doc.md" {
				return
			}
			// Swap the regular file to a symlink pointing outside the root.
			if err := os.Remove(target); err != nil {
				t.Errorf("remove pre-swap: %v", err)
				return
			}
			if err := os.Symlink(secret, target); err != nil {
				t.Errorf("symlink swap: %v", err)
			}
		},
	})
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if got, ok := snap.Files["system/doc.md"]; ok {
		if strings.Contains(got.ContentHash, "outside") || got.Size > 0 {
			// ContentHash is hex so this never matches literally; this guard
			// exists to flag any future regression that leaked plaintext.
			t.Errorf("outside-pointing symlink was followed: %+v", got)
		}
		t.Errorf("swapped file was admitted into snapshot: %+v", got)
	}
	if !status.Degraded && status.Skipped == 0 {
		t.Errorf("expected the symlink-swap to report degraded or skipped, got status %+v", status)
	}
}

func TestScanBoundsFilesCutoff(t *testing.T) {
	root := t.TempDir()
	// Create 5 files; configure a cap of 3 to exercise deterministic cutoff.
	for _, name := range []string{"a.md", "b.md", "c.md", "d.md", "e.md"} {
		writeTestFile(t, filepath.Join(root, name), "x\n")
	}
	snap, status, err := scanWithConfig(context.Background(), root, scanConfig{
		maxFiles:      3,
		maxTotalBytes: MaxTotalBytes,
		maxFileBytes:  MaxFileBytes,
	})
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if !status.Limited {
		t.Errorf("expected Limited=true, got %+v", status)
	}
	if status.Admitted != 3 {
		t.Errorf("expected 3 admitted, got %d", status.Admitted)
	}
	if len(snap.Files) != 3 {
		t.Errorf("expected 3 files in snapshot, got %d", len(snap.Files))
	}
	admitted := make([]string, 0, len(snap.Files))
	for p := range snap.Files {
		admitted = append(admitted, p)
	}
	sort.Strings(admitted)
	want := []string{"a.md", "b.md", "c.md"}
	for i, w := range want {
		if admitted[i] != w {
			t.Errorf("admitted[%d] = %q, want %q (cutoff must be lexical)", i, admitted[i], w)
		}
	}
}

func TestScanBoundsTotalBytesCutoff(t *testing.T) {
	root := t.TempDir()
	// Each file is 4 bytes ("abcd"); cap at 10 bytes admits 2 files then cuts.
	for _, name := range []string{"a.md", "b.md", "c.md"} {
		if err := os.WriteFile(filepath.Join(root, name), []byte("abcd"), 0o644); err != nil {
			t.Fatalf("seed: %v", err)
		}
	}
	_, status, err := scanWithConfig(context.Background(), root, scanConfig{
		maxFiles:      MaxFiles,
		maxTotalBytes: 10,
		maxFileBytes:  MaxFileBytes,
	})
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if !status.Limited {
		t.Errorf("expected Limited=true, got %+v", status)
	}
	if status.Admitted != 2 {
		t.Errorf("expected 2 admitted before total bytes cutoff, got %d", status.Admitted)
	}
}

func TestScanContextCancelStopsWalk(t *testing.T) {
	root := t.TempDir()
	for i := 0; i < 100; i++ {
		writeTestFile(t, filepath.Join(root, "dir", "f"+strings.Repeat("a", i/10+1)+".md"), "x\n")
	}
	ctx, cancel := context.WithCancel(context.Background())
	cancel()
	_, _, err := scanWithConfig(ctx, root, scanConfig{
		maxFiles:      MaxFiles,
		maxTotalBytes: MaxTotalBytes,
		maxFileBytes:  MaxFileBytes,
	})
	if err == nil {
		t.Fatalf("expected ctx cancellation to be returned")
	}
}

func TestScanMarksDegradedOnUnreadableFile(t *testing.T) {
	if runtime.GOOS == "windows" {
		t.Skip("POSIX permission semantics required")
	}
	if os.Geteuid() == 0 {
		t.Skip("running as root bypasses POSIX permission checks")
	}
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "ok.md"), "ok\n")
	secret := filepath.Join(root, "system/secret.md")
	writeTestFile(t, secret, "secret\n")
	if err := os.Chmod(secret, 0o000); err != nil {
		t.Fatalf("chmod: %v", err)
	}
	t.Cleanup(func() { _ = os.Chmod(secret, 0o644) })
	snap, status, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if !status.Degraded {
		t.Errorf("expected Degraded=true on unreadable file, got %+v", status)
	}
	if _, ok := snap.Files["system/secret.md"]; ok {
		t.Errorf("unreadable file admitted into snapshot")
	}
	if _, ok := snap.Files["ok.md"]; !ok {
		t.Errorf("ok.md missing from snapshot")
	}
}

func TestScanMarksArchived(t *testing.T) {
	root := t.TempDir()
	writeTestFile(t, filepath.Join(root, "current.md"), "current\n")
	writeTestFile(t, filepath.Join(root, "archive/old.md"), "archived\n")
	writeTestFile(t, filepath.Join(root, "notes/archived-draft.md"), "archived-stem\n")

	snap, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan: %v", err)
	}
	if !snap.Files["archive/old.md"].Archived {
		t.Errorf("archive/old.md should be archived")
	}
	if !snap.Files["notes/archived-draft.md"].Archived {
		t.Errorf("notes/archived-draft.md should be archived by stem marker")
	}
	if snap.Files["current.md"].Archived {
		t.Errorf("current.md should not be archived")
	}
}

func TestSnapshotDiffAddModifyDeleteRename(t *testing.T) {
	root := sourceFixture(t)
	old, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan old: %v", err)
	}

	writeTestFile(t, filepath.Join(root, "knowledge/concepts/new-concept.md"), "# New\n")
	if err := os.WriteFile(filepath.Join(root, "system/identity.md"), []byte("# Identity\n\nUpdated.\n"), 0o644); err != nil {
		t.Fatalf("modify: %v", err)
	}
	if err := os.Remove(filepath.Join(root, "events/2026-07-15-kickoff.md")); err != nil {
		t.Fatalf("delete: %v", err)
	}
	if err := os.Rename(filepath.Join(root, "reflections/insights.md"), filepath.Join(root, "reflections/learned.md")); err != nil {
		t.Fatalf("rename: %v", err)
	}

	cur, _, err := Scan(context.Background(), root)
	if err != nil {
		t.Fatalf("scan cur: %v", err)
	}
	diff := Diff(old, cur)

	addedPaths := pathsFromFiles(diff.Added)
	if !contains(addedPaths, "knowledge/concepts/new-concept.md") {
		t.Errorf("added missing new-concept.md: %+v", addedPaths)
	}
	if !contains(addedPaths, "reflections/learned.md") {
		t.Errorf("rename target should appear as added: %+v", addedPaths)
	}
	modifiedPaths := pathsFromFiles(diff.Modified)
	if !contains(modifiedPaths, "system/identity.md") {
		t.Errorf("modified missing identity.md: %+v", modifiedPaths)
	}
	if !contains(diff.Removed, "events/2026-07-15-kickoff.md") {
		t.Errorf("removed missing kickoff: %+v", diff.Removed)
	}
	if !contains(diff.Removed, "reflections/insights.md") {
		t.Errorf("rename source should appear as removed: %+v", diff.Removed)
	}
}

func TestSnapshotDiffIsSortedLexically(t *testing.T) {
	old := SourceSnapshot{Files: map[string]SourceFile{
		"a.md":    {Path: "a.md", ContentHash: "h1"},
		"keep.md": {Path: "keep.md", ContentHash: "h"},
	}}
	cur := SourceSnapshot{Files: map[string]SourceFile{
		"keep.md": {Path: "keep.md", ContentHash: "h"},
		"z.md":    {Path: "z.md", ContentHash: "hz"},
		"m.md":    {Path: "m.md", ContentHash: "hm"},
		"a.md":    {Path: "a.md", ContentHash: "h1-modified"},
	}}
	diff := Diff(old, cur)
	if got := pathsFromFiles(diff.Added); !sortedEqual(got, []string{"m.md", "z.md"}) {
		t.Errorf("Added not lexical: %+v", got)
	}
	if got := pathsFromFiles(diff.Modified); !sortedEqual(got, []string{"a.md"}) {
		t.Errorf("Modified not lexical: %+v", got)
	}
	if !sort.StringsAreSorted(diff.Removed) {
		t.Errorf("Removed not sorted: %+v", diff.Removed)
	}
}

func pathsFromFiles(fs []SourceFile) []string {
	out := make([]string, len(fs))
	for i, f := range fs {
		out[i] = f.Path
	}
	return out
}

func contains(haystack []string, needle string) bool {
	for _, s := range haystack {
		if s == needle {
			return true
		}
	}
	return false
}

func sortedEqual(got, want []string) bool {
	if len(got) != len(want) {
		return false
	}
	for i := range got {
		if got[i] != want[i] {
			return false
		}
	}
	return true
}

// validTestBinding constructs a Binding through NewBinding using a synthetic
// resolver that returns an absolute work directory under t.TempDir().
func validTestBinding(t *testing.T, memoryID, branch string) *Binding {
	t.Helper()
	tmp := t.TempDir()
	workDir := filepath.Join(tmp, "agents", "agt_test")
	if err := os.MkdirAll(workDir, 0o755); err != nil {
		t.Fatalf("mkdir workdir: %v", err)
	}
	resolver := AgentWorkDirResolver(func(agentID string) string {
		if agentID != "agt_test" {
			return ""
		}
		return workDir
	})
	b, err := NewBinding(resolver, "agt_test", memoryID, branch, "cjk-v1", "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	return b
}

func TestNewBindingDerivesResolvedRootFromResolver(t *testing.T) {
	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(agentID string) string {
		if agentID != "agt_x" {
			return ""
		}
		return workDir
	})
	b, err := NewBinding(resolver, "agt_x", "mem_1", "main", "cjk-v1", "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	want := filepath.Join(workDir, "memory")
	if b.ResolvedRoot != want {
		t.Errorf("ResolvedRoot = %q, want %q", b.ResolvedRoot, want)
	}
	if b.SchemaVersion != BindingSchemaVersion {
		t.Errorf("SchemaVersion = %d, want %d", b.SchemaVersion, BindingSchemaVersion)
	}
}

func TestNewBindingRejectsNilResolver(t *testing.T) {
	if _, err := NewBinding(nil, "agt_x", "mem", "main", "a", "c"); !errors.Is(err, ErrInvalidBinding) {
		t.Fatalf("expected ErrInvalidBinding for nil resolver, got %v", err)
	}
}

func TestNewBindingRejectsResolverMiss(t *testing.T) {
	resolver := AgentWorkDirResolver(func(string) string { return "" })
	if _, err := NewBinding(resolver, "agt_x", "mem", "main", "a", "c"); !errors.Is(err, ErrInvalidBinding) {
		t.Fatalf("expected ErrInvalidBinding for resolver miss, got %v", err)
	}
}

func TestNewBindingRejectsEmptyIdentity(t *testing.T) {
	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 })
	cases := map[string]struct {
		agentID, memID, branch, analyzer, chunker string
	}{
		"empty agent":    {"", "mem", "main", "a", "c"},
		"empty memory":   {"agt_x", "", "main", "a", "c"},
		"empty branch":   {"agt_x", "mem", "", "a", "c"},
		"empty analyzer": {"agt_x", "mem", "main", "", "c"},
		"empty chunker":  {"agt_x", "mem", "main", "a", ""},
	}
	for name, c := range cases {
		t.Run(name, func(t *testing.T) {
			if _, err := NewBinding(resolver, c.agentID, c.memID, c.branch, c.analyzer, c.chunker); !errors.Is(err, ErrInvalidBinding) {
				t.Fatalf("expected ErrInvalidBinding, got %v", err)
			}
		})
	}
}

// TestBindingValidateWithRejectsForgedRoot verifies that the binding layer
// cannot be fooled into accepting a ResolvedRoot that does not match the
// resolver-derived path. A Binding deserialized from disk must re-run
// ValidateWith against the production resolver, and a mismatch (forged root,
// agent workspace moved) must reject.
func TestBindingValidateWithRejectsForgedRoot(t *testing.T) {
	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 })

	b := &Binding{
		SchemaVersion:   BindingSchemaVersion,
		LocalAgentID:    "agt_x",
		MemoryID:        "mem",
		Branch:          "main",
		ResolvedRoot:    "/tmp/agents/agt_x/memory", // well-shaped but forged
		AnalyzerVersion: "cjk-v1",
		ChunkerVersion:  "goldmark-v1",
	}
	if err := b.ValidateWith(resolver); !errors.Is(err, ErrInvalidBinding) {
		t.Fatalf("expected ErrInvalidBinding for forged root, got %v", err)
	}

	// A binding produced by NewBinding must pass ValidateWith with the same
	// resolver.
	good, err := NewBinding(resolver, "agt_x", "mem", "main", "cjk-v1", "goldmark-v1")
	if err != nil {
		t.Fatalf("NewBinding: %v", err)
	}
	if err := good.ValidateWith(resolver); err != nil {
		t.Fatalf("ValidateWith rejected a freshly constructed binding: %v", err)
	}
}

func TestBindingValidateWithRejectsSchemaDrift(t *testing.T) {
	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 })
	b := &Binding{
		SchemaVersion:   BindingSchemaVersion + 1,
		LocalAgentID:    "agt_x",
		MemoryID:        "mem",
		Branch:          "main",
		ResolvedRoot:    filepath.Join(workDir, "memory"),
		AnalyzerVersion: "cjk-v1",
		ChunkerVersion:  "goldmark-v1",
	}
	if err := b.ValidateWith(resolver); !errors.Is(err, ErrInvalidBinding) {
		t.Fatalf("expected ErrInvalidBinding for schema drift, got %v", err)
	}
}

func TestBindingFingerprintDeterministic(t *testing.T) {
	b := validTestBinding(t, "mem_test", "main")
	if b.Fingerprint() != b.Fingerprint() {
		t.Fatalf("fingerprint not deterministic")
	}
}

func TestBindingFingerprintChangesOnIdentityChange(t *testing.T) {
	base := validTestBinding(t, "mem_test", "main")
	baseFP := base.Fingerprint()

	mutated := *base
	mutated.MemoryID = base.MemoryID + "-x"
	if mutated.Fingerprint() == baseFP {
		t.Errorf("fingerprint did not change after memory mutation")
	}

	mutated = *base
	mutated.Branch = "feature/x"
	if mutated.Fingerprint() == baseFP {
		t.Errorf("fingerprint did not change after branch mutation")
	}

	mutated = *base
	mutated.AnalyzerVersion = "cjk-v2"
	if mutated.Fingerprint() == baseFP {
		t.Errorf("fingerprint did not change after analyzer mutation")
	}

	mutated = *base
	mutated.ChunkerVersion = "goldmark-v2"
	if mutated.Fingerprint() == baseFP {
		t.Errorf("fingerprint did not change after chunker mutation")
	}
}

func TestBindingBranchWhitespaceTolerant(t *testing.T) {
	b := validTestBinding(t, "mem_test", "main")
	bFP := b.Fingerprint()
	loose := *b
	loose.Branch = "  main  "
	if loose.Fingerprint() != bFP {
		t.Errorf("branch whitespace should not affect fingerprint")
	}
}

func TestBindingMatchesRetention(t *testing.T) {
	b := validTestBinding(t, "mem_1", "main")
	meta := RetentionMetadata{
		SchemaVersion: BindingSchemaVersion,
		MemoryID:      "mem_1",
		Branch:        "main",
	}
	if err := b.MatchesRetention(meta); err != nil {
		t.Fatalf("matching retention rejected: %v", err)
	}
}

func TestBindingMatchesRetentionRejectsDrift(t *testing.T) {
	b := validTestBinding(t, "mem_1", "main")
	cases := map[string]RetentionMetadata{
		"schema drift":  {SchemaVersion: BindingSchemaVersion + 1, MemoryID: "mem_1", Branch: "main"},
		"memory drift":  {SchemaVersion: BindingSchemaVersion, MemoryID: "mem_2", Branch: "main"},
		"branch drift":  {SchemaVersion: BindingSchemaVersion, MemoryID: "mem_1", Branch: "feature/x"},
		"revoked clone": {SchemaVersion: BindingSchemaVersion, MemoryID: "mem_1", Branch: "main", RevokedAt: "2026-07-15T00:00:00Z"},
		"cross agent":   {SchemaVersion: BindingSchemaVersion, MemoryID: "mem_1", Branch: "main", AgentID: "agt_other"},
	}
	for name, meta := range cases {
		t.Run(name, func(t *testing.T) {
			if err := b.MatchesRetention(meta); !errors.Is(err, ErrRetentionMismatch) {
				t.Fatalf("expected ErrRetentionMismatch, got %v", err)
			}
		})
	}
}

func TestBindingMatchesRetentionAgentIDOptional(t *testing.T) {
	b := validTestBinding(t, "mem_1", "main")
	// Empty AgentID in metadata is allowed (matches vault behavior): retention
	// marker written without an agent ID still satisfies the binding.
	meta := RetentionMetadata{
		SchemaVersion: BindingSchemaVersion,
		MemoryID:      "mem_1",
		Branch:        "main",
		AgentID:       "",
	}
	if err := b.MatchesRetention(meta); err != nil {
		t.Fatalf("empty AgentID should be tolerated: %v", err)
	}
	// Matching AgentID is accepted.
	meta.AgentID = b.LocalAgentID
	if err := b.MatchesRetention(meta); err != nil {
		t.Fatalf("matching AgentID should be accepted: %v", err)
	}
}
