/**
 * Composio Tool — third-party integration hub (Gmail, Slack, GitHub,
 * Linear, Notion, Drive, Sheets, Calendar, ~250 apps).
 *
 * Architecture: thin wrapper around the official `@composio/core` SDK
 * (v0.6.7+). The SDK is in optionalDependencies so the rest of the
 * runtime works even if it's not installed — agents without the
 * `composio` capability never see this tool.
 *
 * Auth model: per-user. We map agentId → Composio userId. Composio
 * holds the OAuth tokens server-side and auto-refreshes them. Our
 * code never sees raw tokens.
 *
 * API key: resolved by composioService.getClient() — first checks the
 * encrypted vault (where the web-ui's Integrations page saves it),
 * then falls back to process.env.COMPOSIO_API_KEY for ops who set it
 * out-of-band. Obtain at dashboard.composio.dev.
 *
 * Security note: Composio had a security incident in May 2026 that
 * leaked ~5K GitHub OAuth tokens. Key rotation should be automatable
 * on the operator's side (env-var refresh + restart). Keep this tool
 * GATED behind an explicit `composio` capability — never default-on.
 *
 * Plural-canonical envelope (cross-tool convention):
 *   { toolId: "composio", actions: [{ type: "<op>", ...fields }] }
 * Singular {action,...} accepted via reverse-forgiveness.
 */

import { BaseTool } from './baseTool.js';
import { ComposioIndexer } from '../services/embeddings/indexers/composioIndexer.js';
import * as composioService from '../services/composioService.js';

const SUPPORTED_ACTIONS = [
  'list-toolkits',          // discover available integrations
  'list-tools',             // for a specific toolkit, list its action slugs + schemas
  'connect',                // generate a Connect Link URL for the user to OAuth into a toolkit
  'connection-status',      // check whether the current user has an active connection to a toolkit
  'execute',                // invoke a Composio action with arguments
  'find-action',            // semantic search over the Composio catalog (needs index)
  'backfill-actions-index', // (re)build the catalog vector index for find-action
];

class ComposioTool extends BaseTool {
  constructor(config = {}, logger = null) {
    super(config, logger);
    this.id = 'composio';
    this.requiresProject = false;
    this.isAsync = true;
    this.timeout = config.timeout || 60_000;
    // SDK client + key resolution are owned by composioService; we
    // don't keep our own instance cache here because the service's
    // cache is already keyed by API-key fingerprint and gets
    // invalidated when the UI rotates the key. Holding a parallel
    // cache here would mean "agent sees old key" after a rotation —
    // which is exactly the regression that caused the
    // "COMPOSIO_API_KEY env var is not set" bug report.
  }

