/**
 * composioTool tests for find-action + backfill-actions-index.
 *
 * Pins:
 *   - SUPPORTED_ACTIONS includes the two new types
 *   - Validation: query required for find-action; topK must be positive int
 *   - find-action degrades gracefully to substring fallback when:
 *       * embeddings disabled, OR
 *       * index empty, OR
 *       * embedding search returns an error
 *     (Resilience requirement — the agent must always get *something*.)
 *   - find-action with embeddings live + indexed → returns ranked hits
 *   - Substring fallback ranks by token overlap, respects toolkitSlug pin,
 *     and surfaces a `warning` + `fallbackCause` so callers know quality
 *     is degraded and how to upgrade.
 *   - backfill-actions-index walks toolkits, calls indexer.indexCatalog
 *   - backfill-actions-index surfaces SDK list-toolkits failures
 *
 * Mocks: stub the ComposioIndexer at the module boundary (so the tool's
 * wiring is what we test, not the indexer internals — those have their
 * own suite). Stub the SDK loader to inject a fake Composio client, AND
 * inject the same client into composioService so the substring fallback
 * (which goes through composioService.listToolkits / listTools) lands
 * the same stub.
 */

import { jest, describe, test, expect, beforeEach, afterEach } from '@jest/globals';

const mockIndexerInstance = {
  isEnabled: true,
  indexCatalog: jest.fn().mockResolvedValue({
    ok: true, indexedActions: 6, indexedToolkits: 3, failedToolkits: 0, skipped: 0,
  }),
  search: jest.fn().mockResolvedValue({ enabled: true, hits: [] }),
  stats: jest.fn().mockResolvedValue({ count: 6, enabled: true }),
};
const MockComposioIndexer = jest.fn(() => mockIndexerInstance);
jest.unstable_mockModule('../../services/embeddings/indexers/composioIndexer.js', () => ({
  ComposioIndexer: MockComposioIndexer,
  composioActionToText: () => '',
}));

const { default: ComposioTool } = await import('../composioTool.js');
const composioService = await import('../../services/composioService.js');

// composioService.getClient() guards against silent key-rotation by
// comparing the active key's fingerprint to what the cached client was
// built with — if they don't match it drops the cache. That means
// `_injectClientForTests` only sticks when `resolveApiKey()` returns the
// same key the inject helper sees. Easiest way: ensure an env key is
// set before the helper resolves. Restore the original on teardown.
const savedComposioKey = process.env.COMPOSIO_API_KEY;
beforeEach(() => {
  process.env.COMPOSIO_API_KEY = 'test-key';
  composioService._resetForTests();
});
afterEach(() => {
  if (savedComposioKey === undefined) delete process.env.COMPOSIO_API_KEY;
  else process.env.COMPOSIO_API_KEY = savedComposioKey;
  composioService._resetForTests();
  if (currentFetchStub) { currentFetchStub.restore(); currentFetchStub = null; }
});

/**
 * Stub `global.fetch` so composioService.listToolkits / listTools (which
 * go via REST after the SDK-bug fix) see canned responses. Returns the
 * call log + a restore().
 *
 * To behave like the original sdkClient stub, this exposes
 * `.toolkits.list` and `.tools.list` jest.fns that record invocations —
 * tests still assert against those names without caring that the actual
 * call goes through fetch under the hood.
 */
function makeFetchStub(scenarios = {}) {
  const original = global.fetch;
  const toolkitsScenario = scenarios.toolkits ?? [
    { slug: 'slack',  name: 'Slack',  meta: { description: 'Send messages to Slack channels and DMs' } },
    { slug: 'github', name: 'GitHub', meta: { description: 'Repo, issue and PR management' } },
  ];
  const toolsScenario = scenarios.tools ?? [
    { slug: 'SLACK_SEND_DM',       display_name: 'Send a DM',     description: 'Send a Slack direct message to a user', toolkit: { slug: 'slack' } },
    { slug: 'SLACK_LIST_CHANNELS', display_name: 'List channels', description: 'List all Slack channels',                toolkit: { slug: 'slack' } },
  ];

  const recordedToolkitsCalls = jest.fn();
  const recordedToolsCalls = jest.fn();

  global.fetch = jest.fn(async (url) => {
    const u = url instanceof URL ? url : new URL(String(url));
    const query = Object.fromEntries(u.searchParams);
    if (u.pathname === '/api/v3.1/toolkits') {
      recordedToolkitsCalls(query);
      if (typeof toolkitsScenario === 'function') {
        try { const items = await toolkitsScenario(query); return new Response(JSON.stringify({ items, next_cursor: null }), { status: 200 }); }
        catch (e) { return new Response(JSON.stringify({ error: e.message }), { status: 500 }); }
      }
      return new Response(JSON.stringify({ items: toolkitsScenario, next_cursor: null }), { status: 200 });
    }
    if (u.pathname === '/api/v3.1/tools') {
      recordedToolsCalls(query);
      if (typeof toolsScenario === 'function') {
        try { const items = await toolsScenario(query); return new Response(JSON.stringify({ items, next_cursor: null }), { status: 200 }); }
        catch (e) { return new Response(JSON.stringify({ error: e.message }), { status: 500 }); }
      }
      return new Response(JSON.stringify({ items: toolsScenario, next_cursor: null }), { status: 200 });
    }
    return new Response(JSON.stringify({ error: `no stub for ${u.pathname}` }), { status: 404 });
  });

  return {
    // Expose with the same `.toolkits.list` / `.tools.list` shape the
    // previous SDK-stub used, so test assertions don't have to know
    // the implementation switched to fetch.
    toolkits: { list: recordedToolkitsCalls },
    tools:    { list: recordedToolsCalls },
    restore:  () => { global.fetch = original; },
  };
}

