/**
 * EmbeddingProvider — abstract base for cloud + local embedding backends.
 *
 * Why an abstract class (not a duck-typed object): we want the same shape
 * — Float32Array out, batch + single APIs, capability metadata, single
 * error vocabulary — across every provider so the orchestrator never
 * needs to know which one it's talking to. Subclasses override
 * `_embed(texts, kind)` and the base handles input validation, batching,
 * L2-normalization, dimension checks, and error wrapping.
 *
 * Vector convention: we always return L2-normalized Float32Array, so
 * cosine similarity in the VectorStore reduces to a dot product — saves
 * a divide-per-query at search time. The base enforces normalization
 * after the subclass returns raw vectors.
 *
 * Query vs document: BGE / nomic / e5 embed queries and documents into
 * the same space but want different prefixes ("search_query: ..." vs
 * "search_document: ..."). Subclasses that distinguish read the `kind`
 * argument; providers that don't simply ignore it. The base class
 * forwards kind transparently.
 */

/**
 * Error vocabulary shared by every provider. Mirrors the agent-scheduler
 * error categorisation (persistent vs transient): transient codes are
 * retry / fallback candidates; persistent codes surface to the user.
 */
export const EMBEDDING_ERROR_CODES = Object.freeze({
  NETWORK_ERROR:        'NETWORK_ERROR',         // transient
  RATE_LIMITED:         'RATE_LIMITED',          // transient
  PROVIDER_UNAVAILABLE: 'PROVIDER_UNAVAILABLE',  // transient
  AUTH_ERROR:           'AUTH_ERROR',            // persistent
  MODEL_NOT_FOUND:      'MODEL_NOT_FOUND',       // persistent
  INVALID_INPUT:        'INVALID_INPUT',         // persistent
  DIMENSION_MISMATCH:   'DIMENSION_MISMATCH',    // persistent
  NOT_INITIALIZED:      'NOT_INITIALIZED',       // persistent
});

export class EmbeddingError extends Error {
  /**
   * @param {string} code - One of EMBEDDING_ERROR_CODES values.
   * @param {string} message
   * @param {object} [opts]
   * @param {Error}   [opts.cause]
   * @param {string}  [opts.provider]
   * @param {boolean} [opts.transient] - If true, orchestrator may retry / fall back.
   */
  constructor(code, message, opts = {}) {
    super(message);
    this.name = 'EmbeddingError';
    this.code = code;
    this.provider = opts.provider || null;
    this.transient = opts.transient ?? false;
    if (opts.cause) this.cause = opts.cause;
  }
}

/**
 * @typedef {Object} EmbeddingProviderInfo
 * @property {string}   name          - 'azure' | 'openai' | 'local'
 * @property {string}   modelId       - The concrete model identifier
 * @property {number}   dimensions    - Vector size in floats
 * @property {number}   maxBatchSize  - Max texts per `_embed` call
 * @property {number}   maxTokensPerText - Hard upper bound on a single input
 * @property {boolean}  distinguishesQueryAndDocument - Honours the `kind` arg?
 * @property {boolean}  isLocal       - True for offline providers
 */

export class EmbeddingProvider {
  /**
   * Synchronous capability description. Must be cheap and side-effect-free
   * (the orchestrator reads it on every request to validate inputs).
   * @returns {EmbeddingProviderInfo}
   */
  getInfo() {
    throw new Error(`${this.constructor.name}.getInfo() must be implemented`);
  }

  /**
   * Can this provider serve requests right now? For cloud providers this
   * checks that the API key + endpoint are set; for local it checks that
   * the model has been loaded. Should NOT throw — returns a structured
   * result so callers can show a remediation message.
   * @returns {Promise<{ok: boolean, reason?: string}>}
   */
  async health() {
    return { ok: true };
  }

