/**
 * ComposioIndexer — keeps a GLOBAL vector index of the Composio catalog
 * (toolkits × actions) so the agent can find the right action by
 * meaning instead of walking every toolkit's tool list.
 *
 * The discovery problem this solves:
 *   - Composio has ~250 toolkits and ~50–200 actions per toolkit.
 *   - Today the agent must `list-toolkits` → pick → `list-tools` →
 *     scan → pick → `execute`. Three round-trips minimum, more on
 *     wrong guesses. Action slugs like SLACK_SEND_DM aren't
 *     semantically obvious without their description.
 *   - After this indexer is in place: ONE call,
 *     `find-action({query: "send a slack DM"})`, returns ranked
 *     candidates with their inputSchemas already attached.
 *
 * Why GLOBAL (not per-agent) scope:
 *   - The catalog is the same for every agent at any point in time.
 *     Per-agent indexes would waste embedding cost N times for the
 *     same content.
 *   - Per-user connections (which toolkits a user has OAuth'd into)
 *     stay per-user; the catalog is read-only metadata.
 *
 * Failure policy (same as memory/reminisce indexers):
 *   - All public methods are best-effort. Errors are logged, structured
 *     results are returned, nothing throws to the caller. The Composio
 *     SDK errors get surfaced in the result rather than crashing the
 *     agent.
 *
 * Index lifecycle:
 *   - User runs `composio.backfill-actions-index` once after enabling
 *     embeddings (or after Composio refreshes its catalog, or after a
 *     provider/model swap).
 *   - The action then becomes callable. Stale-but-non-empty results
 *     are preferred over hard errors; if a toolkit was added since
 *     the last backfill, it won't appear until the next one runs.
 */

/**
 * Pack a Composio action object into the text the embedding model sees.
 * Order matters: displayName first (most concise), then description
 * (most informative), then slug + toolkit hint (identifier signal). The
 * slug helps when the user knows the exact name.
 */
export function composioActionToText(toolkitSlug, action) {
  if (!action) return '';
  const parts = [];
  if (action.displayName) parts.push(String(action.displayName));
  if (action.description) parts.push(String(action.description));
  // Slug + toolkit at the tail so they don't dominate the embedding.
  const slug = action.slug || action.actionSlug || action.name;
  if (slug) parts.push(`[${toolkitSlug || 'unknown'}/${slug}]`);
  return parts.join('\n');
}

export class ComposioIndexer {
  /**
   * @param {object} opts
   * @param {import('../embeddingService.js').EmbeddingService} opts.embeddingService
   * @param {object} [opts.logger]
   */
  constructor({ embeddingService, logger = null }) {
    this._svc = embeddingService;
    this._logger = logger;
  }

  get isEnabled() {
    return !!(this._svc && this._svc.isEnabled);
  }

  /**
   * Walk a list of toolkits, fetch each toolkit's actions, embed all of
   * them in batched calls, and write them to the global Composio index.
   *
   * `fetchToolsForToolkit` is a callback the caller provides so we don't
   * have to reach into the Composio SDK from inside this module. It
   * receives `toolkitSlug` and must return an array of action objects
   * (or null/empty if the toolkit has no actions or fails to load).
   *
   * @param {Array<{slug: string, name?: string}>} toolkits
   * @param {(toolkitSlug: string) => Promise<Array<object>|null>} fetchToolsForToolkit
   * @returns {Promise<{ok: boolean, indexedActions: number, indexedToolkits: number, failedToolkits: number, skipped: number, error?: string, reason?: string}>}
   */
  async indexCatalog(toolkits, fetchToolsForToolkit) {
    if (!this.isEnabled) return { ok: false, reason: 'disabled', indexedActions: 0, indexedToolkits: 0, failedToolkits: 0, skipped: 0 };
    if (!Array.isArray(toolkits) || toolkits.length === 0) {
      return { ok: false, reason: 'no-toolkits', indexedActions: 0, indexedToolkits: 0, failedToolkits: 0, skipped: 0 };
    }
    if (typeof fetchToolsForToolkit !== 'function') {
      return { ok: false, reason: 'invalid-input', indexedActions: 0, indexedToolkits: 0, failedToolkits: 0, skipped: 0 };
    }

    // Gather all (toolkitSlug, action) pairs across every toolkit. We do
    // this concurrently so the embedding batch sizes are large and the
    // total wallclock stays reasonable even for ~250 toolkits.
    const pairs = [];
    let failedToolkits = 0;
    let toolkitsTouched = new Set();

    for (const tk of toolkits) {
      const toolkitSlug = tk?.slug || tk?.name;
      if (!toolkitSlug) { failedToolkits++; continue; }
      let actions;
      try {
        actions = await fetchToolsForToolkit(toolkitSlug);
      } catch (err) {
        this._logger?.warn?.('[composioIndexer] fetch tools failed', {
          toolkitSlug, error: err?.message,
        });
        failedToolkits++;
        continue;
      }
      if (!Array.isArray(actions) || actions.length === 0) continue;
      for (const action of actions) {
        const slug = action?.slug || action?.actionSlug || action?.name;
        if (!slug) continue;
        const text = composioActionToText(toolkitSlug, action);
        if (!text) continue;
        pairs.push({ toolkitSlug, actionSlug: slug, action, text });
        toolkitsTouched.add(toolkitSlug);
      }
    }

    if (pairs.length === 0) {
      // Nothing to index, but we should still REPLACE the existing index
      // (caller's intent was a full rebuild) — drop the store so a stale
      // index doesn't linger.
      try {
        const store = await this._svc.getStore({ surface: 'composio-actions' });
        await store.drop();
      } catch (_err) { /* best-effort */ }
      return {
        ok: true,
        indexedActions: 0,
        indexedToolkits: 0,
        failedToolkits,
        skipped: 0,
      };
    }

    let vectors;
    try {
      vectors = await this._svc.embedDocuments(pairs.map(p => p.text), 'document');
    } catch (err) {
      this._logger?.warn?.('[composioIndexer] embed batch failed', {
        actions: pairs.length, error: err?.message,
      });
      return {
        ok: false,
        indexedActions: 0,
        indexedToolkits: 0,
        failedToolkits,
        skipped: 0,
        error: err?.message,
      };
    }

    const rows = pairs.map((p, i) => ({
      id: `${p.toolkitSlug}:${p.actionSlug}`,
      vector: vectors[i],
      metadata: this._buildMetadata(p.toolkitSlug, p.action, p.text),
    }));

    try {
      const store = await this._svc.getStore({ surface: 'composio-actions' });
      // Full rebuild semantics: drop the existing index before writing.
      // Stale-row handling on partial failure is owned by the caller —
      // they can re-run backfill.
      await store.drop();
      await store.upsertBatch(rows);
      return {
        ok: true,
        indexedActions: rows.length,
        indexedToolkits: toolkitsTouched.size,
        failedToolkits,
        skipped: 0,
      };
    } catch (err) {
      this._logger?.warn?.('[composioIndexer] index write failed', {
        actions: rows.length, error: err?.message,
      });
      return {
        ok: false,
        indexedActions: 0,
        indexedToolkits: 0,
        failedToolkits,
        skipped: 0,
        error: err?.message,
      };
    }
  }

