/**
 * EmbeddingService — the one orchestrator that consumers talk to.
 *
 * Responsibilities:
 *   1. Read embeddings config, pick the right provider, lazy-instantiate it.
 *   2. Expose `embedQuery` / `embedDocuments` with the same shape as the
 *      provider but with budget enforcement + telemetry on top.
 *   3. Manage per-scope VectorStores (one file per agent per surface
 *      under `<userDataDir>/state/embeddings/`).
 *   4. Hot-swap providers when config changes — invalidates the cached
 *      provider + store map; stores re-load with the new fingerprint
 *      next time they're requested.
 *   5. Treat `provider: 'disabled'` as a fully-supported state. Consumers
 *      should check `isEnabled` and fall back to substring search.
 *
 * Why a single orchestrator vs. each tool instantiating its own provider:
 *   - One place that owns budget / telemetry. No double-counting.
 *   - One place that resolves brand-aware key routing.
 *   - One place that hot-swaps when the user changes the provider in
 *     Settings (without restarting Loxia).
 *
 * Wiring: typically lives on `aiService.embeddingService`, set during
 * LoxiaSystem startup. Tools access it via `aiService.getEmbeddingService()`
 * — matches how `aiService.apiKeyManager` is plumbed.
 */

import path from 'path';
import fsp from 'fs/promises';
import {
  EmbeddingError,
  EMBEDDING_ERROR_CODES,
} from './providerInterface.js';
import { AzureEmbeddingProvider }       from './providers/azureProvider.js';
import { OpenAIEmbeddingProvider }      from './providers/openaiProvider.js';
import { LocalEmbeddingProvider }       from './providers/localProvider.js';
import { AzureCustomEmbeddingProvider } from './providers/azureCustomProvider.js';
import { InMemoryJsonStore }        from './vectorStore/inMemoryJsonStore.js';
import { mergeConfig, validateConfig, estimateTokens } from './embeddingsConfig.js';

export class EmbeddingService {
  /**
   * @param {object} opts
   * @param {object} opts.config        - Embeddings config (will be merged with defaults).
   * @param {object} opts.apiKeyManager - For getEmbeddingApiKey(provider).
   * @param {string} opts.baseUrl       - Loxia backend URL (for azure provider).
   * @param {string} opts.stateDir      - Absolute path to <userDataDir>/state.
   *                                       VectorStore files live under
   *                                       `${stateDir}/embeddings/...`.
   * @param {object} [opts.logger]
   * @param {object} [opts.providerOverrides] - For tests: { azure, openai, local }
   *                                       maps each provider name to a custom factory.
   */
  constructor({ config, apiKeyManager, baseUrl, stateDir, logger = null, providerOverrides = {} }) {
    if (!apiKeyManager) throw new Error('EmbeddingService requires apiKeyManager');
    if (!stateDir)      throw new Error('EmbeddingService requires stateDir');
    this._config = mergeConfig(config);
    this._apiKeyManager = apiKeyManager;
    this._baseUrl = baseUrl;
    this._stateDir = stateDir;
    this._logger = logger;
    this._providerOverrides = providerOverrides;

    /** @type {import('./providerInterface.js').EmbeddingProvider | null} */
    this._provider = null;
    /** @type {Map<string, InMemoryJsonStore>} */
    this._stores = new Map();

    this._telemetry = {
      callsTotal:   0,
      tokensTotal:  0,
      callsByDay:   new Map(), // 'YYYY-MM-DD' -> {calls, tokens}
      lastError:    null,
      lastCallAt:   null,
    };

    const v = validateConfig(this._config);
    if (!v.ok) {
      this._logger?.warn?.('[EmbeddingService] invalid config; using safe defaults', { errors: v.errors });
    }
  }

  /** Convenience flag the consumer side reads to gate semantic features. */
  get isEnabled() {
    return this._config.provider !== 'disabled';
  }

  get config() {
    return this._config;
  }

