import { describe, it, expect, beforeEach } from 'bun:test';
import { vi } from '../../helpers/vi-shim.js';
import { EmbeddingManager } from '@myco/daemon/embedding/manager';
import type {
  VectorStore,
  ManagerEmbeddingProvider,
  EmbeddableRecordSource,
  VectorStoreStats,
  DomainMetadata,
} from '@myco/daemon/embedding/types';

// ---------------------------------------------------------------------------
// Mock factories
// ---------------------------------------------------------------------------

const MOCK_EMBEDDING = [0.1, 0.2, 0.3, 0.4];
const MOCK_MODEL = 'test-model';
const MOCK_PROVIDER_NAME = 'test-provider';
const MOCK_DIMENSIONS = 4;

function createMockVectorStore(): VectorStore & {
  upsert: ReturnType<typeof vi.fn>;
  remove: ReturnType<typeof vi.fn>;
  clear: ReturnType<typeof vi.fn>;
  search: ReturnType<typeof vi.fn>;
  stats: ReturnType<typeof vi.fn>;
  getStaleIds: ReturnType<typeof vi.fn>;
  getEmbeddedIds: ReturnType<typeof vi.fn>;
  computeHubnessStats: ReturnType<typeof vi.fn>;
  upsertHubnessStats: ReturnType<typeof vi.fn>;
  pairwiseSimilarity: ReturnType<typeof vi.fn>;
  patchDomainMetadata: ReturnType<typeof vi.fn>;
} {
  return {
    upsert: vi.fn(),
    remove: vi.fn(),
    clear: vi.fn().mockReturnValue({ cleared: 0 }),
    search: vi.fn().mockReturnValue([]),
    stats: vi.fn().mockReturnValue({
      total: 0,
      by_namespace: {},
      models: {},
    } satisfies VectorStoreStats),
    getStaleIds: vi.fn().mockReturnValue([]),
    getEmbeddedIds: vi.fn().mockReturnValue([]),
    computeHubnessStats: vi.fn().mockResolvedValue([]),
    upsertHubnessStats: vi.fn(),
    pairwiseSimilarity: vi.fn().mockReturnValue([]),
    patchDomainMetadata: vi.fn().mockReturnValue(false),
  };
}

function createMockProvider(
  embedding: number[] | null = MOCK_EMBEDDING,
): ManagerEmbeddingProvider & { embed: ReturnType<typeof vi.fn> } {
  return {
    embed: vi.fn().mockResolvedValue(embedding),
    model: MOCK_MODEL,
    providerName: MOCK_PROVIDER_NAME,
    dimensions: MOCK_DIMENSIONS,
  };
}

function createMockRecordSource(): EmbeddableRecordSource & {
  getEmbeddableRows: ReturnType<typeof vi.fn>;
  getActiveRecordIds: ReturnType<typeof vi.fn>;
  getRecordContent: ReturnType<typeof vi.fn>;
  markEmbedded: ReturnType<typeof vi.fn>;
  clearEmbedded: ReturnType<typeof vi.fn>;
  clearAllEmbedded: ReturnType<typeof vi.fn>;
  getPendingCount: ReturnType<typeof vi.fn>;
} {
  return {
    getEmbeddableRows: vi.fn().mockReturnValue([]),
    getActiveRecordIds: vi.fn().mockReturnValue([]),
    getRecordContent: vi.fn().mockReturnValue([]),
    markEmbedded: vi.fn(),
    clearEmbedded: vi.fn(),
    clearAllEmbedded: vi.fn(),
    getPendingCount: vi.fn().mockReturnValue(0),
  };
}

