/**
 * composioService — shared SDK wrapper used by composioTool, REST
 * endpoints, and the CLI.
 *
 * Tests inject a stub client via `_injectClientForTests` so we cover
 * every method without depending on a real Composio account.
 */
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
import * as composioService from '../composioService.js';
import {
  registerApiKeyManager,
  _resetApiKeyManagerRegistrationForTests,
} from '../apiKeyManager.js';

/** A minimal ApiKeyManager stand-in for tests. */
function makeStubApiKeyManager(initial = {}) {
  return {
    keys: { loxiaApiKey: null, vendorKeys: { ...initial } },
    setSessionKeys: jest.fn(async function (_sid, { vendorKeys = {} }) {
      this.keys.vendorKeys = { ...this.keys.vendorKeys, ...vendorKeys };
    }),
    persist: jest.fn().mockResolvedValue(undefined),
  };
}

function makeStubClient() {
  return {
    toolkits: {
      // listToolkits no longer uses SDK methods (the SDK has no public
      // `.list`, and `.get` drops the pagination cursor) — it goes via
      // REST. Tests for listToolkits mock `global.fetch` instead.
    },
    tools: {
      // Same story — listTools uses REST.
      execute: jest.fn().mockResolvedValue({ successful: true, data: { issueNumber: 42 } }),
    },
    authConfigs: {
      // The connect path needs to look up or create an auth-config.
      // Default: one already exists for the toolkit, so list returns it.
      list:   jest.fn().mockResolvedValue({
        items: [{ id: 'auth-cfg-existing', toolkitSlug: 'github' }],
      }),
      create: jest.fn().mockResolvedValue({ id: 'auth-cfg-new' }),
    },
    connectedAccounts: {
      list:       jest.fn().mockResolvedValue([
        { id: 'conn-1', toolkitSlug: 'github', status: 'ACTIVE' },
        { id: 'conn-2', toolkitSlug: 'slack',  status: 'INITIATED' },
      ]),
      get:        jest.fn().mockResolvedValue({ id: 'conn-1', status: 'ACTIVE' }),
      // The new endpoint replacing the deprecated `initiate`.
      link:       jest.fn().mockResolvedValue({
        id: 'conn-new',
        redirectUrl: 'https://connect.composio.dev/link/abc',
      }),
      delete:     jest.fn().mockResolvedValue({ success: true }),
    },
  };
}

/**
 * Stub `global.fetch` to script REST responses, scoped to the test.
 * Returns the spy so tests can assert calls. The returned cleanup
 * function restores the original.
 *
 * Pages are consumed in order — each call to fetch returns the next
 * page in the list, with `next_cursor` set on all but the last.
 */
function stubFetch(pagesPerPath) {
  const original = global.fetch;
  const calls = [];
  // pagesPerPath: { '/api/v3.1/toolkits': [[item, item], [item, item]] }
  const cursors = new Map(); // path → current page index
  global.fetch = jest.fn(async (url) => {
    const u = url instanceof URL ? url : new URL(String(url));
    const path = u.pathname;
    calls.push({ path, query: Object.fromEntries(u.searchParams) });
    const pages = pagesPerPath[path];
    if (!pages) {
      return new Response(JSON.stringify({ error: `no stub for ${path}` }), { status: 404 });
    }
    const idx = cursors.get(path) || 0;
    const items = pages[idx] || [];
    const isLast = idx >= pages.length - 1;
    cursors.set(path, idx + 1);
    return new Response(JSON.stringify({
      items,
      next_cursor: isLast ? null : `cursor-${idx + 1}`,
    }), { status: 200, headers: { 'content-type': 'application/json' } });
  });
  return {
    fetch: global.fetch,
    calls,
    restore: () => { global.fetch = original; },
  };
}

let savedApiKey;
beforeEach(() => {
  savedApiKey = process.env.COMPOSIO_API_KEY;
  process.env.COMPOSIO_API_KEY = 'test-key';
  _resetApiKeyManagerRegistrationForTests();
  composioService._resetForTests();
});
afterEach(() => {
  if (savedApiKey === undefined) delete process.env.COMPOSIO_API_KEY;
  else process.env.COMPOSIO_API_KEY = savedApiKey;
  _resetApiKeyManagerRegistrationForTests();
  composioService._resetForTests();
});