  getDescription() {
    return `
Composio Tool: third-party integration hub. Connects agents to ~250 apps
(Gmail, Slack, GitHub, Linear, Notion, Drive, Sheets, Calendar, etc.)
via Composio's hosted OAuth and unified action API.

USAGE (plural canonical — same shape as other tools):
\`\`\`json
{
  "toolId": "composio",
  "actions": [
    {"type": "execute", "toolkitSlug": "github", "actionSlug": "GITHUB_CREATE_AN_ISSUE",
     "arguments": {"owner": "acme", "repo": "api", "title": "Bug X", "body": "..."}}
  ]
}
\`\`\`

ACTIONS:

1. **list-toolkits** — discover integrations the org has enabled
\`\`\`json
{"toolId": "composio", "actions": [{"type": "list-toolkits"}]}
\`\`\`
Returns: { toolkits: [{slug, name, authConfigured, ...}] }

2. **list-tools** — list a toolkit's available actions + their schemas
\`\`\`json
{"toolId": "composio", "actions": [{"type": "list-tools", "toolkitSlug": "github"}]}
\`\`\`
Returns: { tools: [{slug, displayName, description, inputSchema, outputSchema}] }

3. **connect** — start an OAuth connection for the current user. Returns
a Connect Link URL the user opens to complete the flow.
\`\`\`json
{"toolId": "composio", "actions": [{"type": "connect", "toolkitSlug": "github"}]}
\`\`\`
Returns: { connectLink: "https://connect.composio.dev/link/...", connectionId }

4. **connection-status** — has the current user connected this toolkit?
\`\`\`json
{"toolId": "composio", "actions": [{"type": "connection-status", "toolkitSlug": "github"}]}
\`\`\`
Returns: { connected: true|false, status: "ACTIVE"|"INITIATED"|"FAILED", connectionId? }

5. **execute** — invoke a Composio action
\`\`\`json
{
  "toolId": "composio",
  "actions": [{
    "type": "execute",
    "toolkitSlug": "slack",
    "actionSlug": "SLACK_SEND_MESSAGE",
    "arguments": {"channel": "#general", "text": "hello"}
  }]
}
\`\`\`
Returns: { success: true|false, output: <upstream result>, error? }

NOTES:
- Each agent is mapped to a distinct Composio userId (agentId is reused).
  Tokens are isolated per-agent.
- The org-level API key is resolved by composioService: encrypted vault
  first (saved via Settings → Integrations), then \`COMPOSIO_API_KEY\`
  env var as an operator fallback.
- If neither source has a key OR the SDK isn't installed, every action
  returns a structured error so the agent can recover gracefully.
- Composio holds OAuth tokens server-side. We never see raw tokens.
    `.trim();
  }

  getRequiredParameters() {
    // Validation happens per-action in customValidateParameters.
    return [];
  }

  customValidateParameters(params) {
    const errors = [];
    // Plural-canonical with singular fallback (reverse-forgiveness).
    // The forgiveness handles {action,...} → {actions:[{type,...}]}.
    let actions = Array.isArray(params?.actions) ? params.actions : null;
    if (!actions && params?.action) {
      const { action, parameters, ...rest } = params;
      actions = [{ type: action, ...(parameters && typeof parameters === 'object' ? parameters : {}), ...rest }];
    }
    if (!actions || actions.length === 0) {
      errors.push('action is required (or actions: [...] for batched calls)');
      return { valid: false, errors };
    }
    for (let i = 0; i < actions.length; i++) {
      const a = actions[i];
      const tag = actions.length > 1 ? `actions[${i}]: ` : '';
      const t = a?.type || a?.action;
      if (!t) {
        errors.push(`${tag}type is required`);
        continue;
      }
      if (!SUPPORTED_ACTIONS.includes(t)) {
        errors.push(`${tag}invalid type "${t}". Must be one of: ${SUPPORTED_ACTIONS.join(', ')}`);
        continue;
      }
      switch (t) {
        case 'list-tools':
        case 'connect':
        case 'connection-status':
          if (!a.toolkitSlug) errors.push(`${tag}toolkitSlug is required for ${t}`);
          break;
        case 'execute':
          if (!a.toolkitSlug) errors.push(`${tag}toolkitSlug is required for execute`);
          if (!a.actionSlug)  errors.push(`${tag}actionSlug is required for execute`);
          break;
        case 'find-action':
          if (!a.query || typeof a.query !== 'string' || !a.query.trim()) {
            errors.push(`${tag}query is required for find-action`);
          }
          if (a.topK != null && (!Number.isInteger(a.topK) || a.topK <= 0)) {
            errors.push(`${tag}topK must be a positive integer when provided`);
          }
          break;
        // backfill-actions-index needs no params (full rebuild of the catalog).
      }
    }
    return { valid: errors.length === 0, errors };
  }

  /**
   * Delegate SDK loading to composioService — single source of truth for
   * key resolution (vault → env-var fallback) and client caching. The
   * service's cache invalidates on key rotation (UI save / forget), so
   * the next invocation here picks up the new key without a restart.
   *
   * Returns { client } on success or { error } on failure — never throws.
   * Tests can still stub this method to inject a mock client.
   */
  async _loadSdk() {
    return composioService.getClient();
  }