function createMockLogger() {
  return {
    debug: vi.fn(),
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('EmbeddingManager', () => {
  let vectorStore: ReturnType<typeof createMockVectorStore>;
  let provider: ReturnType<typeof createMockProvider>;
  let recordSource: ReturnType<typeof createMockRecordSource>;
  let logger: ReturnType<typeof createMockLogger>;
  let manager: EmbeddingManager;

  beforeEach(() => {
    vectorStore = createMockVectorStore();
    provider = createMockProvider();
    recordSource = createMockRecordSource();
    logger = createMockLogger();
    manager = new EmbeddingManager(vectorStore, provider, recordSource, logger);
  });

  // -------------------------------------------------------------------------
  // onContentWritten
  // -------------------------------------------------------------------------

  describe('onContentWritten', () => {
    const namespace = 'sessions';
    const id = 'session-abc123';
    const text = 'This is a test session summary';
    const metadata: DomainMetadata = { project_root: '/tmp/project' };

    it('calls provider.embed, vectorStore.upsert with correct metadata, and recordSource.markEmbedded', async () => {
      await manager.onContentWritten(namespace, id, text, metadata);

      expect(provider.embed).toHaveBeenCalledWith(text);
      expect(vectorStore.upsert).toHaveBeenCalledWith(
        namespace,
        id,
        MOCK_EMBEDDING,
        expect.objectContaining({
          model: MOCK_MODEL,
          provider: MOCK_PROVIDER_NAME,
          dimensions: MOCK_DIMENSIONS,
          domain_metadata: metadata,
        }),
      );
      // Verify content_hash is a sha256 hex string
      const upsertMeta = vectorStore.upsert.mock.calls[0][3] as Record<string, unknown>;
      expect(upsertMeta.content_hash).toMatch(/^[a-f0-9]{64}$/);
      expect(typeof upsertMeta.embedded_at).toBe('number');

      expect(recordSource.markEmbedded).toHaveBeenCalledWith(namespace, id);
      expect(logger.debug).toHaveBeenCalledWith('embedding.embed', 'Vector stored', { namespace, id });
    });

    it('does NOT call upsert or markEmbedded when provider returns null', async () => {
      provider.embed.mockResolvedValue(null);

      await manager.onContentWritten(namespace, id, text, metadata);

      expect(vectorStore.upsert).not.toHaveBeenCalled();
      expect(recordSource.markEmbedded).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.provider',
        'Provider unavailable, skipping embed',
        expect.objectContaining({ namespace, id }),
      );
    });

    it('catches provider.embed() throw without propagating', async () => {
      provider.embed.mockRejectedValue(new Error('network timeout'));

      await expect(manager.onContentWritten(namespace, id, text, metadata))
        .resolves.toBeUndefined();

      expect(vectorStore.upsert).not.toHaveBeenCalled();
      expect(recordSource.markEmbedded).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.embed',
        'Failed to embed content',
        expect.objectContaining({ namespace, id }),
      );
    });

    it('catches vectorStore.upsert() throw without propagating', async () => {
      vectorStore.upsert.mockImplementation(() => {
        throw new Error('disk full');
      });

      await expect(manager.onContentWritten(namespace, id, text, metadata))
        .resolves.toBeUndefined();

      expect(recordSource.markEmbedded).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.embed',
        'Failed to embed content',
        expect.objectContaining({ namespace, id }),
      );
    });
  });

  // -------------------------------------------------------------------------
  // onStatusChanged
  // -------------------------------------------------------------------------

  describe('onStatusChanged', () => {
    const namespace = 'spores' as const;
    const id = 'spore-xyz789';

    it('removes vector and clears embedded for superseded status', () => {
      manager.onStatusChanged(namespace, id, 'superseded');

      expect(vectorStore.remove).toHaveBeenCalledWith(namespace, id);
      expect(recordSource.clearEmbedded).toHaveBeenCalledWith(namespace, id);
      expect(logger.debug).toHaveBeenCalledWith(
        'embedding.cleanup',
        'Vector removed',
        expect.objectContaining({ namespace, id }),
      );
    });

    it('does NOT call remove for active status', () => {
      manager.onStatusChanged(namespace, id, 'active');

      expect(vectorStore.remove).not.toHaveBeenCalled();
      expect(recordSource.clearEmbedded).not.toHaveBeenCalled();
    });

    it('is idempotent — calling twice with same status is safe', () => {
      manager.onStatusChanged(namespace, id, 'archived');
      manager.onStatusChanged(namespace, id, 'archived');

      expect(vectorStore.remove).toHaveBeenCalledTimes(2);
      expect(recordSource.clearEmbedded).toHaveBeenCalledTimes(2);
    });
  });

  // -------------------------------------------------------------------------
  // onRemoved
  // -------------------------------------------------------------------------

  describe('onRemoved', () => {
    it('calls vectorStore.remove for the namespace and id', () => {
      manager.onRemoved('plans', 'plan-abc');

      expect(vectorStore.remove).toHaveBeenCalledWith('plans', 'plan-abc');
      expect(logger.debug).toHaveBeenCalledWith(
        'embedding.cleanup',
        'Vector removed',
        expect.objectContaining({ namespace: 'plans', id: 'plan-abc' }),
      );
    });

    it('handles non-existent vector without throwing', () => {
      // remove() is a no-op for missing vectors
      expect(() => manager.onRemoved('artifacts', 'no-such-id')).not.toThrow();
    });
  });

  // -------------------------------------------------------------------------
  // reconcile
  // -------------------------------------------------------------------------

  describe('reconcile', () => {
    const BATCH_SIZE = 10;

    it('embeds missing rows, marks embedded, and returns correct counts', async () => {
      recordSource.getEmbeddableRows
        .mockImplementation((ns: string) => {
          if (ns === 'sessions') {
            return [
              { id: 's1', text: 'session text 1', metadata: {} },
              { id: 's2', text: 'session text 2', metadata: {} },
            ];
          }
          return [];
        });
      // No stale, no orphans
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.embedded).toBe(2);
      expect(result.stale_reembedded).toBe(0);
      expect(result.orphans_cleaned).toBe(0);
      expect(result.duration_ms).toBeGreaterThanOrEqual(0);

      expect(provider.embed).toHaveBeenCalledTimes(2);
      expect(vectorStore.upsert).toHaveBeenCalledTimes(2);
      expect(recordSource.markEmbedded).toHaveBeenCalledTimes(2);
      expect(logger.info).toHaveBeenCalledWith(
        'embedding.reconcile',
        expect.stringContaining('Reconcile cycle completed: 2 embedded'),
        expect.objectContaining({ embedded: 2, stale_reembedded: 0, orphans_cleaned: 0 }),
      );
    });

    it('returns early with partial counts when provider becomes unavailable mid-batch', async () => {
      recordSource.getEmbeddableRows
        .mockImplementation((ns: string) => {
          if (ns === 'sessions') {
            return [
              { id: 's1', text: 'text1', metadata: {} },
              { id: 's2', text: 'text2', metadata: {} },
            ];
          }
          return [];
        });
      vectorStore.getStaleIds.mockReturnValue([]);

      // First call succeeds, second returns null
      provider.embed
        .mockResolvedValueOnce(MOCK_EMBEDDING)
        .mockResolvedValueOnce(null);

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.embedded).toBe(1);
      expect(result.stale_reembedded).toBe(0);
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.provider',
        'Provider unavailable during reconcile, returning partial progress',
        expect.objectContaining({ namespace: 'sessions', embedded: 1 }),
      );
    });

    it('detects and removes orphan vectors', async () => {
      // No missing rows, no stale
      recordSource.getEmbeddableRows.mockReturnValue([]);
      vectorStore.getStaleIds.mockReturnValue([]);

      // sessions namespace has a vector that has no matching record
      vectorStore.getEmbeddedIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s1', 's2', 's-orphan'];
        return [];
      });
      recordSource.getActiveRecordIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s1', 's2'];
        return [];
      });

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.orphans_cleaned).toBe(1);
      expect(vectorStore.remove).toHaveBeenCalledWith('sessions', 's-orphan');
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.cleanup',
        'Orphan vector cleaned',
        expect.objectContaining({ namespace: 'sessions', id: 's-orphan' }),
      );
    });

    it('re-embeds stale vectors with model mismatch', async () => {
      recordSource.getEmbeddableRows.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      // spores namespace has 2 stale vectors
      vectorStore.getStaleIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp-old1', 'sp-old2'];
        return [];
      });
      recordSource.getRecordContent.mockImplementation((ns: string, ids: string[]) => {
        if (ns === 'spores') {
          return ids.map((id) => ({ id, text: `content for ${id}`, metadata: {} }));
        }
        return [];
      });

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.stale_reembedded).toBe(2);
      expect(result.embedded).toBe(0);
      // 2 embeds for stale re-embedding
      expect(provider.embed).toHaveBeenCalledTimes(2);
      expect(vectorStore.upsert).toHaveBeenCalledTimes(2);
      // Verify model is set to current
      const upsertMeta = vectorStore.upsert.mock.calls[0][3] as Record<string, unknown>;
      expect(upsertMeta.model).toBe(MOCK_MODEL);
    });

    it('limits bulk embedding concurrency to two in-flight requests', async () => {
      let inFlight = 0;
      let maxInFlight = 0;
      provider.embed.mockImplementation(async () => {
        inFlight++;
        maxInFlight = Math.max(maxInFlight, inFlight);
        await new Promise((resolve) => setTimeout(resolve, 10));
        inFlight--;
        return MOCK_EMBEDDING;
      });

      recordSource.getEmbeddableRows.mockImplementation((ns: string) => {
        if (ns === 'sessions') {
          return [
            { id: 's1', text: 'session text 1', metadata: {} },
            { id: 's2', text: 'session text 2', metadata: {} },
            { id: 's3', text: 'session text 3', metadata: {} },
            { id: 's4', text: 'session text 4', metadata: {} },
          ];
        }
        return [];
      });
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.embedded).toBe(4);
      expect(maxInFlight).toBe(2);
    });

    it('cleans stale vectors whose source records no longer exist', async () => {
      recordSource.getEmbeddableRows.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      // 3 stale vectors, but only 1 has a source record
      vectorStore.getStaleIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp1', 'sp-gone1', 'sp-gone2'];
        return [];
      });
      recordSource.getRecordContent.mockImplementation((ns: string) => {
        if (ns === 'spores') return [{ id: 'sp1', text: 'still here', metadata: {} }];
        return [];
      });

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.stale_reembedded).toBe(1);
      expect(result.orphans_cleaned).toBe(2);
      expect(vectorStore.remove).toHaveBeenCalledWith('spores', 'sp-gone1');
      expect(vectorStore.remove).toHaveBeenCalledWith('spores', 'sp-gone2');
      expect(logger.warn).toHaveBeenCalledWith(
        'embedding.cleanup',
        'Stale orphan vector cleaned',
        expect.objectContaining({ namespace: 'spores', id: 'sp-gone1' }),
      );
    });

    it('detects orphans even when vector count matches active count', async () => {
      // This is the fast-path bug fix: equal counts with different members
      recordSource.getEmbeddableRows.mockReturnValue([]);
      vectorStore.getStaleIds.mockReturnValue([]);

      // 3 vectors, 3 active records — but different IDs
      vectorStore.getEmbeddedIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s1', 's2', 's-orphan'];
        return [];
      });
      recordSource.getActiveRecordIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s1', 's2', 's3-no-vector'];
        return [];
      });

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.orphans_cleaned).toBe(1);
      expect(vectorStore.remove).toHaveBeenCalledWith('sessions', 's-orphan');
    });

    it('returns zeros when no work is needed', async () => {
      recordSource.getEmbeddableRows.mockReturnValue([]);
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      const result = await manager.reconcile(BATCH_SIZE);

      expect(result.embedded).toBe(0);
      expect(result.stale_reembedded).toBe(0);
      expect(result.orphans_cleaned).toBe(0);
      // No info log when no work done
      expect(logger.info).not.toHaveBeenCalled();
    });

    // -------------------------------------------------------------------------
    // Round-robin cursor
    // -------------------------------------------------------------------------

    it('round-robin under deadline: cursor advances so late namespaces are not starved', async () => {
      // Hermetic: control time via a mocked Date.now so the deadline break is
      // exact regardless of machine speed. Each embed advances the clock by a
      // fixed step. With deadlineMs = currentNow + 1, the i=0 check passes,
      // namespace[0] processes, its embed advances the clock past the deadline,
      // and the i=1 check breaks — exactly 1 namespace per call, every time.
      const NAMESPACES = ['sessions', 'spores', 'plans', 'artifacts', 'skill_records', 'canopy_entries'];
      const N = NAMESPACES.length;
      const CLOCK_STEP = 10;

      let currentNow = 1_000_000;
      const realNow = Date.now;
      Date.now = () => currentNow;

      try {
        // Each embed advances the clock so the next deadline check trips.
        provider.embed.mockImplementation(async () => {
          currentNow += CLOCK_STEP;
          return MOCK_EMBEDDING;
        });

        recordSource.getEmbeddableRows.mockImplementation((ns: string) => [
          { id: `${ns}-row`, text: `text for ${ns}`, metadata: {} },
        ]);
        vectorStore.getStaleIds.mockReturnValue([]);
        vectorStore.getEmbeddedIds.mockReturnValue([]);
        recordSource.getActiveRecordIds.mockReturnValue([]);

        const allVisited = new Set<string>();
        vectorStore.upsert.mockImplementation((ns: string) => { allVisited.add(ns as string); });

        // N calls, each servicing exactly one namespace; assert the cursor walks
        // 1 → 2 → 3 → 4 → 5 → 0 deterministically (proves no late namespace is
        // skipped) and that every namespace is serviced over the N calls.
        const expectedCursorAfter = [1, 2, 3, 4, 5, 0];
        for (let call = 0; call < N; call++) {
          // deadline = currentNow + 1: namespace[startIdx] processes, its embed
          // pushes the clock to currentNow + CLOCK_STEP, then the next check breaks.
          await manager.reconcile(BATCH_SIZE, currentNow + 1);
          expect(manager['nextNamespaceIndex']).toBe(expectedCursorAfter[call]);
        }

        // Every namespace was serviced across the N calls — the last namespace
        // ('canopy_entries') is not starved.
        for (const ns of NAMESPACES) {
          expect(allVisited).toContain(ns);
        }
      } finally {
        Date.now = realNow;
      }
    });

    it('round-robin: no-deadline call processes all namespaces and cursor returns to start', async () => {
      // Two consecutive no-deadline calls must both process all N namespaces and
      // produce the same visit order (cursor is idempotent after a full pass).
      const NAMESPACES = ['sessions', 'spores', 'plans', 'artifacts', 'skill_records', 'canopy_entries'];

      recordSource.getEmbeddableRows.mockImplementation((ns: string) => [
        { id: `${ns}-row`, text: `text for ${ns}`, metadata: {} },
      ]);
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);

      const visitOrder1: string[] = [];
      const visitOrder2: string[] = [];

      vectorStore.upsert.mockImplementation((ns: string) => { visitOrder1.push(ns as string); });
      const result1 = await manager.reconcile(BATCH_SIZE);

      vectorStore.upsert.mockImplementation((ns: string) => { visitOrder2.push(ns as string); });
      const result2 = await manager.reconcile(BATCH_SIZE);

      // Both calls must have processed all 6 namespaces.
      expect(result1.embedded).toBe(NAMESPACES.length);
      expect(result2.embedded).toBe(NAMESPACES.length);

      // Both calls must cover every namespace.
      for (const ns of NAMESPACES) {
        expect(visitOrder1).toContain(ns);
        expect(visitOrder2).toContain(ns);
      }

      // Both calls start at the same namespace (cursor returns to startIdx after full pass).
      expect(visitOrder1[0]).toBe(visitOrder2[0]);
      // The full visit order is identical.
      expect(visitOrder1).toEqual(visitOrder2);
    });
  });

  // -------------------------------------------------------------------------
  // cleanOrphans
  // -------------------------------------------------------------------------

  describe('cleanOrphans', () => {
    it('removes orphan vectors and returns count', () => {
      vectorStore.getEmbeddedIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp1', 'sp2', 'sp-orphan1', 'sp-orphan2'];
        return [];
      });
      recordSource.getActiveRecordIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp1', 'sp2'];
        return [];
      });

      const result = manager.cleanOrphans();

      expect(result.orphans_cleaned).toBe(2);
      expect(vectorStore.remove).toHaveBeenCalledWith('spores', 'sp-orphan1');
      expect(vectorStore.remove).toHaveBeenCalledWith('spores', 'sp-orphan2');
    });

    it('returns 0 when no orphans exist', () => {
      vectorStore.getEmbeddedIds.mockReturnValue(['a', 'b']);
      recordSource.getActiveRecordIds.mockReturnValue(['a', 'b']);

      const result = manager.cleanOrphans();

      expect(result.orphans_cleaned).toBe(0);
    });

    it('detects orphans even when counts match (fast-path bug fix)', () => {
      // 2 vectors, 2 active records — but one vector is orphaned and one record is missing a vector
      vectorStore.getEmbeddedIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp1', 'sp-orphan'];
        return [];
      });
      recordSource.getActiveRecordIds.mockImplementation((ns: string) => {
        if (ns === 'spores') return ['sp1', 'sp-new'];
        return [];
      });

      const result = manager.cleanOrphans();

      expect(result.orphans_cleaned).toBe(1);
      expect(vectorStore.remove).toHaveBeenCalledWith('spores', 'sp-orphan');
    });
  });

  // -------------------------------------------------------------------------
  // rebuildAll
  // -------------------------------------------------------------------------

  describe('rebuildAll', () => {
    it('calls vectorStore.clear() and recordSource.clearAllEmbedded()', () => {
      vectorStore.clear.mockReturnValue({ cleared: 42 });

      const result = manager.rebuildAll();

      expect(vectorStore.clear).toHaveBeenCalled();
      expect(recordSource.clearAllEmbedded).toHaveBeenCalled();
      expect(result.queued).toBe(42);
      expect(logger.info).toHaveBeenCalledWith(
        'embedding.rebuild',
        expect.stringContaining('Rebuild started: cleared 42 vectors'),
        expect.objectContaining({ cleared: 42 }),
      );
    });
  });

  // -------------------------------------------------------------------------
  // reembedStale
  // -------------------------------------------------------------------------

  describe('reembedStale', () => {
    it('finds stale vectors and re-embeds them', async () => {
      vectorStore.getStaleIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s-old1', 's-old2'];
        return [];
      });
      recordSource.getRecordContent.mockImplementation((ns: string, ids: string[]) => {
        if (ns === 'sessions') {
          return ids.map((id) => ({ id, text: `content for ${id}`, metadata: {} }));
        }
        return [];
      });

      const result = await manager.reembedStale(10);

      expect(result.reembedded).toBe(2);
      expect(provider.embed).toHaveBeenCalledTimes(2);
      expect(vectorStore.upsert).toHaveBeenCalledTimes(2);
    });

    it('returns 0 when no stale vectors exist', async () => {
      vectorStore.getStaleIds.mockReturnValue([]);

      const result = await manager.reembedStale(10);

      expect(result.reembedded).toBe(0);
      expect(provider.embed).not.toHaveBeenCalled();
    });

    it('limits stale re-embed concurrency to two in-flight requests', async () => {
      let inFlight = 0;
      let maxInFlight = 0;
      provider.embed.mockImplementation(async () => {
        inFlight++;
        maxInFlight = Math.max(maxInFlight, inFlight);
        await new Promise((resolve) => setTimeout(resolve, 10));
        inFlight--;
        return MOCK_EMBEDDING;
      });

      vectorStore.getStaleIds.mockImplementation((ns: string) => {
        if (ns === 'sessions') return ['s-old1', 's-old2', 's-old3'];
        return [];
      });
      recordSource.getRecordContent.mockImplementation((ns: string, ids: string[]) => {
        if (ns === 'sessions') return ids.map((id) => ({ id, text: `content for ${id}`, metadata: {} }));
        return [];
      });

      const result = await manager.reembedStale(10);

      expect(result.reembedded).toBe(3);
      expect(maxInFlight).toBe(2);
    });
  });

  // -------------------------------------------------------------------------
  // reconcileSlice
  // -------------------------------------------------------------------------

  describe('reconcileSlice', () => {
    it('returns processed count bounded by maxItems and remaining from totalPendingCount', async () => {
      // sessions namespace has 3 pending rows
      recordSource.getEmbeddableRows.mockImplementation((ns: string) => {
        if (ns === 'sessions') {
          return [
            { id: 's1', text: 'text 1', metadata: {} },
            { id: 's2', text: 'text 2', metadata: {} },
            { id: 's3', text: 'text 3', metadata: {} },
          ];
        }
        return [];
      });
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);
      // After reconcile, pending drops to 0
      recordSource.getPendingCount.mockReturnValue(0);

      const out = await manager.reconcileSlice({ maxItems: 10, softDeadlineMs: 2000 });

      // processed = embedded + stale_reembedded; 3 rows embedded in sessions
      expect(out.processed).toBe(3);
      // remaining = totalPendingCount() after reconcile
      expect(out.remaining).toBe(0);
      // processed + remaining <= maxItems * namespaces
      expect(out.processed).toBeLessThanOrEqual(10 * 6);
    });

    it('processed + remaining is consistent with starting pending count for single-namespace fixture', async () => {
      const PENDING = 4;
      recordSource.getEmbeddableRows.mockImplementation((ns: string) => {
        if (ns === 'sessions') {
          return Array.from({ length: PENDING }, (_, i) => ({
            id: `s${i}`,
            text: `text ${i}`,
            metadata: {},
          }));
        }
        return [];
      });
      vectorStore.getStaleIds.mockReturnValue([]);
      vectorStore.getEmbeddedIds.mockReturnValue([]);
      recordSource.getActiveRecordIds.mockReturnValue([]);
      recordSource.getPendingCount.mockReturnValue(0);

      const out = await manager.reconcileSlice({ maxItems: 10, softDeadlineMs: 2000 });

      expect(out.processed).toBe(PENDING);
      expect(out.remaining).toBe(0);
      expect(out.processed + out.remaining).toBe(PENDING);
    });

    it('breaks early when softDeadlineMs is 0 (deadline already expired) — processed is 0', async () => {
      // With softDeadlineMs: 0, deadlineMs = Date.now() + 0; the check at
      // the top of the namespace loop fires immediately for the first namespace.
      recordSource.getEmbeddableRows.mockImplementation((ns: string) => {
        if (ns === 'sessions') {
          return [
            { id: 's1', text: 'text 1', metadata: {} },
            { id: 's2', text: 'text 2', metadata: {} },
          ];
        }
        return [];
      });
      // totalPendingCount() sums getPendingCount across all 6 EMBEDDABLE_NAMESPACES.
      // Return 2 per namespace → total remaining = 12.
      recordSource.getPendingCount.mockReturnValue(2);

      const out = await manager.reconcileSlice({ maxItems: 10, softDeadlineMs: 0 });

      // Deadline fires on the first namespace check → no embedding work done
      expect(out.processed).toBe(0);
      // remaining = totalPendingCount() = 2 per namespace × 6 namespaces
      expect(out.remaining).toBe(12);
      expect(provider.embed).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // getDetails
  // -------------------------------------------------------------------------

  describe('getDetails', () => {
    it('combines vector stats with pending counts and provider info', () => {
      vectorStore.stats.mockReturnValue({
        total: 100,
        by_namespace: {
          sessions: { embedded: 50, stale: 2 },
          spores: { embedded: 50, stale: 0 },
        },
        models: { [MOCK_MODEL]: 100 },
      } satisfies VectorStoreStats);

      recordSource.getPendingCount.mockImplementation((ns: string) => {
        if (ns === 'sessions') return 1;
        if (ns === 'spores') return 2;
        return 0;
      });

      const details = manager.getDetails();

      expect(details.total).toBe(100);
      expect(details.pending.sessions).toBe(1);
      expect(details.pending.spores).toBe(2);
      expect(details.pending.plans).toBe(0);
      expect(details.pending.artifacts).toBe(0);
      expect(details.provider).toEqual({
        name: MOCK_PROVIDER_NAME,
        model: MOCK_MODEL,
        available: true,
      });
    });
  });

  // -------------------------------------------------------------------------
  // embedQuery
  // -------------------------------------------------------------------------

  describe('embedQuery', () => {
    it('passes through to provider.embed()', async () => {
      const result = await manager.embedQuery('search query text');

      expect(provider.embed).toHaveBeenCalledWith('search query text');
      expect(result).toEqual(MOCK_EMBEDDING);
    });

    it('returns null when provider returns null', async () => {
      provider.embed.mockResolvedValue(null);

      const result = await manager.embedQuery('query');

      expect(result).toBeNull();
    });
  });
});
