/**
 * Tests for the cloud providers + their shared HTTP transport.
 *
 * What this pins:
 *   1. Transport-level HTTP status → EmbeddingError code mapping
 *      (401/403→auth, 429→rate-limit, 5xx→provider-unavailable, network
 *      → network-error). Matches the agent-scheduler classification.
 *   2. Both Azure and OpenAI providers serialize the standard OpenAI
 *      embeddings request shape and parse the standard response shape.
 *   3. The vectors come back in input order, normalized (base-class
 *      contract already enforced; we just verify the cloud path doesn't
 *      break it).
 *   4. Missing API keys fail loudly with AUTH_ERROR — not silently with
 *      a 401 round-trip.
 *   5. The key is read VIA the callback on every request, so
 *      ApiKeyManager rotation works without re-instantiation.
 */

import { describe, test, expect, jest, beforeEach } from '@jest/globals';
import { EmbeddingError, EMBEDDING_ERROR_CODES } from '../providerInterface.js';
import { classifyHttpError, extractOpenAIShape, postEmbeddings } from '../providers/cloudHttpTransport.js';
import { AzureEmbeddingProvider } from '../providers/azureProvider.js';
import { OpenAIEmbeddingProvider } from '../providers/openaiProvider.js';

/** Build a fake fetch that returns a canned Response-like object. */
function makeMockFetch({ status = 200, json = null, text = '', throwError = null }) {
  return jest.fn(async () => {
    if (throwError) throw throwError;
    return {
      ok: status >= 200 && status < 300,
      status,
      async json() {
        if (json === null) throw new Error('no json');
        return json;
      },
      async text() {
        return text;
      },
    };
  });
}

describe('cloudHttpTransport — classifyHttpError', () => {
  test.each([
    [401, EMBEDDING_ERROR_CODES.AUTH_ERROR,           false],
    [403, EMBEDDING_ERROR_CODES.AUTH_ERROR,           false],
    [404, EMBEDDING_ERROR_CODES.MODEL_NOT_FOUND,      false],
    [400, EMBEDDING_ERROR_CODES.INVALID_INPUT,        false],
    [422, EMBEDDING_ERROR_CODES.INVALID_INPUT,        false],
    [429, EMBEDDING_ERROR_CODES.RATE_LIMITED,         true],
    [500, EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE, true],
    [502, EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE, true],
    [503, EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE, true],
    [504, EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE, true],
    [418, EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE, true],  // catch-all
  ])('HTTP %i → %s (transient=%s)', (status, code, transient) => {
    const err = classifyHttpError(status, 'body', 'azure');
    expect(err).toBeInstanceOf(EmbeddingError);
    expect(err.code).toBe(code);
    expect(err.transient).toBe(transient);
    expect(err.provider).toBe('azure');
  });

  test('body snippet is included and collapsed/truncated', () => {
    const longBody = 'x'.repeat(500);
    const err = classifyHttpError(500, longBody, 'openai');
    expect(err.message.length).toBeLessThan(longBody.length);
    expect(err.message).toMatch(/HTTP 500/);
  });
});

describe('cloudHttpTransport — extractOpenAIShape', () => {
  test('extracts vectors in order', () => {
    const json = {
      data: [
        { embedding: [1, 0, 0] },
        { embedding: [0, 1, 0] },
        { embedding: [0, 0, 1] },
      ],
    };
    expect(extractOpenAIShape(json)).toEqual([[1, 0, 0], [0, 1, 0], [0, 0, 1]]);
  });

  test('throws on missing data array', () => {
    expect(() => extractOpenAIShape({})).toThrow(/data/);
    expect(() => extractOpenAIShape({ data: 'oops' })).toThrow(/data/);
  });

  test('throws on missing embedding array per row', () => {
    expect(() => extractOpenAIShape({ data: [{ no: 'embed' }] })).toThrow(/embedding/);
  });
});

