package sqlite

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"github.com/yazanabuashour/openclerk/internal/domain"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

func TestSyncVaultPrunesDeletedDocuments(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	docPath := filepath.Join(vaultRoot, "docs", "widget.md")
	if err := os.MkdirAll(filepath.Dir(docPath), 0o755); err != nil {
		t.Fatalf("mkdir docs: %v", err)
	}
	if err := os.WriteFile(docPath, []byte("# Widget\n\nalpha signal\n"), 0o644); err != nil {
		t.Fatalf("write vault doc: %v", err)
	}

	store := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	search, err := store.Search(context.Background(), domain.SearchQuery{Text: "alpha", Limit: 10})
	if err != nil {
		t.Fatalf("search before delete: %v", err)
	}
	if len(search.Hits) != 1 {
		t.Fatalf("search before delete hit count = %d, want 1", len(search.Hits))
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close initial store: %v", err)
	}

	if err := os.Remove(docPath); err != nil {
		t.Fatalf("remove vault doc: %v", err)
	}

	reopened := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	defer func() {
		_ = reopened.Close()
	}()

	search, err = reopened.Search(context.Background(), domain.SearchQuery{Text: "alpha", Limit: 10})
	if err != nil {
		t.Fatalf("search after delete: %v", err)
	}
	if len(search.Hits) != 0 {
		t.Fatalf("search after delete hit count = %d, want 0", len(search.Hits))
	}

	_, err = reopened.GetDocument(context.Background(), docIDForPath("docs/widget.md"))
	var appErr *domain.Error
	if !errors.As(err, &appErr) || appErr.Status != 404 {
		t.Fatalf("get deleted document error = %v, want not found 404", err)
	}
}

func TestSyncVaultIgnoresDefaultHiddenDirectories(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	diagnosticsPath := filepath.Join(t.TempDir(), "sync.json")
	writeVaultFile(t, vaultRoot, ".git/ignored.md", "# Ignored Git\n\nhiddenonlymarker\n")
	writeVaultFile(t, vaultRoot, ".stversions/history.md", "# Ignored Versions\n\nhiddenonlymarker\n")
	writeVaultFile(t, vaultRoot, ".openclerk/state.md", "# Ignored State\n\nhiddenonlymarker\n")
	writeVaultFile(t, vaultRoot, ".backups/backup.md", "# Ignored Backup\n\nhiddenonlymarker\n")
	writeVaultFile(t, vaultRoot, "docs/live.md", "# Live\n\nlive marker\n")

	store, err := New(context.Background(), Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: diagnosticsPath,
	})
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer func() {
		_ = store.Close()
	}()

	live, err := store.Search(context.Background(), domain.SearchQuery{Text: "live marker", Limit: 10})
	if err != nil {
		t.Fatalf("search live: %v", err)
	}
	if len(live.Hits) != 1 {
		t.Fatalf("live hits = %d, want 1", len(live.Hits))
	}
	ignored, err := store.Search(context.Background(), domain.SearchQuery{Text: "hiddenonlymarker", Limit: 10})
	if err != nil {
		t.Fatalf("search ignored: %v", err)
	}
	if len(ignored.Hits) != 0 {
		t.Fatalf("ignored hits = %d, want 0", len(ignored.Hits))
	}

	diagnostics := readSyncDiagnosticsForTest(t, diagnosticsPath)
	if diagnostics.PathsScanned != 1 || diagnostics.DocumentsCreated != 1 {
		t.Fatalf("sync counts = %+v, want one scanned and created live document", diagnostics)
	}
	if diagnostics.PathsIgnored != 4 || diagnostics.IgnoredDirectories != 4 || diagnostics.IgnoredFiles != 0 {
		t.Fatalf("ignored counts = %+v, want four ignored directories", diagnostics)
	}
	for _, path := range []string{".git", ".stversions", ".openclerk", ".backups"} {
		if !syncDiagnosticsContains(diagnostics.IgnoredPathRules, path) {
			t.Fatalf("ignored path rules = %+v, missing %s", diagnostics.IgnoredPathRules, path)
		}
		if !syncDiagnosticsContains(diagnostics.IgnoredPaths, path) {
			t.Fatalf("ignored paths = %+v, missing %s", diagnostics.IgnoredPaths, path)
		}
	}
}

