/**
 * Composio tool unit tests.
 *
 * SDK is mocked at the tool level via `_loadSdk` override (no need to
 * mock the @composio/core ESM at the resolver layer — the tool isolates
 * SDK access behind that single method).
 *
 * Covers:
 *   - SDK-not-installed path (no @composio/core, no env var)
 *   - API-key-missing path
 *   - Both envelope shapes (plural canonical + singular legacy)
 *   - Each of the 5 actions: list-toolkits, list-tools, connect,
 *     connection-status, execute
 *   - Per-action validation (missing toolkitSlug / actionSlug)
 *   - Batched execution
 *   - userId mapped from context.agentId
 *   - Error surfacing on upstream failures
 */
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
import ComposioTool from '../composioTool.js';
import * as composioService from '../../services/composioService.js';
import {
  registerApiKeyManager,
  _resetApiKeyManagerRegistrationForTests,
} from '../../services/apiKeyManager.js';

/** Minimal ApiKeyManager stand-in (mirrors composioService.test.js helper). */
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),
  };
}

const SILENT_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };

/**
 * Stub `global.fetch` so composioService.listToolkits / listTools (which
 * now go via REST, not the SDK) see canned responses. Returns the spy
 * + a restore() — the tests' afterEach calls restore().
 *
 * Default responses serve a single-toolkit/one-tool catalog; tests can
 * override via the `overrides` map keyed by URL pathname.
 */
function stubFetch(overrides = {}) {
  const original = global.fetch;
  const calls = [];
  const defaults = {
    '/api/v3.1/toolkits': [
      { slug: 'github', name: 'GitHub', meta: { description: 'Code hosting' } },
    ],
    '/api/v3.1/tools': [
      { slug: 'GITHUB_CREATE_AN_ISSUE', display_name: 'Create issue',
        description: 'Create issue',  toolkit: { slug: 'github' } },
    ],
  };
  // Each path can be:
  //   Array  — single-page response
  //   Function — receives query params, returns Array (or throws to surface an error)
  const responders = { ...defaults, ...overrides };
  global.fetch = jest.fn(async (url) => {
    const u = url instanceof URL ? url : new URL(String(url));
    const query = Object.fromEntries(u.searchParams);
    calls.push({ path: u.pathname, query });
    const responder = responders[u.pathname];
    if (responder === undefined) {
      return new Response(JSON.stringify({ error: `no stub for ${u.pathname}` }), { status: 404 });
    }
    let items;
    try { items = typeof responder === 'function' ? await responder(query) : responder; }
    catch (e) {
      return new Response(JSON.stringify({ error: e.message }), { status: 500 });
    }
    return new Response(JSON.stringify({ items, next_cursor: null }), {
      status: 200, headers: { 'content-type': 'application/json' },
    });
  });
  return { fetch: global.fetch, calls, restore: () => { global.fetch = original; } };
}

function makeMockClient() {
  return {
    toolkits: {},
    tools: {
      execute: jest.fn().mockResolvedValue({ successful: true, data: { issueNumber: 42 } }),
    },
    authConfigs: {
      list:   jest.fn().mockResolvedValue({
        items: [{ id: 'auth-cfg-existing', toolkitSlug: 'github' }],
      }),
      create: jest.fn().mockResolvedValue({ id: 'auth-cfg-new' }),
    },
    connectedAccounts: {
      // `link` is the new endpoint (replaces the deprecated `initiate`
      // — Composio returned 400 ConnectedAccount_BadRequest on the old
      // path starting 2026-05-28).
      link: jest.fn().mockResolvedValue({
        id: 'conn-1', redirectUrl: 'https://connect.composio.dev/link/abc',
      }),
      // .list is what connectionStatus + disconnect both use (the fix
      // for the SDK "[object Object]" path-segment trap). `.get` stays
      // available for tests that explicitly target a connectionId
      // lookup, but new code paths must NOT depend on it taking an
      // object.
      list: jest.fn().mockResolvedValue([
        { id: 'conn-1', toolkitSlug: 'github', status: 'ACTIVE' },
      ]),
      get: jest.fn().mockResolvedValue({ id: 'conn-1', status: 'ACTIVE' }),
    },
  };
}