describe('cloudHttpTransport — postEmbeddings', () => {
  test('happy path: returns vectors in order', async () => {
    const fetchImpl = makeMockFetch({
      status: 200,
      json: { data: [{ embedding: [1, 2, 3, 4] }, { embedding: [5, 6, 7, 8] }] },
    });
    const out = await postEmbeddings({
      url: 'https://example/llm/embed',
      headers: { Authorization: 'Bearer k', 'Content-Type': 'application/json' },
      body: { input: ['a', 'b'], model: 'm' },
      providerName: 'azure',
      extractVectors: extractOpenAIShape,
      fetchImpl,
    });
    expect(out).toEqual([[1, 2, 3, 4], [5, 6, 7, 8]]);
    expect(fetchImpl).toHaveBeenCalledTimes(1);
    const [, init] = fetchImpl.mock.calls[0];
    expect(JSON.parse(init.body)).toEqual({ input: ['a', 'b'], model: 'm' });
  });

  test('AbortError → NETWORK_ERROR (transient) with timeout hint', async () => {
    const abortErr = new Error('aborted'); abortErr.name = 'AbortError';
    const fetchImpl = makeMockFetch({ throwError: abortErr });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'openai',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NETWORK_ERROR,
      transient: true,
    });
  });

  test('generic network throw → NETWORK_ERROR', async () => {
    const fetchImpl = makeMockFetch({ throwError: new Error('ECONNREFUSED 127.0.0.1') });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'azure',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.NETWORK_ERROR,
      transient: true,
    });
  });

  test('5xx → PROVIDER_UNAVAILABLE (transient)', async () => {
    const fetchImpl = makeMockFetch({ status: 503, text: 'upstream down' });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'azure',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      transient: true,
    });
  });

  test('401 → AUTH_ERROR (persistent)', async () => {
    const fetchImpl = makeMockFetch({ status: 401, text: 'bad key' });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'openai',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.AUTH_ERROR,
      transient: false,
    });
  });

  test('429 → RATE_LIMITED (transient)', async () => {
    const fetchImpl = makeMockFetch({ status: 429, text: 'too fast' });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'azure',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.RATE_LIMITED,
      transient: true,
    });
  });

  test('non-JSON response → PROVIDER_UNAVAILABLE', async () => {
    const fetchImpl = makeMockFetch({ status: 200, json: null, text: 'gateway error html' });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'azure',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      transient: true,
    });
  });

  test('shape mismatch → PROVIDER_UNAVAILABLE with hint', async () => {
    const fetchImpl = makeMockFetch({ status: 200, json: { weird: 'shape' } });
    await expect(
      postEmbeddings({
        url: 'https://x', headers: {}, body: {}, providerName: 'azure',
        extractVectors: extractOpenAIShape, fetchImpl,
      }),
    ).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      transient: true,
    });
  });
});

