/**
 * sdk.test.ts - Unit tests for the QMD SDK (library mode)
 *
 * Tests the public API exposed via `@tobilu/qmd` (src/index.ts).
 * Uses inline config (no YAML files) to verify the SDK works self-contained.
 */

import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
import YAML from "yaml";
import {
  createStore,
  type QMDStore,
  type CollectionConfig,
  type StoreOptions,
  type UpdateProgress,
  type SearchOptions,
  type LexSearchOptions,
  type VectorSearchOptions,
  type ExpandQueryOptions,
} from "../src/index.js";
import { setDefaultLlamaCpp } from "../src/llm.js";

// =============================================================================
// Test Helpers
// =============================================================================

let testDir: string;
let docsDir: string;
let notesDir: string;

beforeAll(async () => {
  testDir = await mkdtemp(join(tmpdir(), "qmd-sdk-test-"));
  docsDir = join(testDir, "docs");
  notesDir = join(testDir, "notes");

  // Create test directories with sample markdown files
  await mkdir(docsDir, { recursive: true });
  await mkdir(notesDir, { recursive: true });

  await writeFile(join(docsDir, "readme.md"), "# Getting Started\n\nThis is the getting started guide for the project.\n");
  await writeFile(join(docsDir, "auth.md"), "# Authentication\n\nAuthentication uses JWT tokens for session management.\nUsers log in with email and password.\n");
  await writeFile(join(docsDir, "api.md"), "# API Reference\n\n## Endpoints\n\n### POST /login\nAuthenticate a user.\n\n### GET /users\nList all users.\n");
  await writeFile(join(notesDir, "meeting-2025-01.md"), "# January Planning Meeting\n\nDiscussed Q1 roadmap and resource allocation.\n");
  await writeFile(join(notesDir, "meeting-2025-02.md"), "# February Standup\n\nReviewed sprint progress. Authentication feature is on track.\n");
  await writeFile(join(notesDir, "ideas.md"), "# Project Ideas\n\n- Build a search engine\n- Create a knowledge base\n- Implement vector search\n");
});

afterAll(async () => {
  try {
    await rm(testDir, { recursive: true, force: true });
  } catch {
    // Ignore cleanup errors
  }
});