  async execute(params, context = {}) {
    // Reverse-forgiveness: accept both envelopes.
    const actions = this._normalizeToActions(params);
    if (actions.length === 0) {
      return { success: false, error: 'composio requires `action` (singular) or `actions` (plural array).' };
    }

    const userId = context.agentId || 'default-user';
    const { client, error: sdkErr } = await this._loadSdk();
    if (sdkErr) {
      return {
        success: false,
        error: sdkErr,
        hint: 'Add a Composio API key on the Integrations page (Settings → Integrations) or set COMPOSIO_API_KEY in the environment. Also ensure `@composio/core` is installed.',
      };
    }

    // Single-action: return its result directly (back-compat with other
    // tools' single-action shape). Multi-action: return batched results.
    if (actions.length === 1) {
      return await this._dispatch(client, userId, actions[0]);
    }
    const results = [];
    for (const a of actions) {
      try { results.push(await this._dispatch(client, userId, a)); }
      catch (err) { results.push({ success: false, action: a.type, error: err.message }); }
    }
    return {
      success: results.every(r => r.success !== false),
      batched: true,
      actionCount: results.length,
      results,
    };
  }

  /** @private */
  _normalizeToActions(params) {
    if (!params || typeof params !== 'object') return [];
    if (Array.isArray(params.actions)) {
      return params.actions.map(a => {
        if (a && !a.type && a.action) return { type: a.action, ...a };
        return a;
      });
    }
    if (params.action) {
      const { action, parameters, ...rest } = params;
      return [{ type: action, ...(parameters && typeof parameters === 'object' ? parameters : {}), ...rest }];
    }
    return [];
  }

  /**
   * Lazy ComposioIndexer accessor. Mirrors the memoryTool / reminisce
   * pattern: cached on the instance after first use; null when no
   * embedding service is attached to aiService.
   *
   * @private
   * @returns {ComposioIndexer | null}
   */
  _getComposioIndexer() {
    if (this._composioIndexer) return this._composioIndexer;
    const svc = this.aiService?.getEmbeddingService?.();
    if (!svc) return null;
    this._composioIndexer = new ComposioIndexer({ embeddingService: svc, logger: this.logger });
    return this._composioIndexer;
  }

