package datastore

import (
	"context"
	"database/sql"
	"database/sql/driver"
	"errors"
	"fmt"
	"reflect"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/mem9-ai/drive9/pkg/logger"
	"github.com/mem9-ai/drive9/pkg/testtidb"
	"github.com/mem9-ai/drive9/pkg/traceid"
	"go.uber.org/zap"
	"go.uber.org/zap/zaptest/observer"
)

func newTestStore(t *testing.T) *Store {
	t.Helper()
	initDatastoreSchema(t, testDSN)
	s, err := Open(testDSN)
	if err != nil {
		t.Fatal(err)
	}
	testtidb.ResetDB(t, s.DB())
	t.Cleanup(func() { _ = s.Close() })
	return s
}

var seq int

func TestShouldLogStoreOpFailure(t *testing.T) {
	tests := []struct {
		result string
		want   bool
	}{
		{result: "not_found", want: false},
		{result: "canceled", want: false},
		{result: "deadline_exceeded", want: false},
		{result: "conn_closed", want: false},
		{result: "conflict", want: false},
		{result: "error", want: true},
	}
	for _, tt := range tests {
		t.Run(tt.result, func(t *testing.T) {
			if got := shouldLogStoreOpFailure(tt.result); got != tt.want {
				t.Fatalf("shouldLogStoreOpFailure(%q) = %v, want %v", tt.result, got, tt.want)
			}
		})
	}
}

func TestListConfirmedS3RefsIncludesContentLayout(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		return s.InsertFileTx(tx, &File{
			FileID:        "append-log-ref",
			StorageType:   StorageS3,
			ContentLayout: ContentLayoutAppendLog,
			StorageRef:    "append-log/ns-a/file-a/generation-a",
			SizeBytes:     4,
			Revision:      1,
			Status:        StatusConfirmed,
			CreatedAt:     now,
			ConfirmedAt:   &now,
		})
	}); err != nil {
		t.Fatalf("insert append-log ref: %v", err)
	}
	refs, _, err := s.ListConfirmedS3Refs(ctx, "", 10)
	if err != nil {
		t.Fatalf("list confirmed S3 refs: %v", err)
	}
	if len(refs) != 1 || refs[0].ContentLayout != ContentLayoutAppendLog {
		t.Fatalf("confirmed S3 refs = %+v, want one append_log ref", refs)
	}
}

func TestStoreOpResultForErrorClassifiesExpectedConflicts(t *testing.T) {
	tests := []struct {
		name string
		err  error
		want string
	}{
		{name: "nil", err: nil, want: "ok"},
		{name: "not found", err: ErrNotFound, want: "not_found"},
		{name: "path conflict", err: ErrPathConflict, want: "conflict"},
		{name: "directory not empty", err: ErrDirectoryNotEmpty, want: "conflict"},
		{name: "directory not empty wrapped", err: fmt.Errorf("%w: /dir/", ErrDirectoryNotEmpty), want: "conflict"},
		{name: "revision conflict", err: ErrRevisionConflict, want: "conflict"},
		{name: "upload conflict", err: ErrUploadConflict, want: "conflict"},
		{name: "upload not active", err: ErrUploadNotActive, want: "conflict"},
		{name: "idempotency conflict", err: ErrIdempotencyConflict, want: "conflict"},
		{name: "journal conflict", err: ErrJournalConflict, want: "conflict"},
		{name: "canceled", err: context.Canceled, want: "canceled"},
		{name: "deadline", err: context.DeadlineExceeded, want: "deadline_exceeded"},
		{name: "generic", err: errors.New("boom"), want: "error"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := storeOpResultForError(tt.err); got != tt.want {
				t.Errorf("storeOpResultForError(%v) = %q, want %q", tt.err, got, tt.want)
			}
		})
	}
}

func TestStatWithFallbackReturnsPrimaryWithoutFallback(t *testing.T) {
	primary := &NodeWithFile{Node: FileNode{Path: "/file.txt"}}
	var paths []string

	got, err := statWithFallback(context.Background(), "/file.txt", "/file.txt/", func(_ context.Context, path string) (*NodeWithFile, error) {
		paths = append(paths, path)
		return primary, nil
	})
	if err != nil {
		t.Fatalf("statWithFallback: %v", err)
	}
	if got != primary {
		t.Fatalf("result = %p, want primary %p", got, primary)
	}
	if !reflect.DeepEqual(paths, []string{"/file.txt"}) {
		t.Fatalf("queried paths = %v, want [/file.txt]", paths)
	}
}

func TestStatWithFallbackRetriesNotFound(t *testing.T) {
	fallback := &NodeWithFile{Node: FileNode{Path: "/dir/"}}
	var paths []string

	got, err := statWithFallback(context.Background(), "/dir", "/dir/", func(_ context.Context, path string) (*NodeWithFile, error) {
		paths = append(paths, path)
		if path == "/dir" {
			return nil, ErrNotFound
		}
		return fallback, nil
	})
	if err != nil {
		t.Fatalf("statWithFallback: %v", err)
	}
	if got != fallback {
		t.Fatalf("result = %p, want fallback %p", got, fallback)
	}
	if !reflect.DeepEqual(paths, []string{"/dir", "/dir/"}) {
		t.Fatalf("queried paths = %v, want [/dir /dir/]", paths)
	}
}

func TestStatWithFallbackReturnsPrimaryError(t *testing.T) {
	wantErr := errors.New("query failed")
	var paths []string

	got, err := statWithFallback(context.Background(), "/file.txt", "/file.txt/", func(_ context.Context, path string) (*NodeWithFile, error) {
		paths = append(paths, path)
		return nil, wantErr
	})
	if got != nil {
		t.Fatalf("result = %+v, want nil", got)
	}
	if !errors.Is(err, wantErr) {
		t.Fatalf("error = %v, want %v", err, wantErr)
	}
	if !reflect.DeepEqual(paths, []string{"/file.txt"}) {
		t.Fatalf("queried paths = %v, want [/file.txt]", paths)
	}
}

func TestStatWithFallbackDoesNotRepeatSamePath(t *testing.T) {
	var paths []string

	got, err := statWithFallback(context.Background(), "/missing", "/missing", func(_ context.Context, path string) (*NodeWithFile, error) {
		paths = append(paths, path)
		return nil, ErrNotFound
	})
	if got != nil {
		t.Fatalf("result = %+v, want nil", got)
	}
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("error = %v, want %v", err, ErrNotFound)
	}
	if !reflect.DeepEqual(paths, []string{"/missing"}) {
		t.Fatalf("queried paths = %v, want [/missing]", paths)
	}
}

func TestObserveStoreOpConflictLogsWarnDetail(t *testing.T) {
	core, recorded := observer.New(zap.WarnLevel)
	prevLogger := logger.L()
	logger.Set(zap.New(core))
	t.Cleanup(func() { logger.Set(prevLogger) })

	err := ErrPathConflict
	observeStoreOp(context.Background(), "in_tx", time.Now(), &err)

	entries := recorded.FilterMessage("datastore_op_failed").All()
	if len(entries) != 1 {
		t.Fatalf("datastore_op_failed entries = %d, want 1", len(entries))
	}
	if entries[0].Level != zap.WarnLevel {
		t.Fatalf("datastore_op_failed level = %s, want warn", entries[0].Level)
	}
	fields := entries[0].ContextMap()
	if _, ok := fields["error"]; ok {
		t.Fatalf("conflict log must not include error field: %#v", fields)
	}
	if fields["detail"] != ErrPathConflict.Error() {
		t.Fatalf("detail field = %v, want %q", fields["detail"], ErrPathConflict.Error())
	}
}

func TestOpenForTenantPropagatesTraceToDBTiming(t *testing.T) {
	initDatastoreSchema(t, testDSN)
	t.Setenv("DRIVE9_DB_TRACE_LOG_ENABLED", "true")
	t.Setenv("DRIVE9_DB_SLOW_TRACE_MS", "0")
	logger.ResetDBTraceLogEnabledForTest()
	logger.ResetDBSlowTraceThresholdForTest()
	t.Cleanup(logger.ResetDBTraceLogEnabledForTest)
	t.Cleanup(logger.ResetDBSlowTraceThresholdForTest)

	core, recorded := observer.New(zap.InfoLevel)
	prevLogger := logger.L()
	logger.Set(zap.New(core))
	t.Cleanup(func() { logger.Set(prevLogger) })

	const wantTraceID = "trace-open-store"
	store, err := OpenForTenant(traceid.With(context.Background(), wantTraceID), testDSN, "tenant-trace")
	if err != nil {
		t.Fatalf("OpenForTenant: %v", err)
	}
	t.Cleanup(func() { _ = store.Close() })

	entries := recorded.FilterMessage("db_operation_timing").All()
	if len(entries) == 0 {
		t.Fatal("db_operation_timing entries = 0, want at least 1")
	}
	for _, entry := range entries {
		if entry.ContextMap()["trace_id"] == wantTraceID {
			return
		}
	}
	t.Fatalf("no db_operation_timing entry carried trace_id %q; entries=%#v", wantTraceID, entries)
}

func genID() string {
	seq++
	return fmt.Sprintf("id-%04d", seq)
}

func setEmbeddingRevision(t *testing.T, s *Store, fileID string, revision int64) {
	t.Helper()
	if _, err := s.DB().Exec(`UPDATE files SET embedding_revision = ? WHERE file_id = ?`, revision, fileID); err != nil {
		t.Fatalf("set embedding_revision for %s: %v", fileID, err)
	}
	if _, err := s.DB().Exec(`UPDATE semantic SET embedding_revision = ? WHERE inode_id = ?`, revision, fileID); err != nil {
		t.Fatalf("set semantic embedding_revision for %s: %v", fileID, err)
	}
}

func requireEmbeddingRevision(t *testing.T, got *int64, want int64) {
	t.Helper()
	if got == nil {
		t.Fatalf("embedding_revision=nil, want %d", want)
		return
	}
	if *got != want {
		t.Fatalf("embedding_revision=%d, want %d", *got, want)
	}
}

func TestInsertAndGetNode(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	node := &FileNode{
		NodeID: "n1", Path: "/data/file.txt", ParentPath: "/data/",
		Name: "file.txt", FileID: "f1", CreatedAt: now,
	}
	if err := s.InsertNode(context.Background(), node); err != nil {
		t.Fatal(err)
	}
	got, err := s.GetNode(context.Background(), "/data/file.txt")
	if err != nil {
		t.Fatal(err)
	}
	if got.NodeID != "n1" || got.FileID != "f1" || got.Name != "file.txt" {
		t.Errorf("unexpected node: %+v", got)
	}
}

func TestGetNodeNotFound(t *testing.T) {
	s := newTestStore(t)
	_, err := s.GetNode(context.Background(), "/nonexistent")
	if err != ErrNotFound {
		t.Errorf("expected ErrNotFound, got %v", err)
	}
}

