/**
 * ComposioIndexer tests.
 *
 * Same pattern as memory/reminisce indexer tests: real EmbeddingService
 * + InMemoryJsonStore against a tmp dir with a stub provider.
 *
 * Pins:
 *   - GLOBAL scope (one shared store, not per-agent)
 *   - composioActionToText pack order: displayName → description → [toolkit/slug]
 *   - indexCatalog batches every (toolkit, action) pair into ONE embed call
 *   - Re-running indexCatalog DROPS the prior index (full rebuild semantics)
 *   - search filter by toolkitSlug narrows results
 *   - search returns hits with metadata.inputSchema for downstream execute()
 *   - Failures (fetch throws on one toolkit) increment failedToolkits, others succeed
 *   - Disabled path returns clean structured results
 */

import { describe, test, expect, jest, beforeEach, afterEach } from '@jest/globals';
import os from 'os';
import fsp from 'fs/promises';
import path from 'path';
import { ComposioIndexer, composioActionToText } from '../composioIndexer.js';
import { EmbeddingService } from '../../embeddingService.js';
import { EmbeddingProvider } from '../../providerInterface.js';

class StubProvider extends EmbeddingProvider {
  constructor(dimensions = 8) {
    super();
    this._dimensions = dimensions;
    this.calls = [];
  }
  getInfo() {
    return {
      name: 'azure', modelId: 'stub-composio', dimensions: this._dimensions,
      maxBatchSize: 64, maxTokensPerText: 1000,
      distinguishesQueryAndDocument: false, isLocal: false,
    };
  }
  getModelFingerprint() { return 'stub-composio:v1'; }
  async _embed(texts) {
    this.calls.push({ texts: [...texts] });
    return texts.map(t => {
      const vec = Array(this._dimensions).fill(0);
      vec[(t.charCodeAt(0) || 0) % this._dimensions] = 1;
      vec[(t.charCodeAt(t.length - 1) || 0) % this._dimensions] += 0.5;
      return vec;
    });
  }
}

function makeIndexer({ tmpDir, enabled = true } = {}) {
  const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
  if (!enabled) {
    const svc = new EmbeddingService({ apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir });
    return { indexer: new ComposioIndexer({ embeddingService: svc }), svc };
  }
  const provider = new StubProvider();
  const svc = new EmbeddingService({
    config: { provider: 'azure' },
    apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir,
    providerOverrides: { azure: () => provider },
  });
  return { indexer: new ComposioIndexer({ embeddingService: svc }), svc, provider };
}

const slackToolkit  = { slug: 'slack',  name: 'Slack'  };
const githubToolkit = { slug: 'github', name: 'GitHub' };
const notionToolkit = { slug: 'notion', name: 'Notion' };

const slackActions = [
  { slug: 'SLACK_SEND_MESSAGE',         displayName: 'Send a message',     description: 'Post a message to a channel.',     inputSchema: { type: 'object', properties: {} } },
  { slug: 'SLACK_SEND_DM',              displayName: 'Send a direct message', description: 'Direct-message a Slack user.', inputSchema: { type: 'object', properties: {} } },
  { slug: 'SLACK_LIST_CHANNELS',        displayName: 'List channels',      description: 'List all channels in the workspace.', inputSchema: { type: 'object', properties: {} } },
];
const githubActions = [
  { slug: 'GITHUB_CREATE_ISSUE',        displayName: 'Create issue',       description: 'Open a new issue on a repository.',     inputSchema: {} },
  { slug: 'GITHUB_LIST_REPOS',          displayName: 'List repositories',  description: 'List repositories owned by the authenticated user.', inputSchema: {} },
];
const notionActions = [
  { slug: 'NOTION_CREATE_PAGE',         displayName: 'Create a page',      description: 'Create a new Notion page.', inputSchema: {} },
];

function makeFetcher() {
  return jest.fn(async (toolkitSlug) => {
    if (toolkitSlug === 'slack')  return slackActions;
    if (toolkitSlug === 'github') return githubActions;
    if (toolkitSlug === 'notion') return notionActions;
    return [];
  });
}

describe('composioActionToText', () => {
  test('pack order: displayName → description → [toolkit/slug]', () => {
    const text = composioActionToText('slack', slackActions[0]);
    const di = text.indexOf('Send a message');
    const de = text.indexOf('Post a message');
    const tag = text.indexOf('[slack/SLACK_SEND_MESSAGE]');
    expect(di).toBeGreaterThanOrEqual(0);
    expect(de).toBeGreaterThan(di);
    expect(tag).toBeGreaterThan(de);
  });

  test('skips missing fields without leaving blank lines', () => {
    const text = composioActionToText('slack', { slug: 'X' });
    expect(text).toBe('[slack/X]');
  });

  test('null action → empty string', () => {
    expect(composioActionToText('slack', null)).toBe('');
  });
});

describe('ComposioIndexer — disabled path', () => {
  let tmpDir;
  beforeEach(async () => { tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-cidx-')); });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('indexCatalog returns ok:false / reason:"disabled"', async () => {
    const { indexer } = makeIndexer({ tmpDir, enabled: false });
    const r = await indexer.indexCatalog([slackToolkit], makeFetcher());
    expect(r).toEqual({
      ok: false, reason: 'disabled',
      indexedActions: 0, indexedToolkits: 0, failedToolkits: 0, skipped: 0,
    });
  });

  test('search returns enabled:false', async () => {
    const { indexer } = makeIndexer({ tmpDir, enabled: false });
    const r = await indexer.search('hello');
    expect(r).toEqual({ enabled: false, hits: [] });
  });

  test('stats returns enabled:false / count:0', async () => {
    const { indexer } = makeIndexer({ tmpDir, enabled: false });
    expect(await indexer.stats()).toEqual({ count: 0, enabled: false });
  });
});