  /**
   * Execute find-action: semantic search across the indexed catalog.
   * Returns ranked candidates with their toolkitSlug, actionSlug,
   * displayName, description, and inputSchema — everything the agent
   * needs to immediately call `execute` without a second round-trip.
   *
   * When the index is empty / stale, surfaces a clear hint to run
   * `backfill-actions-index` first.
   *
   * @private
   */
  async _executeFindAction(action, context = {}) {
    const query = String(action.query || '').trim();
    if (!query) {
      return { success: false, action: 'find-action', error: 'query is required' };
    }
    const topK = Number.isInteger(action.topK) && action.topK > 0 ? action.topK : 10;
    const hybrid = action.hybrid !== false;
    const toolkitSlug = typeof action.toolkitSlug === 'string' && action.toolkitSlug ? action.toolkitSlug : null;
    const userId = context.agentId || 'default-user';

    // Try the embedding path first. We DEGRADE GRACEFULLY: if embeddings
    // are missing, disabled, the index is empty, or search throws, we
    // fall back to a substring-match scan via the Composio SDK so the
    // agent always gets *something* useful. The fallback comes with a
    // warning so the agent + operator know quality is degraded and how
    // to upgrade it.
    const indexer = this._getComposioIndexer();
    const embeddingsLive = !!(indexer && indexer.isEnabled);
    // Track WHY we fell back so the warning is accurate. Three distinct
    // causes — each routes the operator to a different remediation.
    let fallbackCause = 'embeddings-unavailable';
    let embeddingErrorDetail = null;

    if (embeddingsLive) {
      fallbackCause = 'index-empty';
      let indexCount = 0;
      try {
        const stats = await indexer.stats();
        indexCount = stats?.count || 0;
      } catch { /* fall through to fallback */ }

      if (indexCount > 0) {
        try {
          const { hits, error } = await indexer.search(query, { topK, hybrid, toolkitSlug });
          if (!error) {
            return {
              success: true,
              action: 'find-action',
              mode: hybrid ? 'hybrid' : 'semantic',
              query, topK, toolkitSlug, hits,
              message: hits.length === 0
                ? `No actions matched "${query}"`
                : `Found ${hits.length} candidate actions ranked by similarity to "${query}".`,
            };
          }
          // Embedding search returned an error — fall through to substring.
          fallbackCause = 'embedding-search-failed';
          embeddingErrorDetail = error;
          this.logger?.warn?.('[composio] embedding find-action failed; falling back to substring', { error });
        } catch (err) {
          fallbackCause = 'embedding-search-failed';
          embeddingErrorDetail = err?.message || String(err);
          this.logger?.warn?.('[composio] embedding find-action threw; falling back to substring', { error: err?.message });
        }
      }
      // else: index empty → fall through with a "build me" hint.
    }

    // ─── Substring fallback ────────────────────────────────────────
    let reason, upgradeHint;
    if (fallbackCause === 'embeddings-unavailable') {
      reason = 'Embeddings unavailable.';
      upgradeHint = 'Enable an embedding provider in Settings → Embeddings for semantic ranking.';
    } else if (fallbackCause === 'index-empty') {
      reason = 'Catalog index is empty.';
      upgradeHint = 'Run `composio.backfill-actions-index` once to enable semantic ranking.';
    } else {
      // 'embedding-search-failed'
      reason = `Embedding search failed${embeddingErrorDetail ? ` (${embeddingErrorDetail})` : ''}.`;
      upgradeHint = 'Check the embedding provider status; using substring search in the meantime.';
    }

    const fallback = await this._substringFindAction(query, { topK, toolkitSlug, userId });
    return {
      ...fallback,
      warning: `${reason} Used substring search (lower quality). ${upgradeHint}`,
      fallbackCause,
    };
  }