func TestSyncVaultSkipsSymlinkedMarkdown(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	outsideRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "docs/live.md", "# Live\n\nlive marker\n")
	outsidePath := filepath.Join(outsideRoot, "secret.md")
	if err := os.WriteFile(outsidePath, []byte("# Secret\n\noutsideonlymarker\n"), 0o644); err != nil {
		t.Fatalf("write outside doc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(vaultRoot, "docs"), 0o755); err != nil {
		t.Fatalf("mkdir docs: %v", err)
	}
	if err := os.Symlink(outsidePath, filepath.Join(vaultRoot, "docs", "linked.md")); err != nil {
		t.Skipf("symlink unavailable: %v", err)
	}

	store := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	defer func() {
		_ = store.Close()
	}()

	live, err := store.Search(context.Background(), domain.SearchQuery{Text: "live marker", Limit: 10})
	if err != nil {
		t.Fatalf("search live: %v", err)
	}
	if len(live.Hits) != 1 {
		t.Fatalf("live hits = %d, want 1", len(live.Hits))
	}
	secret, err := store.Search(context.Background(), domain.SearchQuery{Text: "outsideonlymarker", Limit: 10})
	if err != nil {
		t.Fatalf("search symlink target: %v", err)
	}
	if len(secret.Hits) != 0 {
		t.Fatalf("symlink target hits = %d, want 0", len(secret.Hits))
	}
}

func TestSyncVaultConfigurableIgnorePathsPruneExistingDocument(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "drafts/ignored.md", "# Ignored Draft\n\ncustom ignore marker\n")

	store := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	initial, err := store.Search(context.Background(), domain.SearchQuery{Text: "custom ignore marker", Limit: 10})
	if err != nil {
		t.Fatalf("search before ignore: %v", err)
	}
	if len(initial.Hits) != 1 {
		t.Fatalf("initial hits = %d, want 1", len(initial.Hits))
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close initial store: %v", err)
	}
	writeVaultIgnoreConfigForTest(t, dbPath, vaultRoot, []string{"drafts/"})

	diagnosticsPath := filepath.Join(t.TempDir(), "sync.json")
	reopened, err := New(context.Background(), Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: diagnosticsPath,
	})
	if err != nil {
		t.Fatalf("reopen store with ignore: %v", err)
	}
	defer func() {
		_ = reopened.Close()
	}()

	after, err := reopened.Search(context.Background(), domain.SearchQuery{Text: "custom ignore marker", Limit: 10})
	if err != nil {
		t.Fatalf("search after ignore: %v", err)
	}
	if len(after.Hits) != 0 {
		t.Fatalf("after-ignore hits = %d, want 0", len(after.Hits))
	}
	_, err = reopened.GetDocument(context.Background(), docIDForPath("drafts/ignored.md"))
	var appErr *domain.Error
	if !errors.As(err, &appErr) || appErr.Status != 404 {
		t.Fatalf("get ignored document error = %v, want not found 404", err)
	}

	diagnostics := readSyncDiagnosticsForTest(t, diagnosticsPath)
	if diagnostics.DocumentsPruned != 1 {
		t.Fatalf("documents pruned = %d, want 1; diagnostics=%+v", diagnostics.DocumentsPruned, diagnostics)
	}
	if diagnostics.PathsIgnored != 1 || diagnostics.IgnoredDirectories != 1 {
		t.Fatalf("ignored counts = %+v, want one ignored directory", diagnostics)
	}
	if !syncDiagnosticsContains(diagnostics.IgnoredPathRules, "drafts") ||
		!syncDiagnosticsContains(diagnostics.IgnoredPaths, "drafts") {
		t.Fatalf("ignore diagnostics = %+v, want drafts rule and path", diagnostics)
	}
}