func TestInsertAndGetFile(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	f := &File{
		FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		ContentType: "text/plain", SizeBytes: 100, Revision: 1,
		Status: StatusConfirmed, ContentText: "hello world",
		CreatedAt: now, ConfirmedAt: &now,
	}
	if err := s.InsertFile(context.Background(), f); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f1", 7)
	got, err := s.GetFile(context.Background(), "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.StorageType != StorageDB9 || got.SizeBytes != 100 || got.ContentText != "hello world" {
		t.Errorf("unexpected file: %+v", got)
	}
	requireEmbeddingRevision(t, got.EmbeddingRevision, 7)
}

func TestStat(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 42, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f1", 11)
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	nf, err := s.Stat(context.Background(), "/a.txt")
	if err != nil {
		t.Fatal(err)
	}
	if nf.Node.Path != "/a.txt" || nf.File == nil || nf.File.SizeBytes != 42 {
		t.Errorf("unexpected stat: node=%+v file=%+v", nf.Node, nf.File)
	}
	requireEmbeddingRevision(t, nf.File.EmbeddingRevision, 11)
}

func TestStatLite(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	// Insert a file with content_blob and content_text populated.
	if err := s.InsertFile(ctx, &File{
		FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		ContentBlob: []byte("hello world"), ContentText: "hello world",
		SizeBytes: 11, ChecksumSHA256: "checksum-1", Revision: 3,
		Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.DB().Exec(`UPDATE files SET description = 'test desc' WHERE file_id = 'f1'`); err != nil {
		t.Fatal(err)
	}
	if _, err := s.DB().Exec(`UPDATE semantic SET description = 'test desc' WHERE inode_id = 'f1'`); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/lite.txt", ParentPath: "/", Name: "lite.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	// StatLite should return metadata but NOT blob/text/description.
	nf, err := s.StatLite(ctx, "/lite.txt")
	if err != nil {
		t.Fatalf("StatLite: %v", err)
	}
	if nf.Node.Path != "/lite.txt" {
		t.Fatalf("path=%q, want /lite.txt", nf.Node.Path)
	}
	if nf.File == nil {
		t.Fatal("StatLite: file is nil")
	}
	if nf.File.SizeBytes != 11 {
		t.Fatalf("size=%d, want 11", nf.File.SizeBytes)
	}
	if nf.File.Revision != 3 {
		t.Fatalf("revision=%d, want 3", nf.File.Revision)
	}
	if nf.File.ConfirmedAt == nil {
		t.Fatal("confirmed_at is nil")
	}
	// These fields must be zero/empty — lite path does not fetch them.
	if len(nf.File.ContentBlob) != 0 {
		t.Fatalf("ContentBlob should be empty in lite path, got %d bytes", len(nf.File.ContentBlob))
	}
	if nf.File.ContentText != "" {
		t.Fatalf("ContentText should be empty in lite path, got %q", nf.File.ContentText)
	}
	if nf.File.Description != "" {
		t.Fatalf("Description should be empty in lite path, got %q", nf.File.Description)
	}
	if nf.File.ChecksumSHA256 != "" {
		t.Fatalf("ChecksumSHA256 should be empty in default lite path, got %q", nf.File.ChecksumSHA256)
	}
	// StorageType IS fetched by the lite path: the server's HEAD handler
	// advertises it as X-Dat9-Storage-Type so clients can route partial
	// updates on fact instead of a size heuristic.
	if nf.File.StorageType != StorageDB9 {
		t.Fatalf("StorageType = %q, want %q", nf.File.StorageType, StorageDB9)
	}

	withChecksum, err := s.StatLiteWithChecksum(ctx, "/lite.txt")
	if err != nil {
		t.Fatalf("StatLiteWithChecksum: %v", err)
	}
	if withChecksum.File == nil || withChecksum.File.ChecksumSHA256 != "checksum-1" {
		t.Fatalf("StatLiteWithChecksum file = %+v, want checksum-1", withChecksum.File)
	}
	if len(withChecksum.File.ContentBlob) != 0 || withChecksum.File.ContentText != "" || withChecksum.File.Description != "" {
		t.Fatalf("StatLiteWithChecksum loaded non-metadata content: %+v", withChecksum.File)
	}
}

func TestStatPathFallbackLite(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{
		FileID: "f2", StorageType: StorageDB9, StorageRef: "/blobs/f2",
		ContentBlob: []byte("data"), SizeBytes: 4, ChecksumSHA256: "checksum-2", Revision: 7,
		Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n2", Path: "/fb.txt", ParentPath: "/", Name: "fb.txt", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	// Primary path match.
	nf, err := s.StatPathFallbackLite(ctx, "/fb.txt", "/nonexist")
	if err != nil {
		t.Fatalf("StatPathFallbackLite: %v", err)
	}
	if nf.File == nil || nf.File.Revision != 7 {
		t.Fatalf("unexpected file: %+v", nf.File)
	}
	if len(nf.File.ContentBlob) != 0 {
		t.Fatal("lite fallback should not return ContentBlob")
	}

	// Fallback path match.
	nf2, err := s.StatPathFallbackLite(ctx, "/nonexist", "/fb.txt")
	if err != nil {
		t.Fatalf("StatPathFallbackLite fallback: %v", err)
	}
	if nf2.File == nil || nf2.File.Revision != 7 {
		t.Fatalf("fallback: unexpected file: %+v", nf2.File)
	}

	withChecksum, err := s.StatPathFallbackLiteWithChecksum(ctx, "/nonexist", "/fb.txt")
	if err != nil {
		t.Fatalf("StatPathFallbackLiteWithChecksum: %v", err)
	}
	if withChecksum.File == nil || withChecksum.File.ChecksumSHA256 != "checksum-2" {
		t.Fatalf("fallback checksum file = %+v, want checksum-2", withChecksum.File)
	}
}

func TestStatMetadataProjectionAndFallback(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{
		FileID: "f-metadata", StorageType: StorageDB9, StorageRef: "/blobs/f-metadata",
		ContentBlob: []byte("inline metadata body"), ContentType: "text/plain",
		SizeBytes: 20, ChecksumSHA256: "metadata-checksum", Revision: 9,
		Status: StatusConfirmed, ContentText: "semantic metadata text", Description: "private description",
		CreatedAt: now, ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{
		NodeID: "n-metadata", Path: "/metadata.txt", ParentPath: "/", Name: "metadata.txt",
		FileID: "f-metadata", CreatedAt: now,
	}); err != nil {
		t.Fatal(err)
	}

	primary, err := s.StatMetadata(ctx, "/metadata.txt")
	if err != nil {
		t.Fatalf("StatMetadata: %v", err)
	}
	assertStatMetadataProjection(t, primary)

	fallback, err := s.StatPathFallbackMetadata(ctx, "/missing.txt", "/metadata.txt")
	if err != nil {
		t.Fatalf("StatPathFallbackMetadata: %v", err)
	}
	assertStatMetadataProjection(t, fallback)
}

func TestStatMetadataPreservesDirectoryShape(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	if err := s.EnsureParentDirs(ctx, "/metadata-dir/child.txt", genID); err != nil {
		t.Fatalf("EnsureParentDirs: %v", err)
	}

	explicit, err := s.StatMetadata(ctx, "/metadata-dir/")
	if err != nil {
		t.Fatalf("StatMetadata: %v", err)
	}
	if !explicit.Node.IsDirectory || explicit.File != nil {
		t.Fatalf("explicit directory metadata stat = %+v", explicit)
	}

	fallback, err := s.StatPathFallbackMetadata(ctx, "/metadata-dir", "/metadata-dir/")
	if err != nil {
		t.Fatalf("StatPathFallbackMetadata: %v", err)
	}
	if !fallback.Node.IsDirectory || fallback.File == nil {
		t.Fatalf("fallback directory metadata stat = %+v", fallback)
	}
}

func assertStatMetadataProjection(t *testing.T, nf *NodeWithFile) {
	t.Helper()
	if nf == nil || nf.Node.Path != "/metadata.txt" || nf.File == nil {
		t.Fatalf("metadata stat = %+v", nf)
	}
	if nf.File.FileID != "f-metadata" || nf.File.SizeBytes != 20 || nf.File.Revision != 9 {
		t.Fatalf("metadata file identity = %+v", nf.File)
	}
	if nf.File.ContentType != "text/plain" || nf.File.ContentText != "semantic metadata text" {
		t.Fatalf("metadata content fields = %+v", nf.File)
	}
	if nf.File.Description != "private description" {
		t.Fatalf("metadata description = %+v", nf.File)
	}
	if nf.File.ConfirmedAt == nil {
		t.Fatalf("metadata confirmed_at = %v", nf.File.ConfirmedAt)
	}
	if len(nf.File.ContentBlob) != 0 || nf.File.StorageType != "" || nf.File.StorageRef != "" {
		t.Fatalf("metadata stat loaded storage content: %+v", nf.File)
	}
	if nf.File.ChecksumSHA256 != "" || nf.File.EmbeddingRevision != nil || nf.File.DescriptionEmbeddingRevision != nil {
		t.Fatalf("metadata stat loaded unused fields: %+v", nf.File)
	}
}

func TestStatForRead(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{
		FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		ContentBlob: []byte("inline data here"), SizeBytes: 16, Revision: 5,
		Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/read.txt", ParentPath: "/", Name: "read.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	nf, err := s.StatForRead(ctx, "/read.txt")
	if err != nil {
		t.Fatalf("StatForRead: %v", err)
	}
	if nf.File == nil {
		t.Fatal("file is nil")
	}
	// StatForRead MUST return storage_type + content_blob for inline reads.
	if nf.File.StorageType != StorageDB9 {
		t.Fatalf("storage_type=%q, want db9", nf.File.StorageType)
	}
	if string(nf.File.ContentBlob) != "inline data here" {
		t.Fatalf("content_blob=%q, want 'inline data here'", nf.File.ContentBlob)
	}
	if nf.File.SizeBytes != 16 {
		t.Fatalf("size=%d, want 16", nf.File.SizeBytes)
	}
	if nf.File.Revision != 5 {
		t.Fatalf("revision=%d, want 5", nf.File.Revision)
	}
	// But it should NOT return text/description/embedding fields.
	if nf.File.ContentText != "" {
		t.Fatalf("ContentText should be empty, got %q", nf.File.ContentText)
	}
	if nf.File.Description != "" {
		t.Fatalf("Description should be empty, got %q", nf.File.Description)
	}
}

func TestListDir(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d1", Path: "/data/", ParentPath: "/", Name: "data", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f1", 13)
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/data/a.txt", ParentPath: "/data/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d2", Path: "/data/sub/", ParentPath: "/data/", Name: "sub", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	entries, err := s.ListDir(context.Background(), "/data/")
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(entries))
	}
	if entries[0].Node.Name != "a.txt" || entries[1].Node.Name != "sub" {
		t.Errorf("unexpected entries: %+v, %+v", entries[0].Node, entries[1].Node)
	}
	if entries[0].File == nil {
		t.Fatal("expected file entry for a.txt")
	}
	requireEmbeddingRevision(t, entries[0].File.EmbeddingRevision, 13)
}

func TestInsertNodeRejectsRootDentry(t *testing.T) {
	s := newTestStore(t)
	for _, name := range []string{"/", "root-alias"} {
		err := s.InsertNode(context.Background(), &FileNode{
			NodeID:      "root-" + name,
			Path:        "/",
			ParentPath:  "/",
			Name:        name,
			IsDirectory: true,
			CreatedAt:   time.Now(),
		})
		if !errors.Is(err, ErrInvalidRootDentry) {
			t.Fatalf("InsertNode root dentry name %q error = %v, want %v", name, err, ErrInvalidRootDentry)
		}
	}
}

func TestStoreRejectsRootPathWrites(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()

	err := s.InsertNodeTx(s.DB(), &FileNode{
		NodeID:      "root-tx",
		Path:        "/",
		ParentPath:  "/",
		Name:        "root-alias",
		IsDirectory: true,
		CreatedAt:   now,
	})
	if !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("InsertNodeTx root path error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if err := s.UpdateNodePath(context.Background(), "/src.txt", "/", "/", "root-alias"); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("UpdateNodePath root destination error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if err := s.UpdateNodePath(context.Background(), "/", "/dst.txt", "/", "dst.txt"); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("UpdateNodePath root source error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if _, err := s.RenameFileReplacingTarget(context.Background(), "/src.txt", "/", "/", "root-alias"); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("RenameFileReplacingTarget root destination error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if _, err := s.RenameFileReplacingTarget(context.Background(), "/", "/dst.txt", "/", "dst.txt"); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("RenameFileReplacingTarget root source error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if err := s.LinkFileNodeTx(context.Background(), s.DB(), "/src.txt", "/", "/", "root-alias", "link-root", now); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("LinkFileNodeTx root destination error = %v, want %v", err, ErrInvalidRootDentry)
	}
	if err := s.LinkFileNodeTx(context.Background(), s.DB(), "/", "/dst.txt", "/", "dst.txt", "link-dst", now); !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("LinkFileNodeTx root source error = %v, want %v", err, ErrInvalidRootDentry)
	}
}

func TestListDirSkipsHistoricalRootDentry(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if _, err := s.DB().Exec(`
		INSERT INTO file_nodes (node_id, path, path_hash, parent_path, parent_path_hash, name, is_directory, created_at)
		VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
		"root-self", "/", fileNodePathHash("/"), "/", fileNodePathHash("/"), "root-alias", now); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{
		NodeID:      "d1",
		Path:        "/data/",
		ParentPath:  "/",
		Name:        "data",
		IsDirectory: true,
		CreatedAt:   now,
	}); err != nil {
		t.Fatal(err)
	}

	entries, err := s.ListDir(context.Background(), "/")
	if err != nil {
		t.Fatal(err)
	}
	if len(entries) != 1 || entries[0].Node.Name != "data" {
		t.Fatalf("ListDir entries = %+v, want only data", entries)
	}
}

func TestListNodes(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertInode(context.Background(), &Inode{InodeID: "i1", SizeBytes: 0, Revision: 1, Mode: 0o755, Status: StatusConfirmed, CreatedAt: now, Mtime: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d1", Path: "/data/", ParentPath: "/", Name: "data", IsDirectory: true, InodeID: "i1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/data/a.txt", ParentPath: "/data/", Name: "a.txt", FileID: "f1", InodeID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	nodes, err := s.ListNodes(context.Background(), "/data/")
	if err != nil {
		t.Fatal(err)
	}
	if len(nodes) != 1 {
		t.Fatalf("expected 1 node, got %d", len(nodes))
	}
	if nodes[0].Name != "a.txt" {
		t.Errorf("name=%q, want a.txt", nodes[0].Name)
	}
	if nodes[0].InodeID != "f1" {
		t.Errorf("inode_id=%q, want f1", nodes[0].InodeID)
	}
}

func TestListNodesSkipsHistoricalRootDentry(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if _, err := s.DB().Exec(`
		INSERT INTO file_nodes (node_id, path, path_hash, parent_path, parent_path_hash, name, is_directory, created_at)
		VALUES (?, ?, ?, ?, ?, ?, 1, ?)`,
		"root-self", "/", fileNodePathHash("/"), "/", fileNodePathHash("/"), "root-alias", now); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{
		NodeID:      "d1",
		Path:        "/data/",
		ParentPath:  "/",
		Name:        "data",
		IsDirectory: true,
		CreatedAt:   now,
	}); err != nil {
		t.Fatal(err)
	}

	nodes, err := s.ListNodes(context.Background(), "/")
	if err != nil {
		t.Fatal(err)
	}
	if len(nodes) != 1 || nodes[0].Name != "data" {
		t.Fatalf("ListNodes entries = %+v, want only data", nodes)
	}
}

func TestUpdateFileSearchText(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		Revision:    2,
		Status:      StatusConfirmed,
		ContentText: "old text",
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	updated, err := s.UpdateFileSearchText(context.Background(), "f1", 2, "new text")
	if err != nil {
		t.Fatal(err)
	}
	if !updated {
		t.Fatal("expected revision-gated update to succeed")
	}
	if got := mustFile(t, s, "f1").ContentText; got != "new text" {
		t.Fatalf("content_text=%q, want %q", got, "new text")
	}
}

func TestUpdateFileSearchTextRejectsStaleRevision(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		Revision:    3,
		Status:      StatusConfirmed,
		ContentText: "old text",
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	updated, err := s.UpdateFileSearchText(context.Background(), "f1", 2, "new text")
	if err != nil {
		t.Fatal(err)
	}
	if updated {
		t.Fatal("stale revision should not update content_text")
	}
	if got := mustFile(t, s, "f1").ContentText; got != "old text" {
		t.Fatalf("content_text=%q, want %q", got, "old text")
	}
}

func TestUpdateFileSearchTextTxRollsBackWithOuterTransaction(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		Revision:    2,
		Status:      StatusConfirmed,
		ContentText: "old text",
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		updated, err := s.UpdateFileSearchTextTx(tx, "f1", 2, "new text")
		if err != nil {
			return err
		}
		if !updated {
			t.Fatal("expected transactional search-text update to succeed")
		}
		return context.Canceled
	})
	if err != context.Canceled {
		t.Fatalf("rollback error=%v, want %v", err, context.Canceled)
	}
	if got := mustFile(t, s, "f1").ContentText; got != "old text" {
		t.Fatalf("content_text=%q, want %q after rollback", got, "old text")
	}
}

func TestReplaceFileTagsTxAndGetFileTags(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f-tags",
		StorageType: StorageDB9,
		StorageRef:  "inline",
		Revision:    1,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsTx(tx, "f-tags", map[string]string{
			"owner": "alice",
			"topic": "cat",
		})
	}); err != nil {
		t.Fatalf("ReplaceFileTagsTx(initial): %v", err)
	}

	tags, err := s.GetFileTags(context.Background(), "f-tags")
	if err != nil {
		t.Fatalf("GetFileTags(initial): %v", err)
	}
	if tags["owner"] != "alice" || tags["topic"] != "cat" || len(tags) != 2 {
		t.Fatalf("initial tags = %+v, want owner/topic", tags)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsTx(tx, "f-tags", map[string]string{
			"owner": "bob",
		})
	}); err != nil {
		t.Fatalf("ReplaceFileTagsTx(replace): %v", err)
	}
	tags, err = s.GetFileTags(context.Background(), "f-tags")
	if err != nil {
		t.Fatalf("GetFileTags(replace): %v", err)
	}
	if tags["owner"] != "bob" || len(tags) != 1 {
		t.Fatalf("replaced tags = %+v, want only owner=bob", tags)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsTx(tx, "f-tags", map[string]string{})
	}); err != nil {
		t.Fatalf("ReplaceFileTagsTx(clear): %v", err)
	}
	tags, err = s.GetFileTags(context.Background(), "f-tags")
	if err != nil {
		t.Fatalf("GetFileTags(clear): %v", err)
	}
	if len(tags) != 0 {
		t.Fatalf("cleared tags = %+v, want empty", tags)
	}
}

func TestUpdateFileDescriptionTxClearsDescriptionEmbedding(t *testing.T) {
	s := newTestStore(t)
	now := time.Now().UTC()
	if err := s.InsertFile(context.Background(), &File{
		FileID: "f-desc", StorageType: StorageDB9, StorageRef: "/blobs/f-desc",
		Revision: 2, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now,
		Description: "old description",
	}); err != nil {
		t.Fatal(err)
	}
	updated, err := s.UpdateFileDescriptionEmbeddingForGeneration(context.Background(), "f-desc", 2, 7, testEmbeddingVector(0.1, 0.2, 0.3))
	if err != nil || !updated {
		t.Fatalf("seed description embedding: updated=%v err=%v", updated, err)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		if _, err := s.LockConfirmedFileRevisionTx(tx, "f-desc"); err != nil {
			return err
		}
		return s.UpdateFileDescriptionTx(tx, "f-desc", "new description")
	}); err != nil {
		t.Fatalf("UpdateFileDescriptionTx: %v", err)
	}

	sem, err := s.GetSemantic(context.Background(), "f-desc")
	if err != nil {
		t.Fatal(err)
	}
	if sem.Description != "new description" {
		t.Fatalf("description = %q, want %q", sem.Description, "new description")
	}
	if sem.DescriptionEmbeddingRevision != nil {
		t.Fatalf("description_embedding_revision = %v, want cleared", *sem.DescriptionEmbeddingRevision)
	}
	if got := mustFile(t, s, "f-desc").Revision; got != 2 {
		t.Fatalf("revision = %d, want unchanged 2", got)
	}

	// Clearing the description stores NULL.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.UpdateFileDescriptionTx(tx, "f-desc", "")
	}); err != nil {
		t.Fatalf("UpdateFileDescriptionTx(clear): %v", err)
	}
	sem, err = s.GetSemantic(context.Background(), "f-desc")
	if err != nil {
		t.Fatal(err)
	}
	if sem.Description != "" {
		t.Fatalf("description after clear = %q, want empty", sem.Description)
	}

	// Unknown file is reported by the revision lock.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		_, err := s.LockConfirmedFileRevisionTx(tx, "f-missing")
		return err
	}); !errors.Is(err, ErrNotFound) {
		t.Fatalf("LockConfirmedFileRevisionTx(missing) err = %v, want ErrNotFound", err)
	}
}

func TestReplaceUserFileTagsTxPreservesGeneratedTags(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f-user-tags",
		StorageType: StorageDB9,
		StorageRef:  "inline",
		Revision:    1,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		if err := s.ReplaceFileTagsTx(tx, "f-user-tags", map[string]string{"owner": "alice"}); err != nil {
			return err
		}
		_, err := s.InsertFileTagsWithSourceTx(tx, "f-user-tags", TagSourceImageExtract, map[string]string{"scene": "cat"})
		return err
	}); err != nil {
		t.Fatalf("seed tags: %v", err)
	}

	// User-tag replace keeps extractor-generated tags.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceUserFileTagsTx(tx, "f-user-tags", map[string]string{"owner": "bob", "env": "prod"})
	}); err != nil {
		t.Fatalf("ReplaceUserFileTagsTx(replace): %v", err)
	}
	tags, err := s.GetFileTags(context.Background(), "f-user-tags")
	if err != nil {
		t.Fatalf("GetFileTags(replace): %v", err)
	}
	if len(tags) != 3 || tags["owner"] != "bob" || tags["env"] != "prod" || tags["scene"] != "cat" {
		t.Fatalf("tags after user replace = %+v, want owner/env user tags plus scene generated tag", tags)
	}

	// Empty map clears user tags only.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceUserFileTagsTx(tx, "f-user-tags", map[string]string{})
	}); err != nil {
		t.Fatalf("ReplaceUserFileTagsTx(clear): %v", err)
	}
	tags, err = s.GetFileTags(context.Background(), "f-user-tags")
	if err != nil {
		t.Fatalf("GetFileTags(clear): %v", err)
	}
	if len(tags) != 1 || tags["scene"] != "cat" {
		t.Fatalf("tags after user clear = %+v, want only scene generated tag", tags)
	}

	// A user key that collides with a generated key takes ownership of it
	// instead of failing on the (file_id, tag_key) primary key.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceUserFileTagsTx(tx, "f-user-tags", map[string]string{"scene": "dog"})
	}); err != nil {
		t.Fatalf("ReplaceUserFileTagsTx(collide): %v", err)
	}
	var value, source string
	if err := s.DB().QueryRow(`SELECT tag_value, source FROM file_tags WHERE file_id = ? AND tag_key = ?`,
		"f-user-tags", "scene").Scan(&value, &source); err != nil {
		t.Fatalf("read collided tag: %v", err)
	}
	if value != "dog" || source != TagSourceUser {
		t.Fatalf("collided tag = (%q, %q), want (dog, user)", value, source)
	}

	// Extractor writes skip user-owned keys: the user value still wins.
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		_, err := s.InsertFileTagsWithSourceTx(tx, "f-user-tags", TagSourceImageExtract, map[string]string{"scene": "cat", "mood": "calm"})
		return err
	}); err != nil {
		t.Fatalf("re-extract: %v", err)
	}
	tags, err = s.GetFileTags(context.Background(), "f-user-tags")
	if err != nil {
		t.Fatalf("GetFileTags(re-extract): %v", err)
	}
	if tags["scene"] != "dog" || tags["mood"] != "calm" || len(tags) != 2 {
		t.Fatalf("tags after re-extract = %+v, want user scene=dog plus generated mood=calm", tags)
	}
}

func TestLockDentryFileIDTx(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID: "f-dentry", StorageType: StorageDB9, StorageRef: "inline",
		Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{
		NodeID: "n-dentry", Path: "/dentry.txt", ParentPath: "/", Name: "dentry.txt",
		FileID: "f-dentry", CreatedAt: now,
	}); err != nil {
		t.Fatal(err)
	}

	var id string
	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		var err error
		id, err = s.LockDentryFileIDTx(tx, "/dentry.txt")
		return err
	}); err != nil {
		t.Fatalf("LockDentryFileIDTx: %v", err)
	}
	if id != "f-dentry" {
		t.Fatalf("dentry identity = %q, want f-dentry", id)
	}

	if err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		_, err := s.LockDentryFileIDTx(tx, "/missing.txt")
		return err
	}); !errors.Is(err, ErrNotFound) {
		t.Fatalf("LockDentryFileIDTx(missing) err = %v, want ErrNotFound", err)
	}
}

func TestReplaceFileTagsByPrefixTxPreservesOtherTags(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{
		FileID:      "f-prefix-tags",
		StorageType: StorageDB9,
		StorageRef:  "inline",
		Revision:    1,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}

	err := s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsTx(tx, "f-prefix-tags", map[string]string{
			"album":                  "Inbox",
			"drive9.image.schema":    "old_schema",
			"drive9.image.tag.en.0":  "old",
			"drive9.thumbnail.ready": "true",
		})
	})
	if err != nil {
		t.Fatalf("ReplaceFileTagsTx(initial): %v", err)
	}

	err = s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsByPrefixTx(tx, "f-prefix-tags", "drive9.image.", map[string]string{
			"drive9.image.schema":   "structured_v1",
			"drive9.image.tag.en.0": "autumn road",
		})
	})
	if err != nil {
		t.Fatalf("ReplaceFileTagsByPrefixTx(replace): %v", err)
	}
	tags, err := s.GetFileTags(context.Background(), "f-prefix-tags")
	if err != nil {
		t.Fatalf("GetFileTags(replace): %v", err)
	}
	want := map[string]string{
		"album":                  "Inbox",
		"drive9.image.schema":    "structured_v1",
		"drive9.image.tag.en.0":  "autumn road",
		"drive9.thumbnail.ready": "true",
	}
	if !reflect.DeepEqual(tags, want) {
		t.Fatalf("tags after prefix replace = %+v, want %+v", tags, want)
	}

	err = s.InTx(context.Background(), func(tx *sql.Tx) error {
		return s.ReplaceFileTagsByPrefixTx(tx, "f-prefix-tags", "drive9.image.", nil)
	})
	if err != nil {
		t.Fatalf("ReplaceFileTagsByPrefixTx(clear): %v", err)
	}
	tags, err = s.GetFileTags(context.Background(), "f-prefix-tags")
	if err != nil {
		t.Fatalf("GetFileTags(clear): %v", err)
	}
	want = map[string]string{
		"album":                  "Inbox",
		"drive9.thumbnail.ready": "true",
	}
	if !reflect.DeepEqual(tags, want) {
		t.Fatalf("tags after prefix clear = %+v, want %+v", tags, want)
	}
}

func TestZeroCopyCp(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageS3, StorageRef: "blobs/f1",
		SizeBytes: 1000000, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/a.bin", ParentPath: "/", Name: "a.bin", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n2", Path: "/b.bin", ParentPath: "/", Name: "b.bin", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	count, err := s.RefCount(context.Background(), "f1")
	if err != nil {
		t.Fatal(err)
	}
	if count != 2 {
		t.Errorf("expected refcount 2, got %d", count)
	}
}

func TestLinkFileNodeSharesFileAndRefCount(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 50, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{
		NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt",
		FileID: "f1", InodeID: "f1", CreatedAt: now,
	}); err != nil {
		t.Fatal(err)
	}

	if err := s.LinkFileNode(ctx, "/a.txt", "/b.txt", "/", "b.txt", "n2", now); err != nil {
		t.Fatalf("LinkFileNode: %v", err)
	}
	dst, err := s.GetNode(ctx, "/b.txt")
	if err != nil {
		t.Fatalf("GetNode(dst): %v", err)
	}
	if dst.FileID != "f1" || dst.InodeID != "f1" {
		t.Fatalf("dst identity = file %q inode %q, want f1/f1", dst.FileID, dst.InodeID)
	}
	count, err := s.RefCount(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if count != 2 {
		t.Fatalf("refcount = %d, want 2", count)
	}
	counts, err := s.RefCounts(ctx, []string{"f1", "f1", "missing"})
	if err != nil {
		t.Fatal(err)
	}
	if counts["f1"] != 2 {
		t.Fatalf("batch refcount f1 = %d, want 2", counts["f1"])
	}
	if _, ok := counts["missing"]; ok {
		t.Fatal("batch refcount returned missing file")
	}
	if err := s.LinkFileNode(ctx, "/a.txt", "/b.txt", "/", "b.txt", "n3", now); !errors.Is(err, ErrPathConflict) {
		t.Fatalf("duplicate LinkFileNode error = %v, want ErrPathConflict", err)
	}

	deleted, err := s.DeleteFileWithRefCheck(ctx, "/a.txt")
	if err != nil {
		t.Fatal(err)
	}
	if deleted != nil {
		t.Fatalf("first unlink deleted file record: %+v", deleted)
	}
	deleted, err = s.DeleteFileWithRefCheck(ctx, "/b.txt")
	if err != nil {
		t.Fatal(err)
	}
	if deleted == nil || deleted.Status != StatusDeleted {
		t.Fatalf("last unlink deleted = %+v, want DELETED file", deleted)
	}
}

func TestLinkFileNodeRejectsDirectorySource(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertNode(ctx, &FileNode{NodeID: "dir", Path: "/dir/", ParentPath: "/", Name: "dir", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.LinkFileNode(ctx, "/dir/", "/dir-link", "/", "dir-link", "n2", now); !errors.Is(err, ErrInvalidLinkTarget) {
		t.Fatalf("LinkFileNode directory error = %v, want ErrInvalidLinkTarget", err)
	}
}

func TestLinkFileNodeRejectsMissingParent(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 50, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{
		NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt",
		FileID: "f1", InodeID: "f1", CreatedAt: now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.LinkFileNode(ctx, "/a.txt", "/missing/b.txt", "/missing/", "b.txt", "n2", now); !errors.Is(err, ErrNotFound) {
		t.Fatalf("LinkFileNode missing parent error = %v, want ErrNotFound", err)
	}
	if _, err := s.GetNode(ctx, "/missing/b.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("missing-parent link created dst: %v", err)
	}
}

func TestDeleteWithRefCount(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 50, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f1", 17)
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n2", Path: "/b.txt", ParentPath: "/", Name: "b.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	deleted, err := s.DeleteFileWithRefCheck(context.Background(), "/a.txt")
	if err != nil {
		t.Fatal(err)
	}
	if deleted != nil {
		t.Error("expected nil (file should survive, refcount > 0)")
	}
	if _, err := s.GetFileGCTaskByFileID(context.Background(), "f1"); err != ErrNotFound {
		t.Fatalf("expected no gc task while f1 still has refs, got %v", err)
	}

	deleted, err = s.DeleteFileWithRefCheck(context.Background(), "/b.txt")
	if err != nil {
		t.Fatal(err)
	}
	if deleted == nil || deleted.Status != StatusDeleted {
		t.Errorf("expected DELETED file record, got %+v", deleted)
	}
	requireEmbeddingRevision(t, deleted.EmbeddingRevision, 17)
	task, err := s.GetFileGCTaskByFileID(context.Background(), "f1")
	if err != nil {
		t.Fatalf("get gc task: %v", err)
	}
	if task.Status != FileGCTaskQueued || task.StorageRef != "/blobs/f1" {
		t.Fatalf("unexpected gc task: %+v", task)
	}
}

func TestDeleteFileWithRefCheckRejectsDirectory(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertNode(ctx, &FileNode{NodeID: "dir", Path: "/dir/", ParentPath: "/", Name: "dir", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.DeleteFileWithRefCheck(ctx, "/dir/"); err != ErrNotFound {
		t.Fatalf("DeleteFileWithRefCheck dir error = %v, want ErrNotFound", err)
	}
	if _, err := s.GetNode(ctx, "/dir/"); err != nil {
		t.Fatalf("directory should remain after rejected file delete: %v", err)
	}
}

func TestDeleteFileIfFileIDMismatchLeavesOccupant(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 1, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt", FileID: "f1", InodeID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	deleted, err := s.DeleteFileIfFileID(ctx, "/a.txt", "other")
	if err != nil {
		t.Fatal(err)
	}
	if deleted != nil {
		t.Fatalf("mismatch deleted = %+v, want nil", deleted)
	}
	if _, err := s.GetNode(ctx, "/a.txt"); err != nil {
		t.Fatalf("occupant should remain after mismatch: %v", err)
	}

	deleted, err = s.DeleteFileIfFileID(ctx, "/missing.txt", "f1")
	if err != nil || deleted != nil {
		t.Fatalf("missing path deleted=%+v err=%v, want no-op", deleted, err)
	}

	deleted, err = s.DeleteFileIfFileID(ctx, "/a.txt", "f1")
	if err != nil {
		t.Fatal(err)
	}
	if deleted == nil || deleted.FileID != "f1" {
		t.Fatalf("matching delete = %+v, want f1", deleted)
	}
	if _, err := s.GetNode(ctx, "/a.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("matching delete left path: %v", err)
	}
}

func TestDeleteEmptyDirIfInodeIDMismatchLeavesOccupant(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertNode(ctx, &FileNode{NodeID: "d1", Path: "/dir/", ParentPath: "/", Name: "dir", IsDirectory: true, InodeID: "ino-1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	if err := s.DeleteEmptyDirIfInodeID(ctx, "/dir/", "other"); err != nil {
		t.Fatalf("mismatch error = %v, want nil", err)
	}
	if _, err := s.GetNode(ctx, "/dir/"); err != nil {
		t.Fatalf("occupant should remain after mismatch: %v", err)
	}
	if err := s.DeleteEmptyDirIfInodeID(ctx, "/missing/", "ino-1"); err != nil {
		t.Fatalf("missing path error = %v, want nil", err)
	}
	if err := s.DeleteEmptyDirIfInodeID(ctx, "/dir/", "ino-1"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.GetNode(ctx, "/dir/"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("matching delete left path: %v", err)
	}
}

func TestInsertNodeTxRejectsDeletedFile(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 1, Revision: 1, Status: StatusDeleted, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	err := s.InTx(ctx, func(tx *sql.Tx) error {
		return s.InsertNodeTx(tx, &FileNode{
			NodeID: "n1", Path: "/deleted.txt", ParentPath: "/", Name: "deleted.txt",
			FileID: "f1", CreatedAt: now,
		})
	})
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("InsertNodeTx error = %v, want %v", err, ErrNotFound)
	}
}

func TestDeleteDirRecursive(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d1", Path: "/data/", ParentPath: "/", Name: "data", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f1", 19)
	if err := s.InsertFile(context.Background(), &File{FileID: "f2", StorageType: StorageDB9, StorageRef: "/blobs/f2",
		SizeBytes: 20, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	setEmbeddingRevision(t, s, "f2", 23)
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/data/a.txt", ParentPath: "/data/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n2", Path: "/data/b.txt", ParentPath: "/data/", Name: "b.txt", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n3", Path: "/shared.txt", ParentPath: "/", Name: "shared.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	summary, err := s.DeleteDirRecursive(context.Background(), "/data/")
	if err != nil {
		t.Fatal(err)
	}
	if summary.NodesDeleted != 3 || summary.OrphansEnqueued != 1 {
		t.Fatalf("summary = %+v, want 3 nodes deleted and 1 orphan (f2)", summary)
	}
	if _, err := s.GetFileGCTaskByFileID(context.Background(), "f1"); err != ErrNotFound {
		t.Fatalf("expected no gc task for shared f1, got %v", err)
	}
	task, err := s.GetFileGCTaskByFileID(context.Background(), "f2")
	if err != nil {
		t.Fatalf("get gc task: %v", err)
	}
	if task.Status != FileGCTaskQueued || task.StorageRef != "/blobs/f2" {
		t.Fatalf("unexpected gc task: %+v", task)
	}
	// The deleted file record still carries its embedding revision: the old
	// summary returned the orphaned File and asserted this directly; keep the
	// guard here so embedding-revision propagation through the orphan
	// pipeline cannot silently regress.
	requireEmbeddingRevision(t, mustFile(t, s, "f2").EmbeddingRevision, 23)

	_, err = s.GetNode(context.Background(), "/data/")
	if err != ErrNotFound {
		t.Error("expected /data/ deleted")
	}
	_, err = s.GetNode(context.Background(), "/shared.txt")
	if err != nil {
		t.Error("expected /shared.txt to survive")
	}
}

func TestDeleteDirRecursiveDoesNotDeleteSiblingPrefix(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d1", Path: "/data/", ParentPath: "/", Name: "data", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d2", Path: "/data-other/", ParentPath: "/", Name: "data-other", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertFile(context.Background(), &File{FileID: "f2", StorageType: StorageDB9, StorageRef: "/blobs/f2",
		SizeBytes: 20, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/data/a.txt", ParentPath: "/data/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n2", Path: "/data-other/b.txt", ParentPath: "/data-other/", Name: "b.txt", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	summary, err := s.DeleteDirRecursive(context.Background(), "/data/")
	if err != nil {
		t.Fatal(err)
	}
	if summary.OrphansEnqueued != 1 {
		t.Fatalf("summary = %+v, want 1 orphan (f1)", summary)
	}
	if _, err := s.GetNode(context.Background(), "/data-other/"); err != nil {
		t.Fatalf("sibling directory should survive: %v", err)
	}
	if _, err := s.GetNode(context.Background(), "/data-other/b.txt"); err != nil {
		t.Fatalf("sibling file should survive: %v", err)
	}
	if _, err := s.GetFileGCTaskByFileID(context.Background(), "f2"); err != ErrNotFound {
		t.Fatalf("expected no gc task for sibling f2, got %v", err)
	}
}

func TestCountClaimableFileGCTasksDueVsBackoff(t *testing.T) {
	// Claim-parity contract: only queued rows with available_at <= now are
	// claimable. CountQueued includes in-backoff rows; CountClaimable must not.
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC().Truncate(time.Second)
	due := &FileGCTask{
		TaskID:      "gc-due",
		FileID:      "file-due",
		StorageType: StorageS3,
		StorageRef:  "blobs/due",
		Status:      FileGCTaskQueued,
		AvailableAt: now.Add(-time.Second),
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	future := &FileGCTask{
		TaskID:      "gc-backoff",
		FileID:      "file-backoff",
		StorageType: StorageS3,
		StorageRef:  "blobs/backoff",
		Status:      FileGCTaskQueued,
		AvailableAt: now.Add(time.Hour),
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	for _, task := range []*FileGCTask{due, future} {
		inserted, err := s.EnqueueFileGCTaskTx(s.DB(), task)
		if err != nil {
			t.Fatal(err)
		}
		if !inserted {
			t.Fatalf("expected enqueue of %s", task.TaskID)
		}
	}
	queued, err := s.CountQueuedFileGCTasks(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if queued != 2 {
		t.Fatalf("CountQueuedFileGCTasks=%d, want 2 (includes backoff)", queued)
	}
	claimable, oldest, err := s.ObserveClaimableFileGCTasks(ctx, now)
	if err != nil {
		t.Fatal(err)
	}
	if claimable != 1 {
		t.Fatalf("ObserveClaimableFileGCTasks count=%d, want 1 (due only)", claimable)
	}
	if oldest.IsZero() || oldest.After(now) {
		t.Fatalf("oldest claimable available_at=%v, want <= now %v", oldest, now)
	}
	// Future clock makes the backoff row due too.
	later, laterOldest, err := s.ObserveClaimableFileGCTasks(ctx, now.Add(2*time.Hour))
	if err != nil {
		t.Fatal(err)
	}
	if later != 2 {
		t.Fatalf("ObserveClaimableFileGCTasks at future now count=%d, want 2", later)
	}
	if laterOldest.IsZero() {
		t.Fatal("later oldest should be set when claimable > 0")
	}
	// CountClaimable stays consistent with Observe.
	countOnly, err := s.CountClaimableFileGCTasks(ctx, now)
	if err != nil {
		t.Fatal(err)
	}
	if countOnly != 1 {
		t.Fatalf("CountClaimableFileGCTasks=%d, want 1", countOnly)
	}
	// Parity: ClaimFileGCTask at `now` must see the due row only.
	claimed, found, err := s.ClaimFileGCTask(ctx, now, time.Minute)
	if err != nil {
		t.Fatal(err)
	}
	if !found || claimed.TaskID != "gc-due" {
		t.Fatalf("claim at now: found=%v task=%+v, want gc-due", found, claimed)
	}
	// After claiming the due row, claimable count drops to 0 at `now`.
	claimable, err = s.CountClaimableFileGCTasks(ctx, now)
	if err != nil {
		t.Fatal(err)
	}
	if claimable != 0 {
		t.Fatalf("CountClaimableFileGCTasks after claim=%d, want 0", claimable)
	}
	queued, err = s.CountQueuedFileGCTasks(ctx)
	if err != nil {
		t.Fatal(err)
	}
	if queued != 1 {
		t.Fatalf("CountQueuedFileGCTasks after claim=%d, want 1 (backoff still queued)", queued)
	}
}

func TestFileGCTaskClaimAck(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	task := &FileGCTask{
		TaskID:      "f1",
		FileID:      "f1",
		StorageType: StorageS3,
		StorageRef:  "blobs/f1",
		Status:      FileGCTaskQueued,
		AvailableAt: now,
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	inserted, err := s.EnqueueFileGCTaskTx(s.DB(), task)
	if err != nil {
		t.Fatal(err)
	}
	if !inserted {
		t.Fatal("expected first enqueue to insert")
	}
	inserted, err = s.EnqueueFileGCTaskTx(s.DB(), task)
	if err != nil {
		t.Fatal(err)
	}
	if inserted {
		t.Fatal("expected duplicate enqueue to be a no-op")
	}

	claimed, found, err := s.ClaimFileGCTask(ctx, now.Add(time.Second), time.Minute)
	if err != nil {
		t.Fatal(err)
	}
	if !found {
		t.Fatal("expected queued task to be claimed")
	}
	if claimed.Status != FileGCTaskProcessing || claimed.AttemptCount != 1 || claimed.Receipt == "" {
		t.Fatalf("claimed task = %+v", claimed)
	}
	if err := s.AckFileGCTask(ctx, claimed.TaskID, claimed.Receipt); err != nil {
		t.Fatal(err)
	}
	got, err := s.GetFileGCTaskByFileID(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != FileGCTaskSucceeded || got.CompletedAt == nil {
		t.Fatalf("acked task = %+v", got)
	}
	if err := s.AckFileGCTask(ctx, claimed.TaskID, claimed.Receipt); err != ErrFileGCTaskLeaseMismatch {
		t.Fatalf("second ack err = %v, want %v", err, ErrFileGCTaskLeaseMismatch)
	}
}

func TestFileGCTaskRetryDeadLettersAtMaxAttempts(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	task := &FileGCTask{
		TaskID:      "f1",
		FileID:      "f1",
		StorageType: StorageS3,
		StorageRef:  "blobs/f1",
		Status:      FileGCTaskQueued,
		MaxAttempts: 1,
		AvailableAt: now,
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	if _, err := s.EnqueueFileGCTaskTx(s.DB(), task); err != nil {
		t.Fatal(err)
	}
	claimed, found, err := s.ClaimFileGCTask(ctx, now.Add(time.Second), time.Minute)
	if err != nil {
		t.Fatal(err)
	}
	if !found {
		t.Fatal("expected queued task to be claimed")
	}
	if err := s.RetryFileGCTask(ctx, claimed.TaskID, claimed.Receipt, now.Add(time.Second), "delete failed"); err != nil {
		t.Fatal(err)
	}
	got, err := s.GetFileGCTaskByFileID(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != FileGCTaskDeadLettered || got.LastError != "delete failed" || got.CompletedAt == nil {
		t.Fatalf("retried task = %+v", got)
	}
}

func TestFileGCTaskDefaultRetriesWithoutDeadLetter(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now().UTC()
	task := &FileGCTask{
		TaskID:      "f1",
		FileID:      "f1",
		StorageType: StorageS3,
		StorageRef:  "blobs/f1",
		Status:      FileGCTaskQueued,
		AvailableAt: now,
		CreatedAt:   now,
		UpdatedAt:   now,
	}
	if _, err := s.EnqueueFileGCTaskTx(s.DB(), task); err != nil {
		t.Fatal(err)
	}

	claimAt := now.Add(time.Second)
	for i := 0; i < 3; i++ {
		claimed, found, err := s.ClaimFileGCTask(ctx, claimAt, time.Minute)
		if err != nil {
			t.Fatal(err)
		}
		if !found {
			t.Fatalf("attempt %d: expected task to be claimed", i+1)
		}
		retryAt := claimAt.Add(time.Second)
		if err := s.RetryFileGCTask(ctx, claimed.TaskID, claimed.Receipt, retryAt, "still failing"); err != nil {
			t.Fatal(err)
		}
		got, err := s.GetFileGCTaskByFileID(ctx, "f1")
		if err != nil {
			t.Fatal(err)
		}
		if got.Status != FileGCTaskQueued || got.MaxAttempts != 0 || got.CompletedAt != nil {
			t.Fatalf("attempt %d retry task = %+v", i+1, got)
		}
		claimAt = retryAt.Add(time.Second)
	}
}

func TestEnsureParentDirs(t *testing.T) {
	s := newTestStore(t)
	if err := s.EnsureParentDirs(context.Background(), "/a/b/c/file.txt", genID); err != nil {
		t.Fatal(err)
	}
	for _, p := range []string{"/a/", "/a/b/", "/a/b/c/"} {
		n, err := s.GetNode(context.Background(), p)
		if err != nil {
			t.Errorf("expected dir at %s: %v", p, err)
			continue
		}
		if !n.IsDirectory {
			t.Errorf("expected %s to be directory", p)
		}
	}

	// Count directory inodes before the idempotent re-call.
	var before int64
	if err := s.DB().QueryRow(`
		SELECT COUNT(*) FROM inodes i
		JOIN file_nodes fn ON i.inode_id = fn.inode_id
		WHERE fn.is_directory = 1`).Scan(&before); err != nil {
		t.Fatalf("count dir inodes before: %v", err)
	}

	// Idempotent: second call must not create orphan inodes.
	if err := s.EnsureParentDirs(context.Background(), "/a/b/c/file.txt", genID); err != nil {
		t.Fatal(err)
	}

	var after int64
	if err := s.DB().QueryRow(`
		SELECT COUNT(*) FROM inodes i
		JOIN file_nodes fn ON i.inode_id = fn.inode_id
		WHERE fn.is_directory = 1`).Scan(&after); err != nil {
		t.Fatalf("count dir inodes after: %v", err)
	}
	if after != before {
		t.Errorf("orphan inodes leaked: dir inode count before=%d after=%d", before, after)
	}

	// Verify there are no orphan inodes at all.
	var orphanCount int64
	if err := s.DB().QueryRow(`
		SELECT COUNT(*) FROM inodes i
		LEFT JOIN file_nodes fn ON i.inode_id = fn.inode_id
		WHERE fn.inode_id IS NULL`).Scan(&orphanCount); err != nil {
		t.Fatalf("count orphan inodes: %v", err)
	}
	if orphanCount != 0 {
		t.Errorf("found %d orphan inodes (inodes with no file_nodes reference)", orphanCount)
	}
}

func TestEnsureParentDirsConcurrent(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := s.EnsureParentDirs(ctx, "/a/b/c/file.txt", genID); err != nil {
				t.Errorf("ensure parent dirs: %v", err)
			}
		}()
	}
	wg.Wait()

	// Exactly three directory inodes should exist.
	var dirInodeCount int64
	if err := s.DB().QueryRow(`
		SELECT COUNT(*) FROM inodes i
		JOIN file_nodes fn ON i.inode_id = fn.inode_id
		WHERE fn.is_directory = 1`).Scan(&dirInodeCount); err != nil {
		t.Fatalf("count dir inodes: %v", err)
	}
	if dirInodeCount != 3 {
		t.Errorf("dir inode count = %d, want 3", dirInodeCount)
	}

	// No orphan inodes should remain.
	var orphanCount int64
	if err := s.DB().QueryRow(`
		SELECT COUNT(*) FROM inodes i
		LEFT JOIN file_nodes fn ON i.inode_id = fn.inode_id
		WHERE fn.inode_id IS NULL`).Scan(&orphanCount); err != nil {
		t.Fatalf("count orphan inodes: %v", err)
	}
	if orphanCount != 0 {
		t.Errorf("found %d orphan inodes after concurrent creation", orphanCount)
	}
}

func TestRenameFile(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/old.txt", ParentPath: "/", Name: "old.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	if err := s.UpdateNodePath(context.Background(), "/old.txt", "/new.txt", "/", "new.txt"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.GetNode(context.Background(), "/old.txt"); err != ErrNotFound {
		t.Error("old path should be gone")
	}
	got, _ := s.GetNode(context.Background(), "/new.txt")
	if got.Name != "new.txt" || got.FileID != "f1" {
		t.Errorf("unexpected: %+v", got)
	}
}

func TestRenameFileReplacingTarget(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	for _, f := range []*File{
		{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1", SizeBytes: 4, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now},
		{FileID: "f2", StorageType: StorageDB9, StorageRef: "/blobs/f2", SizeBytes: 3, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now},
	} {
		if err := s.InsertFile(ctx, f); err != nil {
			t.Fatal(err)
		}
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/config.lock", ParentPath: "/", Name: "config.lock", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n2", Path: "/config", ParentPath: "/", Name: "config", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.DB().Exec(`INSERT INTO file_tags (file_id, tag_key, tag_value) VALUES (?, ?, ?)`, "f2", "old", "target"); err != nil {
		t.Fatal(err)
	}

	deleted, err := s.RenameFileReplacingTarget(ctx, "/config.lock", "/config", "/", "config")
	if err != nil {
		t.Fatal(err)
	}
	if deleted == nil || deleted.FileID != "f2" {
		t.Fatalf("deleted = %+v, want f2", deleted)
	}
	if _, err := s.GetNode(ctx, "/config.lock"); err != ErrNotFound {
		t.Fatalf("old path err = %v, want ErrNotFound", err)
	}
	got, err := s.GetNode(ctx, "/config")
	if err != nil {
		t.Fatal(err)
	}
	if got.NodeID != "n1" || got.FileID != "f1" || got.Name != "config" {
		t.Fatalf("renamed target = %+v, want source node/file at /config", got)
	}
	replaced, err := s.GetFile(ctx, "f2")
	if err != nil {
		t.Fatal(err)
	}
	if replaced.Status != StatusDeleted {
		t.Fatalf("replaced file status = %s, want %s", replaced.Status, StatusDeleted)
	}
	var tags int
	if err := s.DB().QueryRow(`SELECT COUNT(*) FROM file_tags WHERE file_id = ?`, "f2").Scan(&tags); err != nil {
		t.Fatal(err)
	}
	if tags != 0 {
		t.Fatalf("replaced file tags = %d, want 0", tags)
	}
	task, err := s.GetFileGCTaskByFileID(ctx, "f2")
	if err != nil {
		t.Fatal(err)
	}
	if task.Status != FileGCTaskQueued || task.StorageRef != "/blobs/f2" {
		t.Fatalf("unexpected gc task for replaced file: %+v", task)
	}
}

func TestRenameDir(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d1", Path: "/old/", ParentPath: "/", Name: "old", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n1", Path: "/old/a.txt", ParentPath: "/old/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "d2", Path: "/old/sub/", ParentPath: "/old/", Name: "sub", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "n2", Path: "/old/sub/b.txt", ParentPath: "/old/sub/", Name: "b.txt", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	count, err := s.RenameDir(context.Background(), "/old/", "/new/")
	if err != nil {
		t.Fatal(err)
	}
	if count != 4 {
		t.Errorf("expected 4 updated, got %d", count)
	}
	if _, err := s.GetNode(context.Background(), "/old/"); err != ErrNotFound {
		t.Error("/old/ should be gone")
	}
	for _, p := range []string{"/new/", "/new/a.txt", "/new/sub/", "/new/sub/b.txt"} {
		if _, err := s.GetNode(context.Background(), p); err != nil {
			t.Errorf("expected %s: %v", p, err)
		}
	}
}

func TestRenameDirReplacesEmptyTargetAtomically(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "src", Path: "/src/", ParentPath: "/", Name: "src", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "child", Path: "/src/a.txt", ParentPath: "/src/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "dst", Path: "/dst/", ParentPath: "/", Name: "dst", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	count, err := s.RenameDir(context.Background(), "/src/", "/dst/")
	if err != nil {
		t.Fatal(err)
	}
	if count != 2 {
		t.Fatalf("count = %d, want 2", count)
	}
	if _, err := s.GetNode(context.Background(), "/src/"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("/src/ error = %v, want ErrNotFound", err)
	}
	got, err := s.GetNode(context.Background(), "/dst/")
	if err != nil {
		t.Fatal(err)
	}
	if got.NodeID != "src" {
		t.Fatalf("/dst/ node id = %q, want src", got.NodeID)
	}
	if _, err := s.GetNode(context.Background(), "/dst/a.txt"); err != nil {
		t.Fatalf("/dst/a.txt missing: %v", err)
	}
}

func TestRenameDirRejectsNonEmptyTargetWithoutDeleting(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "src", Path: "/src/", ParentPath: "/", Name: "src", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "dst", Path: "/dst/", ParentPath: "/", Name: "dst", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "dstchild", Path: "/dst/b.txt", ParentPath: "/dst/", Name: "b.txt", FileID: "f2", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	_, err := s.RenameDir(context.Background(), "/src/", "/dst/")
	if !errors.Is(err, ErrPathConflict) {
		t.Fatalf("RenameDir error = %v, want ErrPathConflict", err)
	}
	for _, p := range []string{"/src/", "/dst/", "/dst/b.txt"} {
		if _, err := s.GetNode(context.Background(), p); err != nil {
			t.Fatalf("%s should remain after failed rename: %v", p, err)
		}
	}
}

func TestRenameDirNoReplaceRejectsEmptyDest(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "src", Path: "/src/", ParentPath: "/", Name: "src", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "child", Path: "/src/a.txt", ParentPath: "/src/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(context.Background(), &FileNode{NodeID: "dst", Path: "/dst/", ParentPath: "/", Name: "dst", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	_, err := s.RenameDirNoReplace(context.Background(), "/src/", "/dst/")
	if !errors.Is(err, ErrPathConflict) {
		t.Fatalf("RenameDirNoReplace error = %v, want ErrPathConflict", err)
	}
	got, err := s.GetNode(context.Background(), "/dst/")
	if err != nil {
		t.Fatal(err)
	}
	if got.NodeID != "dst" {
		t.Fatalf("/dst/ node id = %q, want dst", got.NodeID)
	}
	if _, err := s.GetNode(context.Background(), "/src/a.txt"); err != nil {
		t.Fatalf("/src/a.txt should remain: %v", err)
	}
}

func TestRenameFileNoReplaceIfFileIDMismatchLeavesOccupant(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 1, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/src.txt", ParentPath: "/", Name: "src.txt", FileID: "f1", InodeID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	err := s.RenameFileNoReplaceIfFileID(ctx, "/src.txt", "/dst.txt", "/", "dst.txt", "other")
	if !errors.Is(err, ErrPathConflict) {
		t.Fatalf("mismatch error = %v, want ErrPathConflict", err)
	}
	got, err := s.GetNode(ctx, "/src.txt")
	if err != nil {
		t.Fatalf("source should remain: %v", err)
	}
	if got.FileID != "f1" {
		t.Fatalf("source file_id = %q, want f1", got.FileID)
	}
	if _, err := s.GetNode(ctx, "/dst.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("dest should be absent: %v", err)
	}

	if err := s.RenameFileNoReplaceIfFileID(ctx, "/src.txt", "/dst.txt", "/", "dst.txt", "f1"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.GetNode(ctx, "/src.txt"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("source should be gone: %v", err)
	}
	got, err = s.GetNode(ctx, "/dst.txt")
	if err != nil {
		t.Fatal(err)
	}
	if got.FileID != "f1" {
		t.Fatalf("dest file_id = %q, want f1", got.FileID)
	}
}

func TestRenameDirNoReplaceIfInodeIDMismatchLeavesOccupant(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertNode(ctx, &FileNode{NodeID: "src", Path: "/src/", ParentPath: "/", Name: "src", IsDirectory: true, InodeID: "ino-1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "child", Path: "/src/a.txt", ParentPath: "/src/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	_, err := s.RenameDirNoReplaceIfInodeID(ctx, "/src/", "/dst/", "other")
	if !errors.Is(err, ErrPathConflict) {
		t.Fatalf("mismatch error = %v, want ErrPathConflict", err)
	}
	if _, err := s.GetNode(ctx, "/src/a.txt"); err != nil {
		t.Fatalf("source child should remain: %v", err)
	}
	if _, err := s.GetNode(ctx, "/dst/"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("dest should be absent: %v", err)
	}

	if _, err := s.RenameDirNoReplaceIfInodeID(ctx, "/src/", "/dst/", "ino-1"); err != nil {
		t.Fatal(err)
	}
	if _, err := s.GetNode(ctx, "/src/"); !errors.Is(err, ErrNotFound) {
		t.Fatalf("source should be gone: %v", err)
	}
	got, err := s.GetNode(ctx, "/dst/")
	if err != nil {
		t.Fatal(err)
	}
	if got.InodeID != "ino-1" {
		t.Fatalf("dest inode_id = %q, want ino-1", got.InodeID)
	}
}

func TestUpdateFileContent(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}

	newRev, err := s.UpdateFileContent(context.Background(), "f1", StorageDB9, "/blobs/f1-v2", "text/plain", "abc123", "new content", []byte("blob"), 42, "")
	if err != nil {
		t.Fatal(err)
	}
	if newRev != 2 {
		t.Errorf("expected newRev=2, got %d", newRev)
	}
	got, _ := s.GetFile(context.Background(), "f1")
	if got.Revision != 2 || got.SizeBytes != 42 || got.ContentText != "new content" {
		t.Errorf("unexpected: %+v", got)
	}
}

func TestUpdateFileContentWithDescription(t *testing.T) {
	s := newTestStore(t)
	now := time.Now()
	if err := s.InsertFile(context.Background(), &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}

	newRev, err := s.UpdateFileContent(context.Background(), "f1", StorageDB9, "/blobs/f1-v2", "text/plain", "abc123", "new content", []byte("blob"), 42, "my description")
	if err != nil {
		t.Fatal(err)
	}
	if newRev != 2 {
		t.Errorf("expected newRev=2, got %d", newRev)
	}
	got, _ := s.GetFile(context.Background(), "f1")
	if got.Revision != 2 || got.Description != "my description" {
		t.Errorf("unexpected: %+v", got)
	}
	if got.DescriptionEmbeddingRevision != nil {
		t.Errorf("expected description_embedding_revision to be nil after content update, got %v", got.DescriptionEmbeddingRevision)
	}
}

func TestChmod(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 42, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/a.txt", ParentPath: "/", Name: "a.txt", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	if err := s.Chmod(ctx, "/a.txt", 0o600); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Mode != 0o600 {
		t.Errorf("mode=%o, want 0o600", got.Mode)
	}
}

func TestChmodRejectsHistoricalRootDentry(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	const inodeID = "root-inode"
	const originalMode = 0o755

	if err := s.InsertInode(ctx, &Inode{
		InodeID:   inodeID,
		SizeBytes: 0,
		Revision:  1,
		Mode:      originalMode,
		Status:    StatusConfirmed,
		CreatedAt: now,
		Mtime:     now,
	}); err != nil {
		t.Fatal(err)
	}
	if _, err := s.DB().ExecContext(ctx, `
		INSERT INTO file_nodes (node_id, path, path_hash, parent_path, parent_path_hash, name, is_directory, inode_id, created_at)
		VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
		"root-node", "/", fileNodePathHash("/"), "/", fileNodePathHash("/"), "root-alias", inodeID, now); err != nil {
		t.Fatal(err)
	}

	err := s.Chmod(ctx, "/", 0o700)
	if !errors.Is(err, ErrInvalidRootDentry) {
		t.Fatalf("Chmod(/) error = %v, want %v", err, ErrInvalidRootDentry)
	}
	var mode uint32
	if err := s.DB().QueryRowContext(ctx, `SELECT mode FROM inodes WHERE inode_id = ?`, inodeID).Scan(&mode); err != nil {
		t.Fatal(err)
	}
	if mode != originalMode {
		t.Fatalf("root inode mode = %o, want unchanged %o", mode, originalMode)
	}
}

func TestChmodPreservesFileTypeBits(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		SizeBytes:   6,
		Revision:    1,
		Mode:        0o120777,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/link", ParentPath: "/", Name: "link", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	if err := s.Chmod(ctx, "/link", 0o600); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Mode != 0o120600 {
		t.Errorf("mode=%o, want 0o120600", got.Mode)
	}
}

func TestChmodPreservesSpecialPermissionBits(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		SizeBytes:   6,
		Revision:    1,
		Mode:        0o100644,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/special", ParentPath: "/", Name: "special", FileID: "f1", CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	if err := s.Chmod(ctx, "/special", 0o6755); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Mode != 0o106755 {
		t.Errorf("mode=%o, want 0o106755", got.Mode)
	}
}