  /**
   * Brute-force substring/keyword search across the Composio catalog.
   * Used when the embedding index is unavailable or empty so the agent
   * still gets ranked candidates.
   *
   * Strategy:
   *   1. List toolkits (or use the supplied toolkitSlug as the only one).
   *   2. Score each by token overlap with the query against name/slug/desc.
   *   3. For the top-5 toolkits (by score), list their tools and score
   *      each tool the same way. Top-5 cap keeps API fan-out bounded —
   *      a query against 250 toolkits should not produce 250 list-tools
   *      calls in the worst case.
   *   4. Rank tools by score, return top-K with the same hit shape as
   *      the embedding path (toolkitSlug, actionSlug, displayName,
   *      description, inputSchema, score).
   *
   * @private
   */
  async _substringFindAction(query, { topK, toolkitSlug, userId }) {
    const tokenize = (s) => String(s || '')
      .toLowerCase()
      .split(/[\s_,.\-/()\[\]{}]+/)
      .filter(t => t.length > 1);
    const qTokens = tokenize(query);
    if (qTokens.length === 0) {
      return { success: true, action: 'find-action', mode: 'substring-fallback', query, topK, toolkitSlug, hits: [], message: 'No searchable tokens in query.' };
    }
    const scoreText = (text) => {
      const tTokens = tokenize(text);
      let s = 0;
      for (const q of qTokens) {
        for (const t of tTokens) {
          if (t === q) s += 3;        // exact token match
          else if (t.includes(q) || q.includes(t)) s += 1; // substring either way
        }
      }
      return s;
    };

    // Step 1: get the candidate toolkit list.
    let toolkits;
    if (toolkitSlug) {
      toolkits = [{ slug: toolkitSlug, name: toolkitSlug }];
    } else {
      const tk = await composioService.listToolkits();
      if (!tk.success) {
        return { success: false, action: 'find-action', mode: 'substring-fallback', error: tk.error };
      }
      toolkits = tk.toolkits;
    }

    if (toolkits.length === 0) {
      return {
        success: true,
        action: 'find-action',
        mode: 'substring-fallback',
        query, topK, toolkitSlug,
        hits: [],
        message: 'Composio returned no toolkits to search.',
      };
    }

    // Step 2: rank toolkits, take top-5 (or all if toolkitSlug is pinned).
    const toolkitMaxFanout = toolkitSlug ? 1 : 5;
    const toolkitsScored = toolkits
      .map(tk => ({ tk, score: scoreText(`${tk.slug || ''} ${tk.name || ''} ${tk.description || ''}`) }))
      .sort((a, b) => b.score - a.score);
    // If at least one toolkit scored, scan those. Otherwise scan the first
    // N alphabetically as a last-ditch — the query may not match any
    // toolkit name but might match action names within a toolkit.
    const toolkitsToScan = (toolkitsScored[0]?.score > 0
      ? toolkitsScored.filter(x => x.score > 0)
      : toolkitsScored
    ).slice(0, toolkitMaxFanout);

    // Step 3: scan tools in those toolkits.
    const allHits = [];
    for (const { tk } of toolkitsToScan) {
      const r = await composioService.listTools(tk.slug, userId);
      if (!r.success) continue;
      for (const tool of (r.tools || [])) {
        const text = `${tool.slug || ''} ${tool.displayName || tool.name || ''} ${tool.description || ''}`;
        const score = scoreText(text);
        if (score > 0) {
          allHits.push({
            toolkitSlug:  tk.slug,
            actionSlug:   tool.slug,
            displayName:  tool.displayName || tool.name || tool.slug,
            description:  tool.description || '',
            inputSchema:  tool.input || tool.parameters || tool.inputParameters || null,
            score,
          });
        }
      }
    }
    allHits.sort((a, b) => b.score - a.score);
    const hits = allHits.slice(0, topK);

    return {
      success: true,
      action: 'find-action',
      mode: 'substring-fallback',
      query, topK, toolkitSlug,
      hits,
      message: hits.length === 0
        ? `No actions matched "${query}" via substring search.`
        : `Found ${hits.length} candidate actions via substring search.`,
    };
  }

  /**
   * Execute backfill-actions-index: walk every Composio toolkit, fetch
   * its actions, embed them all in batched calls, write the global
   * catalog index. Drops any prior index first (full rebuild semantics).
   *
   * @private
   */
  async _executeBackfillActionsIndex(client, userId) {
    const indexer = this._getComposioIndexer();
    if (!indexer || !indexer.isEnabled) {
      return {
        success: false,
        action: 'backfill-actions-index',
        message: 'Embeddings are not enabled — nothing to backfill. Enable a provider in Settings → Embeddings first.',
      };
    }

    // Delegate to composioService — same code path everything else uses.
    // The previous implementation called `client.toolkits.list()` and
    // `client.tools.list()` directly, which silently returned undefined
    // because those methods don't exist on the @composio/core SDK
    // (it's `toolkits.get` / `tools.get`, and toolkits.get drops
    // pagination so even that wouldn't be enough). The service now
    // paginates via REST and returns the full ~1043-toolkit catalog.
    const tkResult = await composioService.listToolkits();
    if (!tkResult.success) {
      return {
        success: false,
        action: 'backfill-actions-index',
        error: `Could not list toolkits: ${tkResult.error}`,
      };
    }
    const toolkits = tkResult.toolkits;
    if (toolkits.length === 0) {
      return {
        success: false,
        action: 'backfill-actions-index',
        error: 'Composio returned no toolkits — check the Composio API key and that the org has at least one integration enabled.',
      };
    }

    const fetcher = async (toolkitSlug) => {
      const r = await composioService.listTools(toolkitSlug, userId);
      return r.success ? (r.tools || []) : [];
    };

    const r = await indexer.indexCatalog(toolkits, fetcher);
    return {
      success: r.ok !== false,
      action: 'backfill-actions-index',
      indexedActions: r.indexedActions || 0,
      indexedToolkits: r.indexedToolkits || 0,
      failedToolkits: r.failedToolkits || 0,
      ...(r.error ? { error: r.error } : {}),
      message: r.ok === false
        ? `Backfill failed: ${r.reason || r.error || 'unknown'}`
        : `Indexed ${r.indexedActions || 0} actions across ${r.indexedToolkits || 0} toolkits` +
          (r.failedToolkits ? ` (${r.failedToolkits} toolkits failed to load)` : '') + '.',
    };
  }