func TestSyncVaultRejectsDirectCreateUnderIgnoredPath(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultIgnoreConfigForTest(t, dbPath, vaultRoot, []string{"drafts/"})
	store, err := New(context.Background(), Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	defer func() {
		_ = store.Close()
	}()

	_, err = store.CreateDocument(context.Background(), domain.CreateDocumentInput{
		Path:  "drafts/ignored.md",
		Title: "Ignored",
		Body:  "# Ignored\n\nShould not be written.\n",
	})
	var appErr *domain.Error
	if !errors.As(err, &appErr) || appErr.Code != "validation_error" {
		t.Fatalf("create ignored document error = %v, want validation error", err)
	}
	if _, statErr := os.Stat(filepath.Join(vaultRoot, "drafts", "ignored.md")); !errors.Is(statErr, os.ErrNotExist) {
		t.Fatalf("ignored file stat err = %v, want not exist", statErr)
	}
	writeVaultFile(t, vaultRoot, "drafts/manual.md", "# Manual\n\nShould not be synced.\n")
	if _, err := store.syncDocumentFromDiskWithOptions(context.Background(), "drafts/manual.md", "", documentSyncOptions{}); !errors.As(err, &appErr) || appErr.Code != "validation_error" {
		t.Fatalf("direct sync ignored document error = %v, want validation error", err)
	}
	list, err := store.ListDocuments(context.Background(), domain.DocumentListQuery{Limit: 10})
	if err != nil {
		t.Fatalf("list documents after rejected create: %v", err)
	}
	if len(list.Documents) != 0 {
		t.Fatalf("documents after rejected create = %+v, want none", list.Documents)
	}
}

func TestSyncDocumentRejectsMarkdownResourceBudgets(t *testing.T) {
	t.Parallel()

	cases := []struct {
		name string
		body string
		want string
	}{
		{
			name: "oversized body",
			body: strings.Repeat("x", maxVaultMarkdownDocumentBytes+1),
			want: "maximum supported Markdown size",
		},
		{
			name: "too many sections",
			body: manyMarkdownSectionsForTest(maxVaultMarkdownSections + 1),
			want: "maximum supported Markdown sections",
		},
		{
			name: "too many frontmatter fields",
			body: manyFrontmatterFieldsForTest(maxVaultMarkdownMetadataFields + 1),
			want: "maximum supported frontmatter fields",
		},
	}
	for _, tc := range cases {
		tc := tc
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()

			ctx := context.Background()
			vaultRoot := t.TempDir()
			dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
			writeVaultFile(t, vaultRoot, "docs/budget.md", tc.body)
			store, err := NewUnsynced(ctx, Config{
				Backend:      domain.BackendOpenClerk,
				DatabasePath: dbPath,
				VaultRoot:    vaultRoot,
			})
			if err != nil {
				t.Fatalf("open unsynced store: %v", err)
			}
			defer func() {
				_ = store.Close()
			}()

			_, err = store.syncDocumentFromDiskWithOptions(ctx, "docs/budget.md", "", documentSyncOptions{})
			var appErr *domain.Error
			if !errors.As(err, &appErr) || appErr.Code != "validation_error" || !strings.Contains(err.Error(), tc.want) {
				t.Fatalf("sync error = %v, want validation containing %q", err, tc.want)
			}
		})
	}
}

func TestSyncVaultDiagnosticsSkipUnchangedDocumentsAndProjectionRebuild(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "sources/alpha.md", "# Alpha\n\nalpha signal\n")
	writeVaultFile(t, vaultRoot, "synthesis/alpha.md", strings.TrimSpace(`---
type: synthesis
status: active
freshness: fresh
source_refs: sources/alpha.md
---
# Alpha Synthesis

## Summary
alpha signal synthesis
`)+"\n")

	initialDiagnosticsPath := filepath.Join(t.TempDir(), "initial-sync.json")
	store, err := New(context.Background(), Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: initialDiagnosticsPath,
	})
	if err != nil {
		t.Fatalf("open initial store: %v", err)
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close initial store: %v", err)
	}
	initialDiagnostics := readSyncDiagnosticsForTest(t, initialDiagnosticsPath)
	if initialDiagnostics.DocumentsCreated != 2 || initialDiagnostics.DocumentsUnchanged != 0 {
		t.Fatalf("initial document counts = %+v, want 2 created and 0 unchanged", initialDiagnostics)
	}
	if initialDiagnostics.ChunksWritten == 0 || initialDiagnostics.FTSRowsWritten == 0 {
		t.Fatalf("initial diagnostics did not record chunk/FTS writes: %+v", initialDiagnostics)
	}
	if initialDiagnostics.FTSStrategy != ftsStrategyBulkRebuild || initialDiagnostics.FTSRebuildSkipped {
		t.Fatalf("initial FTS diagnostics = %+v, want bulk rebuild", initialDiagnostics)
	}
	if initialDiagnostics.IncrementalFTSWriteSeconds != 0 {
		t.Fatalf("initial incremental FTS seconds = %.2f, want deferred bulk rebuild", initialDiagnostics.IncrementalFTSWriteSeconds)
	}
	if len(initialDiagnostics.ProjectionRebuilds) == 0 || initialDiagnostics.ProjectionRebuildSkipped {
		t.Fatalf("initial diagnostics did not record projection rebuilds: %+v", initialDiagnostics)
	}

	reopenDiagnosticsPath := filepath.Join(t.TempDir(), "reopen-sync.json")
	reopened, err := New(context.Background(), Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: reopenDiagnosticsPath,
	})
	if err != nil {
		t.Fatalf("reopen store: %v", err)
	}
	defer func() {
		_ = reopened.Close()
	}()

	reopenDiagnostics := readSyncDiagnosticsForTest(t, reopenDiagnosticsPath)
	if reopenDiagnostics.DocumentsCreated != 0 || reopenDiagnostics.DocumentsUpdated != 0 || reopenDiagnostics.DocumentsPruned != 0 {
		t.Fatalf("reopen changed document counts = %+v, want no changes", reopenDiagnostics)
	}
	if reopenDiagnostics.DocumentsUnchanged != 2 {
		t.Fatalf("reopen unchanged documents = %d, want 2", reopenDiagnostics.DocumentsUnchanged)
	}
	if reopenDiagnostics.ChunksWritten != 0 || reopenDiagnostics.FTSRowsWritten != 0 {
		t.Fatalf("reopen wrote chunks/FTS rows: %+v", reopenDiagnostics)
	}
	if reopenDiagnostics.FTSStrategy != ftsStrategySkippedNoChanges || !reopenDiagnostics.FTSRebuildSkipped {
		t.Fatalf("reopen FTS diagnostics = %+v, want rebuild skipped", reopenDiagnostics)
	}
	if !reopenDiagnostics.ProjectionRebuildSkipped || len(reopenDiagnostics.ProjectionRebuilds) != 0 {
		t.Fatalf("reopen projection diagnostics = %+v, want rebuild skipped", reopenDiagnostics)
	}
}

