/**
 * composioService — shared client + operations for the Composio
 * integration hub. Used by:
 *
 *   - composioTool (agent-facing) — invokes execute/connect/etc.
 *   - REST endpoints (UI-facing)  — power the Connections page
 *   - CLI subcommands (operator)  — `loxia composio <verb>`
 *
 * Single source of truth for:
 *   - SDK loading (dynamic + optional)
 *   - API-key resolution (COMPOSIO_API_KEY env var)
 *   - userId convention (agentId for agent calls; "operator" for UI/CLI)
 *
 * Every method returns a structured `{ success, ...data }` object so
 * callers can render rich errors instead of relying on thrown exceptions.
 * The SDK and API key are checked once and cached; transient SDK errors
 * are surfaced per-call.
 *
 * userId conventions:
 *   - composioTool maps agentId → userId so each agent has isolated
 *     OAuth tokens.
 *   - UI/CLI use the literal string "operator" so the human user's
 *     connections are shared across agents the operator manages.
 *     (Override via `?userId=…` on the REST routes, or `--user=…`
 *     on the CLI, if multi-tenant ever lands.)
 */

import { getApiKeyManager } from './apiKeyManager.js';

let _client = null;
let _unavailableReason = null;
let _activeKeyFingerprint = null; // First 8 chars of the key the cached client was built with.

/**
 * Reset cached client + unavailability — for tests, and for live
 * key-rotation (the API-key endpoint calls this so the next op picks
 * up the new key without a process restart).
 */
export function _resetForTests() {
  _client = null;
  _unavailableReason = null;
  _activeKeyFingerprint = null;
  _toolkitsCache = null;
  _toolkitsCacheAt = 0;
}

/**
 * Cache-invalidation hook for live key rotation. Production callers
 * (the POST /api/composio/apikey route) call this after persisting a
 * new key so the next service op rebuilds the SDK client with it.
 *
 * Different name than `_resetForTests` so it's obvious in production
 * code that this is intentional, not a test shortcut.
 */
export function invalidateClientCache() {
  _client = null;
  _unavailableReason = null;
  _activeKeyFingerprint = null;
  // Toolkits are per-org. After a key rotation the next listToolkits()
  // call must hit REST again, not the previous key's cached catalog.
  _invalidateToolkitsCache();
}

/**
 * Resolve the Composio API key from the highest-priority source:
 *   1. ApiKeyManager (user-stored via the UI — encrypted on disk)
 *   2. process.env.COMPOSIO_API_KEY (legacy / operator override)
 *
 * Returns { key, source: 'vault' | 'env' | null }.
 */
function resolveApiKey() {
  const mgr = getApiKeyManager();
  const vaultKey = mgr?.keys?.vendorKeys?.composio;
  if (vaultKey && typeof vaultKey === 'string' && vaultKey.trim()) {
    return { key: vaultKey.trim(), source: 'vault' };
  }
  const envKey = process.env.COMPOSIO_API_KEY;
  if (envKey && typeof envKey === 'string' && envKey.trim()) {
    return { key: envKey.trim(), source: 'env' };
  }
  return { key: null, source: null };
}

/**
 * Load the SDK on first use. Returns { client } on success or { error }
 * (string) when either the SDK is missing or the API key is unset.
 *
 * Exported so composioTool (agent-facing) can share the same key
 * resolution + client cache the REST + CLI surfaces use. Previously
 * the tool kept its own client and read process.env.COMPOSIO_API_KEY
 * directly, which meant keys saved via the UI (encrypted into
 * ApiKeyManager.vendorKeys.composio) were invisible to the agent
 * runtime — every agent invocation failed with "COMPOSIO_API_KEY env
 * var is not set" even though the UI had reported the key as ready.
 */
