/**
 * SkillsIndexer tests.
 *
 * Pins:
 *   - GLOBAL scope (surface='skills')
 *   - skillToText pack order + content truncation
 *   - indexOne / deleteOne / backfill round-trip
 *   - Hybrid search surfaces substring-only hits
 *   - Disabled path
 *   - Embed failure cleanly surfaced
 */

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

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

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

describe('skillToText', () => {
  test('pack order: name → description → content', () => {
    const t = skillToText({
      name: 'git-rebase-recovery',
      description: 'Recover from a broken rebase mid-flight',
      content: 'Step 1: git rebase --abort. Step 2: ...',
    });
    expect(t.indexOf('git-rebase-recovery')).toBe(0);
    expect(t.indexOf('Recover from')).toBeGreaterThan(0);
    expect(t.indexOf('Step 1')).toBeGreaterThan(t.indexOf('Recover from'));
  });

  test('truncates content past maxContentChars', () => {
    const t = skillToText({ name: 'X', content: 'A'.repeat(5000) }, { maxContentChars: 100 });
    expect(t).toContain('A'.repeat(100));
    expect(t).not.toContain('A'.repeat(101));
  });

  test('null / no-name returns empty', () => {
    expect(skillToText(null)).toBe('');
    expect(skillToText({})).toBe('');
  });
});

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

  test('returns disabled-shaped results', async () => {
    const { indexer } = makeIndexer({ tmpDir, enabled: false });
    expect((await indexer.indexOne({ name: 'x' })).reason).toBe('disabled');
    expect((await indexer.backfill([])).reason).toBe('disabled');
    expect((await indexer.search('q')).enabled).toBe(false);
    expect((await indexer.stats()).enabled).toBe(false);
  });
});

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

  test('indexOne + search finds the skill', async () => {
    await indexer.indexOne({ name: 'rebase-recovery', description: 'Fix broken rebases' });
    const r = await indexer.search('rebase');
    expect(r.hits.some(h => h.id === 'rebase-recovery')).toBe(true);
  });

  test('backfill drops + rebuilds with all skills', async () => {
    await indexer.indexOne({ name: 'stale', description: 'old' });
    const r = await indexer.backfill([
      { name: 'first',  description: 'one' },
      { name: 'second', description: 'two' },
    ]);
    expect(r.ok).toBe(true);
    expect(r.indexed).toBe(2);
    expect((await indexer.stats()).count).toBe(2);
  });

  test('deleteOne removes the skill', async () => {
    await indexer.indexOne({ name: 'x' });
    await indexer.indexOne({ name: 'y' });
    await indexer.deleteOne('x');
    expect((await indexer.stats()).count).toBe(1);
  });

  test('hybrid surfaces substring-only matches', async () => {
    await indexer.indexOne({
      name: 'verbose-debug-tools',
      description: 'Tools mentioning UNIQUE_RARE_TOKEN explicitly.',
    });
    const r = await indexer.search('UNIQUE_RARE_TOKEN', { topK: 5 });
    expect(r.hits.find(h => h.id === 'verbose-debug-tools')).toBeDefined();
  });

  test('skips invalid skills in backfill', async () => {
    const r = await indexer.backfill([
      { name: 'ok', description: 'd' },
      { /* no name */ description: 'orphan' },
    ]);
    expect(r.indexed).toBe(1);
    expect(r.skipped).toBe(1);
  });
});