function makeTool({ client, fetchOverrides } = {}) {
  const tool = new ComposioTool({}, SILENT_LOGGER);
  if (client) {
    // Two surfaces need the client:
    //   1. tool._loadSdk for actions that still talk to the SDK directly
    //      from inside the tool (execute, connection-status, backfill).
    //   2. composioService.* for actions the tool delegates to the
    //      service via the SDK (connect, disconnect).
    // The list-toolkits and list-tools actions now go via REST through
    // composioService — those see the stubbed global.fetch below.
    tool._loadSdk = jest.fn().mockResolvedValue({ client });
    composioService._injectClientForTests(client);
  }
  // Restore any leftover stub from a previous test (defensive — afterEach
  // also cleans up, but parallel describes can hand off in odd orders).
  if (currentFetchStub) { currentFetchStub.restore(); }
  currentFetchStub = stubFetch(fetchOverrides);
  // Expose the stub on the tool so individual tests can inspect calls.
  tool._fetchStub = currentFetchStub;
  return tool;
}

let savedApiKey;
let currentFetchStub = null;
beforeEach(() => {
  savedApiKey = process.env.COMPOSIO_API_KEY;
  process.env.COMPOSIO_API_KEY = 'test-key';
});
afterEach(() => {
  if (savedApiKey === undefined) delete process.env.COMPOSIO_API_KEY;
  else process.env.COMPOSIO_API_KEY = savedApiKey;
  if (currentFetchStub) { currentFetchStub.restore(); currentFetchStub = null; }
  // Don't leak injected service state across tests.
  composioService._resetForTests();
});

describe('ComposioTool — startup guards', () => {
  test('returns structured error when COMPOSIO_API_KEY is missing', async () => {
    delete process.env.COMPOSIO_API_KEY;
    const tool = new ComposioTool({}, SILENT_LOGGER);
    const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/COMPOSIO_API_KEY/);
  });

  test('returns structured error when @composio/core SDK is not installed', async () => {
    const tool = new ComposioTool({}, SILENT_LOGGER);
    // Force the dynamic import to fail.
    tool._loadSdk = async () => ({ error: '@composio/core SDK is not installed.' });
    const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/SDK is not installed/);
  });
});

describe('ComposioTool — validation', () => {
  test('rejects empty params with helpful error', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({}, { agentId: 'a' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/action/);
  });

  test('customValidateParameters rejects unknown action type', () => {
    const tool = new ComposioTool({}, SILENT_LOGGER);
    const v = tool.customValidateParameters({ actions: [{ type: 'invade-poland' }] });
    expect(v.valid).toBe(false);
    expect(v.errors.join(' ')).toMatch(/invalid type/);
  });

  test('customValidateParameters requires toolkitSlug for list-tools/connect/connection-status', () => {
    const tool = new ComposioTool({}, SILENT_LOGGER);
    for (const t of ['list-tools', 'connect', 'connection-status']) {
      const v = tool.customValidateParameters({ actions: [{ type: t }] });
      expect(v.valid).toBe(false);
      expect(v.errors.join(' ')).toMatch(/toolkitSlug/);
    }
  });

  test('customValidateParameters requires both toolkitSlug AND actionSlug for execute', () => {
    const tool = new ComposioTool({}, SILENT_LOGGER);
    const v1 = tool.customValidateParameters({ actions: [{ type: 'execute', toolkitSlug: 'gh' }] });
    expect(v1.errors.join(' ')).toMatch(/actionSlug/);
    const v2 = tool.customValidateParameters({ actions: [{ type: 'execute', actionSlug: 'X' }] });
    expect(v2.errors.join(' ')).toMatch(/toolkitSlug/);
    const vOk = tool.customValidateParameters({ actions: [{ type: 'execute', toolkitSlug: 'gh', actionSlug: 'X' }] });
    expect(vOk.valid).toBe(true);
  });
});