function freshDbPath(): string {
  return join(testDir, `test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
}

// =============================================================================
// Constructor Tests
// =============================================================================

describe("createStore", () => {
  test("creates store with inline config", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    expect(store).toBeDefined();
    expect(store.dbPath).toBeTruthy();
    expect(store.internal).toBeDefined();
    await store.close();
  });

  test("creates store with YAML config file", async () => {
    const configPath = join(testDir, "test-config.yml");
    const config: CollectionConfig = {
      collections: {
        docs: { path: docsDir, pattern: "**/*.md" },
      },
    };
    writeFileSync(configPath, YAML.stringify(config));

    const store = await createStore({
      dbPath: freshDbPath(),
      configPath,
    });

    expect(store).toBeDefined();
    await store.close();
  });

  test("throws if dbPath is missing", async () => {
    await expect(
      createStore({ dbPath: "", config: { collections: {} } })
    ).rejects.toThrow("dbPath is required");
  });

  test("opens with just dbPath (DB-only mode)", async () => {
    const store = await createStore({ dbPath: freshDbPath() } as StoreOptions);
    expect(store).toBeDefined();
    // No collections yet — fresh DB
    const collections = await store.listCollections();
    expect(collections).toEqual([]);
    await store.close();
  });

  test("throws if both configPath and config are provided", async () => {
    await expect(
      createStore({
        dbPath: freshDbPath(),
        configPath: "/some/path.yml",
        config: { collections: {} },
      })
    ).rejects.toThrow("Provide either configPath or config, not both");
  });

  test("creates database file on disk", async () => {
    const dbPath = freshDbPath();
    const store = await createStore({
      dbPath,
      config: { collections: {} },
    });

    expect(existsSync(dbPath)).toBe(true);
    await store.close();
  });

  test("store.dbPath matches the provided path", async () => {
    const dbPath = freshDbPath();
    const store = await createStore({
      dbPath,
      config: { collections: {} },
    });

    expect(store.dbPath).toBe(dbPath);
    await store.close();
  });
});

// =============================================================================
// Collection Management Tests
// =============================================================================

describe("collection management", () => {
  let store: QMDStore;

  beforeEach(async () => {
    store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });
  });

  afterEach(async () => {
    await store.close();
  });

  test("addCollection adds a collection to inline config", async () => {
    await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });

    const collections = await store.listCollections();
    const names = collections.map(c => c.name);
    expect(names).toContain("docs");
  });

  test("addCollection with default pattern", async () => {
    await store.addCollection("notes", { path: notesDir });

    const collections = await store.listCollections();
    expect(collections.find(c => c.name === "notes")).toBeDefined();
  });

  test("removeCollection removes existing collection", async () => {
    await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
    const removed = await store.removeCollection("docs");

    expect(removed).toBe(true);
    const collections = await store.listCollections();
    expect(collections.map(c => c.name)).not.toContain("docs");
  });

  test("removeCollection returns false for non-existent collection", async () => {
    const removed = await store.removeCollection("nonexistent");
    expect(removed).toBe(false);
  });

  test("renameCollection renames a collection", async () => {
    await store.addCollection("old-name", { path: docsDir, pattern: "**/*.md" });
    const renamed = await store.renameCollection("old-name", "new-name");

    expect(renamed).toBe(true);
    const names = (await store.listCollections()).map(c => c.name);
    expect(names).toContain("new-name");
    expect(names).not.toContain("old-name");
  });

  test("renameCollection returns false for non-existent source", async () => {
    const renamed = await store.renameCollection("nonexistent", "new-name");
    expect(renamed).toBe(false);
  });

  test("renameCollection throws if target exists", async () => {
    await store.addCollection("a", { path: docsDir, pattern: "**/*.md" });
    await store.addCollection("b", { path: notesDir, pattern: "**/*.md" });

    await expect(store.renameCollection("a", "b")).rejects.toThrow("already exists");
  });

  test("listCollections returns empty array for empty config", async () => {
    const collections = await store.listCollections();
    expect(collections).toEqual([]);
  });

  test("multiple collections can be added", async () => {
    await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
    await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });

    const names = (await store.listCollections()).map(c => c.name);
    expect(names).toContain("docs");
    expect(names).toContain("notes");
    expect(names).toHaveLength(2);
  });
});

// =============================================================================
// Context Management Tests
// =============================================================================

describe("context management", () => {
  let store: QMDStore;

  beforeEach(async () => {
    store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });
  });

  afterEach(async () => {
    await store.close();
  });

  test("addContext adds context to a collection path", async () => {
    const added = await store.addContext("docs", "/auth", "Authentication docs");
    expect(added).toBe(true);

    const contexts = await store.listContexts();
    expect(contexts).toContainEqual({
      collection: "docs",
      path: "/auth",
      context: "Authentication docs",
    });
  });

  test("addContext returns false for non-existent collection", async () => {
    const added = await store.addContext("nonexistent", "/path", "Some context");
    expect(added).toBe(false);
  });

  test("removeContext removes existing context", async () => {
    await store.addContext("docs", "/auth", "Authentication docs");
    const removed = await store.removeContext("docs", "/auth");

    expect(removed).toBe(true);
    const contexts = await store.listContexts();
    expect(contexts.find(c => c.path === "/auth")).toBeUndefined();
  });

  test("removeContext returns false for non-existent context", async () => {
    const removed = await store.removeContext("docs", "/nonexistent");
    expect(removed).toBe(false);
  });

  test("setGlobalContext sets and retrieves global context", async () => {
    await store.setGlobalContext("Global knowledge base");
    const global = await store.getGlobalContext();

    expect(global).toBe("Global knowledge base");
  });

  test("setGlobalContext with undefined clears it", async () => {
    await store.setGlobalContext("Some context");
    await store.setGlobalContext(undefined);
    const global = await store.getGlobalContext();

    expect(global).toBeUndefined();
  });

  test("listContexts includes global context", async () => {
    await store.setGlobalContext("Global context");
    const contexts = await store.listContexts();

    expect(contexts).toContainEqual({
      collection: "*",
      path: "/",
      context: "Global context",
    });
  });

  test("listContexts returns contexts across multiple collections", async () => {
    await store.addContext("docs", "/", "Documentation");
    await store.addContext("notes", "/", "Personal notes");

    const contexts = await store.listContexts();
    expect(contexts.filter(c => c.path === "/")).toHaveLength(2);
  });

  test("multiple contexts on same collection", async () => {
    await store.addContext("docs", "/auth", "Auth docs");
    await store.addContext("docs", "/api", "API docs");

    const contexts = (await store.listContexts()).filter(c => c.collection === "docs");
    expect(contexts).toHaveLength(2);
    expect(contexts.map(c => c.path).sort()).toEqual(["/api", "/auth"]);
  });

  test("addContext overwrites existing context for same path", async () => {
    await store.addContext("docs", "/auth", "Old context");
    await store.addContext("docs", "/auth", "New context");

    const contexts = (await store.listContexts()).filter(c => c.path === "/auth");
    expect(contexts).toHaveLength(1);
    expect(contexts[0]!.context).toBe("New context");
  });
});

// =============================================================================
// Inline Config Isolation Tests
// =============================================================================

describe("inline config isolation", () => {
  test("inline config does not write any files to disk", async () => {
    const configDir = join(testDir, "should-not-exist");
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
    await store.addContext("docs", "/", "Documentation");

    expect(existsSync(configDir)).toBe(false);
    await store.close();
  });

  test("inline config mutations persist within session", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
    await store.addContext("docs", "/", "My docs");

    // Verify the mutations are visible
    const collections = await store.listCollections();
    expect(collections.map(c => c.name)).toContain("docs");

    const contexts = await store.listContexts();
    expect(contexts).toContainEqual({
      collection: "docs",
      path: "/",
      context: "My docs",
    });

    await store.close();
  });

  test("two stores with different inline configs are independent", async () => {
    const store1 = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    // Close first store (resets config source)
    await store1.close();

    const store2 = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const names = (await store2.listCollections()).map(c => c.name);
    expect(names).toContain("notes");
    expect(names).not.toContain("docs");

    await store2.close();
  });
});

// =============================================================================
// YAML Config File Tests
// =============================================================================

describe("YAML config file mode", () => {
  test("loads collections from YAML file", async () => {
    const configPath = join(testDir, `config-${Date.now()}.yml`);
    const config: CollectionConfig = {
      collections: {
        docs: { path: docsDir, pattern: "**/*.md" },
        notes: { path: notesDir, pattern: "**/*.md" },
      },
    };
    writeFileSync(configPath, YAML.stringify(config));

    const store = await createStore({ dbPath: freshDbPath(), configPath });
    const names = (await store.listCollections()).map(c => c.name);

    expect(names).toContain("docs");
    expect(names).toContain("notes");
    await store.close();
  });

  test("addCollection persists to YAML file", async () => {
    const configPath = join(testDir, `config-persist-${Date.now()}.yml`);
    writeFileSync(configPath, YAML.stringify({ collections: {} }));

    const store = await createStore({ dbPath: freshDbPath(), configPath });
    await store.addCollection("newcol", { path: docsDir, pattern: "**/*.md" });
    await store.close();

    // Read the YAML file directly and verify
    const raw = readFileSync(configPath, "utf-8");
    const parsed = YAML.parse(raw) as CollectionConfig;
    expect(parsed.collections).toHaveProperty("newcol");
    expect(parsed.collections.newcol!.path).toBe(docsDir);
  });

  test("context persists to YAML file", async () => {
    const configPath = join(testDir, `config-ctx-${Date.now()}.yml`);
    writeFileSync(configPath, YAML.stringify({
      collections: { docs: { path: docsDir, pattern: "**/*.md" } },
    }));

    const store = await createStore({ dbPath: freshDbPath(), configPath });
    await store.addContext("docs", "/api", "API documentation");
    await store.close();

    const raw = readFileSync(configPath, "utf-8");
    const parsed = YAML.parse(raw) as CollectionConfig;
    expect(parsed.collections.docs!.context).toEqual({ "/api": "API documentation" });
  });

  test("non-existent config file returns empty collections", async () => {
    const configPath = join(testDir, "nonexistent-config.yml");
    const store = await createStore({ dbPath: freshDbPath(), configPath });
    const collections = await store.listCollections();

    expect(collections).toEqual([]);
    await store.close();
  });
});

// =============================================================================
// Search Tests (BM25 - no LLM needed)
// =============================================================================

describe("searchLex (BM25)", () => {
  let store: QMDStore;
  let dbPath: string;

  beforeAll(async () => {
    dbPath = join(testDir, "search-test.sqlite");
    store = await createStore({
      dbPath,
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    // Index documents manually using internal store
    const now = new Date().toISOString();
    const { internal } = store;
    const fs = require("fs");

    // Index docs collection
    for (const file of ["readme.md", "auth.md", "api.md"]) {
      const fullPath = join(docsDir, file);
      const content = fs.readFileSync(fullPath, "utf-8");
      const hash = require("crypto").createHash("sha256").update(content).digest("hex");
      const title = content.match(/^#\s+(.+)/m)?.[1] || file;

      internal.insertContent(hash, content, now);
      internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
    }

    // Index notes collection
    for (const file of ["meeting-2025-01.md", "meeting-2025-02.md", "ideas.md"]) {
      const fullPath = join(notesDir, file);
      const content = fs.readFileSync(fullPath, "utf-8");
      const hash = require("crypto").createHash("sha256").update(content).digest("hex");
      const title = content.match(/^#\s+(.+)/m)?.[1] || file;

      internal.insertContent(hash, content, now);
      internal.insertDocument("notes", `qmd://notes/${file}`, title, hash, now, now);
    }
  });

  afterAll(async () => {
    await store.close();
  });

  test("searchLex returns results for matching query", async () => {
    const results = await store.searchLex("authentication");
    expect(results.length).toBeGreaterThan(0);
  });

  test("searchLex results have expected shape", async () => {
    const results = await store.searchLex("authentication");
    expect(results.length).toBeGreaterThan(0);

    const result = results[0]!;
    expect(result).toHaveProperty("filepath");
    expect(result).toHaveProperty("score");
    expect(result).toHaveProperty("title");
    expect(result).toHaveProperty("docid");
    expect(result).toHaveProperty("collectionName");
    expect(typeof result.score).toBe("number");
    expect(result.score).toBeGreaterThan(0);
  });

  test("searchLex respects limit option", async () => {
    const results = await store.searchLex("meeting", { limit: 1 });
    expect(results.length).toBeLessThanOrEqual(1);
  });

  test("searchLex with collection filter", async () => {
    const results = await store.searchLex("authentication", { collection: "notes" });
    for (const r of results) {
      expect(r.collectionName).toBe("notes");
    }
  });

  test("searchLex returns empty for non-matching query", async () => {
    const results = await store.searchLex("xyznonexistentterm123");
    expect(results).toHaveLength(0);
  });

  test("searchLex finds documents across collections", async () => {
    const results = await store.searchLex("authentication", { limit: 10 });
    const collections = new Set(results.map(r => r.collectionName));
    // Auth appears in both docs/auth.md and notes/meeting-2025-02.md
    expect(collections.size).toBeGreaterThanOrEqual(1);
  });
});