  /**
   * Lazy-instantiate the configured provider. Subsequent calls return the
   * cached instance until `updateConfig()` flips the provider name.
   */
  getProvider() {
    if (!this.isEnabled) {
      throw new EmbeddingError(
        EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
        'Embeddings are disabled. Enable a provider in Settings → Embeddings.',
        { provider: 'disabled', transient: false },
      );
    }
    if (this._provider) return this._provider;

    const name = this._config.provider;
    const override = this._providerOverrides?.[name];
    if (override) {
      this._provider = typeof override === 'function' ? override(this._config) : override;
      return this._provider;
    }

    switch (name) {
      case 'azure':
        if (!this._baseUrl) {
          throw new EmbeddingError(
            EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
            'Azure provider needs baseUrl; pass one to EmbeddingService',
            { provider: 'azure', transient: false },
          );
        }
        this._provider = new AzureEmbeddingProvider({
          baseUrl: this._baseUrl,
          getApiKey: () => this._apiKeyManager.getEmbeddingApiKey('azure'),
          modelId: this._config.azure.modelId,
          dimensions: this._config.azure.dimensions,
          maxBatchSize: this._config.azure.maxBatchSize,
        });
        break;
      case 'openai':
        this._provider = new OpenAIEmbeddingProvider({
          getApiKey: () => this._apiKeyManager.getEmbeddingApiKey('openai'),
          modelId: this._config.openai.modelId,
          dimensions: this._config.openai.dimensions,
          maxBatchSize: this._config.openai.maxBatchSize,
          url: this._config.openai.url || undefined,
        });
        break;
      case 'local':
        this._provider = new LocalEmbeddingProvider({
          modelId: this._config.local.modelId,
          // Default cacheDir lives inside the Loxia state tree so weights
          // travel with backups / uninstall cleanly.
          cacheDir: this._config.local.cacheDir
            || path.join(this._stateDir, 'embeddings', 'cache'),
          maxBatchSize: this._config.local.maxBatchSize,
          logger: this._logger,
        });
        break;
      case 'azure-custom':
        if (!this._config.azureCustom?.endpoint || !this._config.azureCustom?.deployment) {
          throw new EmbeddingError(
            EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
            'Azure custom provider needs `endpoint` + `deployment` in config. Set them in Settings → Embeddings.',
            { provider: 'azure-custom', transient: false },
          );
        }
        this._provider = new AzureCustomEmbeddingProvider({
          endpoint: this._config.azureCustom.endpoint,
          deployment: this._config.azureCustom.deployment,
          dimensions: this._config.azureCustom.dimensions,
          maxBatchSize: this._config.azureCustom.maxBatchSize,
          getApiKey: () => this._apiKeyManager.getEmbeddingApiKey('azure-custom'),
        });
        break;
      default:
        throw new EmbeddingError(
          EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
          `Unknown embeddings provider: ${name}`,
          { provider: name, transient: false },
        );
    }
    return this._provider;
  }

  /**
   * Health check the user-facing UI can call to show a status pill.
   * Always resolves (never throws); returns the provider's own health
   * result plus whether the budget is exhausted today.
   */
  async health() {
    if (!this.isEnabled) {
      return { ok: false, reason: 'Embeddings are disabled.' };
    }
    let providerHealth;
    try {
      providerHealth = await this.getProvider().health();
    } catch (err) {
      return { ok: false, reason: err?.message || String(err) };
    }
    if (!providerHealth.ok) return providerHealth;

    if (this._budgetExceeded()) {
      return { ok: false, reason: 'Daily embedding budget reached. Resets at midnight UTC.' };
    }
    return { ok: true };
  }

  /**
   * Force the provider to load (only meaningful for local; cloud is a no-op).
   * Useful after the user enables the local provider so the model
   * downloads while they're still on the Settings screen — first agent
   * use doesn't pay a 3s latency.
   */
  async warmup() {
    if (!this.isEnabled) return;
    const provider = this.getProvider();
    if (typeof provider.warmup === 'function') {
      await provider.warmup();
    }
  }

  /** Embed a single query. */
  async embedQuery(text) {
    this._assertEnabled();
    const tokens = estimateTokens(text);
    // Pre-flight budget check: refuse the call BEFORE spending tokens
    // we're about to blow past the cap. Without this we'd let the call
    // succeed then block the next one even though THIS one already
    // crossed the limit.
    this._enforceBudgetOrThrow(tokens);
    try {
      const out = await this.getProvider().embedQuery(text);
      this._recordCall(1, tokens);
      return out;
    } catch (err) {
      this._telemetry.lastError = { code: err?.code, message: err?.message, at: new Date().toISOString() };
      throw err;
    }
  }