func TestChmodNotFound(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()

	err := s.Chmod(ctx, "/nonexistent.txt", 0o600)
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestChmodDirectory(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertNode(ctx, &FileNode{NodeID: "n1", Path: "/dir/", ParentPath: "/", Name: "dir", IsDirectory: true, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	err := s.Chmod(ctx, "/dir/", 0o700)
	if err == nil {
		t.Fatal("expected error for chmod on directory")
	}
}

func TestConfirmPendingFileTx(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	checksum := strings.Repeat("a", 64)

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusPending, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	tx, err := s.DB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = tx.Rollback() }()

	if err := s.ConfirmPendingFileTx(tx, "f1", StorageS3, "/s3/f1", "text/plain", 42, "desc"); err != nil {
		t.Fatal(err)
	}
	if err := s.UpdateFileChecksumTx(tx, "f1", checksum); err != nil {
		t.Fatal(err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != StatusConfirmed {
		t.Errorf("status=%s, want CONFIRMED", got.Status)
	}
	if got.SizeBytes != 42 {
		t.Errorf("size=%d, want 42", got.SizeBytes)
	}
	if got.StorageType != StorageS3 {
		t.Errorf("storage_type=%s, want s3", got.StorageType)
	}
	if got.Description != "desc" {
		t.Errorf("description=%q, want desc", got.Description)
	}
	if got.ChecksumSHA256 != checksum {
		t.Errorf("checksum=%q, want %q", got.ChecksumSHA256, checksum)
	}
}

type rowsAffectedExecer struct {
	rows []int64
	next int
}

func (e *rowsAffectedExecer) Exec(string, ...interface{}) (sql.Result, error) {
	rows := e.rows[e.next]
	e.next++
	return driver.RowsAffected(rows), nil
}
func (*rowsAffectedExecer) QueryRow(string, ...interface{}) *sql.Row { return nil }
func (*rowsAffectedExecer) Query(string, ...interface{}) (*sql.Rows, error) {
	return nil, nil
}
func (e *rowsAffectedExecer) ExecContext(_ context.Context, query string, args ...interface{}) (sql.Result, error) {
	return e.Exec(query, args...)
}
func (*rowsAffectedExecer) QueryRowContext(context.Context, string, ...interface{}) *sql.Row {
	return nil
}

func TestUpdateFileChecksumTxChecksRowsAffected(t *testing.T) {
	tests := []struct {
		name           string
		useLegacyFiles bool
		rows           []int64
		wantNotFound   bool
	}{
		{name: "split content missing", rows: []int64{0}, wantNotFound: true},
		{name: "legacy file missing", useLegacyFiles: true, rows: []int64{0}, wantNotFound: true},
		{name: "legacy content missing", useLegacyFiles: true, rows: []int64{1, 0}, wantNotFound: true},
		{name: "dual write success", useLegacyFiles: true, rows: []int64{1, 1}},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			exec := &rowsAffectedExecer{rows: tc.rows}
			err := (&Store{useLegacyFiles: tc.useLegacyFiles}).UpdateFileChecksumTx(exec, "file-1", strings.Repeat("a", 64))
			if errors.Is(err, ErrNotFound) != tc.wantNotFound {
				t.Fatalf("error = %v, wantNotFound=%v", err, tc.wantNotFound)
			}
		})
	}
}

func TestConfirmPendingFileAutoEmbeddingTx(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusPending, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	tx, err := s.DB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = tx.Rollback() }()

	if err := s.ConfirmPendingFileAutoEmbeddingTx(tx, "f1", StorageS3, "/s3/f1", "text/plain", 42, "auto-desc"); err != nil {
		t.Fatal(err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != StatusConfirmed {
		t.Errorf("status=%s, want CONFIRMED", got.Status)
	}
	if got.SizeBytes != 42 {
		t.Errorf("size=%d, want 42", got.SizeBytes)
	}
	if got.Description != "auto-desc" {
		t.Errorf("description=%q, want auto-desc", got.Description)
	}
	if got.ChecksumSHA256 != "" {
		t.Errorf("checksum=%q, want omitted checksum to remain empty", got.ChecksumSHA256)
	}
}

func TestDeletePendingFileTx(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 10, Revision: 1, Status: StatusPending, CreatedAt: now}); err != nil {
		t.Fatal(err)
	}

	tx, err := s.DB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = tx.Rollback() }()

	if err := s.DeletePendingFileTx(tx, "f1"); err != nil {
		t.Fatal(err)
	}
	if err := tx.Commit(); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Status != StatusDeleted {
		t.Errorf("status=%s, want DELETED", got.Status)
	}
	if got.StorageRef != "" {
		t.Errorf("storage_ref=%q, want empty", got.StorageRef)
	}
}

