/**
 * AgentIndexer tests.
 *
 * Pins:
 *   - GLOBAL scope (one shared store under surface='agents')
 *   - agentToText pack order: type → name → systemPrompt → capabilities → skills
 *   - systemPrompt is truncated past maxPromptChars
 *   - backfill batches into ONE embed call + drops prior index
 *   - Search hits expose name + type + capabilities + skills + promptSnippet
 *   - excludeIds filter removes the requester's own id
 *   - Hybrid search surfaces substring-only matches
 *   - Disabled path returns structured falsy 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 { AgentIndexer, agentToText } from '../agentIndexer.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-agents', dimensions: this._dimensions,
      maxBatchSize: 32, maxTokensPerText: 1000,
      distinguishesQueryAndDocument: false, isLocal: false,
    };
  }
  getModelFingerprint() { return 'stub-agents: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(20) || 0) + 1) % 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 AgentIndexer({ 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 AgentIndexer({ embeddingService: svc }), svc, provider };
}

const agentSQL = {
  id: 'agent-dba',
  name: 'Database Specialist',
  type: 'data-engineer',
  systemPrompt: 'You are a senior database engineer. Specialise in SQL migrations, schema design, and query performance tuning.',
  capabilities: ['terminal', 'filesystem', 'staticanalysis'],
  skills: ['migrations', 'indexing'],
};
const agentFrontend = {
  id: 'agent-fe',
  name: 'Frontend Developer',
  type: 'developer',
  systemPrompt: 'You are a frontend developer. Build React components, handle CSS, and ship visual changes.',
  capabilities: ['filesystem', 'terminal', 'visual-editor'],
  skills: ['react', 'css'],
};
const agentDevOps = {
  id: 'agent-devops',
  name: 'DevOps Engineer',
  type: 'devops',
  systemPrompt: 'You are a DevOps engineer. Set up CI/CD, manage Docker images, and run kubectl.',
  capabilities: ['terminal', 'web'],
  skills: ['docker', 'kubernetes'],
};

describe('agentToText', () => {
  test('pack order: [type] → name → systemPrompt → capabilities → skills', () => {
    const text = agentToText(agentSQL);
    const tType   = text.indexOf('[data-engineer]');
    const tName   = text.indexOf('Database Specialist');
    const tPrompt = text.indexOf('SQL migrations');
    const tCap    = text.indexOf('capabilities:');
    const tSkill  = text.indexOf('skills:');
    expect(tType).toBe(0);
    expect(tName).toBeGreaterThan(tType);
    expect(tPrompt).toBeGreaterThan(tName);
    expect(tCap).toBeGreaterThan(tPrompt);
    expect(tSkill).toBeGreaterThan(tCap);
  });

  test('truncates systemPrompt past maxPromptChars', () => {
    const long = { id: 'x', name: 'L', systemPrompt: 'A'.repeat(5000) };
    const out = agentToText(long, { maxPromptChars: 100 });
    // 100-char prompt slice should be inside, but no 'A'.repeat(2000) lump.
    expect(out).toContain('A'.repeat(100));
    expect(out).not.toContain('A'.repeat(101));
  });

  test('falls back to originalSystemPrompt when systemPrompt is empty', () => {
    const out = agentToText({ id: 'x', originalSystemPrompt: 'OG prompt content' });
    expect(out).toContain('OG prompt content');
  });

  test('null / no-id agent returns empty', () => {
    expect(agentToText(null)).toBe('');
    expect(agentToText({})).toBe('');
  });

  test('skips capabilities / skills sections when empty', () => {
    const text = agentToText({ id: 'x', name: 'Y', systemPrompt: 'Z' });
    expect(text).not.toContain('capabilities:');
    expect(text).not.toContain('skills:');
  });
});

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

  test('indexOne / backfill / search return disabled-shaped results', async () => {
    const { indexer } = makeIndexer({ tmpDir, enabled: false });
    expect(await indexer.indexOne(agentSQL)).toEqual({ indexed: false, reason: 'disabled' });
    expect(await indexer.search('migration')).toEqual({ enabled: false, hits: [] });
    expect(await indexer.backfill([agentSQL])).toEqual({ ok: false, reason: 'disabled', indexed: 0, skipped: 0, failed: 0 });
  });
});

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

  test('indexOne + search returns the agent as a hit', async () => {
    await indexer.indexOne(agentSQL);
    const r = await indexer.search('database migration expert');
    expect(r.enabled).toBe(true);
    expect(r.hits.find(h => h.agentId === agentSQL.id)).toBeDefined();
  });

  test('backfill indexes all agents in ONE embed call', async () => {
    const r = await indexer.backfill([agentSQL, agentFrontend, agentDevOps]);
    expect(r.ok).toBe(true);
    expect(r.indexed).toBe(3);
    expect(provider.calls).toHaveLength(1);
    expect(provider.calls[0].texts).toHaveLength(3);
  });

  test('backfill drops the prior index (full-rebuild semantics)', async () => {
    await indexer.backfill([agentSQL, agentFrontend, agentDevOps]);
    const before = (await indexer.stats()).count;
    expect(before).toBe(3);
    await indexer.backfill([agentSQL]);   // only one remains
    expect((await indexer.stats()).count).toBe(1);
  });

  test('deleteOne removes the agent', async () => {
    await indexer.backfill([agentSQL, agentFrontend]);
    expect((await indexer.stats()).count).toBe(2);
    const r = await indexer.deleteOne(agentSQL.id);
    expect(r.deleted).toBe(true);
    expect((await indexer.stats()).count).toBe(1);
  });

  test('search excludeIds filters out a list of agent ids', async () => {
    await indexer.backfill([agentSQL, agentFrontend, agentDevOps]);
    const r = await indexer.search('engineer', { topK: 5, excludeIds: [agentSQL.id, agentDevOps.id] });
    expect(r.hits.every(h => h.agentId === agentFrontend.id)).toBe(true);
  });

  test('search hits expose name + type + capabilities + skills + promptSnippet', async () => {
    await indexer.backfill([agentSQL, agentFrontend]);
    const r = await indexer.search('react', { topK: 1 });
    const hit = r.hits[0];
    expect(hit).toMatchObject({
      name: expect.any(String),
      type: expect.any(String),
      capabilities: expect.any(Array),
      skills: expect.any(Array),
      promptSnippet: expect.any(String),
    });
  });

  test('hybrid mode surfaces substring matches (slug-like terms)', async () => {
    await indexer.backfill([agentSQL, agentFrontend, agentDevOps]);
    // "kubernetes" appears verbatim in devops skills; hybrid should rank it.
    const r = await indexer.search('kubernetes', { topK: 3 });
    expect(r.hits.find(h => h.agentId === agentDevOps.id)).toBeDefined();
  });

  test('skips invalid / no-text agents during backfill', async () => {
    const r = await indexer.backfill([
      agentSQL,
      { /* no id */ name: 'nameless' },
      { id: 'empty' /* no prompt / name */ },
    ]);
    expect(r.indexed).toBe(1);
    expect(r.skipped).toBe(2);
  });

  test('embed failure reports failed counts cleanly', 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 AgentIndexer({ embeddingService: svc });
    const r = await idx.backfill([agentSQL, agentFrontend]);
    expect(r.ok).toBe(false);
    expect(r.failed).toBe(2);
    expect(r.error).toMatch(/rate-limited/);
  });
});