describe('ComposioIndexer — indexCatalog', () => {
  let tmpDir, indexer, provider;
  beforeEach(async () => {
    tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-cidx-'));
    ({ indexer, provider } = makeIndexer({ tmpDir }));
  });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('walks all toolkits, batches every action into ONE embed call', async () => {
    const r = await indexer.indexCatalog([slackToolkit, githubToolkit, notionToolkit], makeFetcher());
    expect(r.ok).toBe(true);
    expect(r.indexedActions).toBe(6);   // 3 + 2 + 1
    expect(r.indexedToolkits).toBe(3);
    expect(provider.calls).toHaveLength(1);
    expect(provider.calls[0].texts).toHaveLength(6);
  });

  test('toolkit fetch failure counts as failed, others succeed', async () => {
    const fetcher = jest.fn(async (slug) => {
      if (slug === 'github') throw new Error('rate-limited');
      if (slug === 'slack')  return slackActions;
      if (slug === 'notion') return notionActions;
      return [];
    });
    const r = await indexer.indexCatalog([slackToolkit, githubToolkit, notionToolkit], fetcher);
    expect(r.ok).toBe(true);
    expect(r.indexedActions).toBe(slackActions.length + notionActions.length);
    expect(r.indexedToolkits).toBe(2);
    expect(r.failedToolkits).toBe(1);
  });

  test('empty toolkit list → reason:"no-toolkits"', async () => {
    const r = await indexer.indexCatalog([], makeFetcher());
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('no-toolkits');
  });

  test('invalid fetcher → reason:"invalid-input"', async () => {
    const r = await indexer.indexCatalog([slackToolkit], 'not-a-function');
    expect(r.ok).toBe(false);
    expect(r.reason).toBe('invalid-input');
  });

  test('full rebuild: re-indexing drops the prior store first', async () => {
    await indexer.indexCatalog([slackToolkit, githubToolkit], makeFetcher());
    const beforeCount = (await indexer.stats()).count;
    expect(beforeCount).toBeGreaterThan(0);

    // Re-index with only Slack; expect github actions to be gone.
    await indexer.indexCatalog([slackToolkit], makeFetcher());
    const afterCount = (await indexer.stats()).count;
    expect(afterCount).toBe(slackActions.length);

    const ghHit = await indexer.search('Create issue', { topK: 5 });
    expect(ghHit.hits.find(h => h.actionSlug === 'GITHUB_CREATE_ISSUE')).toBeUndefined();
  });

  test('empty actions in every toolkit → indexes nothing but drops stale index', async () => {
    // Seed something first.
    await indexer.indexCatalog([slackToolkit], makeFetcher());
    expect((await indexer.stats()).count).toBeGreaterThan(0);
    // Now re-index with toolkits that return no actions.
    const emptyFetcher = jest.fn(async () => []);
    const r = await indexer.indexCatalog([slackToolkit], emptyFetcher);
    expect(r.ok).toBe(true);
    expect(r.indexedActions).toBe(0);
    expect((await indexer.stats()).count).toBe(0);
  });

  test('embed batch failure → ok:false with error surfaced', async () => {
    const apiKeyManager = { getEmbeddingApiKey: () => 'k' };
    class FailingProvider extends StubProvider {
      async _embed() { throw new Error('rate-limited'); }
    }
    const svc = new EmbeddingService({
      config: { provider: 'azure' },
      apiKeyManager, baseUrl: 'https://x', stateDir: tmpDir,
      providerOverrides: { azure: () => new FailingProvider() },
    });
    const idx = new ComposioIndexer({ embeddingService: svc });
    const r = await idx.indexCatalog([slackToolkit], makeFetcher());
    expect(r.ok).toBe(false);
    expect(r.error).toMatch(/rate-limited/);
  });
});

describe('ComposioIndexer — search', () => {
  let tmpDir, indexer;
  beforeEach(async () => {
    tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'loxia-cidx-'));
    ({ indexer } = makeIndexer({ tmpDir }));
    await indexer.indexCatalog([slackToolkit, githubToolkit, notionToolkit], makeFetcher());
  });
  afterEach(async () => { await fsp.rm(tmpDir, { recursive: true, force: true }); });

  test('returns hits with toolkitSlug + actionSlug + displayName + description', async () => {
    const r = await indexer.search('Send a direct message in Slack', { topK: 5 });
    expect(r.enabled).toBe(true);
    expect(r.hits.length).toBeGreaterThan(0);
    const first = r.hits[0];
    expect(first.toolkitSlug).toBeTruthy();
    expect(first.actionSlug).toBeTruthy();
    expect(first.displayName).toBeTruthy();
    expect(first.metadata.inputSchema).toBeDefined();
  });

  test('toolkitSlug filter narrows scope to one toolkit', async () => {
    const r = await indexer.search('list', { toolkitSlug: 'slack', topK: 10 });
    expect(r.hits.every(h => h.toolkitSlug === 'slack')).toBe(true);
  });

  test('topK respected', async () => {
    const r = await indexer.search('send message', { topK: 2 });
    expect(r.hits).toHaveLength(2);
  });

  test('hybrid search surfaces substring-only hits', async () => {
    // Query an action slug verbatim — hybrid should pull it via substring.
    const r = await indexer.search('NOTION_CREATE_PAGE', { topK: 5 });
    expect(r.hits.some(h => h.actionSlug === 'NOTION_CREATE_PAGE')).toBe(true);
  });

  test('empty query returns empty hits without throwing', async () => {
    const r = await indexer.search('');
    expect(r.hits).toEqual([]);
  });
});