func TestSyncVaultRebuildsProjectionAfterInterruptedImport(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "records/services/openclerk-runner.md", strings.TrimSpace(`---
service_id: openclerk-runner
service_name: OpenClerk runner
service_status: active
service_owner: runner
service_interface: JSON runner
---
# OpenClerk runner

## Summary
Production service.
`)+"\n")

	store, err := NewUnsynced(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open unsynced store: %v", err)
	}
	if _, err := store.syncDocumentFromDiskWithOptions(ctx, "records/services/openclerk-runner.md", "", documentSyncOptions{
		RebuildProjections: false,
	}); err != nil {
		t.Fatalf("sync document without projection rebuild: %v", err)
	}
	pending, err := store.projectionRebuildPending(ctx)
	if err != nil {
		t.Fatalf("check pending projection rebuild: %v", err)
	}
	if !pending {
		t.Fatalf("projection rebuild pending = false, want true after interrupted import")
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close interrupted store: %v", err)
	}

	reopened, err := New(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("reopen store: %v", err)
	}
	defer func() {
		_ = reopened.Close()
	}()

	services, err := reopened.ServicesLookup(ctx, domain.ServiceLookupInput{Text: "OpenClerk runner", Limit: 10})
	if err != nil {
		t.Fatalf("services lookup after rebuild: %v", err)
	}
	if len(services.Services) != 1 {
		t.Fatalf("services after interrupted import recovery = %+v, want one service", services.Services)
	}
	pending, err = reopened.projectionRebuildPending(ctx)
	if err != nil {
		t.Fatalf("check pending projection rebuild after recovery: %v", err)
	}
	if pending {
		t.Fatalf("projection rebuild pending = true after successful recovery")
	}
}