function makeTool({ embeddingsEnabled = true, fetchScenarios = null } = {}) {
  const tool = new ComposioTool({}, null);
  mockIndexerInstance.isEnabled = embeddingsEnabled;
  mockIndexerInstance.indexCatalog.mockClear();
  mockIndexerInstance.search.mockClear();
  mockIndexerInstance.stats.mockClear();
  // Re-arm the default stats response — individual tests may have
  // overridden it to {count: 0} for the empty-index path.
  mockIndexerInstance.stats.mockResolvedValue({ count: 6, enabled: true });
  // Re-arm the default search response — individual tests may have
  // overridden it to inject errors.
  mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [] });
  MockComposioIndexer.mockClear();

  // Inject the embedding service via aiService.
  tool.aiService = { getEmbeddingService: () => ({ isEnabled: embeddingsEnabled }) };
  tool._composioIndexer = null;

  // composioService.listToolkits/listTools go via REST now (the SDK had
  // wrong method names and dropped pagination). Stub global.fetch so
  // those calls see canned data — the substring fallback uses them.
  if (currentFetchStub) { currentFetchStub.restore(); currentFetchStub = null; }
  currentFetchStub = makeFetchStub(fetchScenarios || {});
  // Reset composioService caches so each test starts fresh.
  composioService._resetForTests();

  // _loadSdk still needs to resolve for the dispatcher (the 'execute'
  // action uses it directly). Provide an empty-but-valid client.
  tool._loadSdk = jest.fn().mockResolvedValue({ client: {
    tools: { execute: jest.fn() },
  }});

  return { tool, sdkClient: currentFetchStub };
}

let currentFetchStub = null;

describe('composioTool — SUPPORTED_ACTIONS', () => {
  test('find-action and backfill-actions-index are in the enum', async () => {
    const { tool } = makeTool();
    // customValidateParameters refuses unknown types — round-trip both as valid.
    const v1 = tool.customValidateParameters({ actions: [{ type: 'find-action', query: 'send a slack dm' }] });
    const v2 = tool.customValidateParameters({ actions: [{ type: 'backfill-actions-index' }] });
    expect(v1.valid).toBe(true);
    expect(v2.valid).toBe(true);
  });
});

describe('composioTool — find-action validation', () => {
  test('query required', () => {
    const { tool } = makeTool();
    const v = tool.customValidateParameters({ actions: [{ type: 'find-action' }] });
    expect(v.valid).toBe(false);
    expect(v.errors.join(' ')).toMatch(/query/i);
  });

  test('topK must be a positive int', () => {
    const { tool } = makeTool();
    expect(tool.customValidateParameters({ actions: [{ type: 'find-action', query: 'q', topK: 0 }] }).valid).toBe(false);
    expect(tool.customValidateParameters({ actions: [{ type: 'find-action', query: 'q', topK: -1 }] }).valid).toBe(false);
    expect(tool.customValidateParameters({ actions: [{ type: 'find-action', query: 'q', topK: 0.5 }] }).valid).toBe(false);
    expect(tool.customValidateParameters({ actions: [{ type: 'find-action', query: 'q', topK: 5 }] }).valid).toBe(true);
  });
});

