/**
 * autoRecall.test.js — silent retrieval (hybrid call path).
 *
 * Pins:
 *   - Disabled state: returns enabled:false / items:[] with reason
 *   - Invalid input: guards against missing agentId / empty text / bad deps
 *   - Threshold filtering: items below threshold dropped
 *   - Top-K cap respected
 *   - Token budget respected (won't emit overflow except first-item allowance)
 *   - Multiple surfaces: results merged + dedup'd by id
 *   - Hits from each surface get normalised consistently
 *   - Individual surface failure doesn't break the call
 *   - Format helper produces a labelled section the model can recognise
 *   - Format helper returns '' on empty input (caller-friendly)
 */

import { describe, test, expect, jest } from '@jest/globals';
import { recallForPrompt, formatRecallSection } from '../autoRecall.js';

function makeDeps({ enabled = true, memoryHits = [], reminisceHits = [], extraConfig = {} } = {}) {
  const memory = {
    isEnabled: true,
    search: jest.fn(async () => ({ enabled: true, hits: memoryHits })),
  };
  const reminisce = {
    isEnabled: true,
    search: jest.fn(async () => ({ enabled: true, hits: reminisceHits })),
  };
  return {
    getIndexers: () => ({ memory, reminisce }),
    getConfig: () => ({
      autoRecall: {
        enabled,
        threshold: 0.5,    // gentler than the prod default so tests don't all fail by threshold
        topK: 3,
        maxTokens: 400,
        surfaces: ['memory', 'reminisce'],
        ...extraConfig,
      },
    }),
    _memory: memory,
    _reminisce: reminisce,
  };
}

function memHit(id, score, text, extra = {}) {
  return { id, score, metadata: { text, title: `t-${id}`, createdAt: '2026-05-26T00:00:00Z', ...extra } };
}
function remHit(messageId, score, chunkText, extra = {}) {
  return { messageId, score, chunkIndex: 0, chunkText, metadata: { role: 'user', createdAt: '2026-05-26T00:00:00Z', ...extra } };
}

describe('recallForPrompt — guards', () => {
  test('invalid deps → reason:invalid-deps', async () => {
    expect(await recallForPrompt(null, 'a', 'x')).toMatchObject({ enabled: false, reason: 'invalid-deps' });
    expect(await recallForPrompt({}, 'a', 'x')).toMatchObject({ enabled: false, reason: 'invalid-deps' });
  });

  test('missing agentId / empty text → reason:invalid-input', async () => {
    const deps = makeDeps();
    expect(await recallForPrompt(deps, null, 'x')).toMatchObject({ reason: 'invalid-input' });
    expect(await recallForPrompt(deps, 'a', '')).toMatchObject({ reason: 'invalid-input' });
    expect(await recallForPrompt(deps, 'a', '   ')).toMatchObject({ reason: 'invalid-input' });
  });

  test('disabled → reason:disabled, no surface call', async () => {
    const deps = makeDeps({ enabled: false });
    const r = await recallForPrompt(deps, 'a', 'something');
    expect(r.enabled).toBe(false);
    expect(r.reason).toBe('disabled');
    expect(deps._memory.search).not.toHaveBeenCalled();
  });
});