func TestSyncVaultRebuildsFTSAfterInterruptedDeferredImport(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "sources/alpha.md", "# Alpha\n\nalpha interrupted marker\n")

	store, err := NewUnsynced(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open unsynced store: %v", err)
	}
	diagnostics := newSyncDiagnostics()
	if _, err := store.syncDocumentFromDiskWithOptions(ctx, "sources/alpha.md", "", documentSyncOptions{
		RebuildProjections: false,
		DeferFTS:           true,
		Diagnostics:        &diagnostics,
	}); err != nil {
		t.Fatalf("sync document with deferred FTS: %v", err)
	}
	if diagnostics.FTSRowsWritten != 0 || diagnostics.IncrementalFTSWriteSeconds != 0 {
		t.Fatalf("deferred FTS diagnostics = %+v, want no incremental writes", diagnostics)
	}
	pending, err := store.ftsRebuildPending(ctx)
	if err != nil {
		t.Fatalf("check pending FTS rebuild: %v", err)
	}
	if !pending {
		t.Fatalf("FTS rebuild pending = false, want true after deferred import")
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close interrupted store: %v", err)
	}

	diagnosticsPath := filepath.Join(t.TempDir(), "recovered-sync.json")
	reopened, err := New(ctx, Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: diagnosticsPath,
	})
	if err != nil {
		t.Fatalf("reopen store: %v", err)
	}
	defer func() {
		_ = reopened.Close()
	}()

	search, err := reopened.Search(ctx, domain.SearchQuery{Text: "interrupted marker", Limit: 10})
	if err != nil {
		t.Fatalf("search after FTS rebuild: %v", err)
	}
	if len(search.Hits) != 1 {
		t.Fatalf("search after FTS rebuild hits = %d, want 1", len(search.Hits))
	}
	pending, err = reopened.ftsRebuildPending(ctx)
	if err != nil {
		t.Fatalf("check pending FTS rebuild after recovery: %v", err)
	}
	if pending {
		t.Fatalf("FTS rebuild pending = true after successful recovery")
	}
	recoveryDiagnostics := readSyncDiagnosticsForTest(t, diagnosticsPath)
	if recoveryDiagnostics.FTSStrategy != ftsStrategyBulkRebuild || recoveryDiagnostics.FTSRebuildPending || !recoveryDiagnostics.FTSBootstrap {
		t.Fatalf("recovery FTS diagnostics = %+v, want completed bulk rebuild", recoveryDiagnostics)
	}
	if recoveryDiagnostics.FTSRowsWritten == 0 {
		t.Fatalf("recovery FTS rows written = 0, want rebuilt rows")
	}
}

func TestSyncVaultRebuildsFTSWhenCountsMismatch(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "sources/alpha.md", "# Alpha\n\nalpha mismatch marker\n")

	store := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	if err := store.Close(); err != nil {
		t.Fatalf("close initial store: %v", err)
	}

	corrupt, err := NewUnsynced(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open unsynced store for mismatch setup: %v", err)
	}
	if _, err := corrupt.db.ExecContext(ctx, `DELETE FROM chunk_fts`); err != nil {
		t.Fatalf("delete FTS rows for mismatch setup: %v", err)
	}
	if err := corrupt.Close(); err != nil {
		t.Fatalf("close mismatch setup store: %v", err)
	}

	diagnosticsPath := filepath.Join(t.TempDir(), "mismatch-sync.json")
	reopened, err := New(ctx, Config{
		Backend:             domain.BackendOpenClerk,
		DatabasePath:        dbPath,
		VaultRoot:           vaultRoot,
		SyncDiagnosticsPath: diagnosticsPath,
	})
	if err != nil {
		t.Fatalf("reopen store after mismatch: %v", err)
	}
	defer func() {
		_ = reopened.Close()
	}()

	search, err := reopened.Search(ctx, domain.SearchQuery{Text: "mismatch marker", Limit: 10})
	if err != nil {
		t.Fatalf("search after mismatch rebuild: %v", err)
	}
	if len(search.Hits) != 1 {
		t.Fatalf("search after mismatch rebuild hits = %d, want 1", len(search.Hits))
	}
	diagnostics := readSyncDiagnosticsForTest(t, diagnosticsPath)
	if diagnostics.FTSStrategy != ftsStrategyBulkRebuild || !diagnostics.FTSBootstrap || diagnostics.FTSRebuildPending {
		t.Fatalf("mismatch FTS diagnostics = %+v, want bootstrap bulk rebuild without pending flag", diagnostics)
	}
}

func TestSyncDocumentFromDiskUsesIncrementalFTS(t *testing.T) {
	t.Parallel()

	ctx := context.Background()
	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	writeVaultFile(t, vaultRoot, "sources/alpha.md", "# Alpha\n\nalpha incremental marker\n")

	store, err := NewUnsynced(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open unsynced store: %v", err)
	}
	defer func() {
		_ = store.Close()
	}()

	diagnostics := newSyncDiagnostics()
	if _, err := store.syncDocumentFromDiskWithOptions(ctx, "sources/alpha.md", "", documentSyncOptions{
		RebuildProjections: false,
		Diagnostics:        &diagnostics,
	}); err != nil {
		t.Fatalf("sync document with incremental FTS: %v", err)
	}
	if diagnostics.FTSStrategy != ftsStrategyIncrementalRows || diagnostics.FTSRowsWritten == 0 {
		t.Fatalf("incremental FTS diagnostics = %+v, want row writes", diagnostics)
	}
	pending, err := store.ftsRebuildPending(ctx)
	if err != nil {
		t.Fatalf("check pending FTS rebuild: %v", err)
	}
	if pending {
		t.Fatalf("FTS rebuild pending = true after incremental sync")
	}
	search, err := store.Search(ctx, domain.SearchQuery{Text: "incremental marker", Limit: 10})
	if err != nil {
		t.Fatalf("search after incremental sync: %v", err)
	}
	if len(search.Hits) != 1 {
		t.Fatalf("search after incremental sync hits = %d, want 1", len(search.Hits))
	}
}