describe('composioTool — find-action execution', () => {
  test('embeddings disabled → falls back to substring search with warning', async () => {
    const { tool, sdkClient } = makeTool({ embeddingsEnabled: false });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    // Resilience guarantee: the agent still gets ranked candidates even
    // without embeddings — degraded, but usable.
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    expect(r.fallbackCause).toBe('embeddings-unavailable');
    expect(r.warning).toMatch(/embeddings unavailable/i);
    expect(r.warning).toMatch(/embedding provider/i);
    // The embedding indexer was never touched (no provider attached).
    expect(mockIndexerInstance.search).not.toHaveBeenCalled();
    // The fallback fan-out went through the SDK (via composioService).
    expect(sdkClient.toolkits.list).toHaveBeenCalled();
    expect(sdkClient.tools.list).toHaveBeenCalled();
    // And it actually ranked the SLACK_SEND_DM action above the noise.
    expect(r.hits.length).toBeGreaterThan(0);
    expect(r.hits[0].actionSlug).toBe('SLACK_SEND_DM');
  });

  test('empty index → falls back to substring search with backfill hint', async () => {
    const { tool, sdkClient } = makeTool();
    mockIndexerInstance.stats.mockResolvedValue({ count: 0, enabled: true });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    expect(r.fallbackCause).toBe('index-empty');
    expect(r.warning).toMatch(/catalog index is empty/i);
    expect(r.warning).toMatch(/backfill-actions-index/);
    // Search is skipped when the index is empty — don't burn an
    // embedding call we know will be useless.
    expect(mockIndexerInstance.search).not.toHaveBeenCalled();
    expect(sdkClient.toolkits.list).toHaveBeenCalled();
    expect(r.hits.length).toBeGreaterThan(0);
  });

  test('happy path → returns hits + inputSchema metadata', async () => {
    const { tool } = makeTool();
    mockIndexerInstance.stats.mockResolvedValue({ count: 6, enabled: true });
    mockIndexerInstance.search.mockResolvedValue({
      enabled: true,
      hits: [
        {
          id: 'slack:SLACK_SEND_DM',
          score: 0.92,
          toolkitSlug: 'slack',
          actionSlug: 'SLACK_SEND_DM',
          displayName: 'Send a DM',
          description: 'Direct-message a Slack user.',
          metadata: { toolkitSlug: 'slack', actionSlug: 'SLACK_SEND_DM', inputSchema: { type: 'object' } },
        },
      ],
    });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send a slack dm', topK: 3 }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.action).toBe('find-action');
    expect(r.hits).toHaveLength(1);
    expect(r.hits[0]).toMatchObject({
      toolkitSlug: 'slack',
      actionSlug: 'SLACK_SEND_DM',
      displayName: 'Send a DM',
    });
    expect(r.hits[0].metadata.inputSchema).toBeDefined();
    expect(mockIndexerInstance.search).toHaveBeenCalledWith('send a slack dm', { topK: 3, hybrid: true, toolkitSlug: null });
  });

  test('toolkitSlug + hybrid:false forwarded', async () => {
    const { tool } = makeTool();
    mockIndexerInstance.stats.mockResolvedValue({ count: 6, enabled: true });
    mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [] });
    await tool.execute(
      { actions: [{ type: 'find-action', query: 'q', toolkitSlug: 'github', hybrid: false }] },
      { agentId: 'a1' },
    );
    expect(mockIndexerInstance.search).toHaveBeenCalledWith('q', { topK: 10, hybrid: false, toolkitSlug: 'github' });
  });

  test('indexer error → falls back to substring search (does not bubble up)', async () => {
    const { tool, sdkClient } = makeTool();
    mockIndexerInstance.stats.mockResolvedValue({ count: 6, enabled: true });
    mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [], error: 'rate-limited' });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    // Embedding path was attempted (we got the error from it) but the
    // outer call SUCCEEDS because the substring fallback covered it.
    expect(mockIndexerInstance.search).toHaveBeenCalledTimes(1);
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    expect(r.fallbackCause).toBe('embedding-search-failed');
    expect(r.warning).toMatch(/embedding search failed/i);
    expect(r.warning).toMatch(/rate-limited/);
    expect(sdkClient.toolkits.list).toHaveBeenCalled();
  });

  test('indexer.search throws → falls back to substring search', async () => {
    const { tool, sdkClient } = makeTool();
    mockIndexerInstance.stats.mockResolvedValue({ count: 6, enabled: true });
    mockIndexerInstance.search.mockRejectedValue(new Error('embedding service down'));
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    expect(r.fallbackCause).toBe('embedding-search-failed');
    expect(r.warning).toMatch(/embedding service down/);
    expect(sdkClient.toolkits.list).toHaveBeenCalled();
  });
});

