/**
 * Shared VectorStore conformance tests.
 *
 * Every concrete VectorStore implementation (today: inMemoryJsonStore;
 * tomorrow: sqliteVecStore) re-runs this suite to prove it meets the
 * contract defined in `storeInterface.js`. The concrete store's test
 * file does:
 *
 *     describe('inMemoryJsonStore conforms to VectorStore', () => {
 *       runStoreContractTests((opts) => new InMemoryJsonStore(opts));
 *     });
 *
 * Keeping this in a separate file (not in `storeInterface.js`) means
 * production callers of the interface — which only need the RRF helper
 * and JSDoc types — don't transitively import `@jest/globals`.
 *
 * `makeStore` is a factory taking `{ filePath, dimensions, modelFingerprint }`
 * and returning a fresh store. The helper supplies temp file paths so
 * stores can be exercised against real disk.
 */

import { describe, test, expect, beforeEach, afterEach } from '@jest/globals';
import os from 'os';
import fsp from 'fs/promises';
import path from 'path';

export function runStoreContractTests(makeStore) {
  describe('VectorStore contract', () => {
    let tmpDir, filePath, store;

    beforeEach(async () => {
      tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-vec-test-'));
      filePath = path.join(tmpDir, 'store.vec.json');
      store = makeStore({
        filePath,
        dimensions: 4,
        modelFingerprint: 'test:fp-v1',
      });
      await store.load();
    });

    afterEach(async () => {
      await fsp.rm(tmpDir, { recursive: true, force: true });
    });

    test('starts empty', async () => {
      const s = await store.stats();
      expect(s.count).toBe(0);
      expect(s.dimensions).toBe(4);
      expect(s.modelFingerprint).toBe('test:fp-v1');
    });

    test('upsert + query: returns the inserted vector as top hit', async () => {
      const v = Float32Array.from([1, 0, 0, 0]);
      await store.upsert('a', v, { text: 'apple' });
      const hits = await store.query(v, { topK: 5 });
      expect(hits[0]).toMatchObject({ id: 'a' });
      expect(hits[0].score).toBeCloseTo(1, 6);
      expect(hits[0].metadata.text).toBe('apple');
    });

    test('upsert overwrites existing id', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), { text: 'v1' });
      await store.upsert('a', Float32Array.from([0, 1, 0, 0]), { text: 'v2' });
      const s = await store.stats();
      expect(s.count).toBe(1);
      const hits = await store.query(Float32Array.from([0, 1, 0, 0]), { topK: 1 });
      expect(hits[0].metadata.text).toBe('v2');
    });

    test('delete removes the row', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), { text: 'a' });
      await store.upsert('b', Float32Array.from([0, 1, 0, 0]), { text: 'b' });
      expect((await store.stats()).count).toBe(2);
      await store.delete('a');
      expect((await store.stats()).count).toBe(1);
      const hits = await store.query(Float32Array.from([1, 0, 0, 0]), { topK: 5 });
      expect(hits.map(h => h.id)).not.toContain('a');
    });

    test('query ranks by cosine similarity (desc)', async () => {
      await store.upsert('north', Float32Array.from([0, 1, 0, 0]), {});
      await store.upsert('east',  Float32Array.from([1, 0, 0, 0]), {});
      await store.upsert('south', Float32Array.from([0, -1, 0, 0]), {});
      const hits = await store.query(Float32Array.from([0, 1, 0, 0]), { topK: 3 });
      expect(hits.map(h => h.id)).toEqual(['north', 'east', 'south']);
      expect(hits[0].score).toBeCloseTo(1, 5);
      expect(hits[2].score).toBeCloseTo(-1, 5);
    });

    test('query respects topK', async () => {
      await store.upsertBatch([
        { id: 'a', vector: Float32Array.from([1, 0, 0, 0]), metadata: {} },
        { id: 'b', vector: Float32Array.from([0, 1, 0, 0]), metadata: {} },
        { id: 'c', vector: Float32Array.from([0, 0, 1, 0]), metadata: {} },
      ]);
      const hits = await store.query(Float32Array.from([1, 0, 0, 0]), { topK: 2 });
      expect(hits).toHaveLength(2);
    });

    test('query applies metadata filter (function form)', async () => {
      await store.upsertBatch([
        { id: 'a', vector: Float32Array.from([1, 0, 0, 0]), metadata: { agentId: 'A' } },
        { id: 'b', vector: Float32Array.from([0.9, 0.1, 0, 0]), metadata: { agentId: 'B' } },
      ]);
      const hits = await store.query(Float32Array.from([1, 0, 0, 0]), {
        topK: 5,
        filter: m => m.agentId === 'A',
      });
      expect(hits.map(h => h.id)).toEqual(['a']);
    });

    test('persists across reload', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), { text: 'first' });
      const fresh = makeStore({ filePath, dimensions: 4, modelFingerprint: 'test:fp-v1' });
      await fresh.load();
      const hits = await fresh.query(Float32Array.from([1, 0, 0, 0]), { topK: 1 });
      expect(hits[0]).toMatchObject({ id: 'a' });
      expect(hits[0].metadata.text).toBe('first');
    });

    test('drop() empties the store + clears disk', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), {});
      await store.drop();
      expect((await store.stats()).count).toBe(0);
      const fresh = makeStore({ filePath, dimensions: 4, modelFingerprint: 'test:fp-v1' });
      await fresh.load();
      expect((await fresh.stats()).count).toBe(0);
    });

    test('rejects load when stored fingerprint differs (forces re-embed)', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), {});
      // Open the same file with a different fingerprint — store should
      // refuse and present an empty corpus (caller re-embeds from source).
      const swapped = makeStore({ filePath, dimensions: 4, modelFingerprint: 'test:fp-v2' });
      await swapped.load();
      expect((await swapped.stats()).count).toBe(0);
    });

    test('rejects load when stored dimensions differ', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), {});
      const wrongDim = makeStore({ filePath, dimensions: 8, modelFingerprint: 'test:fp-v1' });
      await wrongDim.load();
      expect((await wrongDim.stats()).count).toBe(0);
    });

    test('rejects upsert with wrong-dim vector', async () => {
      await expect(
        store.upsert('a', Float32Array.from([1, 0]), {}),
      ).rejects.toThrow(/dim/i);
    });

    test('stats.lastIndexedAt tracks the newest createdAt in metadata', async () => {
      await store.upsert('a', Float32Array.from([1, 0, 0, 0]), { createdAt: '2026-01-01T00:00:00Z' });
      await store.upsert('b', Float32Array.from([0, 1, 0, 0]), { createdAt: '2026-05-01T00:00:00Z' });
      const s = await store.stats();
      expect(s.lastIndexedAt).toBe('2026-05-01T00:00:00Z');
    });

    test('hybridQuery merges semantic + substring rankings via RRF', async () => {
      await store.upsertBatch([
        // 'a' loses on semantic (orthogonal to query) but wins on substring.
        { id: 'a', vector: Float32Array.from([0, 0, 1, 0]), metadata: { text: 'telegram dedup floods' } },
        // 'b' wins on semantic but no substring overlap.
        { id: 'b', vector: Float32Array.from([1, 0, 0, 0]), metadata: { text: 'completely unrelated content' } },
        // 'c' is mediocre on both.
        { id: 'c', vector: Float32Array.from([0.7, 0, 0.7, 0]), metadata: { text: 'telegram error' } },
      ]);
      const queryVec = Float32Array.from([1, 0, 0, 0]);
      const hits = await store.hybridQuery(queryVec, 'telegram', { topK: 3 });
      // Hybrid: 'c' (both signals) typically wins, 'a' and 'b' close behind.
      // We don't pin exact order — just that 'c' beats one of the unbalanced
      // candidates, validating fusion is doing something.
      const ids = hits.map(h => h.id);
      expect(ids).toContain('c');
      expect(ids.indexOf('c')).toBeLessThan(2);  // c is in top-2
    });
  });
}