describe('composioService — availability', () => {
  test('reports unavailable when no key is set anywhere', async () => {
    delete process.env.COMPOSIO_API_KEY;
    composioService._resetForTests();
    const r = await composioService.isAvailable();
    expect(r.available).toBe(false);
    expect(r.reason).toMatch(/No Composio API key/i);
    expect(r.keySource).toBeNull();
  });

  test('isAvailable returns true when client is injected', async () => {
    composioService._injectClientForTests(makeStubClient());
    const r = await composioService.isAvailable();
    expect(r.available).toBe(true);
  });

  test('keySource = "env" when only the env var is set', async () => {
    process.env.COMPOSIO_API_KEY = 'env-key-12345';
    composioService._resetForTests();
    composioService._injectClientForTests(makeStubClient());
    const r = await composioService.isAvailable();
    expect(r.keySource).toBe('env');
  });

  test('keySource = "vault" when ApiKeyManager has the key (vault > env precedence)', async () => {
    process.env.COMPOSIO_API_KEY = 'env-key';
    registerApiKeyManager(makeStubApiKeyManager({ composio: 'vault-key-XYZ' }));
    composioService._resetForTests();
    composioService._injectClientForTests(makeStubClient());
    const r = await composioService.isAvailable();
    expect(r.keySource).toBe('vault');
  });
});

describe('composioService — setApiKey + forgetApiKey', () => {
  test('setApiKey persists via apiKeyManager and invalidates the client', async () => {
    const mgr = makeStubApiKeyManager();
    registerApiKeyManager(mgr);
    // Seed a cached client with the old (env) key.
    composioService._injectClientForTests(makeStubClient());

    const r = await composioService.setApiKey('comp_abcdef123456');
    expect(r.success).toBe(true);
    expect(mgr.setSessionKeys).toHaveBeenCalledWith(null, { vendorKeys: { composio: 'comp_abcdef123456' } });
    // The cached client was wiped — next op will re-derive from the new vault key.
    composioService._injectClientForTests(makeStubClient()); // re-seed for the next assertion
    const r2 = await composioService.isAvailable();
    expect(r2.keySource).toBe('vault');
  });

  test('setApiKey rejects too-short keys', async () => {
    registerApiKeyManager(makeStubApiKeyManager());
    const r = await composioService.setApiKey('hi');
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/too short/i);
  });

  test('setApiKey with empty string clears the stored key', async () => {
    const mgr = makeStubApiKeyManager({ composio: 'existing-key-XYZ' });
    registerApiKeyManager(mgr);
    const r = await composioService.setApiKey('');
    expect(r.success).toBe(true);
    expect(r.cleared).toBe(true);
    expect(mgr.keys.vendorKeys.composio).toBeUndefined();
    expect(mgr.persist).toHaveBeenCalled();
  });

  test('forgetApiKey is an alias for setApiKey("")', async () => {
    const mgr = makeStubApiKeyManager({ composio: 'k' });
    registerApiKeyManager(mgr);
    const r = await composioService.forgetApiKey();
    expect(r.success).toBe(true);
    expect(r.cleared).toBe(true);
    expect(mgr.keys.vendorKeys.composio).toBeUndefined();
  });

  test('setApiKey returns a friendly error if no ApiKeyManager is registered', async () => {
    _resetApiKeyManagerRegistrationForTests();
    const r = await composioService.setApiKey('comp_abcdef123456');
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/ApiKeyManager is not initialized/i);
  });
});

