/**
 * Tests for LocalEmbeddingProvider.
 *
 * Key design: tests inject a `pipelineFactory` instead of letting the
 * provider load real ONNX models — we'd otherwise download ~250 MB
 * on every CI run. The factory returns a stub pipeline that mirrors
 * the Transformers.js return shape (Tensor with .tolist()).
 *
 * Real model loading is covered by an opt-in integration test that
 * runs only when LOXIA_RUN_LOCAL_EMBED_E2E=1.
 *
 * What this pins:
 *   - Lazy model load (no work at construction; first embed triggers it)
 *   - Load failure is sticky — repeated calls don't retry forever
 *   - Concurrent first-calls dedup the load (no double download)
 *   - nomic prefix applied to query vs document
 *   - BGE prefix applied to query only
 *   - 1-D pipeline output gets wrapped back into 2-D (Transformers.js
 *     versioning quirk we defend against)
 *   - getModelFingerprint changes when model changes
 *   - warmup() forces the load + can be called repeatedly safely
 */

import { describe, test, expect, jest } from '@jest/globals';
import {
  LocalEmbeddingProvider,
  LOCAL_MODELS,
  DEFAULT_LOCAL_MODEL,
} from '../providers/localProvider.js';
import { EmbeddingError, EMBEDDING_ERROR_CODES } from '../providerInterface.js';

/**
 * Build a stub pipeline that records every call and returns a
 * Transformers.js-shape Tensor-like.
 */
function makeStubPipeline({ dimensions, returnShape = '2d' } = {}) {
  const calls = [];
  const pipe = jest.fn(async (input, options) => {
    calls.push({ input, options });
    const inputs = Array.isArray(input) ? input : [input];
    const matrix = inputs.map((text, i) =>
      // Distinct, non-zero vectors per input so order can be verified.
      Array.from({ length: dimensions }, (_, j) => (i + 1) * (j + 1) * 0.1 + text.length * 0.001),
    );
    if (returnShape === '1d' && matrix.length === 1) {
      // Simulate a Transformers.js version that returns 1-D for single input.
      return { tolist: () => matrix[0] };
    }
    return { tolist: () => matrix };
  });
  pipe._calls = calls;
  return pipe;
}

describe('LocalEmbeddingProvider — defaults + info', () => {
  test('default model is nomic-embed-text-v1.5', () => {
    const p = new LocalEmbeddingProvider();
    const info = p.getInfo();
    expect(info.name).toBe('local');
    expect(info.modelId).toBe(DEFAULT_LOCAL_MODEL);
    expect(info.dimensions).toBe(768);
    expect(info.isLocal).toBe(true);
    expect(info.distinguishesQueryAndDocument).toBe(true);
  });

  test('known models auto-discover their dimensions', () => {
    const bge = new LocalEmbeddingProvider({ modelId: 'Xenova/bge-small-en-v1.5' });
    expect(bge.getInfo().dimensions).toBe(384);
    const mini = new LocalEmbeddingProvider({ modelId: 'Xenova/all-MiniLM-L6-v2' });
    expect(mini.getInfo().dimensions).toBe(384);
  });

  test('unknown models default to 768 dims (safe fallback)', () => {
    const p = new LocalEmbeddingProvider({ modelId: 'Custom/some-unknown' });
    expect(p.getInfo().dimensions).toBe(768);
    expect(p.getInfo().distinguishesQueryAndDocument).toBe(false);
  });

  test('LOCAL_MODELS table includes all three sanctioned models', () => {
    expect(Object.keys(LOCAL_MODELS).sort()).toEqual([
      'Xenova/all-MiniLM-L6-v2',
      'Xenova/bge-small-en-v1.5',
      'Xenova/nomic-embed-text-v1.5',
    ]);
  });

  test('getModelFingerprint includes modelId', () => {
    const a = new LocalEmbeddingProvider();
    const b = new LocalEmbeddingProvider({ modelId: 'Xenova/bge-small-en-v1.5' });
    expect(a.getModelFingerprint()).toBe('local:Xenova/nomic-embed-text-v1.5');
    expect(b.getModelFingerprint()).toBe('local:Xenova/bge-small-en-v1.5');
    expect(a.getModelFingerprint()).not.toBe(b.getModelFingerprint());
  });
});