export async function getClient() {
  const { key: apiKey, source } = resolveApiKey();
  const fingerprint = apiKey ? apiKey.slice(0, 8) : null;

  // If the active key has changed since we last built the client (e.g.
  // user just saved a new one via the UI), force a rebuild.
  if (_client && fingerprint !== _activeKeyFingerprint) {
    _client = null;
    _unavailableReason = null;
  }

  if (_client) return { client: _client };
  if (_unavailableReason) return { error: _unavailableReason };

  if (!apiKey) {
    const reason = 'No Composio API key configured. Add one on the Integrations page or set COMPOSIO_API_KEY in the environment.';
    _unavailableReason = reason;
    return { error: reason };
  }

  let Composio;
  try {
    const mod = await import('@composio/core');
    Composio = mod.Composio || mod.default?.Composio || mod.default;
  } catch (e) {
    const reason = `@composio/core SDK is not installed. Run \`npm install @composio/core\`. Detail: ${e.message}`;
    _unavailableReason = reason;
    return { error: reason };
  }

  try {
    _client = new Composio({ apiKey });
    _activeKeyFingerprint = fingerprint;
    return { client: _client };
  } catch (e) {
    const reason = `Composio SDK initialization failed: ${e.message}`;
    _unavailableReason = reason;
    return { error: reason };
  }
}

/**
 * Some Composio SDK methods return plain Arrays (after their own
 * internal transform), others return paginated wrappers like
 * `{items: [], nextCursor}`. Normalises any of: Array | {items} |
 * {data} | {results} | falsy → a plain Array. Exported so callers
 * outside this module can use the same logic on raw SDK responses.
 *
 * Note: this used to be the entire fix for "list-toolkits returns
 * empty". It wasn't enough. The real bug was that we called
 * `client.toolkits.list()` (and `client.tools.list()`) — methods that
 * DON'T EXIST on @composio/core v0.6.x. The SDK's public surface is
 * `toolkits.get(query)` and `tools.get(userId, filter)`. Calling a
 * non-existent method silently returned `undefined`, which this
 * normaliser then turned into `[]`, masking the bug as "zero results".
 *
 * Now that listToolkits/listTools go through direct REST calls (the
 * SDK's `toolkits.get` drops the pagination cursor in its transform,
 * so we can't get past page 1 through it), this helper is mostly used
 * by callers that touch the SDK directly. It stays exported for them.
 */
export function _normalizeListResult(result) {
  if (Array.isArray(result)) return result;
  if (!result || typeof result !== 'object') return [];
  if (Array.isArray(result.items))   return result.items;
  if (Array.isArray(result.data))    return result.data;
  if (Array.isArray(result.results)) return result.results;
  return [];
}

/* ------------------------------------------------------------------------ */
/* REST helpers — paginated discovery against the Composio v3.1 API.         */
/*                                                                           */
/* Two of the SDK's "list" methods are unusable for our purposes:            */
/*                                                                           */
/*   - `client.toolkits.list()` doesn't exist (no public list method).       */
/*     The SDK exposes `toolkits.get(query)` which internally calls the      */
/*     inner OpenAPI client's toolkits.list, then transforms the result      */
/*     down to a flat Array — discarding `nextCursor`. So even when we       */
/*     do call the right method, we only get page 1 (max 50 items) and       */
/*     have no way to ask for page 2.                                        */
/*                                                                           */
/*   - `client.tools.list()` is similarly internal-only.                     */
/*                                                                           */
/* Solution: bypass the SDK for full-catalog discovery and call REST         */
/* directly with cursor-based pagination. The SDK is still used for          */
/* everything else (connect/initiate, execute, etc.).                        */
/* ------------------------------------------------------------------------ */

function _getApiBaseUrl() {
  return process.env.COMPOSIO_BASE_URL || 'https://backend.composio.dev';
}

async function _restGet(apiKey, path, query = {}) {
  const url = new URL(path, _getApiBaseUrl());
  for (const [k, v] of Object.entries(query)) {
    if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
  }
  const res = await fetch(url, {
    method: 'GET',
    headers: { 'x-api-key': apiKey, accept: 'application/json' },
  });
  const text = await res.text();
  let body; try { body = text ? JSON.parse(text) : null; } catch { body = { _rawText: text }; }
  if (!res.ok) {
    const detail = body?.error || body?.message || (text && text.slice(0, 200)) || `HTTP ${res.status}`;
    const e = new Error(`Composio REST ${path} → HTTP ${res.status}: ${detail}`);
    e.status = res.status;
    e.body = body;
    throw e;
  }
  return body;
}