// =============================================================================
// Unified search() API Tests
// =============================================================================

describe("search (unified API)", () => {
  let store: QMDStore;

  beforeAll(async () => {
    store = await createStore({
      dbPath: join(testDir, "unified-search-test.sqlite"),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });
    await store.update();
  });

  afterAll(async () => {
    await store.close();
  });

  test("search() requires query or queries", async () => {
    await expect(store.search({} as SearchOptions)).rejects.toThrow("requires either 'query' or 'queries'");
  });

  test("search() with pre-expanded queries and rerank:false", async () => {
    const results = await store.search({
      queries: [
        { type: "lex", query: "authentication JWT" },
        { type: "lex", query: "login session" },
      ],
      rerank: false,
    });
    expect(results.length).toBeGreaterThan(0);
  });

  test("search() forwards candidateLimit to structured search", async () => {
    const results = await store.search({
      queries: [
        { type: "lex", query: "authentication" },
        { type: "lex", query: "meeting" },
      ],
      limit: 5,
      candidateLimit: 1,
      rerank: false,
    });

    expect(results).toHaveLength(1);
  });

  // Tests below use search({ query: ... }) which triggers LLM query expansion
  describe.skipIf(!!process.env.CI)("with LLM query expansion", () => {
    test("search() with query and rerank:false returns results", async () => {
      const results = await store.search({ query: "authentication", rerank: false });
      expect(results.length).toBeGreaterThan(0);
      expect(results[0]).toHaveProperty("file");
      expect(results[0]).toHaveProperty("score");
      expect(results[0]).toHaveProperty("title");
      expect(results[0]).toHaveProperty("bestChunk");
      expect(results[0]).toHaveProperty("docid");
    }, 90000);

    test("search() with intent and rerank:false returns results", async () => {
      const results = await store.search({
        query: "meeting",
        intent: "quarterly planning and roadmap",
        rerank: false,
      });
      expect(results.length).toBeGreaterThan(0);
    }, 60000);

    test("search() with collection filter", async () => {
      const results = await store.search({
        query: "authentication",
        collection: "docs",
        rerank: false,
      });
      for (const r of results) {
        expect(r.file).toMatch(/^qmd:\/\/docs\//);
      }
    });

    test("search() with collections filter", async () => {
      const results = await store.search({
        query: "authentication",
        collections: ["docs"],
        rerank: false,
      });
      for (const r of results) {
        expect(r.file).toMatch(/^qmd:\/\/docs\//);
      }
    });

    test("search() with limit", async () => {
      const results = await store.search({ query: "meeting", limit: 1, rerank: false });
      expect(results.length).toBeLessThanOrEqual(1);
    });

    test("search() returns empty for non-matching query", async () => {
      const results = await store.search({ query: "xyznonexistentterm123", rerank: false });
      expect(results).toHaveLength(0);
    });
  });
});

// =============================================================================
// Document Retrieval Tests
// =============================================================================

describe("get and multiGet", () => {
  let store: QMDStore;

  beforeAll(async () => {
    store = await createStore({
      dbPath: join(testDir, "get-test.sqlite"),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    // Index documents
    const now = new Date().toISOString();
    const { internal } = store;
    const fs = require("fs");

    for (const file of ["readme.md", "auth.md", "api.md"]) {
      const fullPath = join(docsDir, file);
      const content = fs.readFileSync(fullPath, "utf-8");
      const hash = require("crypto").createHash("sha256").update(content).digest("hex");
      const title = content.match(/^#\s+(.+)/m)?.[1] || file;

      internal.insertContent(hash, content, now);
      internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
    }
  });

  afterAll(async () => {
    await store.close();
  });

  test("get retrieves a document by path", async () => {
    const result = await store.get("qmd://docs/auth.md");

    expect("error" in result).toBe(false);
    if (!("error" in result)) {
      expect(result.title).toBe("Authentication");
      expect(result.collectionName).toBe("docs");
    }
  });

  test("get with includeBody returns body content", async () => {
    const result = await store.get("qmd://docs/auth.md", { includeBody: true });

    if (!("error" in result)) {
      expect(result.body).toBeDefined();
      expect(result.body).toContain("JWT tokens");
    }
  });

  test("get returns not_found for missing document", async () => {
    const result = await store.get("qmd://docs/nonexistent.md");

    expect("error" in result).toBe(true);
    if ("error" in result) {
      expect(result.error).toBe("not_found");
    }
  });

  test("get by docid", async () => {
    // First get a document to find its docid
    const doc = await store.get("qmd://docs/readme.md");
    if (!("error" in doc)) {
      const byDocid = await store.get(`#${doc.docid}`);
      expect("error" in byDocid).toBe(false);
      if (!("error" in byDocid)) {
        expect(byDocid.docid).toBe(doc.docid);
      }
    }
  });

  test("multiGet retrieves multiple documents", async () => {
    const { docs, errors } = await store.multiGet("qmd://docs/*.md");
    expect(docs.length).toBeGreaterThan(0);
  });
});

// =============================================================================
// Index Health Tests
// =============================================================================

describe("index health", () => {
  let store: QMDStore;

  beforeEach(async () => {
    store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });
  });

  afterEach(async () => {
    await store.close();
  });

  test("getStatus returns valid structure", async () => {
    const status = await store.getStatus();

    expect(status).toHaveProperty("totalDocuments");
    expect(status).toHaveProperty("needsEmbedding");
    expect(status).toHaveProperty("hasVectorIndex");
    expect(status).toHaveProperty("collections");
    expect(typeof status.totalDocuments).toBe("number");
  });

  test("getIndexHealth returns valid structure", async () => {
    const health = await store.getIndexHealth();

    expect(health).toHaveProperty("needsEmbedding");
    expect(health).toHaveProperty("totalDocs");
    expect(typeof health.needsEmbedding).toBe("number");
    expect(typeof health.totalDocs).toBe("number");
  });

  test("fresh store has zero documents", async () => {
    const status = await store.getStatus();
    expect(status.totalDocuments).toBe(0);
  });
});

// =============================================================================
// Update Tests
// =============================================================================

describe("update", () => {
  test("indexes files and returns correct stats", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    const result = await store.update();

    expect(result.collections).toBe(1);
    expect(result.indexed).toBe(3); // readme.md, auth.md, api.md
    expect(result.updated).toBe(0);
    expect(result.unchanged).toBe(0);
    expect(result.removed).toBe(0);
    expect(typeof result.needsEmbedding).toBe("number");

    await store.close();
  });

  test("second update shows unchanged files", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    await store.update();
    const result = await store.update();

    expect(result.indexed).toBe(0);
    expect(result.unchanged).toBe(3);

    await store.close();
  });

  test("update with onProgress callback fires", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    const progress: UpdateProgress[] = [];
    await store.update({
      onProgress: (info) => progress.push(info),
    });

    expect(progress.length).toBeGreaterThan(0);
    expect(progress[0]!.collection).toBe("docs");
    expect(progress[0]!.current).toBeGreaterThanOrEqual(1);
    expect(progress[0]!.total).toBe(3);

    await store.close();
  });

  test("update with collection filter", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const result = await store.update({ collections: ["docs"] });

    expect(result.collections).toBe(1);
    expect(result.indexed).toBe(3); // Only docs

    await store.close();
  });

  test("update multiple collections", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const result = await store.update();

    expect(result.collections).toBe(2);
    expect(result.indexed).toBe(6); // 3 docs + 3 notes

    await store.close();
  });

  test("documents are searchable after update", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    await store.update();

    const results = await store.searchLex("authentication");
    expect(results.length).toBeGreaterThan(0);

    await store.close();
  });
});