  /** Embed a batch of documents (or queries via `kind='query'`). */
  async embedDocuments(texts, kind = 'document') {
    this._assertEnabled();
    const tokens = texts.reduce((s, t) => s + estimateTokens(t), 0);
    this._enforceBudgetOrThrow(tokens);
    try {
      const out = await this.getProvider().embedDocuments(texts, kind);
      this._recordCall(texts.length, tokens);
      return out;
    } catch (err) {
      this._telemetry.lastError = { code: err?.code, message: err?.message, at: new Date().toISOString() };
      throw err;
    }
  }

  /**
   * Get-or-create a VectorStore for the given scope.
   *
   *   scope = { agentId: 'a1', surface: 'memory' }   // per-agent
   *   scope = { surface: 'composio' }                // global
   *
   * Stores are file-backed under
   *   `${stateDir}/embeddings/agents/<agentId>/<surface>.vec.json`     (agent-scoped)
   *   `${stateDir}/embeddings/global/<surface>.vec.json`               (global)
   *
   * The fingerprint baked into the file is `provider.getModelFingerprint()` —
   * swapping providers (azure ↔ local) makes the store present an empty
   * corpus so the caller knows to re-embed.
   */
  async getStore(scope) {
    this._assertEnabled();
    const key = this._scopeKey(scope);
    if (this._stores.has(key)) return this._stores.get(key);

    const provider = this.getProvider();
    const info = provider.getInfo();
    const store = new InMemoryJsonStore({
      filePath: this._storePathFor(scope),
      dimensions: info.dimensions,
      modelFingerprint: provider.getModelFingerprint(),
      logger: this._logger,
    });
    await store.load();
    this._stores.set(key, store);
    return store;
  }

  /**
   * Hot-swap config (e.g. user picked a different provider in Settings).
   * Drops the cached provider + store map so the next request rebuilds
   * with the new shape. Existing per-store files remain on disk; they
   * either reload (same fingerprint) or auto-empty (drift triggers
   * re-embed). No data is destroyed by this call.
   *
   * @param {object}  newConfig
   * @param {object}  [opts]
   * @param {boolean} [opts.persist=false] - When true, also writes the
   *        merged config to `<stateDir>/embeddings/embeddings-config.json`
   *        so the choice survives restarts. Settings-UI consumers should
   *        pass true; transient/in-memory callers leave it false.
   */
  async updateConfig(newConfig, { persist = false } = {}) {
    this._config = mergeConfig(newConfig);
    this._provider = null;
    this._stores.clear();
    const v = validateConfig(this._config);
    if (v.ok && persist) {
      try {
        await this._persistConfig();
      } catch (err) {
        this._logger?.warn?.('[EmbeddingService] config persist failed', { error: err?.message });
      }
    }
    return { ok: v.ok, errors: v.errors };
  }

  /**
   * Read config from disk (called once during system boot) and apply it.
   * Errors during read leave the current in-memory config untouched —
   * we treat a missing or corrupt file as "use defaults".
   *
   * @returns {Promise<boolean>} true if a config was loaded from disk
   */
  async loadPersistedConfig() {
    const file = this._configFilePath();
    let raw;
    try {
      raw = await fsp.readFile(file, 'utf8');
    } catch (err) {
      if (err?.code !== 'ENOENT') {
        this._logger?.warn?.('[EmbeddingService] could not read persisted config', { file, error: err?.message });
      }
      return false;
    }
    let parsed;
    try { parsed = JSON.parse(raw); }
    catch (err) {
      this._logger?.warn?.('[EmbeddingService] persisted config is not JSON; ignoring', { file, error: err?.message });
      return false;
    }
    this._config = mergeConfig(parsed);
    this._provider = null;
    this._stores.clear();
    return true;
  }