  /**
   * Find ranked actions for a query. Optional filter by toolkit so the
   * model can narrow scope when it already knows which integration.
   *
   * @param {string} query
   * @param {object} [opts]
   * @param {string} [opts.toolkitSlug]  Filter to one toolkit when set.
   * @param {number} [opts.topK=10]
   * @param {boolean} [opts.hybrid=true] Hybrid semantic + substring via RRF.
   * @returns {Promise<{enabled: boolean, hits: Array<{id, score, toolkitSlug, actionSlug, displayName, description, metadata}>, error?: string}>}
   */
  async search(query, { toolkitSlug = null, topK = 10, hybrid = true } = {}) {
    if (!this.isEnabled) return { enabled: false, hits: [] };
    if (typeof query !== 'string' || !query.trim()) {
      return { enabled: true, hits: [] };
    }
    try {
      const store = await this._svc.getStore({ surface: 'composio-actions' });
      const queryVec = await this._svc.embedQuery(query);
      const filter = toolkitSlug
        ? (m) => m?.toolkitSlug === toolkitSlug
        : null;
      const raw = hybrid
        ? await store.hybridQuery(queryVec, query, { topK, filter })
        : await store.query(queryVec, { topK, filter });

      const hits = raw.map(r => ({
        id: r.id,
        score: r.score,
        toolkitSlug: r.metadata?.toolkitSlug || '',
        actionSlug: r.metadata?.actionSlug || '',
        displayName: r.metadata?.displayName || '',
        description: r.metadata?.description || '',
        metadata: r.metadata,
      }));
      return { enabled: true, hits };
    } catch (err) {
      this._logger?.warn?.('[composioIndexer] search failed', { error: err?.message });
      return { enabled: true, hits: [], error: err?.message };
    }
  }

  /** Read-only stats — used by the cost dashboard / status pill. */
  async stats() {
    if (!this.isEnabled) return { count: 0, enabled: false };
    try {
      const store = await this._svc.getStore({ surface: 'composio-actions' });
      const s = await store.stats();
      return { ...s, enabled: true };
    } catch (err) {
      return { count: 0, enabled: true, error: err?.message };
    }
  }

  /** @private */
  _buildMetadata(toolkitSlug, action, text) {
    return {
      toolkitSlug,
      actionSlug: action?.slug || action?.actionSlug || action?.name || '',
      displayName: action?.displayName || '',
      description: action?.description || '',
      // For hybrid substring matching.
      text,
      // Surface the inputSchema in metadata so the agent can call execute
      // without a second round-trip. Cap size so we don't blow up the
      // JSON store with huge schemas.
      inputSchema: _capObjectSize(action?.inputSchema, 4000),
      // Snapshot of indexing time for staleness UX.
      indexedAt: new Date().toISOString(),
    };
  }
}

/**
 * Cap an object's stringified size by truncating top-level string fields
 * once their cumulative serialized size exceeds `maxBytes`. Returns the
 * original object when it's already under the cap. Best-effort — used to
 * keep VectorStore JSON sane without losing the most important parts of
 * the inputSchema.
 * @private
 */
function _capObjectSize(obj, maxBytes) {
  if (obj == null || typeof obj !== 'object') return obj;
  let serialized;
  try { serialized = JSON.stringify(obj); } catch { return null; }
  if (serialized.length <= maxBytes) return obj;
  return { _truncated: true, _approxBytes: serialized.length, _preview: serialized.slice(0, maxBytes) + '…' };
}