describe('ComposioTool — envelope acceptance', () => {
  test('accepts canonical {actions: [{type}]}', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(true);
    expect(tool._fetchStub.calls.some(c => c.path === '/api/v3.1/toolkits')).toBe(true);
  });

  test('accepts legacy singular {action}', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({ action: 'list-toolkits' }, { agentId: 'a' });
    expect(r.success).toBe(true);
    expect(tool._fetchStub.calls.some(c => c.path === '/api/v3.1/toolkits')).toBe(true);
  });

  test('accepts {action} inside items (action-named discriminator)', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({ actions: [{ action: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(true);
  });
});

describe('ComposioTool — actions', () => {
  test('list-toolkits returns the toolkits array', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(true);
    expect(r.action).toBe('list-toolkits');
    expect(r.toolkits[0].slug).toBe('github');
  });

  test('list-tools forwards toolkitSlug and userId to REST', async () => {
    const tool = makeTool({ client: makeMockClient() });
    const r = await tool.execute({ actions: [{ type: 'list-tools', toolkitSlug: 'github' }] }, { agentId: 'agent-42' });
    expect(r.success).toBe(true);
    const call = tool._fetchStub.calls.find(c => c.path === '/api/v3.1/tools');
    expect(call).toBeDefined();
    expect(call.query.toolkit_slugs).toBe('github');
    expect(call.query.user_id).toBe('agent-42');
  });

  test('connect returns a Connect Link URL and connectionId via the new /link endpoint', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    const r = await tool.execute({ actions: [{ type: 'connect', toolkitSlug: 'github' }] }, { agentId: 'agent-99' });
    expect(r.success).toBe(true);
    expect(r.connectLink).toMatch(/^https:\/\/connect\.composio\.dev/);
    expect(r.connectionId).toBe('conn-1');
    // New connect path: find an existing auth-config via authConfigs.list,
    // then call connectedAccounts.link (the supported endpoint as of
    // 2026-05-28 — the previous .initiate path was deprecated).
    expect(client.connectedAccounts.link).toHaveBeenCalledWith(
      'agent-99', 'auth-cfg-existing', {}
    );
  });

  test('connection-status maps ACTIVE → connected: true', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    const r = await tool.execute({ actions: [{ type: 'connection-status', toolkitSlug: 'github' }] }, { agentId: 'a' });
    expect(r.connected).toBe(true);
    expect(r.status).toBe('ACTIVE');
  });

  test('connection-status maps missing connection → connected: false', async () => {
    const client = makeMockClient();
    client.connectedAccounts.get.mockResolvedValueOnce(null);
    const tool = makeTool({ client });
    const r = await tool.execute({ actions: [{ type: 'connection-status', toolkitSlug: 'slack' }] }, { agentId: 'a' });
    expect(r.connected).toBe(false);
    expect(r.status).toBe('NOT_CONNECTED');
  });

  test('execute forwards arguments and returns upstream output', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    const r = await tool.execute({
      actions: [{
        type: 'execute',
        toolkitSlug: 'github',
        actionSlug: 'GITHUB_CREATE_AN_ISSUE',
        arguments: { owner: 'acme', repo: 'api', title: 'Bug' },
      }],
    }, { agentId: 'agent-1' });
    expect(r.success).toBe(true);
    expect(r.output).toMatchObject({ issueNumber: 42 });
    expect(client.tools.execute).toHaveBeenCalledWith(
      'GITHUB_CREATE_AN_ISSUE',
      { userId: 'agent-1', arguments: { owner: 'acme', repo: 'api', title: 'Bug' } }
    );
  });

  test('execute surfaces upstream errors structurally', async () => {
    const client = makeMockClient();
    client.tools.execute.mockResolvedValueOnce({ successful: false, error: 'rate limited' });
    const tool = makeTool({ client });
    const r = await tool.execute({
      actions: [{ type: 'execute', toolkitSlug: 'gh', actionSlug: 'X', arguments: {} }],
    }, { agentId: 'a' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/rate limited/);
  });
});

