/**
 * cloudHttpTransport — shared HTTP plumbing for the cloud embedding
 * providers (Azure, OpenAI).
 *
 * Why factored out: Azure and OpenAI exchange the SAME request/response
 * shape (the OpenAI embeddings API: `{ input, model }` in, `{ data: [{
 * embedding: number[] }] }` out). They differ only in:
 *   - URL (Loxia backend's `/llm/embed` proxy vs. api.openai.com directly)
 *   - auth header (Loxia Authorization Bearer vs. OpenAI Bearer or
 *     Azure api-key)
 *   - whether a `deployment` is on the URL path or in the body
 *
 * The shared transport handles: timeouts via AbortController, JSON
 * parse/serialize, status-code → EmbeddingError categorisation, and
 * shape normalisation. The provider subclasses configure the URL +
 * auth headers and stay thin.
 *
 * Error categorisation matches the agent-scheduler convention (see
 * src/core/agentScheduler.js handleAIServiceFailure):
 *   401/403         → AUTH_ERROR        (persistent)
 *   404             → MODEL_NOT_FOUND   (persistent)
 *   400/422         → INVALID_INPUT     (persistent)
 *   429             → RATE_LIMITED      (transient)
 *   5xx             → PROVIDER_UNAVAILABLE (transient)
 *   network / abort → NETWORK_ERROR     (transient)
 */

import {
  EmbeddingError,
  EMBEDDING_ERROR_CODES,
} from '../providerInterface.js';

/**
 * Default HTTP fetch timeout for embedding calls. Embedding APIs are
 * fast (typically <1s for a batch of 16); a 30s ceiling catches stuck
 * connections without making the agent wait noticeably on failure.
 */
const DEFAULT_TIMEOUT_MS = 30_000;

/**
 * Send a single embeddings batch over HTTP and return raw number[][].
 *
 * @param {object} opts
 * @param {string} opts.url      - Full POST URL.
 * @param {Object} opts.headers  - Auth + content-type headers.
 * @param {Object} opts.body     - Request body (will be JSON.stringify'd).
 * @param {string} opts.providerName - For error tagging only.
 * @param {(json: any) => number[][]} opts.extractVectors
 *        - Pull the vectors out of the response payload. Different
 *          providers nest them slightly differently; we don't try to
 *          guess — each provider declares it explicitly.
 * @param {number} [opts.timeoutMs=30_000]
 * @param {typeof fetch} [opts.fetchImpl]  - Injectable for tests.
 * @returns {Promise<number[][]>}
 * @throws  {EmbeddingError}
 */
export async function postEmbeddings(opts) {
  const {
    url, headers, body, providerName, extractVectors,
    timeoutMs = DEFAULT_TIMEOUT_MS, fetchImpl = globalThis.fetch,
  } = opts;

  if (typeof fetchImpl !== 'function') {
    throw new EmbeddingError(
      EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
      'global fetch is not available; pass a fetchImpl explicitly',
      { provider: providerName },
    );
  }

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  let response;
  try {
    response = await fetchImpl(url, {
      method: 'POST',
      headers,
      body: JSON.stringify(body),
      signal: controller.signal,
    });
  } catch (err) {
    // Network failure / DNS / connection reset / abort.
    const isAbort = err?.name === 'AbortError';
    throw new EmbeddingError(
      EMBEDDING_ERROR_CODES.NETWORK_ERROR,
      isAbort ? `Request timed out after ${timeoutMs}ms` : `Network error: ${err?.message || err}`,
      { provider: providerName, transient: true, cause: err },
    );
  } finally {
    clearTimeout(timer);
  }

  if (!response.ok) {
    const text = await safeReadBody(response);
    throw classifyHttpError(response.status, text, providerName);
  }

  let json;
  try {
    json = await response.json();
  } catch (err) {
    throw new EmbeddingError(
      EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      `Provider returned non-JSON response: ${err?.message || err}`,
      { provider: providerName, transient: true, cause: err },
    );
  }

  let vectors;
  try {
    vectors = extractVectors(json);
  } catch (err) {
    throw new EmbeddingError(
      EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      `Could not extract vectors from response: ${err?.message || err}`,
      { provider: providerName, transient: true, cause: err },
    );
  }

  if (!Array.isArray(vectors)) {
    throw new EmbeddingError(
      EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      `extractVectors returned ${typeof vectors}, expected array`,
      { provider: providerName, transient: true },
    );
  }

  return vectors;
}

/** Map an HTTP error response to the typed EmbeddingError. */
export function classifyHttpError(status, bodyText, providerName) {
  // Truncate the body so the error message doesn't bloat the log line.
  const snippet = (bodyText || '').slice(0, 240).replace(/\s+/g, ' ').trim();
  const tail = snippet ? ` — ${snippet}` : '';

  if (status === 401 || status === 403) {
    return new EmbeddingError(
      EMBEDDING_ERROR_CODES.AUTH_ERROR,
      `Auth rejected (HTTP ${status})${tail}`,
      { provider: providerName, transient: false },
    );
  }
  if (status === 404) {
    return new EmbeddingError(
      EMBEDDING_ERROR_CODES.MODEL_NOT_FOUND,
      `Model or deployment not found (HTTP 404)${tail}`,
      { provider: providerName, transient: false },
    );
  }
  if (status === 400 || status === 422) {
    return new EmbeddingError(
      EMBEDDING_ERROR_CODES.INVALID_INPUT,
      `Request rejected (HTTP ${status})${tail}`,
      { provider: providerName, transient: false },
    );
  }
  if (status === 429) {
    return new EmbeddingError(
      EMBEDDING_ERROR_CODES.RATE_LIMITED,
      `Rate limited (HTTP 429)${tail}`,
      { provider: providerName, transient: true },
    );
  }
  if (status >= 500) {
    return new EmbeddingError(
      EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
      `Provider error (HTTP ${status})${tail}`,
      { provider: providerName, transient: true },
    );
  }
  // Anything else gets the catch-all.
  return new EmbeddingError(
    EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
    `Unexpected HTTP ${status}${tail}`,
    { provider: providerName, transient: true },
  );
}

/** Read a response body as text, swallowing further errors. */
async function safeReadBody(response) {
  try {
    return await response.text();
  } catch {
    return '';
  }
}

/**
 * Extractor for the standard OpenAI-shape embeddings response:
 *   { data: [{ embedding: number[] }, ...] }
 * Both Azure OpenAI and OpenAI directly use this. Azure-via-Loxia-backend
 * forwards the same shape after the proxy layer.
 */
export function extractOpenAIShape(json) {
  if (!json || !Array.isArray(json.data)) {
    throw new Error('response missing `data` array');
  }
  return json.data.map((row, i) => {
    if (!row || !Array.isArray(row.embedding)) {
      throw new Error(`data[${i}].embedding is not an array`);
    }
    return row.embedding;
  });
}