  /** @private */
  async _dispatch(client, userId, action) {
    const type = action.type || action.action;
    try {
      switch (type) {
        case 'list-toolkits': {
          // Delegate to composioService — single source of truth for
          // the SDK's paginated/wrapped response shape (`{items:[]}` vs
          // `{data:[]}` vs Array). Direct `await client.toolkits.list()`
          // here used to return the wrapper object as "the list" or
          // collapse to `[]` via the `|| []` fallback, which is how
          // agents saw "0 toolkits" even when integrations existed.
          const r = await composioService.listToolkits();
          return r.success
            ? { success: true, action: 'list-toolkits', toolkits: r.toolkits }
            : { success: false, action: 'list-toolkits', error: r.error };
        }
        case 'list-tools': {
          const r = await composioService.listTools(action.toolkitSlug, userId);
          return r.success
            ? { success: true, action: 'list-tools', toolkitSlug: action.toolkitSlug, tools: r.tools }
            : { success: false, action: 'list-tools', toolkitSlug: action.toolkitSlug, error: r.error };
        }
        case 'connect': {
          // composioService.connect maps the cryptic SDK error
          // "authConfigIds.0 should be a string, but you provided
          // undefined" into a human message that names the actual
          // remediation (go to Composio dashboard → Integrations,
          // add the toolkit's integration).
          const r = await composioService.connect(userId, action.toolkitSlug, {
            authConfigId: action.authConfigId,
          });
          return r.success
            ? {
                success: true,
                action: 'connect',
                connectLink: r.connectLink,
                connectionId: r.connectionId,
                raw: r.raw,
              }
            : {
                success: false,
                action: 'connect',
                error: r.error,
                ...(r.code ? { code: r.code } : {}),
              };
        }
        case 'connection-status': {
          // composioService.connectionStatus uses `.list({userId,
          // toolkit})` + client-side filter — the old `.get({…})`
          // path passed an object as a connectionId and got back the
          // "Path parameters result in path with invalid segments"
          // SDK error.
          const r = await composioService.connectionStatus(userId, action.toolkitSlug);
          return {
            success: r.success,
            action: 'connection-status',
            toolkitSlug: action.toolkitSlug,
            connected: r.connected,
            status: r.status,
            connectionId: r.connectionId,
            ...(r.error ? { error: r.error } : {}),
          };
        }
        case 'execute': {
          const result = await client.tools?.execute?.(action.actionSlug, {
            userId,
            arguments: action.arguments || {},
          });
          return {
            success: result?.successful !== false,
            action: 'execute',
            actionSlug: action.actionSlug,
            toolkitSlug: action.toolkitSlug,
            output: result?.data ?? result?.output ?? result,
            error: result?.error || null,
          };
        }
        case 'find-action':
          return await this._executeFindAction(action, { agentId: userId });
        case 'backfill-actions-index':
          return await this._executeBackfillActionsIndex(client, userId);
        default:
          return { success: false, error: `Unknown composio action: ${type}` };
      }
    } catch (err) {
      this.logger?.error?.('[ComposioTool] action failed', { type, userId, error: err.message });
      return { success: false, action: type, error: err.message };
    }
  }
}

export default ComposioTool;