describe('composioService — listToolkits', () => {
  // listToolkits used to call `client.toolkits.list()` — a method that
  // doesn't actually exist on the @composio/core SDK. It silently
  // returned `undefined`, which the normaliser converted to `[]`, so
  // every agent saw "0 toolkits available" no matter what was enabled.
  // The fix bypasses the SDK and calls REST directly so we can also
  // follow cursor pagination (SDK's `toolkits.get` discards nextCursor).

  test('paginates the REST endpoint until exhausted', async () => {
    const stub = stubFetch({
      '/api/v3.1/toolkits': [
        [{ slug: 'github', name: 'GitHub', meta: { description: 'Repos' } },
         { slug: 'slack',  name: 'Slack',  meta: { description: 'Messaging' } }],
        [{ slug: 'canva',  name: 'Canva',  meta: { description: 'Design'    } }],
      ],
    });
    try {
      const r = await composioService.listToolkits({ force: true });
      expect(r.success).toBe(true);
      expect(r.toolkits.map(t => t.slug).sort()).toEqual(['canva', 'github', 'slack']);
      // Two REST round-trips: page 1 (no cursor) + page 2 (with cursor).
      const toolkitCalls = stub.calls.filter(c => c.path === '/api/v3.1/toolkits');
      expect(toolkitCalls).toHaveLength(2);
      expect(toolkitCalls[0].query.cursor).toBeUndefined();
      expect(toolkitCalls[1].query.cursor).toBe('cursor-1');
    } finally { stub.restore(); }
  });

  test('camelizes snake_case fields in the REST response', async () => {
    const stub = stubFetch({
      '/api/v3.1/toolkits': [[
        {
          slug: 'gh', name: 'GitHub',
          is_local_toolkit: false,
          auth_schemes: ['OAUTH2'],
          composio_managed_auth_schemes: ['OAUTH2'],
          no_auth: false,
          meta: {
            description: 'Code hosting',
            tools_count: 42,
            triggers_count: 3,
            app_url: 'https://github.com',
            created_at: '2024-01-01',
            updated_at: '2024-12-31',
          },
        },
      ]],
    });
    try {
      const r = await composioService.listToolkits({ force: true });
      expect(r.success).toBe(true);
      const t = r.toolkits[0];
      expect(t.slug).toBe('gh');
      expect(t.isLocalToolkit).toBe(false);
      expect(t.authSchemes).toEqual(['OAUTH2']);
      expect(t.composioManagedAuthSchemes).toEqual(['OAUTH2']);
      expect(t.noAuth).toBe(false);
      expect(t.meta.toolsCount).toBe(42);
      expect(t.meta.triggersCount).toBe(3);
      expect(t.meta.appUrl).toBe('https://github.com');
      expect(t.meta.createdAt).toBe('2024-01-01');
      // Top-level description alias is the only "denormalised" field — it's
      // there so the agent UI can show toolkit summaries without diving
      // into .meta.
      expect(t.description).toBe('Code hosting');
    } finally { stub.restore(); }
  });

  test('surfaces REST errors as success:false', async () => {
    const original = global.fetch;
    global.fetch = jest.fn(async () => new Response(
      JSON.stringify({ error: 'rate limited' }), { status: 429 }
    ));
    try {
      const r = await composioService.listToolkits({ force: true });
      expect(r.success).toBe(false);
      expect(r.error).toMatch(/rate limited/);
      expect(r.error).toMatch(/HTTP 429/);
    } finally { global.fetch = original; }
  });

  test('caches the catalog for ~1 minute (avoid hammering REST in agent loops)', async () => {
    const stub = stubFetch({
      '/api/v3.1/toolkits': [[{ slug: 'gh', name: 'GitHub' }]],
    });
    try {
      const r1 = await composioService.listToolkits({ force: true });
      const r2 = await composioService.listToolkits();
      expect(r1.toolkits).toHaveLength(1);
      expect(r2.toolkits).toHaveLength(1);
      expect(r2.cached).toBe(true);
      // Only ONE REST call despite two listToolkits invocations.
      expect(stub.calls.filter(c => c.path === '/api/v3.1/toolkits')).toHaveLength(1);
    } finally { stub.restore(); }
  });

  test('key rotation drops the cache (next call hits REST with the new key)', async () => {
    // Hand-rolled fetch stub: each call returns ONE complete (single-page)
    // catalog response. The two listToolkits() invocations across the
    // rotation should therefore produce two separate REST calls.
    const responses = [
      [{ slug: 'gh', name: 'GitHub' }],
      [{ slug: 'sl', name: 'Slack'  }],
    ];
    const original = global.fetch;
    const calls = [];
    global.fetch = jest.fn(async (url) => {
      const u = url instanceof URL ? url : new URL(String(url));
      calls.push(u.pathname);
      const items = responses.shift() ?? [];
      return new Response(JSON.stringify({ items, next_cursor: null }), {
        status: 200, headers: { 'content-type': 'application/json' },
      });
    });
    try {
      const r1 = await composioService.listToolkits({ force: true });
      expect(r1.toolkits.map(t => t.slug)).toEqual(['gh']);
      composioService.invalidateClientCache();
      const r2 = await composioService.listToolkits();
      // New REST round-trip after rotation — fresh response fires.
      expect(r2.toolkits.map(t => t.slug)).toEqual(['sl']);
      expect(calls.filter(p => p === '/api/v3.1/toolkits')).toHaveLength(2);
    } finally { global.fetch = original; }
  });
});

