/**
 * Base-class contract tests for EmbeddingProvider.
 *
 * These tests pin the invariants every concrete provider relies on:
 *   1. Input validation surfaces typed EmbeddingError (no raw throws).
 *   2. Batching respects maxBatchSize and preserves order across batches.
 *   3. Returned vectors are L2-normalized — the VectorStore depends on
 *      this so cosine reduces to dot-product on read.
 *   4. Provider-side dimension drift surfaces as DIMENSION_MISMATCH,
 *      and inconsistent batch sizes as PROVIDER_UNAVAILABLE (transient).
 *
 * Concrete provider tests (azure, openai, local) plug into the same
 * StubProvider pattern so we don't repeat these invariants there.
 */

import { describe, test, expect } from '@jest/globals';
import {
  EmbeddingProvider,
  EmbeddingError,
  EMBEDDING_ERROR_CODES,
  normalizeInPlace,
} from '../providerInterface.js';

class StubProvider extends EmbeddingProvider {
  constructor({ dimensions = 4, maxBatchSize = 2, name = 'stub' } = {}) {
    super();
    this._info = {
      name,
      modelId: 'stub-model',
      dimensions,
      maxBatchSize,
      maxTokensPerText: 1000,
      distinguishesQueryAndDocument: false,
      isLocal: true,
    };
    this._calls = [];
  }
  getInfo() { return this._info; }
  async _embed(texts, kind) {
    this._calls.push({ texts: [...texts], kind });
    // Produce distinct non-zero vectors per input so order can be verified.
    return texts.map((_, i) =>
      Array.from({ length: this._info.dimensions }, (_, j) => (i + 1) * (j + 1) * 0.1),
    );
  }
}

describe('EmbeddingProvider — base class', () => {
  test('embedQuery rejects empty / non-string with INVALID_INPUT', async () => {
    const p = new StubProvider();
    for (const bad of ['', null, undefined, 42, {}]) {
      await expect(p.embedQuery(bad)).rejects.toMatchObject({
        code: EMBEDDING_ERROR_CODES.INVALID_INPUT,
      });
    }
  });

  test('embedDocuments rejects empty array / empty entries', async () => {
    const p = new StubProvider();
    await expect(p.embedDocuments([])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.INVALID_INPUT,
    });
    await expect(p.embedDocuments(['ok', ''])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.INVALID_INPUT,
    });
    await expect(p.embedDocuments(['ok', null])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.INVALID_INPUT,
    });
  });

  test('embedQuery passes kind:"query" to _embed', async () => {
    const p = new StubProvider();
    await p.embedQuery('hello');
    expect(p._calls).toHaveLength(1);
    expect(p._calls[0].kind).toBe('query');
  });

  test('embedDocuments defaults kind to "document"', async () => {
    const p = new StubProvider();
    await p.embedDocuments(['a']);
    expect(p._calls[0].kind).toBe('document');
  });

  test('embedDocuments forwards explicit kind', async () => {
    const p = new StubProvider();
    await p.embedDocuments(['a'], 'query');
    expect(p._calls[0].kind).toBe('query');
  });

  test('batches input through _embed in chunks of maxBatchSize, preserving order', async () => {
    const p = new StubProvider({ maxBatchSize: 2 });
    const out = await p.embedDocuments(['a', 'b', 'c', 'd', 'e']);
    expect(p._calls.map(c => c.texts.length)).toEqual([2, 2, 1]);
    expect(p._calls[0].texts).toEqual(['a', 'b']);
    expect(p._calls[1].texts).toEqual(['c', 'd']);
    expect(p._calls[2].texts).toEqual(['e']);
    expect(out).toHaveLength(5);
    for (const v of out) expect(v).toBeInstanceOf(Float32Array);
  });

  test('result vectors are L2-normalized to unit length', async () => {
    const p = new StubProvider({ dimensions: 4 });
    const vecs = await p.embedDocuments(['x', 'y', 'z']);
    for (const v of vecs) {
      let sum = 0;
      for (let i = 0; i < v.length; i++) sum += v[i] * v[i];
      expect(Math.sqrt(sum)).toBeCloseTo(1, 6);
    }
  });

  test('DIMENSION_MISMATCH when provider returns the wrong shape', async () => {
    class BadDimProvider extends StubProvider {
      async _embed(texts) { return texts.map(() => [1, 2, 3]); }
    }
    const p = new BadDimProvider({ dimensions: 4 });
    await expect(p.embedDocuments(['x'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.DIMENSION_MISMATCH,
    });
  });

  test('PROVIDER_UNAVAILABLE (transient) when result count mismatches batch size', async () => {
    class WrongCountProvider extends StubProvider {
      async _embed() { return [[1, 0, 0, 0]]; }
    }
    const p = new WrongCountProvider({ maxBatchSize: 5 });
    await expect(p.embedDocuments(['a', 'b'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      transient: true,
    });
  });

  test('getModelFingerprint = name:modelId by default', () => {
    const p = new StubProvider({ name: 'azure' });
    expect(p.getModelFingerprint()).toBe('azure:stub-model');
  });

  test('base health() returns ok:true by default', async () => {
    const p = new StubProvider();
    expect(await p.health()).toEqual({ ok: true });
  });

  test('subclass that does not implement getInfo() throws', () => {
    class Incomplete extends EmbeddingProvider {}
    expect(() => new Incomplete().getInfo()).toThrow(/getInfo/);
  });

  test('subclass that does not implement _embed() throws', async () => {
    class Incomplete extends EmbeddingProvider {
      getInfo() {
        return { name: 'x', modelId: 'm', dimensions: 4, maxBatchSize: 1,
                 maxTokensPerText: 100, distinguishesQueryAndDocument: false, isLocal: true };
      }
    }
    await expect(new Incomplete().embedDocuments(['hi'])).rejects.toThrow(/_embed/);
  });
});