describe('composioTool — find-action substring fallback', () => {
  test('ranks tools by token overlap and returns the top-K', async () => {
    const { tool } = makeTool({ embeddingsEnabled: false });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'slack dm direct message', topK: 1 }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.hits).toHaveLength(1);
    // SLACK_SEND_DM matches "dm" + "direct" + "message"; SLACK_LIST_CHANNELS
    // doesn't. Token overlap should put SEND_DM first.
    expect(r.hits[0].actionSlug).toBe('SLACK_SEND_DM');
    expect(r.hits[0].toolkitSlug).toBe('slack');
    expect(typeof r.hits[0].score).toBe('number');
  });

  test('toolkitSlug pin restricts the scan to a single toolkit', async () => {
    const { tool, sdkClient } = makeTool({ embeddingsEnabled: false });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send dm', toolkitSlug: 'slack' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    // When toolkitSlug is pinned, we skip listToolkits entirely (we
    // already know which toolkit to scan).
    expect(sdkClient.toolkits.list).not.toHaveBeenCalled();
    // …and we call tools.list exactly once, for that one toolkit.
    expect(sdkClient.tools.list).toHaveBeenCalledTimes(1);
    expect(sdkClient.tools.list).toHaveBeenCalledWith(
      expect.objectContaining({ toolkit_slugs: 'slack', user_id: 'a1' })
    );
    // Every returned hit belongs to the pinned toolkit.
    for (const h of r.hits) {
      expect(h.toolkitSlug).toBe('slack');
    }
  });

  test('empty toolkit list → empty hits (no crash)', async () => {
    const { tool, sdkClient } = makeTool({
      embeddingsEnabled: false,
      fetchScenarios: { toolkits: [] },
    });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.mode).toBe('substring-fallback');
    expect(r.hits).toEqual([]);
    expect(r.message).toMatch(/no toolkits/i);
    // tools.list was never called — we had nothing to scan.
    expect(sdkClient.tools.list).not.toHaveBeenCalled();
  });

  test('listToolkits failure → clean error in fallback mode', async () => {
    const { tool } = makeTool({
      embeddingsEnabled: false,
      fetchScenarios: { toolkits: () => { throw new Error('upstream 503'); } },
    });
    const r = await tool.execute(
      { actions: [{ type: 'find-action', query: 'send slack dm' }] },
      { agentId: 'a1' },
    );
    // Substring fallback couldn't even start (no toolkit list to scan).
    // Surface the underlying error rather than pretending success.
    expect(r.success).toBe(false);
    expect(r.mode).toBe('substring-fallback');
    expect(r.error).toMatch(/upstream 503/);
  });
});

describe('composioTool — backfill-actions-index', () => {
  test('embeddings disabled → no SDK call, clean error', async () => {
    const { tool, sdkClient } = makeTool({ embeddingsEnabled: false });
    const r = await tool.execute(
      { actions: [{ type: 'backfill-actions-index' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(false);
    expect(r.message).toMatch(/not enabled/i);
    expect(sdkClient.toolkits.list).not.toHaveBeenCalled();
  });

  test('walks toolkits + calls indexer.indexCatalog with the right fetcher', async () => {
    const { tool, sdkClient } = makeTool();
    const r = await tool.execute(
      { actions: [{ type: 'backfill-actions-index' }] },
      { agentId: 'a1' },
    );
    expect(sdkClient.toolkits.list).toHaveBeenCalled();
    expect(mockIndexerInstance.indexCatalog).toHaveBeenCalledTimes(1);
    // First arg = toolkits[], second = fetcher fn that uses listTools (REST).
    const [toolkits, fetcher] = mockIndexerInstance.indexCatalog.mock.calls[0];
    expect(toolkits.map(t => t.slug).sort()).toEqual(['github', 'slack']);
    expect(typeof fetcher).toBe('function');
    // Calling the fetcher should route through composioService → REST.
    await fetcher('slack');
    expect(sdkClient.tools.list).toHaveBeenCalledWith(
      expect.objectContaining({ toolkit_slugs: 'slack', user_id: 'a1' })
    );
    expect(r.success).toBe(true);
    expect(r.indexedActions).toBe(6);
  });

  test('toolkits.list failure surfaces as clean error', async () => {
    const { tool } = makeTool({
      fetchScenarios: { toolkits: () => { throw new Error('upstream down'); } },
    });
    const r = await tool.execute(
      { actions: [{ type: 'backfill-actions-index' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/Could not list toolkits/);
    expect(mockIndexerInstance.indexCatalog).not.toHaveBeenCalled();
  });

  test('empty toolkits list surfaces actionable hint', async () => {
    const { tool } = makeTool({ fetchScenarios: { toolkits: [] } });
    const r = await tool.execute(
      { actions: [{ type: 'backfill-actions-index' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(false);
    expect(r.error).toMatch(/no toolkits/i);
  });

  test('reports failedToolkits cleanly', async () => {
    const { tool } = makeTool();
    mockIndexerInstance.indexCatalog.mockResolvedValue({
      ok: true, indexedActions: 4, indexedToolkits: 2, failedToolkits: 1, skipped: 0,
    });
    const r = await tool.execute(
      { actions: [{ type: 'backfill-actions-index' }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.failedToolkits).toBe(1);
    expect(r.message).toMatch(/1 toolkits failed/);
  });
});