describe('ComposioTool — batched execution', () => {
  test('two actions in one call returns results[] with both', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    const r = await tool.execute({
      actions: [
        { type: 'list-toolkits' },
        { type: 'list-tools', toolkitSlug: 'github' },
      ],
    }, { agentId: 'a' });
    expect(r.batched).toBe(true);
    expect(r.actionCount).toBe(2);
    expect(r.results).toHaveLength(2);
    expect(r.results[0].action).toBe('list-toolkits');
    expect(r.results[1].action).toBe('list-tools');
  });

  test('one action failure does not abort the batch', async () => {
    // Inject a REST failure on the /tools endpoint so the second action
    // (list-tools) fails while the first (list-toolkits) succeeds.
    const tool = makeTool({
      client: makeMockClient(),
      fetchOverrides: {
        '/api/v3.1/tools': () => { throw new Error('upstream down'); },
      },
    });
    const r = await tool.execute({
      actions: [
        { type: 'list-toolkits' },
        { type: 'list-tools', toolkitSlug: 'github' },
      ],
    }, { agentId: 'a' });
    expect(r.results[0].success).toBe(true);
    expect(r.results[1].success).toBe(false);
    expect(r.results[1].error).toMatch(/upstream down/);
  });
});

describe('ComposioTool — userId scoping', () => {
  test('agentId is used as Composio userId', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    await tool.execute({ actions: [{ type: 'execute', toolkitSlug: 'gh', actionSlug: 'X', arguments: {} }] }, { agentId: 'agent-zeta' });
    expect(client.tools.execute).toHaveBeenCalledWith(
      'X',
      expect.objectContaining({ userId: 'agent-zeta' })
    );
  });

  test('falls back to default-user when agentId is missing (no crash)', async () => {
    const client = makeMockClient();
    const tool = makeTool({ client });
    await tool.execute({ actions: [{ type: 'list-toolkits' }] }, {});
    // The fallback userId is used internally; list-toolkits doesn't pass
    // it explicitly to the SDK in this implementation, but we verified
    // no crash. Spot-check with execute too:
    await tool.execute({ actions: [{ type: 'execute', toolkitSlug: 'gh', actionSlug: 'X', arguments: {} }] }, {});
    expect(client.tools.execute).toHaveBeenCalledWith(
      'X',
      expect.objectContaining({ userId: 'default-user' })
    );
  });
});