func TestGetFileStorageMetaForUpdateTx(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageS3, StorageRef: "/s3/f1",
		ContentType: "text/plain", SizeBytes: 42, Revision: 3, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}

	tx, err := s.DB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = tx.Rollback() }()

	meta, err := s.GetFileStorageMetaForUpdateTx(tx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if meta.StorageType != StorageS3 {
		t.Errorf("storage_type=%s, want s3", meta.StorageType)
	}
	if meta.StorageRef != "/s3/f1" {
		t.Errorf("storage_ref=%q, want /s3/f1", meta.StorageRef)
	}
	if meta.Revision != 3 {
		t.Errorf("revision=%d, want 3", meta.Revision)
	}
	if meta.SizeBytes != 42 {
		t.Errorf("size=%d, want 42", meta.SizeBytes)
	}
	if meta.ContentType != "text/plain" {
		t.Errorf("content_type=%q, want text/plain", meta.ContentType)
	}
}

func TestGetFileStorageMetaForUpdateTxNotFound(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()

	tx, err := s.DB().BeginTx(ctx, nil)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = tx.Rollback() }()

	_, err = s.GetFileStorageMetaForUpdateTx(tx, "missing")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("expected ErrNotFound, got %v", err)
	}
}

func TestModeDefault(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	// Insert without Mode set (defaults to 0).
	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 42, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now}); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Mode != 0o644 {
		t.Errorf("mode=%o, want 0o644", got.Mode)
	}
}