describe('AzureEmbeddingProvider', () => {
  let fetchImpl, getApiKey, provider;

  beforeEach(() => {
    fetchImpl = makeMockFetch({
      status: 200,
      json: { data: [{ embedding: Array(1536).fill(0.01) }] },
    });
    getApiKey = jest.fn(() => 'loxia-key-abc');
    provider = new AzureEmbeddingProvider({
      baseUrl: 'https://loxia-api.example/',
      getApiKey,
      fetchImpl,
    });
  });

  test('constructor rejects missing baseUrl / getApiKey', () => {
    expect(() => new AzureEmbeddingProvider({ getApiKey })).toThrow(/baseUrl/);
    expect(() => new AzureEmbeddingProvider({ baseUrl: 'x' })).toThrow(/getApiKey/);
  });

  test('getInfo() reports expected defaults', () => {
    const info = provider.getInfo();
    expect(info.name).toBe('azure');
    expect(info.modelId).toBe('text-embedding-3-small');
    expect(info.dimensions).toBe(1536);
    expect(info.isLocal).toBe(false);
    expect(info.distinguishesQueryAndDocument).toBe(false);
  });

  test('happy path: POSTs to /llm/embed with bearer token', async () => {
    const [vec] = await provider.embedDocuments(['hello']);
    expect(vec).toBeInstanceOf(Float32Array);
    expect(vec.length).toBe(1536);
    const [url, init] = fetchImpl.mock.calls[0];
    expect(url).toBe('https://loxia-api.example/llm/embed');
    expect(init.headers.Authorization).toBe('Bearer loxia-key-abc');
    expect(JSON.parse(init.body)).toEqual({ input: ['hello'], model: 'text-embedding-3-small' });
  });

  test('strips trailing slashes from baseUrl', async () => {
    provider = new AzureEmbeddingProvider({
      baseUrl: 'https://loxia-api.example//',
      getApiKey,
      fetchImpl,
    });
    await provider.embedDocuments(['hi']);
    const [url] = fetchImpl.mock.calls[0];
    expect(url).toBe('https://loxia-api.example/llm/embed');
  });

  test('missing API key → AUTH_ERROR, no HTTP call', async () => {
    getApiKey.mockReturnValue(null);
    await expect(provider.embedDocuments(['x'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.AUTH_ERROR,
      transient: false,
    });
    expect(fetchImpl).not.toHaveBeenCalled();
  });

  test('health() reflects missing key', async () => {
    expect(await provider.health()).toEqual({ ok: true });
    getApiKey.mockReturnValue(null);
    const h = await provider.health();
    expect(h.ok).toBe(false);
    expect(h.reason).toMatch(/key/i);
  });

  test('key is read fresh on each call (rotation works without re-instantiation)', async () => {
    await provider.embedDocuments(['a']);
    getApiKey.mockReturnValue('loxia-key-rotated');
    await provider.embedDocuments(['b']);
    expect(fetchImpl.mock.calls[1][1].headers.Authorization).toBe('Bearer loxia-key-rotated');
  });
});

describe('OpenAIEmbeddingProvider', () => {
  let fetchImpl, getApiKey, provider;

  beforeEach(() => {
    fetchImpl = makeMockFetch({
      status: 200,
      json: { data: [{ embedding: Array(1536).fill(0.01) }, { embedding: Array(1536).fill(0.02) }] },
    });
    getApiKey = jest.fn(() => 'sk-openai-test');
    provider = new OpenAIEmbeddingProvider({ getApiKey, fetchImpl });
  });

  test('constructor rejects missing getApiKey', () => {
    expect(() => new OpenAIEmbeddingProvider({})).toThrow(/getApiKey/);
  });

  test('default URL is the real OpenAI endpoint', async () => {
    await provider.embedDocuments(['a', 'b']);
    const [url] = fetchImpl.mock.calls[0];
    expect(url).toBe('https://api.openai.com/v1/embeddings');
  });

  test('happy path: POSTs OpenAI-compat payload with bearer key', async () => {
    const out = await provider.embedDocuments(['a', 'b']);
    expect(out).toHaveLength(2);
    const [, init] = fetchImpl.mock.calls[0];
    expect(init.headers.Authorization).toBe('Bearer sk-openai-test');
    expect(JSON.parse(init.body)).toEqual({ input: ['a', 'b'], model: 'text-embedding-3-small' });
  });

  test('missing key → AUTH_ERROR, no HTTP call', async () => {
    getApiKey.mockReturnValue(null);
    await expect(provider.embedDocuments(['x'])).rejects.toMatchObject({
      code: EMBEDDING_ERROR_CODES.AUTH_ERROR,
    });
    expect(fetchImpl).not.toHaveBeenCalled();
  });

  test('custom URL is respected (compat-layer providers)', async () => {
    // Fresh mock with a single-vector response since this test only sends 1 input.
    const compatFetch = makeMockFetch({
      status: 200,
      json: { data: [{ embedding: Array(1536).fill(0.01) }] },
    });
    provider = new OpenAIEmbeddingProvider({
      getApiKey,
      fetchImpl: compatFetch,
      url: 'https://api.deepinfra.com/v1/openai/embeddings',
    });
    await provider.embedDocuments(['x']);
    expect(compatFetch.mock.calls[0][0]).toBe('https://api.deepinfra.com/v1/openai/embeddings');
  });

  test('getModelFingerprint = openai:text-embedding-3-small', () => {
    expect(provider.getModelFingerprint()).toBe('openai:text-embedding-3-small');
  });
});