// ─────────────────── key resolution via composioService ──────────────────────
//
// Locks the behavior the user reported as broken: after saving a Composio
// key through the web-ui (which persists into ApiKeyManager, NOT the
// process env), agent invocations must successfully find that key and
// build a client. Pre-fix the tool read process.env.COMPOSIO_API_KEY
// directly and ignored ApiKeyManager entirely.
//
// Also pins live-rotation: when the UI saves a new key via
// composioService.setApiKey, the next tool invocation must see the new
// client (no parallel cache holding the stale one).
describe('ComposioTool — key resolution via composioService', () => {
  beforeEach(() => {
    delete process.env.COMPOSIO_API_KEY;
    _resetApiKeyManagerRegistrationForTests();
    composioService._resetForTests();
  });
  afterEach(() => {
    _resetApiKeyManagerRegistrationForTests();
    composioService._resetForTests();
    if (currentFetchStub) { currentFetchStub.restore(); currentFetchStub = null; }
  });

  // These tests use `connection-status` as the call vehicle because
  // it's the action that still goes through composioService.getClient
  // (i.e. through resolveApiKey + ApiKeyManager + cache). The
  // list-toolkits action skips getClient entirely and goes straight to
  // REST with the resolved key, so we test it separately below.

  test('tool gets a client when ApiKeyManager has a vault key (env var unset)', async () => {
    registerApiKeyManager(makeStubApiKeyManager({ composio: 'vault-key-abcdefgh' }));
    composioService._injectClientForTests(makeMockClient());

    const tool = new ComposioTool({}, SILENT_LOGGER);
    const r = await tool.execute(
      { actions: [{ type: 'connection-status', toolkitSlug: 'github' }] },
      { agentId: 'a' },
    );
    expect(r.success).toBe(true);
  });

  test('vault key overrides env var (both set → vault wins)', async () => {
    process.env.COMPOSIO_API_KEY = 'env-key-shouldlose';
    registerApiKeyManager(makeStubApiKeyManager({ composio: 'vault-key-shouldwin' }));
    composioService._injectClientForTests(makeMockClient());

    const tool = new ComposioTool({}, SILENT_LOGGER);
    const r = await tool.execute(
      { actions: [{ type: 'connection-status', toolkitSlug: 'github' }] },
      { agentId: 'a' },
    );
    expect(r.success).toBe(true);
  });

  test('rotating the vault key via setApiKey invalidates the cache; next tool call uses the new key', async () => {
    const mgr = makeStubApiKeyManager({ composio: 'vault-key-original-x' });
    registerApiKeyManager(mgr);

    const firstClient = makeMockClient();
    composioService._injectClientForTests(firstClient);

    const tool = new ComposioTool({}, SILENT_LOGGER);
    await tool.execute(
      { actions: [{ type: 'connection-status', toolkitSlug: 'github' }] },
      { agentId: 'a' },
    );
    expect(firstClient.connectedAccounts.list).toHaveBeenCalledTimes(1);

    // Rotate. setApiKey persists + invalidates the cache.
    await composioService.setApiKey('vault-key-rotated-yyyyyy');
    expect(mgr.keys.vendorKeys.composio).toBe('vault-key-rotated-yyyyyy');

    // Inject a SECOND client for the new key fingerprint and verify
    // the next tool call hits it, not the original.
    const secondClient = makeMockClient();
    composioService._injectClientForTests(secondClient);

    await tool.execute(
      { actions: [{ type: 'connection-status', toolkitSlug: 'github' }] },
      { agentId: 'a' },
    );
    expect(secondClient.connectedAccounts.list).toHaveBeenCalledTimes(1);
    // First client wasn't called again — the tool isn't pinned to it.
    expect(firstClient.connectedAccounts.list).toHaveBeenCalledTimes(1);
  });

  test('list-toolkits uses the vault key when env var is unset (REST path)', async () => {
    // Independent test: list-toolkits goes through resolveApiKey → REST.
    // No SDK client involved. Stub fetch and assert the key landed in
    // the x-api-key header. Filter for the /toolkits call specifically —
    // the SDK may make telemetry/version-check fetches that we don't
    // care about here.
    registerApiKeyManager(makeStubApiKeyManager({ composio: 'vault-key-rest-aaa' }));
    const seen = [];
    const original = global.fetch;
    global.fetch = jest.fn(async (url, init) => {
      const u = url instanceof URL ? url : new URL(String(url));
      seen.push({ path: u.pathname, key: init?.headers?.['x-api-key'] });
      // Echo a happy response so listToolkits + any other fetch the SDK
      // triggers both succeed.
      return new Response(JSON.stringify({ items: [{ slug: 'gh', name: 'GitHub' }], next_cursor: null }), {
        status: 200, headers: { 'content-type': 'application/json' },
      });
    });
    try {
      const tool = new ComposioTool({}, SILENT_LOGGER);
      const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
      expect(r.success).toBe(true);
      const toolkitsCall = seen.find(s => s.path === '/api/v3.1/toolkits');
      expect(toolkitsCall).toBeDefined();
      expect(toolkitsCall.key).toBe('vault-key-rest-aaa');
    } finally { global.fetch = original; }
  });

  test('error message points operators at the Integrations page first, not just env var', async () => {
    delete process.env.COMPOSIO_API_KEY;
    _resetApiKeyManagerRegistrationForTests();
    composioService._resetForTests();

    const tool = new ComposioTool({}, SILENT_LOGGER);
    const r = await tool.execute({ actions: [{ type: 'list-toolkits' }] }, { agentId: 'a' });
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/Integrations page|COMPOSIO_API_KEY/);
    expect(r.hint).toMatch(/Integrations|COMPOSIO_API_KEY/);
  });
});