describe('composioService — listConnections', () => {
  test('forwards userId and returns array', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    const r = await composioService.listConnections('operator');
    expect(r.success).toBe(true);
    expect(r.connections).toHaveLength(2);
    expect(client.connectedAccounts.list).toHaveBeenCalledWith({ userId: 'operator' });
  });

  test('accepts SDK list responses wrapped in {items: ...}', async () => {
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue({
      items: [{ id: 'x', toolkitSlug: 'gh', status: 'ACTIVE' }],
    });
    composioService._injectClientForTests(client);
    const r = await composioService.listConnections('operator');
    expect(r.connections).toHaveLength(1);
    expect(r.connections[0].id).toBe('x');
  });

  // Regression guard for the UI crash:
  //   TypeError: (S.slug || "").toLowerCase is not a function
  // The SDK returns `toolkit` as an OBJECT `{slug, name, ...}` (camelCased
  // pass-through of the REST shape). The UI did `c.toolkitSlug || c.toolkit`,
  // got the whole object as the "slug", then called `.toLowerCase()` on it
  // and crashed. The service now always exposes a STRING `toolkitSlug` at
  // the top level so callers never have to dig.
  test('normalises connection.toolkit (object) → top-level toolkitSlug (string)', async () => {
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue([
      // SDK-style: toolkit is an object, no top-level toolkitSlug.
      { id: 'c1', toolkit: { slug: 'canva', name: 'Canva' }, status: 'INITIATED' },
      // Snake-case-style: toolkit_slug instead of toolkitSlug.
      { id: 'c2', toolkit_slug: 'slack',                     status: 'ACTIVE'   },
      // String fallback: toolkit is a bare string.
      { id: 'c3', toolkit: 'github',                          status: 'ACTIVE'   },
      // Already-normalised: toolkitSlug already a string.
      { id: 'c4', toolkitSlug: 'notion',                      status: 'ACTIVE'   },
    ]);
    composioService._injectClientForTests(client);
    const r = await composioService.listConnections('operator');
    expect(r.success).toBe(true);
    expect(r.connections).toHaveLength(4);
    // Every connection has a string toolkitSlug — no objects sneaking through.
    for (const c of r.connections) {
      expect(typeof c.toolkitSlug).toBe('string');
    }
    expect(r.connections.map(c => c.toolkitSlug)).toEqual(['canva', 'slack', 'github', 'notion']);
  });

  test('returns empty toolkitSlug (not undefined/object) when nothing is extractable', async () => {
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue([
      { id: 'mystery', status: 'INITIATED' }, // no toolkit field at all
    ]);
    composioService._injectClientForTests(client);
    const r = await composioService.listConnections('operator');
    expect(r.connections[0].toolkitSlug).toBe('');
  });
});

describe('composioService — connectionStatus', () => {
  test('maps ACTIVE → connected:true', async () => {
    composioService._injectClientForTests(makeStubClient());
    const r = await composioService.connectionStatus('operator', 'github');
    expect(r.connected).toBe(true);
    expect(r.status).toBe('ACTIVE');
    expect(r.connectionId).toBe('conn-1');
  });

  test('maps missing connection → connected:false', async () => {
    // After the fix, connectionStatus uses .list({userId, toolkit})
    // and filters client-side rather than calling .get(object) (which
    // the SDK serializes to "[object Object]" in a URL path segment).
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue([]); // no connections
    composioService._injectClientForTests(client);
    const r = await composioService.connectionStatus('operator', 'github');
    expect(r.connected).toBe(false);
    expect(r.status).toBe('NOT_CONNECTED');
  });

  test('uses connectedAccounts.list (not .get) — guards against the SDK "[object Object]" path-segment bug', async () => {
    const client = makeStubClient();
    // If anything calls .get with an object, the SDK would barf. The
    // fix should NEVER reach .get for status lookups.
    client.connectedAccounts.get = jest.fn(() => {
      throw new Error('Path parameters result in path with invalid segments: [object Object]');
    });
    composioService._injectClientForTests(client);
    const r = await composioService.connectionStatus('operator', 'github');
    expect(r.success).toBe(true);
    expect(client.connectedAccounts.get).not.toHaveBeenCalled();
    expect(client.connectedAccounts.list).toHaveBeenCalledWith(
      expect.objectContaining({ userId: 'operator', toolkit: 'github' })
    );
  });

  test('handles wrapped {items:[]} response shape from the SDK', async () => {
    // The Composio TS SDK returns paginated wrappers; the fix
    // normalises Array | {items} | {data} → flat Array.
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue({
      items: [{ id: 'conn-1', toolkitSlug: 'github', status: 'ACTIVE' }],
    });
    composioService._injectClientForTests(client);
    const r = await composioService.connectionStatus('operator', 'github');
    expect(r.connected).toBe(true);
    expect(r.connectionId).toBe('conn-1');
  });
});