describe('recallForPrompt — threshold + topK + budget', () => {
  test('items below threshold are dropped', async () => {
    const deps = makeDeps({
      memoryHits: [
        memHit('m1', 0.9, 'high'),
        memHit('m2', 0.3, 'low'),
        memHit('m3', 0.7, 'mid'),
      ],
      extraConfig: { threshold: 0.6 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.map(i => i.id).sort()).toEqual(['m1', 'm3']);
  });

  test('topK caps the number of items', async () => {
    const deps = makeDeps({
      memoryHits: [
        memHit('m1', 0.95, 'one'),
        memHit('m2', 0.94, 'two'),
        memHit('m3', 0.93, 'three'),
        memHit('m4', 0.92, 'four'),
      ],
      extraConfig: { threshold: 0.5, topK: 2 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items).toHaveLength(2);
    expect(r.items.map(i => i.id)).toEqual(['m1', 'm2']);
  });

  test('token budget stops further additions once exhausted', async () => {
    // Each item ~100 chars → ~25 tokens. With budget=50, only 2 fit.
    const text = 'a'.repeat(100);
    const deps = makeDeps({
      memoryHits: [
        memHit('m1', 0.95, text),
        memHit('m2', 0.94, text),
        memHit('m3', 0.93, text),
        memHit('m4', 0.92, text),
      ],
      extraConfig: { threshold: 0.5, topK: 10, maxTokens: 50 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.length).toBeLessThanOrEqual(2);
  });

  test('first-item allowance: a single oversized item is still included', async () => {
    // 400-char item ~= 100 tokens. Budget=50. First-item allowance let
    // it in if length ≤ maxTokens * 1.5 = 75 tokens; ours is 100 → over.
    // So with truly massive single item, we get empty list (not partial).
    const text = 'a'.repeat(400);
    const deps = makeDeps({
      memoryHits: [memHit('m1', 0.95, text)],
      extraConfig: { threshold: 0.5, maxTokens: 50 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items).toHaveLength(0);
  });

  test('first-item allowance: slightly-over-budget item is admitted', async () => {
    // Budget=100; item ~30 tokens long. Within budget. Easy include.
    const text = 'a'.repeat(120);   // ~30 tokens
    const deps = makeDeps({
      memoryHits: [memHit('m1', 0.95, text)],
      extraConfig: { threshold: 0.5, maxTokens: 100 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items).toHaveLength(1);
  });

  test('returns enabled:true / empty items when nothing crosses threshold', async () => {
    const deps = makeDeps({
      memoryHits: [memHit('m1', 0.2, 'low'), memHit('m2', 0.3, 'low')],
      extraConfig: { threshold: 0.7 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.enabled).toBe(true);
    expect(r.items).toEqual([]);
  });
});

describe('recallForPrompt — multi-surface', () => {
  test('merges results from memory + reminisce, sorted by score', async () => {
    const deps = makeDeps({
      memoryHits:    [memHit('m1', 0.7, 'memory one')],
      reminisceHits: [remHit('r1', 0.9, 'reminisce one'), remHit('r2', 0.8, 'reminisce two')],
      extraConfig: { threshold: 0.5, topK: 5 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.map(i => i.id)).toEqual(['r1', 'r2', 'm1']);
  });

  test('source tag on each item identifies the originating surface', async () => {
    const deps = makeDeps({
      memoryHits:    [memHit('m1', 0.9, 'memory text')],
      reminisceHits: [remHit('r1', 0.8, 'reminisce text')],
      extraConfig: { threshold: 0.5 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.find(i => i.id === 'm1').source).toBe('memory');
    expect(r.items.find(i => i.id === 'r1').source).toBe('reminisce');
  });

  test('dedups by id (same item across surfaces only emitted once)', async () => {
    const deps = makeDeps({
      memoryHits:    [memHit('shared', 0.9, 'text')],
      reminisceHits: [remHit('shared', 0.95, 'text')],
      extraConfig: { threshold: 0.5 },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.filter(i => i.id === 'shared')).toHaveLength(1);
  });

  test('a surface that throws is silently skipped (others still produce hits)', async () => {
    const deps = makeDeps({
      memoryHits:    [memHit('m1', 0.9, 'kept')],
      reminisceHits: [],
    });
    deps._reminisce.search = jest.fn(async () => { throw new Error('boom'); });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.map(i => i.id)).toEqual(['m1']);
  });

  test('surfaces config narrows the lookup', async () => {
    const deps = makeDeps({
      memoryHits:    [memHit('m1', 0.9, 'mem')],
      reminisceHits: [remHit('r1', 0.95, 'rem')],
      extraConfig: { threshold: 0.5, surfaces: ['memory'] },
    });
    const r = await recallForPrompt(deps, 'a', 'query');
    expect(r.items.map(i => i.id)).toEqual(['m1']);
    expect(deps._reminisce.search).not.toHaveBeenCalled();
  });
});

describe('formatRecallSection', () => {
  test('empty input → empty string', () => {
    expect(formatRecallSection([])).toBe('');
    expect(formatRecallSection(null)).toBe('');
  });

  test('emits a labelled section with the recall items', () => {
    const items = [
      { id: 'm1', source: 'memory',    text: 'we throttle telegram errors',  title: 'Telegram dedup', createdAt: '2026-05-25T00:00:00Z' },
      { id: 'r1', source: 'reminisce', text: 'how does retry work?',          role: 'user',            createdAt: '2026-04-26T00:00:00Z' },
    ];
    const out = formatRecallSection(items);
    expect(out).toContain('## RELEVANT CONTEXT (auto-recalled)');
    expect(out).toContain('background information');
    expect(out).toContain('from memory');
    expect(out).toContain('"Telegram dedup"');
    expect(out).toContain('we throttle telegram errors');
    expect(out).toContain('from reminisce');
    expect(out).toContain('user turn');
  });

  test('newlines in item text are flattened so the section reads cleanly', () => {
    const out = formatRecallSection([
      { id: 'm1', source: 'memory', text: 'line one\nline two\n\nline three', title: 't' },
    ]);
    expect(out).not.toMatch(/line one\n\s*line two/);
    expect(out).toContain('line one line two line three');
  });
});