func TestModeRoundTrip(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()

	if err := s.InsertFile(ctx, &File{FileID: "f1", StorageType: StorageDB9, StorageRef: "/blobs/f1",
		SizeBytes: 42, Revision: 1, Status: StatusConfirmed, CreatedAt: now, ConfirmedAt: &now, Mode: 0o755}); err != nil {
		t.Fatal(err)
	}

	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatal(err)
	}
	if got.Mode != 0o755 {
		t.Errorf("mode=%o, want 0o755", got.Mode)
	}
}

// TestInsertFileWithoutLegacyTable verifies that a store without the legacy
// `files` table writes only to split tables and can read data back correctly.
func TestInsertFileWithoutLegacyTable(t *testing.T) {
	// Open a store without creating the files table.
	initDatastoreSchema(t, testDSN)
	db, err := sql.Open("mysql", testDSN)
	if err != nil {
		t.Fatalf("open db: %v", err)
	}
	defer func() { _ = db.Close() }()
	if _, err := db.Exec(`DROP TABLE IF EXISTS files`); err != nil {
		t.Fatalf("drop files: %v", err)
	}
	testtidb.ResetDBWithoutFiles(t, db)
	_ = db.Close()

	s, err := Open(testDSN)
	if err != nil {
		t.Fatal(err)
	}
	defer func() { _ = s.Close() }()

	if s.HasLegacyFiles() {
		t.Fatal("expected HasLegacyFiles() = false without files table")
	}

	ctx := context.Background()
	now := time.Now()
	f := &File{
		FileID:      "f1",
		StorageType: StorageDB9,
		StorageRef:  "/blobs/f1",
		SizeBytes:   42,
		Revision:    1,
		Status:      StatusConfirmed,
		Mode:        0o600,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}
	if err := s.InsertFile(ctx, f); err != nil {
		t.Fatalf("insert file: %v", err)
	}

	// Verify read from split tables.
	got, err := s.GetFile(ctx, "f1")
	if err != nil {
		t.Fatalf("get file: %v", err)
	}
	if got.SizeBytes != 42 {
		t.Errorf("size=%d, want 42", got.SizeBytes)
	}
	if got.Mode != 0o600 {
		t.Errorf("mode=%o, want 0o600", got.Mode)
	}

	// Verify files table was NOT written.
	var count int
	if err := s.DB().QueryRow(`SELECT COUNT(*) FROM files`).Scan(&count); err == nil {
		t.Error("expected error querying files table (should not exist), got nil")
	}
}