describe('composioService — connect', () => {
  // History:
  //   v0: called connectedAccounts.initiate({obj}) — wrong shape; threw
  //       "authConfigIds.0 should be a string …".
  //   v1: switched to client.toolkits.authorize(userId, slug, authConfigId)
  //       — that worked locally but Composio deprecated the endpoint it
  //       calls last (POST /connected_accounts) on 2026-05-28. Started
  //       returning 400 ConnectedAccount_BadRequest with the message
  //       "Use POST /api/v3/connected_accounts/link instead".
  //   v2 (current): replicate authorize's auth-config find-or-create
  //       flow ourselves, then finish with connectedAccounts.link()
  //       — the SDK method that maps to the new endpoint.

  test('reuses an existing auth-config and calls the link endpoint', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'github');
    expect(r.success).toBe(true);
    expect(r.connectLink).toMatch(/^https:\/\/connect\.composio\.dev/);
    expect(r.connectionId).toBe('conn-new');
    expect(r.authConfigId).toBe('auth-cfg-existing');
    expect(r.usedExistingConfig).toBe(true);
    // The deprecated path (.initiate) must NEVER be called.
    expect(client.connectedAccounts.initiate).toBeUndefined();
    expect(client.connectedAccounts.link).toHaveBeenCalledWith(
      'operator', 'auth-cfg-existing', {}
    );
    // Didn't try to create a new one — there was already a hit.
    expect(client.authConfigs.create).not.toHaveBeenCalled();
  });

  test('creates a managed auth-config when none exists for the toolkit', async () => {
    const client = makeStubClient();
    client.authConfigs.list = jest.fn().mockResolvedValue({ items: [] });
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'canva');
    expect(r.success).toBe(true);
    expect(r.authConfigId).toBe('auth-cfg-new');
    expect(r.createdConfig).toBe(true);
    expect(client.authConfigs.create).toHaveBeenCalledWith('canva', {
      type: 'use_composio_managed_auth',
      name: 'canva Auth Config',
    });
    expect(client.connectedAccounts.link).toHaveBeenCalledWith(
      'operator', 'auth-cfg-new', {}
    );
  });

  test('passes through an explicit authConfigId (skips lookup + create)', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    await composioService.connect('operator', 'github', { authConfigId: 'auth-cfg-explicit' });
    expect(client.authConfigs.list).not.toHaveBeenCalled();
    expect(client.authConfigs.create).not.toHaveBeenCalled();
    expect(client.connectedAccounts.link).toHaveBeenCalledWith(
      'operator', 'auth-cfg-explicit', {}
    );
  });

  test('forwards callbackUrl to the link options', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    await composioService.connect('operator', 'github', {
      authConfigId: 'auth-cfg-xyz', callbackUrl: 'https://app.example.com/oauth/cb',
    });
    expect(client.connectedAccounts.link).toHaveBeenCalledWith(
      'operator', 'auth-cfg-xyz', { callbackUrl: 'https://app.example.com/oauth/cb' }
    );
  });

  test('returns a clean error if the SDK is missing connectedAccounts.link', async () => {
    const client = makeStubClient();
    delete client.connectedAccounts.link;
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'github');
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/missing connectedAccounts\.link/);
  });

  test('authConfigs.create 400 → actionable "no managed-OAuth client" hint', async () => {
    // Composio returns 400 when no managed OAuth client exists for the
    // toolkit. The operator needs to register one in the dashboard.
    const client = makeStubClient();
    client.authConfigs.list = jest.fn().mockResolvedValue({ items: [] });
    const err = new Error('No default auth config found for toolkit canva');
    err.status = 400;
    client.authConfigs.create = jest.fn().mockRejectedValue(err);
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'canva');
    expect(r.success).toBe(false);
    expect(r.code).toBe('COMPOSIO_AUTH_CONFIG_MISSING');
    expect(r.error).toMatch(/no managed-OAuth client/i);
    expect(r.error).toMatch(/canva/);
    expect(r.error).toMatch(/app\.composio\.dev/);
    expect(r.rawError).toMatch(/No default auth config/);
  });

  test('link() "toolkit not found" → actionable slug-mismatch hint', async () => {
    const client = makeStubClient();
    client.connectedAccounts.link = jest.fn().mockRejectedValue(
      new Error('ComposioToolkitNotFoundError: Toolkit with slug canva_design not found')
    );
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'canva_design');
    expect(r.success).toBe(false);
    expect(r.code).toBe('COMPOSIO_TOOLKIT_NOT_FOUND');
    expect(r.error).toMatch(/doesn't exist in Composio's catalog/);
    expect(r.error).toMatch(/list-toolkits/);
  });

  test('unrelated SDK errors pass through unchanged with authConfigId attached', async () => {
    const client = makeStubClient();
    client.connectedAccounts.link = jest.fn().mockRejectedValue(new Error('rate limit hit'));
    composioService._injectClientForTests(client);
    const r = await composioService.connect('operator', 'github');
    expect(r.success).toBe(false);
    expect(r.code).toBeUndefined();
    expect(r.error).toMatch(/rate limit hit/);
    // We still know which auth-config we were trying to link — surface
    // it so the operator can debug.
    expect(r.authConfigId).toBe('auth-cfg-existing');
  });
});