async function _restListPaginated(apiKey, path, baseQuery = {}, options = {}) {
  const limit = options.limit || 50;
  const maxPages = options.maxPages || 100;
  const items = [];
  let cursor;
  let pages = 0;
  do {
    const body = await _restGet(apiKey, path, { ...baseQuery, limit, ...(cursor ? { cursor } : {}) });
    const pageItems = body?.items || body?.data || (Array.isArray(body) ? body : []);
    items.push(...pageItems);
    cursor = body?.next_cursor || body?.nextCursor || null;
    pages++;
    if (pages >= maxPages) break;
  } while (cursor);
  return items;
}

/** Map a REST v3.1 toolkit (snake_case) to the camelCase shape callers
 *  inside this codebase already expect. */
function _camelizeToolkit(t) {
  return {
    slug: t.slug,
    name: t.name,
    description: t.meta?.description,
    meta: t.meta ? {
      description: t.meta.description,
      logo: t.meta.logo,
      categories: t.meta.categories,
      createdAt: t.meta.created_at,
      updatedAt: t.meta.updated_at,
      toolsCount: t.meta.tools_count,
      triggersCount: t.meta.triggers_count,
      appUrl: t.meta.app_url,
    } : undefined,
    isLocalToolkit: t.is_local_toolkit,
    authSchemes: t.auth_schemes,
    composioManagedAuthSchemes: t.composio_managed_auth_schemes,
    noAuth: t.no_auth,
  };
}

/** Map a REST v3.1 tool (snake_case) to the camelCase shape callers
 *  inside this codebase expect — including a few aliases (input,
 *  parameters, inputParameters) because the various code paths each
 *  evolved their own field name preference. */
function _camelizeTool(t) {
  const params = t.input_parameters || t.parameters || t.input;
  return {
    slug: t.slug,
    name: t.name,
    displayName: t.display_name || t.name,
    description: t.description,
    inputParameters: params,
    input: params,
    parameters: params,
    outputParameters: t.output_parameters,
    toolkit: t.toolkit,
    toolkitSlug: t.toolkit?.slug || t.toolkit_slug,
    deprecated: t.deprecated,
    tags: t.tags,
    scopes: t.scopes,
  };
}

/**
 * Optionally inject a stub client. ONLY for tests.
 *
 * Also pins the fingerprint to whatever the current resolved key
 * happens to be, so the live-rotation check inside `getClient()`
 * doesn't immediately discard the stub.
 *
 * @param {object} stub
 */
export function _injectClientForTests(stub) {
  _client = stub;
  _unavailableReason = null;
  const { key } = resolveApiKey();
  _activeKeyFingerprint = key ? key.slice(0, 8) : '__test__';
}

/* ------------------------------------------------------------------------ */
/* Operations                                                                */
/* ------------------------------------------------------------------------ */

/**
 * Lightweight readiness check. Avoids running a real SDK call.
 *
 * Returns `keySource` so the UI can tell whether the active key came
 * from the encrypted vault (user set it via the form) or from the env
 * var (operator-managed). That changes the affordances on the
 * Integrations page (e.g. don't show "Forget" for env-var keys).
 *
 * @returns {Promise<{available: boolean, reason?: string, keySource?: 'vault'|'env'|null}>}
 */
export async function isAvailable() {
  const { source } = resolveApiKey();
  const { client, error } = await getClient();
  if (error) return { available: false, reason: error, keySource: source };
  return { available: !!client, keySource: source };
}