func TestSyncVaultPrunesDeletedServiceProjection(t *testing.T) {
	t.Parallel()

	vaultRoot := t.TempDir()
	dbPath := filepath.Join(t.TempDir(), "openclerk.sqlite")
	store := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	if _, err := store.CreateDocument(context.Background(), domain.CreateDocumentInput{
		Path:  "records/services/openclerk-runner.md",
		Title: "OpenClerk runner",
		Body: strings.TrimSpace(`---
service_id: openclerk-runner
service_name: OpenClerk runner
service_status: active
service_owner: runner
service_interface: JSON runner
---
# OpenClerk runner

## Summary
Production service.
`) + "\n",
	}); err != nil {
		t.Fatalf("create service document: %v", err)
	}
	if err := store.Close(); err != nil {
		t.Fatalf("close initial store: %v", err)
	}

	if err := os.Remove(filepath.Join(vaultRoot, "records", "services", "openclerk-runner.md")); err != nil {
		t.Fatalf("remove service doc: %v", err)
	}

	reopened := openTestStore(t, domain.BackendOpenClerk, dbPath, vaultRoot)
	defer func() {
		_ = reopened.Close()
	}()

	services, err := reopened.ServicesLookup(context.Background(), domain.ServiceLookupInput{Text: "OpenClerk runner", Limit: 10})
	if err != nil {
		t.Fatalf("services lookup after delete: %v", err)
	}
	if len(services.Services) != 0 {
		t.Fatalf("services after delete = %+v, want none", services.Services)
	}
}

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

func manyMarkdownSectionsForTest(count int) string {
	var builder strings.Builder
	builder.WriteString("# Budget\n\n")
	for idx := 0; idx < count; idx++ {
		_, _ = fmt.Fprintf(&builder, "## Section %04d\nsection body\n\n", idx)
	}
	return builder.String()
}

func manyFrontmatterFieldsForTest(count int) string {
	var builder strings.Builder
	builder.WriteString("---\n")
	for idx := 0; idx < count; idx++ {
		_, _ = fmt.Fprintf(&builder, "field_%04d: value\n", idx)
	}
	builder.WriteString("---\n# Metadata Budget\n")
	return builder.String()
}

func readSyncDiagnosticsForTest(t *testing.T, path string) SyncDiagnostics {
	t.Helper()
	content, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("read sync diagnostics: %v", err)
	}
	var diagnostics SyncDiagnostics
	if err := json.Unmarshal(content, &diagnostics); err != nil {
		t.Fatalf("decode sync diagnostics: %v", err)
	}
	return diagnostics
}

func writeVaultIgnoreConfigForTest(t *testing.T, dbPath string, vaultRoot string, paths []string) {
	t.Helper()
	ctx := context.Background()
	store, err := NewUnsynced(ctx, Config{
		Backend:      domain.BackendOpenClerk,
		DatabasePath: dbPath,
		VaultRoot:    vaultRoot,
	})
	if err != nil {
		t.Fatalf("open store for vault ignore config: %v", err)
	}
	defer func() {
		_ = store.Close()
	}()
	normalized, err := domain.NormalizeVaultIgnorePaths(paths)
	if err != nil {
		t.Fatalf("normalize vault ignore paths: %v", err)
	}
	content, err := json.Marshal(normalized)
	if err != nil {
		t.Fatalf("encode vault ignore paths: %v", err)
	}
	if err := upsertRuntimeConfigValue(ctx, store.db, configKeyVaultIgnorePaths, string(content), testClock().Format(time.RFC3339Nano)); err != nil {
		t.Fatalf("write vault ignore config: %v", err)
	}
}

func syncDiagnosticsContains(values []string, want string) bool {
	for _, value := range values {
		if value == want {
			return true
		}
	}
	return false
}