describe('LocalEmbeddingProvider — health + lazy load', () => {
  test('health() reports not-loaded BEFORE first embed call', async () => {
    const factory = jest.fn(async () => makeStubPipeline({ dimensions: 768 }));
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    const h = await p.health();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/not loaded/);
    expect(factory).not.toHaveBeenCalled();   // construction is free
  });

  test('health() reports ok after model loads', async () => {
    const factory = jest.fn(async () => makeStubPipeline({ dimensions: 768 }));
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await p.warmup();
    expect(await p.health()).toEqual({ ok: true });
    expect(factory).toHaveBeenCalledTimes(1);
  });

  test('concurrent first-calls dedup the load (factory called once)', async () => {
    const factory = jest.fn(async () => makeStubPipeline({ dimensions: 768 }));
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    // Fire 3 concurrent loads; they should all settle on the same pipeline.
    const [a, b, c] = await Promise.all([
      p.embedDocuments(['x']),
      p.embedDocuments(['y']),
      p.embedDocuments(['z']),
    ]);
    expect(factory).toHaveBeenCalledTimes(1);
    expect(a).toHaveLength(1);
    expect(b).toHaveLength(1);
    expect(c).toHaveLength(1);
  });

  test('load failure surfaces as NOT_INITIALIZED with remediation', async () => {
    const factory = jest.fn(async () => { throw new Error('disk full'); });
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await expect(p.embedDocuments(['x'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
      transient: false,
    });
  });

  test('load failure is sticky — second call does NOT re-attempt', async () => {
    const factory = jest.fn(async () => { throw new Error('disk full'); });
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await expect(p.embedDocuments(['x'])).rejects.toThrow();
    await expect(p.embedDocuments(['y'])).rejects.toThrow();
    // Only one load attempt — failure caches.
    expect(factory).toHaveBeenCalledTimes(1);
  });

  test('factory must return a callable', async () => {
    const factory = jest.fn(async () => 'not-callable');
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await expect(p.embedDocuments(['x'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
    });
  });
});

describe('LocalEmbeddingProvider — task prefixes', () => {
  test('nomic applies search_query: / search_document: prefixes', async () => {
    const pipe = makeStubPipeline({ dimensions: 768 });
    const p = new LocalEmbeddingProvider({ pipelineFactory: async () => pipe });
    await p.embedDocuments(['hello world'], 'document');
    await p.embedQuery('hello world');
    expect(pipe._calls[0].input).toEqual(['search_document: hello world']);
    expect(pipe._calls[1].input).toEqual(['search_query: hello world']);
  });

  test('BGE applies query prefix only — documents stay raw', async () => {
    const pipe = makeStubPipeline({ dimensions: 384 });
    const p = new LocalEmbeddingProvider({
      modelId: 'Xenova/bge-small-en-v1.5',
      pipelineFactory: async () => pipe,
    });
    await p.embedDocuments(['raw document'], 'document');
    await p.embedQuery('question');
    expect(pipe._calls[0].input).toEqual(['raw document']);
    expect(pipe._calls[1].input).toEqual([
      'Represent this sentence for searching relevant passages: question',
    ]);
  });

  test('MiniLM applies no prefix in either direction', async () => {
    const pipe = makeStubPipeline({ dimensions: 384 });
    const p = new LocalEmbeddingProvider({
      modelId: 'Xenova/all-MiniLM-L6-v2',
      pipelineFactory: async () => pipe,
    });
    await p.embedDocuments(['a doc'], 'document');
    await p.embedQuery('a query');
    expect(pipe._calls[0].input).toEqual(['a doc']);
    expect(pipe._calls[1].input).toEqual(['a query']);
  });
});

describe('LocalEmbeddingProvider — pipeline output handling', () => {
  test('pools mean and skips normalize (base class will normalize)', async () => {
    const pipe = makeStubPipeline({ dimensions: 768 });
    const p = new LocalEmbeddingProvider({ pipelineFactory: async () => pipe });
    await p.embedQuery('x');
    expect(pipe._calls[0].options).toEqual({ pooling: 'mean', normalize: false });
  });

  test('1-D output (single input quirk) gets wrapped to 2-D', async () => {
    // Simulate the Transformers.js version that flattens single-input output.
    const pipe = makeStubPipeline({ dimensions: 768, returnShape: '1d' });
    const p = new LocalEmbeddingProvider({ pipelineFactory: async () => pipe });
    const [v] = await p.embedDocuments(['just one']);
    expect(v).toBeInstanceOf(Float32Array);
    expect(v.length).toBe(768);
  });

  test('batches respect maxBatchSize across underlying calls', async () => {
    const pipe = makeStubPipeline({ dimensions: 768 });
    const p = new LocalEmbeddingProvider({
      maxBatchSize: 2,
      pipelineFactory: async () => pipe,
    });
    await p.embedDocuments(['a', 'b', 'c', 'd', 'e']);
    expect(pipe).toHaveBeenCalledTimes(3);
    expect(pipe._calls[0].input).toHaveLength(2);
    expect(pipe._calls[2].input).toHaveLength(1);
  });

  test('returned vectors are L2-normalized (base-class contract)', async () => {
    const pipe = makeStubPipeline({ dimensions: 8 });
    const p = new LocalEmbeddingProvider({
      modelId: 'Xenova/bge-small-en-v1.5',  // 384-dim known, but stub returns 8
      pipelineFactory: async () => pipe,
    });
    // Override dims so the test isn't blocked by the 384-vs-8 mismatch.
    p._dimensions = 8;
    const [v] = await p.embedDocuments(['x']);
    let sum = 0;
    for (let i = 0; i < v.length; i++) sum += v[i] * v[i];
    expect(Math.sqrt(sum)).toBeCloseTo(1, 6);
  });

  test('pipeline inference error → PROVIDER_UNAVAILABLE (transient)', async () => {
    const failingPipe = jest.fn(async () => { throw new Error('OOM'); });
    const p = new LocalEmbeddingProvider({ pipelineFactory: async () => failingPipe });
    await expect(p.embedQuery('hi')).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      transient: true,
    });
  });
});

describe('LocalEmbeddingProvider — warmup', () => {
  test('warmup() is idempotent', async () => {
    const factory = jest.fn(async () => makeStubPipeline({ dimensions: 768 }));
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await p.warmup();
    await p.warmup();
    await p.warmup();
    expect(factory).toHaveBeenCalledTimes(1);
  });

  test('warmup() throws when load fails', async () => {
    const factory = jest.fn(async () => { throw new Error('no internet'); });
    const p = new LocalEmbeddingProvider({ pipelineFactory: factory });
    await expect(p.warmup()).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
    });
  });
});