  /** @private */
  async _persistConfig() {
    const file = this._configFilePath();
    await fsp.mkdir(path.dirname(file), { recursive: true });
    // Strip default-y fields? Keep it simple — just dump the merged config.
    // Atomic write via .tmp + rename so a crash mid-write doesn't leave
    // a partial file on disk.
    const tmp = `${file}.tmp`;
    await fsp.writeFile(tmp, JSON.stringify(this._config, null, 2));
    await fsp.rename(tmp, file);
  }

  /** @private */
  _configFilePath() {
    return path.join(this._stateDir, 'embeddings', 'embeddings-config.json');
  }

  /** Read-only telemetry snapshot for the Settings cost dashboard. */
  getTelemetry() {
    const todayKey = isoDateKey(new Date());
    const today = this._telemetry.callsByDay.get(todayKey) || { calls: 0, tokens: 0 };
    return {
      callsTotal: this._telemetry.callsTotal,
      tokensTotal: this._telemetry.tokensTotal,
      lastError: this._telemetry.lastError,
      lastCallAt: this._telemetry.lastCallAt,
      today: { ...today, dateKey: todayKey },
    };
  }

  /** @private */
  _assertEnabled() {
    if (!this.isEnabled) {
      throw new EmbeddingError(
        EMBEDDING_ERROR_CODES.NOT_INITIALIZED,
        'Embeddings are disabled. Enable a provider in Settings → Embeddings.',
        { provider: 'disabled', transient: false },
      );
    }
  }

  /**
   * Pre-flight budget check.
   * @param {number} tokensAboutToConsume - Estimated tokens for the request
   *        about to be made. Counted against today's usage BEFORE the call.
   * @private
   */
  _enforceBudgetOrThrow(tokensAboutToConsume = 0) {
    const limit = this._config.budget?.dailyTokenLimit;
    if (!limit) return;
    // Local provider doesn't consume the cloud budget — only cloud providers do.
    if (this._config.provider === 'local') return;
    const today = this._telemetry.callsByDay.get(isoDateKey(new Date())) || { tokens: 0 };
    if (today.tokens + tokensAboutToConsume > limit) {
      throw new EmbeddingError(
        EMBEDDING_ERROR_CODES.RATE_LIMITED,
        `Daily embedding budget (${limit} tokens) reached. Resets at midnight UTC.`,
        { provider: this._config.provider, transient: true },
      );
    }
  }

  /**
   * "Have we already hit today's cap?" — used by `health()` so the UI
   * can show a status pill before any embed call is attempted.
   * @private
   */
  _budgetExceeded() {
    const limit = this._config.budget?.dailyTokenLimit;
    if (!limit) return false;
    if (this._config.provider === 'local') return false;
    const today = this._telemetry.callsByDay.get(isoDateKey(new Date()));
    return !!today && today.tokens >= limit;
  }

  /** @private */
  _recordCall(count, tokens) {
    const day = isoDateKey(new Date());
    const cur = this._telemetry.callsByDay.get(day) || { calls: 0, tokens: 0 };
    cur.calls += count;
    cur.tokens += tokens;
    this._telemetry.callsByDay.set(day, cur);
    this._telemetry.callsTotal += count;
    this._telemetry.tokensTotal += tokens;
    this._telemetry.lastCallAt = new Date().toISOString();
  }

  /** @private */
  _scopeKey(scope) {
    if (!scope || typeof scope !== 'object' || !scope.surface) {
      throw new Error('scope must be { agentId?, teamId?, surface }');
    }
    if (scope.teamId) return `team:${scope.teamId}:${scope.surface}`;
    if (scope.agentId) return `agent:${scope.agentId}:${scope.surface}`;
    return `global:${scope.surface}`;
  }

  /** @private */
  _storePathFor(scope) {
    const root = path.join(this._stateDir, 'embeddings');
    if (scope.teamId) {
      return path.join(root, 'teams', scope.teamId, `${scope.surface}.vec.json`);
    }
    if (scope.agentId) {
      return path.join(root, 'agents', scope.agentId, `${scope.surface}.vec.json`);
    }
    return path.join(root, 'global', `${scope.surface}.vec.json`);
  }
}

function isoDateKey(d) {
  const y = d.getUTCFullYear();
  const m = String(d.getUTCMonth() + 1).padStart(2, '0');
  const day = String(d.getUTCDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}