/**
 * Persist a Composio API key via ApiKeyManager (encrypted at rest)
 * and invalidate the SDK client cache so the next op uses it.
 *
 * Validation: an empty string clears the key. Otherwise we trim
 * whitespace and require >= 8 chars (placeholder guard — the real
 * key shape is `comp_…` and is much longer).
 *
 * Does NOT verify the key against Composio's API here — the UI calls
 * isAvailable() right after to do a real probe and surface the result.
 *
 * @param {string|null} apiKey   The key to persist, or empty/null to clear.
 * @returns {Promise<{success: true, cleared?: boolean} | {success: false, error: string}>}
 */
export async function setApiKey(apiKey) {
  const mgr = getApiKeyManager();
  if (!mgr) {
    return { success: false, error: 'ApiKeyManager is not initialized; key cannot be persisted.' };
  }
  const trimmed = (apiKey || '').trim();

  // Empty → clear the stored key.
  if (!trimmed) {
    const existing = { ...(mgr.keys?.vendorKeys || {}) };
    delete existing.composio;
    mgr.keys.vendorKeys = existing;
    await mgr.persist();
    invalidateClientCache();
    return { success: true, cleared: true };
  }

  // Cheap shape guard. The full validation is "does it actually authenticate"
  // which the caller probes with isAvailable() after.
  if (trimmed.length < 8) {
    return { success: false, error: 'API key looks too short. Paste the full key from dashboard.composio.dev.' };
  }

  await mgr.setSessionKeys(null, { vendorKeys: { composio: trimmed } });
  invalidateClientCache();
  return { success: true };
}

/**
 * Convenience: clear the stored key. Equivalent to setApiKey('').
 * Exposed separately so the DELETE route reads naturally.
 */
export async function forgetApiKey() {
  return setApiKey('');
}

/**
 * List ALL toolkits in Composio's catalog, paginated to exhaustion.
 *
 * Cannot use the SDK here:
 *   - `client.toolkits.list()` doesn't exist on @composio/core ≤0.6.x
 *     (it's `toolkits.get(query)`); we shipped the wrong method name
 *     for a long time and got `success: true, toolkits: []` because
 *     `client.toolkits?.list?.()` returned `undefined` and
 *     `_normalizeListResult` dutifully turned that into `[]`.
 *   - Even after switching to `toolkits.get`, the SDK's internal
 *     `transformToolkitListResponse` discards `nextCursor`, so we'd
 *     only see page 1 (max 50 toolkits — out of ~1043 in the catalog).
 *
 * So we go straight to REST. Pagination via cursor, ~21 round-trips
 * for the full catalog (~10s total), then we cache the result for a
 * minute so subsequent calls in the same agent turn are free.
 *
 * @returns {Promise<{success, toolkits?: Array, error?: string}>}
 */
let _toolkitsCache = null;
let _toolkitsCacheAt = 0;
const TOOLKITS_CACHE_TTL_MS = 60_000;