func TestInsertFileTagsWithSourceAndDeleteBySource(t *testing.T) {
	s := newTestStore(t)
	ctx := context.Background()
	now := time.Now()
	if err := s.InsertFile(ctx, &File{
		FileID:      "f-src-tags",
		StorageType: StorageDB9,
		StorageRef:  "inline",
		Revision:    1,
		Status:      StatusConfirmed,
		CreatedAt:   now,
		ConfirmedAt: &now,
	}); err != nil {
		t.Fatal(err)
	}
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		matched, err := s.MatchFileRevisionForUpdateTx(tx, "f-src-tags", 1)
		if err != nil {
			return err
		}
		if !matched {
			return errors.New("revision 1 did not match confirmed file")
		}
		matched, err = s.MatchFileRevisionForUpdateTx(tx, "f-src-tags", 2)
		if err != nil {
			return err
		}
		if matched {
			return errors.New("revision 2 unexpectedly matched revision 1 file")
		}
		return nil
	}); err != nil {
		t.Fatal(err)
	}

	// Image-extract tags land under their own provenance.
	var skipped []string
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		var err error
		skipped, err = s.InsertFileTagsWithSourceTx(tx, "f-src-tags", TagSourceImageExtract, map[string]string{"风格": "人像"})
		return err
	}); err != nil {
		t.Fatalf("InsertFileTagsWithSourceTx(image_extract): %v", err)
	}
	if len(skipped) != 0 {
		t.Fatalf("skipped = %v, want empty", skipped)
	}

	// The user tag is written via the existing replace path, which relies on
	// the column default to stamp source='user'.
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		return s.ReplaceFileTagsTx(tx, "f-src-tags", map[string]string{"色调": "暖"})
	}); err != nil {
		t.Fatalf("ReplaceFileTagsTx(user): %v", err)
	}

	// Re-running the model pass must not overwrite the user-owned key.
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		var err error
		skipped, err = s.InsertFileTagsWithSourceTx(tx, "f-src-tags", TagSourceImageExtract, map[string]string{"色调": "冷", "场景": "户外"})
		return err
	}); err != nil {
		t.Fatalf("InsertFileTagsWithSourceTx(rerun): %v", err)
	}
	if len(skipped) != 1 || skipped[0] != "色调" {
		t.Fatalf("skipped = %v, want [色调]", skipped)
	}
	tags, err := s.GetFileTags(ctx, "f-src-tags")
	if err != nil {
		t.Fatalf("GetFileTags: %v", err)
	}
	if len(tags) != 2 || tags["色调"] != "暖" || tags["场景"] != "户外" {
		t.Fatalf("tags = %+v, want 色调=暖(user wins) and 场景=户外", tags)
	}

	// The model may refresh its own values: same-source keys are overwritten.
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		var err error
		skipped, err = s.InsertFileTagsWithSourceTx(tx, "f-src-tags", TagSourceImageExtract, map[string]string{"场景": "室内"})
		return err
	}); err != nil {
		t.Fatalf("InsertFileTagsWithSourceTx(refresh): %v", err)
	}
	if len(skipped) != 0 {
		t.Fatalf("skipped = %v, want empty", skipped)
	}
	tags, err = s.GetFileTags(ctx, "f-src-tags")
	if err != nil {
		t.Fatalf("GetFileTags(refresh): %v", err)
	}
	if len(tags) != 2 || tags["场景"] != "室内" || tags["色调"] != "暖" {
		t.Fatalf("tags after refresh = %+v, want 场景=室内, 色调=暖", tags)
	}

	// A generated tag claimed by a different extractor transfers provenance,
	// so future refresh cleanup is owned by the extractor that last wrote it.
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		if _, err := s.InsertFileTagsWithSourceTx(tx, "f-src-tags", TagSourceAudioExtract, map[string]string{"topic": "meeting"}); err != nil {
			return err
		}
		_, err := s.InsertFileTagsWithSourceTx(tx, "f-src-tags", TagSourceVideoExtract, map[string]string{"topic": "conference"})
		return err
	}); err != nil {
		t.Fatalf("transfer generated provenance: %v", err)
	}
	var source, value string
	if err := s.DB().QueryRow(`SELECT source, tag_value FROM file_tags WHERE file_id = ? AND tag_key = ?`, "f-src-tags", "topic").Scan(&source, &value); err != nil {
		t.Fatal(err)
	}
	if source != TagSourceVideoExtract || value != "conference" {
		t.Fatalf("source=%q value=%q, want video_extract/conference", source, value)
	}
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		return s.DeleteFileTagsBySourceTx(tx, "f-src-tags", TagSourceVideoExtract)
	}); err != nil {
		t.Fatal(err)
	}
	var count int
	if err := s.DB().QueryRow(`SELECT COUNT(*) FROM file_tags WHERE file_id = ? AND tag_key = ?`, "f-src-tags", "topic").Scan(&count); err != nil {
		t.Fatal(err)
	}
	if count != 0 {
		t.Fatalf("transferred video tag count=%d after video cleanup, want 0", count)
	}

	// Deleting by provenance removes only image_extract rows; user tags stay.
	if err := s.InTx(ctx, func(tx *sql.Tx) error {
		return s.DeleteFileTagsBySourceTx(tx, "f-src-tags", TagSourceImageExtract)
	}); err != nil {
		t.Fatalf("DeleteFileTagsBySourceTx: %v", err)
	}
	tags, err = s.GetFileTags(ctx, "f-src-tags")
	if err != nil {
		t.Fatalf("GetFileTags(after delete): %v", err)
	}
	if len(tags) != 1 || tags["色调"] != "暖" {
		t.Fatalf("tags after source delete = %+v, want only 色调=暖", tags)
	}
}
