/**
 * ADR-121 Phase 1 — tests for RuvectorOnnxEmbeddingService.
 *
 * Fast unit tests that don't require downloading the 23MB MiniLM model:
 *  - Service construction + provider tag
 *  - Lazy load: optional peer dep missing → clean error naming the dep
 *  - Factory wiring: createEmbeddingService({ provider: 'ruvector-onnx' })
 *    returns the right concrete class
 *  - Mocked-embedder round-trip: embed/embedBatch shape, normalization,
 *    cache behavior
 *
 * Live model parity vs Xenova baseline runs in a separate slow-path
 * test (gated by an env flag) and in the CI smoke — keeps the
 * default test suite under 1s.
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
  RuvectorOnnxEmbeddingService,
  createRuvectorOnnxEmbeddingService,
} from '../ruvector-onnx-embedding-service.js';
import { createEmbeddingService } from '../embedding-service.js';

describe('RuvectorOnnxEmbeddingService — construction + factory wiring', () => {
  it('constructs with provider tag "ruvector-onnx"', () => {
    const svc = createRuvectorOnnxEmbeddingService();
    expect(svc).toBeInstanceOf(RuvectorOnnxEmbeddingService);
    expect(svc.provider).toBe('ruvector-onnx');
  });

  it('factory routes provider: "ruvector-onnx" to the right class', () => {
    const svc = createEmbeddingService({ provider: 'ruvector-onnx' });
    expect(svc).toBeInstanceOf(RuvectorOnnxEmbeddingService);
  });

  it('accepts a custom model name on the config', () => {
    const svc = createRuvectorOnnxEmbeddingService({ model: 'bge-small-en-v1.5' });
    // Internal field — assert it survived through the constructor.
    expect((svc as unknown as { modelName: string }).modelName).toBe('bge-small-en-v1.5');
  });

  it('defaults normalize=true when omitted', () => {
    const svc = createRuvectorOnnxEmbeddingService();
    expect((svc as unknown as { normalizeOutput: boolean }).normalizeOutput).toBe(true);
  });

  it('honors normalize=false when supplied', () => {
    const svc = createRuvectorOnnxEmbeddingService({ normalize: false });
    expect((svc as unknown as { normalizeOutput: boolean }).normalizeOutput).toBe(false);
  });
});

describe('RuvectorOnnxEmbeddingService — optional peer dep contract', () => {
  it('surfaces a clear, named error when the WASM runtime fails to initialize', async () => {
    // The dep is installed via the @ruvector workspace, but the
    // upstream wasm-bindgen output currently mismatches ESM vs CJS in
    // its packaging (the .js wrapper references `module` from an ESM
    // context). The service must catch the runtime failure and
    // surface a *named* error so operators don't waste time
    // diagnosing a generic "module is not defined" trace.
    //
    // This test asserts the error message:
    //   1. names the dep
    //   2. names the model that failed
    //   3. recommends the `transformers` fallback so callers aren't stuck
    const svc = createRuvectorOnnxEmbeddingService({ model: 'all-MiniLM-L6-v2' });
    await expect(svc.embed('hello world')).rejects.toThrow(/ruvector-onnx-embeddings-wasm/);
    await expect(svc.embed('hello world')).rejects.toThrow(/all-MiniLM-L6-v2/);
    await expect(svc.embed('hello world')).rejects.toThrow(/provider: 'transformers'/);
  });
});

describe('RuvectorOnnxEmbeddingService — mocked WASM embedder round-trip', () => {
  // Build a synthetic embedder that returns deterministic vectors,
  // then inject it into the service's lazy-initialize path so we can
  // exercise embed / embedBatch / cache / normalization without a real
  // WASM module loaded.
  let svc: RuvectorOnnxEmbeddingService;
  const DIM = 4;

  beforeEach(() => {
    svc = createRuvectorOnnxEmbeddingService();
    // Inject a synthetic embedder. Each text → vector = [textLen, 0, 0, 0]
    // (deterministic, distinct per length, easy to assert).
    const fakeEmbedder = {
      embedBatch(texts: string[]): Float32Array {
        const flat = new Float32Array(texts.length * DIM);
        for (let i = 0; i < texts.length; i++) {
          flat[i * DIM] = texts[i]!.length;
        }
        return flat;
      },
      dimension() { return DIM; },
      maxLength() { return 256; },
      similarity() { return 0; },
      free() { /* no-op */ },
    };
    // Reach in via the same shape the real initialize() would set.
    (svc as unknown as { embedder: unknown }).embedder = fakeEmbedder;
    (svc as unknown as { dimensionCache: number }).dimensionCache = DIM;
  });

  it('embed() returns a Float32Array of the model dimension', async () => {
    const result = await svc.embed('hi');
    expect(result.embedding).toBeInstanceOf(Float32Array);
    expect(result.embedding.length).toBe(DIM);
  });

  it('embed() L2-normalizes by default (norm === 1)', async () => {
    const result = await svc.embed('hi');
    let sumSq = 0;
    for (let i = 0; i < result.embedding.length; i++) {
      sumSq += result.embedding[i]! * result.embedding[i]!;
    }
    expect(Math.sqrt(sumSq)).toBeCloseTo(1.0, 5);
  });

  it('embed() caches the result so a second call is a hit', async () => {
    const first = await svc.embed('hello');
    expect(first.cached).toBeUndefined();
    const second = await svc.embed('hello');
    expect(second.cached).toBe(true);
    expect(second.latencyMs).toBe(0);
    // Same Float32Array instance (cache returns reference, not copy).
    expect(second.embedding).toBe(first.embedding);
  });

  it('embedBatch() returns one Float32Array per input text', async () => {
    const result = await svc.embedBatch(['a', 'bb', 'ccc']);
    expect(result.embeddings).toHaveLength(3);
    for (const emb of result.embeddings) {
      expect(emb).toBeInstanceOf(Float32Array);
      expect(emb.length).toBe(DIM);
    }
  });

  it('embedBatch() separates cached from uncached + reports cache stats', async () => {
    await svc.embed('cached-already');
    const result = await svc.embedBatch(['cached-already', 'fresh-1', 'fresh-2']);
    expect(result.cacheStats?.hits).toBe(1);
    expect(result.cacheStats?.misses).toBe(2);
  });

  it('embedBatch() preserves input order', async () => {
    // length-of-text encoded in slot 0 of the vector pre-normalize;
    // post-normalize the order is preserved (single non-zero slot).
    await svc.embed('aa'); // pre-cache length-2
    const result = await svc.embedBatch(['ccc', 'aa', 'dddd']);
    // ccc → length 3 → vector [3, 0, 0, 0] / 3 = [1, 0, 0, 0]
    // aa → cached from earlier
    // dddd → length 4 → [1, 0, 0, 0] (normalized)
    expect(result.embeddings[0]![0]).toBeCloseTo(1.0, 5);
    expect(result.embeddings[1]![0]).toBeCloseTo(1.0, 5);
    expect(result.embeddings[2]![0]).toBeCloseTo(1.0, 5);
  });
});