describe("embed", () => {
  function createFakeTokenizer() {
    return {
      async tokenize(text: string) {
        return new Array(Math.max(1, Math.ceil(text.length / 16))).fill(1);
      },
    };
  }

  function createFakeEmbedLlm() {
    const embedBatchCalls: string[][] = [];
    return {
      embedBatchCalls,
      async embed(_text: string) {
        return { embedding: [0.1, 0.2, 0.3], model: "fake-embed" };
      },
      async embedBatch(texts: string[]) {
        embedBatchCalls.push([...texts]);
        return texts.map((_text, index) => ({
          embedding: [index + 1, index + 2, index + 3],
          model: "fake-embed",
        }));
      },
    };
  }

  test("store.embed forwards batch limit options", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    const fakeLlm = createFakeEmbedLlm();
    setDefaultLlamaCpp(createFakeTokenizer() as any);
    store.internal.llm = fakeLlm as any;

    try {
      await store.update();
      const result = await store.embed({
        maxDocsPerBatch: 1,
        maxBatchBytes: 1024 * 1024,
      });

      expect(fakeLlm.embedBatchCalls).toHaveLength(3);
      expect(fakeLlm.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
      expect(result.docsProcessed).toBe(3);
      expect(result.chunksEmbedded).toBe(3);
    } finally {
      setDefaultLlamaCpp(null);
      await store.close();
    }
  });

  test("store.embed scopes pending documents to the requested collection", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const fakeLlm = createFakeEmbedLlm();
    setDefaultLlamaCpp(createFakeTokenizer() as any);
    store.internal.llm = fakeLlm as any;

    try {
      await store.update();
      const result = await store.embed({ collection: "docs" });

      const vectorCounts = store.internal.db.prepare(`
        SELECT d.collection, COUNT(DISTINCT v.hash) AS count
        FROM documents d
        LEFT JOIN content_vectors v ON v.hash = d.hash AND v.seq = 0
        WHERE d.active = 1
        GROUP BY d.collection
        ORDER BY d.collection
      `).all() as Array<{ collection: string; count: number }>;

      expect(result.docsProcessed).toBe(3);
      expect(result.chunksEmbedded).toBe(3);
      expect(vectorCounts).toEqual([
        { collection: "docs", count: 3 },
        { collection: "notes", count: 0 },
      ]);
    } finally {
      setDefaultLlamaCpp(null);
      await store.close();
    }
  });

  test("store.embed with force only clears the requested collection", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const fakeLlm = createFakeEmbedLlm();
    setDefaultLlamaCpp(createFakeTokenizer() as any);
    store.internal.llm = fakeLlm as any;

    const vectorCounts = () => store.internal.db.prepare(`
      SELECT d.collection, COUNT(DISTINCT v.hash) AS count
      FROM documents d
      LEFT JOIN content_vectors v ON v.hash = d.hash AND v.seq = 0
      WHERE d.active = 1
      GROUP BY d.collection
      ORDER BY d.collection
    `).all() as Array<{ collection: string; count: number }>;

    try {
      await store.update();
      await store.embed();
      expect(vectorCounts()).toEqual([
        { collection: "docs", count: 3 },
        { collection: "notes", count: 3 },
      ]);

      const result = await store.embed({ force: true, collection: "docs" });

      expect(result.docsProcessed).toBe(3);
      expect(result.chunksEmbedded).toBe(3);
      expect(vectorCounts()).toEqual([
        { collection: "docs", count: 3 },
        { collection: "notes", count: 3 },
      ]);
    } finally {
      setDefaultLlamaCpp(null);
      await store.close();
    }
  });

  test("store.embed rejects invalid batch limits", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    try {
      await expect(store.embed({ maxDocsPerBatch: 0 })).rejects.toThrow("maxDocsPerBatch");
      await expect(store.embed({ maxBatchBytes: 0 })).rejects.toThrow("maxBatchBytes");
    } finally {
      setDefaultLlamaCpp(null);
      await store.close();
    }
  });
});