describe('composioService — _normalizeListResult', () => {
  test('returns plain arrays unchanged', () => {
    expect(composioService._normalizeListResult([1, 2, 3])).toEqual([1, 2, 3]);
    expect(composioService._normalizeListResult([])).toEqual([]);
  });
  test('unwraps {items}, {data}, {results} shapes', () => {
    expect(composioService._normalizeListResult({ items: [1] })).toEqual([1]);
    expect(composioService._normalizeListResult({ data: [2] })).toEqual([2]);
    expect(composioService._normalizeListResult({ results: [3] })).toEqual([3]);
  });
  test('non-list-shaped / falsy inputs → []', () => {
    expect(composioService._normalizeListResult(null)).toEqual([]);
    expect(composioService._normalizeListResult(undefined)).toEqual([]);
    expect(composioService._normalizeListResult('string')).toEqual([]);
    expect(composioService._normalizeListResult({ unknown: true })).toEqual([]);
  });
});

describe('composioService — disconnect', () => {
  test('disconnect by connectionId', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    const r = await composioService.disconnect({ connectionId: 'conn-1' });
    expect(r.success).toBe(true);
    expect(client.connectedAccounts.delete).toHaveBeenCalledWith('conn-1');
  });

  test('disconnect by {userId, toolkitSlug} looks up the id via .list (not .get)', async () => {
    // The lookup now uses .list({userId, toolkit}) + client-side
    // filter — same fix as connectionStatus to avoid the SDK
    // "[object Object]" path-segment trap.
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    const r = await composioService.disconnect({ userId: 'operator', toolkitSlug: 'github' });
    expect(r.success).toBe(true);
    expect(client.connectedAccounts.list).toHaveBeenCalledWith(
      expect.objectContaining({ userId: 'operator', toolkit: 'github' })
    );
    expect(client.connectedAccounts.delete).toHaveBeenCalledWith('conn-1');
  });

  test('rejects with helpful error when no target shape is provided', async () => {
    composioService._injectClientForTests(makeStubClient());
    const r = await composioService.disconnect({});
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/connectionId/);
  });

  test('handles missing connection cleanly when looking up by toolkit', async () => {
    const client = makeStubClient();
    client.connectedAccounts.list = jest.fn().mockResolvedValue([]); // no connections
    composioService._injectClientForTests(client);
    const r = await composioService.disconnect({ userId: 'operator', toolkitSlug: 'github' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/No active connection found/);
  });
});