describe('normalizeInPlace', () => {
  test('3-4-5 triangle → 0.6, 0.8', () => {
    const v = Float32Array.from([3, 4]);
    normalizeInPlace(v);
    expect(v[0]).toBeCloseTo(0.6, 6);
    expect(v[1]).toBeCloseTo(0.8, 6);
  });

  test('already-normalized vector stays the same', () => {
    const v = Float32Array.from([1, 0, 0, 0]);
    normalizeInPlace(v);
    expect(Array.from(v)).toEqual([1, 0, 0, 0]);
  });

  test('zero vector is left untouched (no NaN division)', () => {
    const v = Float32Array.from([0, 0, 0]);
    normalizeInPlace(v);
    expect(Array.from(v)).toEqual([0, 0, 0]);
  });

  test('negative components are preserved', () => {
    const v = Float32Array.from([-3, 4]);
    normalizeInPlace(v);
    expect(v[0]).toBeCloseTo(-0.6, 6);
    expect(v[1]).toBeCloseTo(0.8, 6);
  });
});

describe('EmbeddingError', () => {
  test('code + provider + transient flag surface on error', () => {
    const err = new EmbeddingError(
      EMBEDDING_ERROR_CODES.RATE_LIMITED,
      'too fast',
      { provider: 'azure', transient: true },
    );
    expect(err.code).toBe('RATE_LIMITED');
    expect(err.provider).toBe('azure');
    expect(err.transient).toBe(true);
    expect(err.name).toBe('EmbeddingError');
    expect(err).toBeInstanceOf(Error);
  });

  test('cause is preserved when supplied', () => {
    const underlying = new Error('socket reset');
    const err = new EmbeddingError(
      EMBEDDING_ERROR_CODES.NETWORK_ERROR,
      'wrapped',
      { provider: 'openai', transient: true, cause: underlying },
    );
    expect(err.cause).toBe(underlying);
  });
});