// =============================================================================
// Lifecycle Tests
// =============================================================================

describe("lifecycle", () => {
  test("close() is async and does not throw", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    // close() should return a promise
    const result = store.close();
    expect(result).toBeInstanceOf(Promise);
    await result;
  });

  test("close() makes subsequent operations throw", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    await store.close();

    // Database operations should fail after close
    await expect(store.getStatus()).rejects.toThrow();
  });

  test("multiple stores can coexist with different databases", async () => {
    const store1 = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    // Note: since config source is module-level, we close store1 first
    await store1.close();

    const store2 = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          notes: { path: notesDir, pattern: "**/*.md" },
        },
      },
    });

    const names = (await store2.listCollections()).map(c => c.name);
    expect(names).toContain("notes");
    expect(names).not.toContain("docs");

    await store2.close();
  });
});

// =============================================================================
// Config Initialization Tests
// =============================================================================

describe("config initialization", () => {
  test("inline config with global_context is preserved", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        global_context: "System knowledge base",
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });

    const global = await store.getGlobalContext();
    expect(global).toBe("System knowledge base");
    await store.close();
  });

  test("inline config with pre-existing contexts is preserved", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: {
            path: docsDir,
            pattern: "**/*.md",
            context: { "/auth": "Authentication docs" },
          },
        },
      },
    });

    const contexts = await store.listContexts();
    expect(contexts).toContainEqual({
      collection: "docs",
      path: "/auth",
      context: "Authentication docs",
    });
    await store.close();
  });

  test("inline config with empty collections object works", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    expect(await store.listCollections()).toEqual([]);
    expect(await store.listContexts()).toEqual([]);
    await store.close();
  });

  test("inline config with multiple collection options", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: {
        collections: {
          docs: {
            path: docsDir,
            pattern: "**/*.md",
            ignore: ["drafts/**"],
            includeByDefault: true,
          },
          notes: {
            path: notesDir,
            pattern: "**/*.md",
            includeByDefault: false,
          },
        },
      },
    });

    const collections = await store.listCollections();
    expect(collections).toHaveLength(2);
    await store.close();
  });
});