describe('composioService — listTools', () => {
  // Same story as listToolkits — the SDK's `client.tools.list` is
  // internal-only; the public `tools.get(userId, filter)` wraps results
  // in the LLM provider's format (function-calling shape) and strips
  // toolkitSlug/displayName. We need the raw catalog shape, so we call
  // REST directly.

  test('forwards toolkit_slugs + user_id to REST', async () => {
    const stub = stubFetch({
      '/api/v3.1/tools': [[
        { slug: 'GITHUB_CREATE_AN_ISSUE', display_name: 'Create Issue',
          description: 'Create issue', input_parameters: { type: 'object' },
          toolkit: { slug: 'github' } },
      ]],
    });
    try {
      const r = await composioService.listTools('github', 'operator');
      expect(r.success).toBe(true);
      expect(r.tools).toHaveLength(1);
      expect(r.tools[0].slug).toBe('GITHUB_CREATE_AN_ISSUE');
      expect(r.tools[0].displayName).toBe('Create Issue');
      expect(r.tools[0].toolkitSlug).toBe('github');
      // input_parameters → camelCase + aliases for legacy callers.
      expect(r.tools[0].inputParameters).toEqual({ type: 'object' });
      expect(r.tools[0].input).toEqual({ type: 'object' });
      expect(r.tools[0].parameters).toEqual({ type: 'object' });
      const call = stub.calls.find(c => c.path === '/api/v3.1/tools');
      expect(call.query.toolkit_slugs).toBe('github');
      expect(call.query.user_id).toBe('operator');
    } finally { stub.restore(); }
  });

  test('omits user_id from the REST query when not provided', async () => {
    const stub = stubFetch({
      '/api/v3.1/tools': [[
        { slug: 'X', toolkit: { slug: 'github' } },
      ]],
    });
    try {
      await composioService.listTools('github');
      const call = stub.calls.find(c => c.path === '/api/v3.1/tools');
      expect(call.query.toolkit_slugs).toBe('github');
      expect(call.query.user_id).toBeUndefined();
    } finally { stub.restore(); }
  });

  test('rejects empty toolkitSlug with a clean error', async () => {
    const r = await composioService.listTools('');
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/toolkitSlug is required/);
  });
});

describe('composioService — execute', () => {
  test('forwards userId + actionSlug + arguments', async () => {
    const client = makeStubClient();
    composioService._injectClientForTests(client);
    const r = await composioService.execute('operator', 'github', 'GITHUB_CREATE_AN_ISSUE', {
      owner: 'acme', repo: 'api', title: 'Bug', body: '…',
    });
    expect(r.success).toBe(true);
    expect(r.output).toEqual({ issueNumber: 42 });
    expect(client.tools.execute).toHaveBeenCalledWith('GITHUB_CREATE_AN_ISSUE', {
      userId: 'operator',
      arguments: { owner: 'acme', repo: 'api', title: 'Bug', body: '…' },
    });
  });

  test('upstream success=false is surfaced unchanged', async () => {
    const client = makeStubClient();
    client.tools.execute = jest.fn().mockResolvedValue({ successful: false, error: 'repo not found' });
    composioService._injectClientForTests(client);
    const r = await composioService.execute('op', 'gh', 'X', {});
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/repo not found/);
  });
});

describe('composioService — every op fails gracefully when SDK is unavailable', () => {
  beforeEach(() => {
    delete process.env.COMPOSIO_API_KEY;
    composioService._resetForTests();
  });

  test.each([
    ['listToolkits',     () => composioService.listToolkits()],
    ['listConnections',  () => composioService.listConnections('op')],
    ['connectionStatus', () => composioService.connectionStatus('op', 'gh')],
    ['connect',          () => composioService.connect('op', 'gh')],
    ['disconnect',       () => composioService.disconnect({ connectionId: 'x' })],
    ['listTools',        () => composioService.listTools('gh')],
    ['execute',          () => composioService.execute('op', 'gh', 'X', {})],
  ])('%s returns success:false with "no key" error', async (_label, op) => {
    const r = await op();
    expect(r.success).toBe(false);
    expect((r.error || r.reason || '')).toMatch(/No Composio API key/i);
  });
});