export async function listToolkits(options = {}) {
  const { key: apiKey } = resolveApiKey();
  if (!apiKey) {
    return { success: false, error: 'No Composio API key configured. Add one on the Integrations page or set COMPOSIO_API_KEY in the environment.' };
  }
  // Cache the full-catalog scan briefly — listToolkits is a hot path
  // for both backfill and the agent's catalog discovery.
  const now = Date.now();
  if (!options.force && _toolkitsCache && (now - _toolkitsCacheAt) < TOOLKITS_CACHE_TTL_MS) {
    return { success: true, toolkits: _toolkitsCache, cached: true };
  }
  try {
    const items = await _restListPaginated(apiKey, '/api/v3.1/toolkits');
    const toolkits = items.map(_camelizeToolkit);
    _toolkitsCache = toolkits;
    _toolkitsCacheAt = now;
    return { success: true, toolkits };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

/** Drop the in-memory toolkits cache. Called after key rotation so the
 *  next call sees the correct key's catalog. */
function _invalidateToolkitsCache() {
  _toolkitsCache = null;
  _toolkitsCacheAt = 0;
}

/**
 * @param {string} userId
 * @returns {Promise<{success, connections?: Array, error?: string}>}
 */
export async function listConnections(userId) {
  const { client, error } = await getClient();
  if (error) return { success: false, error };
  try {
    const raw = await client.connectedAccounts?.list?.({ userId });
    const connections = _normalizeListResult(raw).map(_normalizeConnection);
    return { success: true, connections };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

/**
 * Normalize a connected-account record so callers see a consistent
 * shape regardless of SDK version. Two specific problems we hide:
 *
 *   1. The SDK returns `toolkit` as an OBJECT `{slug, name, ...}` —
 *      callers (especially the UI) that wrote `c.toolkitSlug ||
 *      c.toolkit` ended up with the whole object as the slug and
 *      crashed on `.toLowerCase()`.
 *   2. Some SDK versions name the field `toolkit_slug` (snake_case
 *      passthrough); others use `toolkitSlug`. We expose both.
 *
 * Top-level `toolkitSlug` is now guaranteed to be a string (empty if
 * we genuinely couldn't extract one).
 */
function _normalizeConnection(c) {
  if (!c || typeof c !== 'object') return c;
  const toolkitSlug =
    (typeof c.toolkitSlug === 'string' && c.toolkitSlug) ||
    (typeof c.toolkit_slug === 'string' && c.toolkit_slug) ||
    (c.toolkit && typeof c.toolkit.slug === 'string' && c.toolkit.slug) ||
    (typeof c.toolkit === 'string' && c.toolkit) ||
    '';
  return { ...c, toolkitSlug };
}

/**
 * @param {string} userId
 * @param {string} toolkitSlug
 * @returns {Promise<{success, connected: boolean, status: string, connectionId?: string, error?: string}>}
 */
export async function connectionStatus(userId, toolkitSlug) {
  const { client, error } = await getClient();
  if (error) return { success: false, connected: false, status: 'UNAVAILABLE', error };
  try {
    // The SDK's `connectedAccounts.get(id)` expects a connectionId STRING
    // and treats its argument as a URL path segment. Passing the
    // {userId, toolkit} object we previously used here ended up URL-
    // encoded as "[object Object]", producing the "Path parameters
    // result in path with invalid segments" error users hit in
    // production. To look up by (userId, toolkit) we LIST connections
    // and filter — that's the supported access path.
    const raw = await client.connectedAccounts?.list?.({ userId, toolkit: toolkitSlug });
    const connections = _normalizeListResult(raw);
    // The list response may not pre-filter by toolkit in older SDKs;
    // belt-and-braces filter on the client.
    const conn = connections.find(c =>
      c?.toolkitSlug === toolkitSlug ||
      c?.toolkit === toolkitSlug ||
      c?.toolkit?.slug === toolkitSlug
    ) || null;
    const connected = !!(conn && (conn.status === 'ACTIVE' || conn.connected === true));
    return {
      success: true,
      connected,
      status: conn?.status || 'NOT_CONNECTED',
      connectionId: conn?.id || null,
    };
  } catch (err) {
    return { success: false, connected: false, status: 'ERROR', error: err.message || String(err) };
  }
}

/**
 * Start an OAuth connection for `userId` to `toolkitSlug`. Returns a
 * Connect-Link URL the user opens in the browser.
 *
 * What changed (2026-05-28): Composio deprecated the legacy
 * `POST /api/v3/connected_accounts` endpoint for managed-OAuth
 * auth-configs. Any caller that hit it (including the SDK's
 * `toolkits.authorize` → `connectedAccounts.initiate` path) now gets:
 *
 *     400 ConnectedAccount_BadRequest — "Creating connections on this
 *     endpoint for Composio-managed OAuth auth configs is no longer
 *     supported. Use POST /api/v3/connected_accounts/link instead."
 *
 * The new flow is two-step:
 *   1. Find or create an auth-config for the toolkit (via the SDK's
 *      `authConfigs.list` / `authConfigs.create` — those endpoints
 *      weren't touched by the deprecation).
 *   2. Call `connectedAccounts.link(userId, authConfigId)` — the SDK
 *      method that maps to the new `/connected_accounts/link` endpoint.
 *
 * We can't keep using `toolkits.authorize` because its last step is
 * the deprecated `initiate` call. Same flow, replicated here with the
 * link call swapped in.
 *
 * @param {string} userId
 * @param {string} toolkitSlug
 * @param {object} [options]   { authConfigId?: string, callbackUrl?: string }
 * @returns {Promise<{success, connectLink?: string, connectionId?: string, authConfigId?: string, raw?: any, error?: string}>}
 */
export async function connect(userId, toolkitSlug, options = {}) {
  const { client, error } = await getClient();
  if (error) return { success: false, error };
  if (typeof client.connectedAccounts?.link !== 'function') {
    return {
      success: false,
      error: '@composio/core SDK is missing connectedAccounts.link() — upgrade to >= 0.6.11 (the version that supports the new /connected_accounts/link endpoint).',
    };
  }

  let authConfigId = options.authConfigId;
  let usedExistingConfig = false;
  let createdConfig = false;

  try {
    // ─── Step 1: Find or create an auth-config ──────────────────────
    if (!authConfigId && typeof client.authConfigs?.list === 'function') {
      try {
        const existing = await client.authConfigs.list({ toolkit: toolkitSlug });
        const items = existing?.items || _normalizeListResult(existing);
        if (items.length > 0) {
          authConfigId = items[0].id;
          usedExistingConfig = true;
        }
      } catch (listErr) {
        // Non-fatal — fall through to create.
      }
    }

    if (!authConfigId && typeof client.authConfigs?.create === 'function') {
      try {
        const created = await client.authConfigs.create(toolkitSlug, {
          type: 'use_composio_managed_auth',
          name: `${toolkitSlug} Auth Config`,
        });
        authConfigId = created?.id || created?.authConfigId;
        createdConfig = true;
      } catch (createErr) {
        const msg = createErr?.message || String(createErr);
        const status = createErr?.status;
        // The 400 here typically means "Composio doesn't have a managed
        // OAuth client for this toolkit — go set one up in the dashboard".
        if (status === 400 || /no.*default.*auth/i.test(msg) || /must.*configure/i.test(msg)) {
          return {
            success: false,
            error: `Composio has no managed-OAuth client for "${toolkitSlug}". Open https://app.composio.dev → Integrations → Add Integration → "${toolkitSlug}" and complete the OAuth client setup once. Then retry.`,
            code: 'COMPOSIO_AUTH_CONFIG_MISSING',
            rawError: msg,
          };
        }
        throw createErr;
      }
    }

    if (!authConfigId) {
      return {
        success: false,
        error: `Could not find or create an auth-config for "${toolkitSlug}". Set one up at https://app.composio.dev → Integrations.`,
        code: 'COMPOSIO_AUTH_CONFIG_MISSING',
      };
    }

    // ─── Step 2: Create the Connect-Link via the new endpoint ──────
    const linkOptions = {};
    if (options.callbackUrl) linkOptions.callbackUrl = options.callbackUrl;
    const result = await client.connectedAccounts.link(userId, authConfigId, linkOptions);

    return {
      success: true,
      connectLink: result?.redirectUrl || result?.redirect_url || null,
      connectionId: result?.id || result?.connectionId || result?.connected_account_id || null,
      authConfigId,
      usedExistingConfig,
      createdConfig,
      raw: result,
    };
  } catch (err) {
    const rawMsg = err.message || String(err);
    if (/toolkit.*not found/i.test(rawMsg) || /ComposioToolkitNotFoundError/.test(rawMsg)) {
      return {
        success: false,
        error: `Toolkit "${toolkitSlug}" doesn't exist in Composio's catalog under that slug. Run \`composio.list-toolkits\` to see the canonical slug.`,
        code: 'COMPOSIO_TOOLKIT_NOT_FOUND',
        rawError: rawMsg,
      };
    }
    if (/no auth config|authConfig.*not found/i.test(rawMsg)) {
      return {
        success: false,
        error: `No auth configuration exists in your Composio org for "${toolkitSlug}". Open https://app.composio.dev → Integrations and add the "${toolkitSlug}" integration.`,
        code: 'COMPOSIO_AUTH_CONFIG_MISSING',
        rawError: rawMsg,
        authConfigId,
      };
    }
    return { success: false, error: rawMsg, authConfigId };
  }
}

/**
 * Disconnect a connection. Accepts either a connectionId (preferred) or
 * a {userId, toolkitSlug} pair (looks up the connection first).
 *
 * @param {{connectionId?: string, userId?: string, toolkitSlug?: string}} target
 * @returns {Promise<{success, error?: string}>}
 */
export async function disconnect(target) {
  const { client, error } = await getClient();
  if (error) return { success: false, error };
  if (!target || (!target.connectionId && !(target.userId && target.toolkitSlug))) {
    return { success: false, error: 'disconnect requires connectionId OR {userId, toolkitSlug}' };
  }
  let connectionId = target.connectionId;
  try {
    if (!connectionId) {
      // Same `.get(object)` → "Path parameters invalid" trap as
      // connectionStatus. Use `.list({userId, toolkit})` instead.
      const raw = await client.connectedAccounts?.list?.({ userId: target.userId, toolkit: target.toolkitSlug });
      const conn = _normalizeListResult(raw).find(c =>
        c?.toolkitSlug === target.toolkitSlug ||
        c?.toolkit === target.toolkitSlug ||
        c?.toolkit?.slug === target.toolkitSlug
      ) || null;
      connectionId = conn?.id || null;
      if (!connectionId) {
        return { success: false, error: `No active connection found for toolkit "${target.toolkitSlug}"` };
      }
    }
    // SDK exposes connectedAccounts.delete(id) or .disconnect(id).
    if (typeof client.connectedAccounts?.delete === 'function') {
      await client.connectedAccounts.delete(connectionId);
    } else if (typeof client.connectedAccounts?.disconnect === 'function') {
      await client.connectedAccounts.disconnect(connectionId);
    } else {
      return { success: false, error: 'SDK does not expose a disconnect method' };
    }
    return { success: true, connectionId };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

/**
 * List the available actions for a single toolkit.
 *
 * Same story as listToolkits — `client.tools.list()` isn't a public SDK
 * method (it's `tools.get(userId, filter)`, which then wraps results in
 * the configured LLM-provider's format). We want the raw catalog shape
 * with slug + description + input_parameters, so we call REST directly.
 *
 * @param {string} toolkitSlug
 * @param {string} [userId]    forwarded to REST as `user_id` so the
 *                             endpoint can scope tools the user is
 *                             authorised for. Optional.
 * @returns {Promise<{success, tools?: Array, error?: string}>}
 */
export async function listTools(toolkitSlug, userId) {
  const { key: apiKey } = resolveApiKey();
  if (!apiKey) return { success: false, error: 'No Composio API key configured.' };
  if (!toolkitSlug) return { success: false, error: 'listTools: toolkitSlug is required.' };
  try {
    const items = await _restListPaginated(apiKey, '/api/v3.1/tools', {
      toolkit_slugs: toolkitSlug,
      ...(userId ? { user_id: userId } : {}),
    });
    const tools = items.map(_camelizeTool);
    return { success: true, tools };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

/**
 * @param {string} userId
 * @param {string} toolkitSlug
 * @param {string} actionSlug
 * @param {object} args
 */
export async function execute(userId, toolkitSlug, actionSlug, args = {}) {
  const { client, error } = await getClient();
  if (error) return { success: false, error };
  try {
    const result = await client.tools?.execute?.(actionSlug, { userId, arguments: args });
    return {
      success: result?.successful !== false,
      output: result?.data ?? result?.output ?? result,
      error: result?.error || null,
    };
  } catch (err) {
    return { success: false, error: err.message || String(err) };
  }
}

/* ------------------------------------------------------------------------ */
/* Default export — convenient grouping for callers                          */
/* ------------------------------------------------------------------------ */

export default {
  getClient,
  isAvailable,
  setApiKey,
  forgetApiKey,
  listToolkits,
  listConnections,
  connectionStatus,
  connect,
  disconnect,
  listTools,
  execute,
};