// =============================================================================
// Type Export Tests (compile-time checks, runtime verification)
// =============================================================================

describe("type exports", () => {
  test("StoreOptions type is usable", () => {
    const opts: StoreOptions = {
      dbPath: "/tmp/test.sqlite",
      config: { collections: {} },
    };
    expect(opts.dbPath).toBe("/tmp/test.sqlite");
  });

  test("CollectionConfig type is usable", () => {
    const config: CollectionConfig = {
      global_context: "test",
      collections: {
        test: { path: "/tmp", pattern: "**/*.md" },
      },
    };
    expect(config.collections).toHaveProperty("test");
  });

  test("QMDStore type exposes expected methods", async () => {
    const store = await createStore({
      dbPath: freshDbPath(),
      config: { collections: {} },
    });

    // Verify all methods exist
    expect(typeof store.search).toBe("function");
    expect(typeof store.searchLex).toBe("function");
    expect(typeof store.searchVector).toBe("function");
    expect(typeof store.expandQuery).toBe("function");
    expect(typeof store.get).toBe("function");
    expect(typeof store.multiGet).toBe("function");
    expect(typeof store.addCollection).toBe("function");
    expect(typeof store.removeCollection).toBe("function");
    expect(typeof store.renameCollection).toBe("function");
    expect(typeof store.listCollections).toBe("function");
    expect(typeof store.addContext).toBe("function");
    expect(typeof store.removeContext).toBe("function");
    expect(typeof store.setGlobalContext).toBe("function");
    expect(typeof store.getGlobalContext).toBe("function");
    expect(typeof store.listContexts).toBe("function");
    expect(typeof store.getStatus).toBe("function");
    expect(typeof store.getIndexHealth).toBe("function");
    expect(typeof store.update).toBe("function");
    expect(typeof store.embed).toBe("function");
    expect(typeof store.close).toBe("function");

    await store.close();
  });
});