  /**
   * Embed a single query. Convenience over `embedDocuments([text], 'query')`.
   * @param {string} text
   * @returns {Promise<Float32Array>}
   */
  async embedQuery(text) {
    if (typeof text !== 'string' || text.length === 0) {
      throw new EmbeddingError(
        EMBEDDING_ERROR_CODES.INVALID_INPUT,
        'embedQuery requires a non-empty string',
        { provider: this.getInfo().name },
      );
    }
    const [vec] = await this.embedDocuments([text], 'query');
    return vec;
  }

  /**
   * Embed many texts. Returns an array of Float32Array (L2-normalized) in
   * the SAME order as the input. Batches under `maxBatchSize` transparently.
   * Throws EmbeddingError on failure with a code from EMBEDDING_ERROR_CODES.
   *
   * @param {string[]} texts
   * @param {'document'|'query'} [kind='document']
   * @returns {Promise<Float32Array[]>}
   */
  async embedDocuments(texts, kind = 'document') {
    if (!Array.isArray(texts) || texts.length === 0) {
      throw new EmbeddingError(
        EMBEDDING_ERROR_CODES.INVALID_INPUT,
        'embedDocuments requires a non-empty array of strings',
        { provider: this.getInfo().name },
      );
    }
    for (let i = 0; i < texts.length; i++) {
      if (typeof texts[i] !== 'string' || texts[i].length === 0) {
        throw new EmbeddingError(
          EMBEDDING_ERROR_CODES.INVALID_INPUT,
          `embedDocuments[${i}] is not a non-empty string`,
          { provider: this.getInfo().name },
        );
      }
    }

    const { maxBatchSize, dimensions, name } = this.getInfo();
    const out = [];
    for (let start = 0; start < texts.length; start += maxBatchSize) {
      const batch = texts.slice(start, start + maxBatchSize);
      const raw = await this._embed(batch, kind);
      if (!Array.isArray(raw) || raw.length !== batch.length) {
        throw new EmbeddingError(
          EMBEDDING_ERROR_CODES.PROVIDER_UNAVAILABLE,
          `Provider returned ${Array.isArray(raw) ? raw.length : 'non-array'} vectors for ${batch.length} inputs`,
          { provider: name, transient: true },
        );
      }
      for (const v of raw) {
        const f32 = v instanceof Float32Array ? v : Float32Array.from(v);
        if (f32.length !== dimensions) {
          throw new EmbeddingError(
            EMBEDDING_ERROR_CODES.DIMENSION_MISMATCH,
            `Expected ${dimensions} dims; got ${f32.length}`,
            { provider: name },
          );
        }
        normalizeInPlace(f32);
        out.push(f32);
      }
    }
    return out;
  }

  /**
   * Subclasses implement this: send `texts` to the provider, return an
   * array of array-likes of numbers (the base class normalizes + validates
   * dimensions before handing the result back to callers).
   *
   * Should throw EmbeddingError with a useful code on failure. Network /
   * 5xx / 429 → transient codes; auth / bad request → persistent codes.
   *
   * @param {string[]} texts
   * @param {'document'|'query'} kind
   * @returns {Promise<(number[]|Float32Array)[]>}
   * @protected
   */
  async _embed(texts, kind) {
    throw new Error(`${this.constructor.name}._embed() must be implemented`);
  }

  /**
   * Stable model fingerprint, used by VectorStore to detect provider /
   * model changes and trigger re-embed. Default = `name:modelId`; override
   * if a single modelId has multiple incompatible internal versions.
   * @returns {string}
   */
  getModelFingerprint() {
    const { name, modelId } = this.getInfo();
    return `${name}:${modelId}`;
  }
}

/**
 * L2-normalize a vector in place so cosine(a, b) reduces to dot(a, b).
 * Exposed for tests and for the VectorStore to validate stored vectors.
 * Hot path — avoid allocations.
 * @param {Float32Array} v
 * @returns {Float32Array} the same array, normalized
 */
export function normalizeInPlace(v) {
  let sum = 0;
  for (let i = 0; i < v.length; i++) sum += v[i] * v[i];
  const norm = Math.sqrt(sum);
  if (norm === 0) return v;
  const inv = 1 / norm;
  for (let i = 0; i < v.length; i++) v[i] = v[i] * inv;
  return v;
}