// =============================================================================
// DB-Only Mode Tests (self-contained store)
// =============================================================================

describe("DB-only mode", () => {
  test("reopen store with just dbPath after config+update session", async () => {
    const dbPath = freshDbPath();

    // Session 1: create store with config, update, close
    const store1 = await createStore({
      dbPath,
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
          notes: { path: notesDir, pattern: "**/*.md" },
        },
        global_context: "Test knowledge base",
      },
    });

    await store1.update();

    // Verify documents indexed
    const status1 = await store1.getStatus();
    expect(status1.totalDocuments).toBe(6);
    await store1.close();

    // Session 2: reopen with just dbPath — no config
    const store2 = await createStore({ dbPath } as StoreOptions);

    // Collections should still be available
    const collections = await store2.listCollections();
    expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);

    // Search should still work
    const results = await store2.searchLex("authentication");
    expect(results.length).toBeGreaterThan(0);

    // Global context should still be available
    const globalCtx = await store2.getGlobalContext();
    expect(globalCtx).toBe("Test knowledge base");

    // Contexts from collections should persist
    const status2 = await store2.getStatus();
    expect(status2.totalDocuments).toBe(6);

    await store2.close();
  });

  test("config sync populates store_collections table", async () => {
    const dbPath = freshDbPath();
    const store = await createStore({
      dbPath,
      config: {
        collections: {
          docs: {
            path: docsDir,
            pattern: "**/*.md",
            context: { "/auth": "Auth documentation" },
          },
        },
      },
    });

    // Verify collections are in the DB via listCollections
    const collections = await store.listCollections();
    expect(collections).toHaveLength(1);
    expect(collections[0]!.name).toBe("docs");
    expect(collections[0]!.pwd).toBe(docsDir);

    // Verify contexts are accessible
    const contexts = await store.listContexts();
    expect(contexts).toContainEqual({
      collection: "docs",
      path: "/auth",
      context: "Auth documentation",
    });

    await store.close();
  });

  test("config hash skip: second init with same config skips sync", async () => {
    const dbPath = freshDbPath();
    const config = {
      collections: {
        docs: { path: docsDir, pattern: "**/*.md" },
      },
    };

    // First init — syncs config
    const store1 = await createStore({ dbPath, config });
    await store1.close();

    // Second init with same config — should skip sync (no-op, but should not error)
    const store2 = await createStore({ dbPath, config });
    const collections = await store2.listCollections();
    expect(collections).toHaveLength(1);
    expect(collections[0]!.name).toBe("docs");
    await store2.close();
  });

  test("DB-only mode supports collection mutations", async () => {
    const dbPath = freshDbPath();

    // Session 1: create with config
    const store1 = await createStore({
      dbPath,
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });
    await store1.close();

    // Session 2: reopen DB-only, add a collection
    const store2 = await createStore({ dbPath } as StoreOptions);
    await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });

    const names = (await store2.listCollections()).map(c => c.name).sort();
    expect(names).toEqual(["docs", "notes"]);

    await store2.close();

    // Session 3: reopen DB-only again, verify both collections persist
    const store3 = await createStore({ dbPath } as StoreOptions);
    const names3 = (await store3.listCollections()).map(c => c.name).sort();
    expect(names3).toEqual(["docs", "notes"]);
    await store3.close();
  });

  test("DB-only mode supports context mutations", async () => {
    const dbPath = freshDbPath();

    // Session 1: create with config
    const store1 = await createStore({
      dbPath,
      config: {
        collections: {
          docs: { path: docsDir, pattern: "**/*.md" },
        },
      },
    });
    await store1.addContext("docs", "/api", "API docs");
    await store1.setGlobalContext("Global context");
    await store1.close();

    // Session 2: reopen DB-only
    const store2 = await createStore({ dbPath } as StoreOptions);

    const contexts = await store2.listContexts();
    expect(contexts).toContainEqual({
      collection: "docs",
      path: "/api",
      context: "API docs",
    });
    expect(contexts).toContainEqual({
      collection: "*",
      path: "/",
      context: "Global context",
    });

    await store2.close();
  });
});
