/**
 * memoryTool tests for the new semantic-search + backfill-embeddings
 * actions, plus the async indexer hooks fired by add/update/append/delete.
 *
 * Pins:
 *   - semantic-search with embeddings DISABLED falls back to substring
 *     via memoryService.searchMemories (so the action is always callable).
 *   - semantic-search with embeddings ENABLED routes through the indexer
 *     and hydrates results via readMemory (live content, not snapshot).
 *   - add/update/append fire an async indexer.indexOne (best-effort —
 *     errors don't break the memory write).
 *   - delete fires an async indexer.deleteOne.
 *   - backfill-embeddings calls indexer.backfill with the full memory set.
 *   - When the underlying indexer signals an error, semantic-search
 *     returns { success: false, error } but doesn't throw.
 *
 * Mocks: memoryService (per the existing memoryTool.test.js pattern),
 * plus an injected `aiService` whose `getEmbeddingService` returns a
 * stub. The MemoryIndexer module is also mocked so we can assert the
 * exact calls without depending on the real Indexer's internals.
 */

import { jest, describe, test, expect, beforeEach } from '@jest/globals';
import { createMockLogger } from '../../__test-utils__/mockFactories.js';

// ──── mocks ────────────────────────────────────────────────────────

const mockMemoryService = {
  initialize: jest.fn().mockResolvedValue(undefined),
  addMemory:    jest.fn(),
  updateMemory: jest.fn(),
  appendToMemory: jest.fn(),
  deleteMemory: jest.fn(),
  listMemories: jest.fn(),
  readMemory:   jest.fn(),
  searchMemories: jest.fn(),
  getMemoryStats: jest.fn(),
};

jest.unstable_mockModule('../../services/memoryService.js', () => ({
  getMemoryService: jest.fn(() => mockMemoryService),
  PLAN_TITLE_PREFIX: 'plan/',
}));

// Stub the MemoryIndexer module — we want to assert exactly which
// methods the tool calls without depending on the real indexer's
// embedding pipeline (the real indexer has its own dedicated tests).
const mockIndexerInstance = {
  isEnabled: true,
  indexOne:   jest.fn().mockResolvedValue({ indexed: true }),
  deleteOne:  jest.fn().mockResolvedValue({ deleted: true }),
  indexMany:  jest.fn().mockResolvedValue({ indexed: 0, skipped: 0, failed: 0 }),
  backfill:   jest.fn().mockResolvedValue({ ok: true, indexed: 3, skipped: 0, failed: 0 }),
  search:     jest.fn().mockResolvedValue({ enabled: true, hits: [] }),
};
const MockMemoryIndexer = jest.fn(() => mockIndexerInstance);

jest.unstable_mockModule('../../services/embeddings/indexers/memoryIndexer.js', () => ({
  MemoryIndexer: MockMemoryIndexer,
  memoryToText: () => '',
}));

const { default: MemoryTool } = await import('../memoryTool.js');

// ──── helpers ──────────────────────────────────────────────────────

function makeTool({ embeddingsEnabled = true, embeddingService = null } = {}) {
  const logger = createMockLogger();
  const tool = new MemoryTool({}, logger);
  // Reset indexer mock state per test
  mockIndexerInstance.isEnabled = embeddingsEnabled;
  mockIndexerInstance.indexOne.mockClear();
  mockIndexerInstance.deleteOne.mockClear();
  mockIndexerInstance.indexMany.mockClear();
  mockIndexerInstance.backfill.mockClear();
  mockIndexerInstance.search.mockClear();
  MockMemoryIndexer.mockClear();
  // Reset memoryService calls
  for (const fn of Object.values(mockMemoryService)) {
    if (typeof fn?.mockClear === 'function') fn.mockClear();
  }

  // Attach aiService stub so the tool can find the indexer.
  if (embeddingService !== null) {
    tool.aiService = { getEmbeddingService: () => embeddingService };
  } else {
    tool.aiService = { getEmbeddingService: () => ({ isEnabled: embeddingsEnabled }) };
  }
  // Reset the lazy cache so isEnabled flips between tests.
  tool._memoryIndexer = null;
  return tool;
}

const context = { agentId: 'a1' };

// ──── async hook tests ────────────────────────────────────────────

describe('memoryTool — async indexer hooks', () => {
  test('add fires indexOne with the memory just written', async () => {
    const tool = makeTool();
    const memory = { id: 'm1', title: 'A', content: 'B', createdAt: '2026-05-26T00:00:00Z' };
    mockMemoryService.addMemory.mockResolvedValue(memory);

    await tool.execute(
      { actions: [{ type: 'add', title: 'A', content: 'B' }] },
      context,
    );

    // The fire-and-forget call is awaited inside the test by flushing microtasks.
    await Promise.resolve();
    expect(mockIndexerInstance.indexOne).toHaveBeenCalledWith('a1', memory);
  });

  test('update fires indexOne with the updated memory', async () => {
    const tool = makeTool();
    const updated = { id: 'm1', title: 'A', content: 'B-new' };
    mockMemoryService.updateMemory.mockResolvedValue(updated);

    await tool.execute(
      { actions: [{ type: 'update', id: 'm1', content: 'B-new' }] },
      context,
    );

    await Promise.resolve();
    expect(mockIndexerInstance.indexOne).toHaveBeenCalledWith('a1', updated);
  });

  test('append fires indexOne with the grown memory', async () => {
    const tool = makeTool();
    const appended = { id: 'm1', title: 'A', content: 'old\nnew' };
    mockMemoryService.appendToMemory.mockResolvedValue(appended);

    await tool.execute(
      { actions: [{ type: 'append', id: 'm1', content: 'new' }] },
      context,
    );

    await Promise.resolve();
    expect(mockIndexerInstance.indexOne).toHaveBeenCalledWith('a1', appended);
  });

  test('delete fires deleteOne with the memory id', async () => {
    const tool = makeTool();
    mockMemoryService.deleteMemory.mockResolvedValue(true);

    await tool.execute(
      { actions: [{ type: 'delete', id: 'm1' }] },
      context,
    );

    await Promise.resolve();
    expect(mockIndexerInstance.deleteOne).toHaveBeenCalledWith('a1', 'm1');
  });

  test('failed memory write does NOT fire indexer', async () => {
    const tool = makeTool();
    mockMemoryService.deleteMemory.mockResolvedValue(false);   // not found

    await tool.execute(
      { actions: [{ type: 'delete', id: 'missing' }] },
      context,
    );

    await Promise.resolve();
    expect(mockIndexerInstance.deleteOne).not.toHaveBeenCalled();
  });

  test('disabled indexer → no async calls (no waste)', async () => {
    const tool = makeTool({ embeddingsEnabled: false });
    mockMemoryService.addMemory.mockResolvedValue({ id: 'm1', title: 'A', content: 'B' });

    await tool.execute(
      { actions: [{ type: 'add', title: 'A', content: 'B' }] },
      context,
    );

    await Promise.resolve();
    expect(mockIndexerInstance.indexOne).not.toHaveBeenCalled();
  });

  test('indexer error does NOT break the memory write response', async () => {
    const tool = makeTool();
    mockMemoryService.addMemory.mockResolvedValue({ id: 'm1', title: 'A', content: 'B' });
    mockIndexerInstance.indexOne.mockRejectedValueOnce(new Error('boom'));

    const result = await tool.execute(
      { actions: [{ type: 'add', title: 'A', content: 'B' }] },
      context,
    );

    expect(result.success).toBe(true);
    // Wait a microtask for the catch handler to fire so no
    // unhandled-rejection is in flight when the test ends.
    await Promise.resolve();
    await Promise.resolve();
  });
});

// ──── semantic-search tests ───────────────────────────────────────

describe('memoryTool — semantic-search action', () => {
  // customValidateParameters is the canonical entry point for the
  // tool-framework validation gate (BaseTool wires it up). Test the
  // validation contract there; execute() itself is intentionally forgiving
  // and falls back to defaults for invalid optional params (topK).
  test('validation: query is required for semantic-search', () => {
    const tool = makeTool();
    const v = tool.customValidateParameters({ actions: [{ type: 'semantic-search' }] });
    expect(v.valid).toBe(false);
    expect(v.errors.join(' ')).toMatch(/query.*semantic-search/i);
  });

  test('validation: topK must be a positive integer if supplied', () => {
    const tool = makeTool();
    const bad = tool.customValidateParameters({
      actions: [{ type: 'semantic-search', query: 'q', topK: -1 }],
    });
    expect(bad.valid).toBe(false);
    expect(bad.errors.join(' ')).toMatch(/topK/i);

    const alsoBad = tool.customValidateParameters({
      actions: [{ type: 'semantic-search', query: 'q', topK: 0.5 }],
    });
    expect(alsoBad.valid).toBe(false);

    const good = tool.customValidateParameters({
      actions: [{ type: 'semantic-search', query: 'q', topK: 5 }],
    });
    expect(good.valid).toBe(true);
  });

  test('execute(): invalid topK falls back to default 10 (forgiving)', async () => {
    const tool = makeTool();
    mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [] });

    await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'q', topK: -1 }] },
      context,
    );

    expect(mockIndexerInstance.search).toHaveBeenCalledWith('a1', 'q', { topK: 10, hybrid: true });
  });

  test('embeddings DISABLED → falls back to substring search', async () => {
    const tool = makeTool({ embeddingsEnabled: false });
    mockMemoryService.searchMemories.mockResolvedValue([{ id: 'm1', title: 'hit' }]);

    const r = await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'hit' }] },
      context,
    );

    expect(mockIndexerInstance.search).not.toHaveBeenCalled();
    expect(mockMemoryService.searchMemories).toHaveBeenCalledWith('a1', 'hit');
    expect(r.action).toBe('semantic-search');
    expect(r.mode).toBe('substring-fallback');
    expect(r.results).toHaveLength(1);
  });

  test('embeddings ENABLED → routes through indexer + hydrates via readMemory', async () => {
    const tool = makeTool();
    mockIndexerInstance.search.mockResolvedValue({
      enabled: true,
      hits: [
        { id: 'm1', score: 0.9, metadata: { title: 'snap-title', text: 'snap' } },
        { id: 'm2', score: 0.4, metadata: { title: 'other',     text: 'snap2' } },
      ],
    });
    mockMemoryService.readMemory
      .mockResolvedValueOnce({ id: 'm1', title: 'fresh-title', content: 'live' })
      .mockResolvedValueOnce({ id: 'm2', title: 'fresh-other', content: 'live2' });

    const r = await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'something', topK: 3 }] },
      context,
    );

    expect(mockIndexerInstance.search).toHaveBeenCalledWith('a1', 'something', { topK: 3, hybrid: true });
    expect(r.action).toBe('semantic-search');
    expect(r.mode).toBe('hybrid');
    expect(r.results).toHaveLength(2);
    expect(r.results[0]).toMatchObject({ id: 'm1', title: 'fresh-title', score: 0.9 });
    expect(r.results[1]).toMatchObject({ id: 'm2', title: 'fresh-other', score: 0.4 });
  });

  test('hybrid:false forwarded to indexer', async () => {
    const tool = makeTool();
    mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [] });

    await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'q', hybrid: false }] },
      context,
    );

    expect(mockIndexerInstance.search).toHaveBeenCalledWith('a1', 'q', { topK: 10, hybrid: false });
  });

  test('missing memory in store: marked as missing in result', async () => {
    const tool = makeTool();
    mockIndexerInstance.search.mockResolvedValue({
      enabled: true,
      hits: [{ id: 'gone', score: 0.5, metadata: { title: 'stale' } }],
    });
    mockMemoryService.readMemory.mockResolvedValue(null);

    const r = await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'q' }] },
      context,
    );

    expect(r.results[0]).toMatchObject({ id: 'gone', missing: true });
  });

  test('indexer signals error → success:false, error surfaces in result', async () => {
    const tool = makeTool();
    mockIndexerInstance.search.mockResolvedValue({ enabled: true, hits: [], error: 'rate-limited' });

    const r = await tool.execute(
      { actions: [{ type: 'semantic-search', query: 'q' }] },
      context,
    );

    expect(r.success).toBe(false);
    expect(r.error).toBe('rate-limited');
  });
});

// ──── backfill-embeddings ─────────────────────────────────────────

describe('memoryTool — backfill-embeddings action', () => {
  test('embeddings DISABLED → clean error message', async () => {
    const tool = makeTool({ embeddingsEnabled: false });
    const r = await tool.execute(
      { actions: [{ type: 'backfill-embeddings' }] },
      context,
    );
    expect(r.success).toBe(false);
    expect(r.message).toMatch(/not enabled/i);
    expect(mockIndexerInstance.backfill).not.toHaveBeenCalled();
  });

  test('embeddings ENABLED → walks listMemories(\'full\') and calls indexer.backfill', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue({
      count: 3,
      grouped: {
        '2026-05-25': [{ id: 'm1', title: 'a' }, { id: 'm2', title: 'b' }],
        '2026-05-24': [{ id: 'm3', title: 'c' }],
      },
    });

    const r = await tool.execute(
      { actions: [{ type: 'backfill-embeddings' }] },
      context,
    );

    expect(mockMemoryService.listMemories).toHaveBeenCalledWith('a1', 'full');
    expect(mockIndexerInstance.backfill).toHaveBeenCalledWith(
      'a1',
      expect.arrayContaining([
        expect.objectContaining({ id: 'm1' }),
        expect.objectContaining({ id: 'm2' }),
        expect.objectContaining({ id: 'm3' }),
      ]),
    );
    expect(r.success).toBe(true);
    expect(r.indexed).toBe(3);
  });

  test('reports skipped + failed counts from indexer', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue({
      count: 2,
      grouped: { '2026-05-25': [{ id: 'm1' }, { id: 'm2' }] },
    });
    mockIndexerInstance.backfill.mockResolvedValue({
      ok: true, indexed: 1, skipped: 1, failed: 0,
    });

    const r = await tool.execute(
      { actions: [{ type: 'backfill-embeddings' }] },
      context,
    );

    expect(r.indexed).toBe(1);
    expect(r.skipped).toBe(1);
    expect(r.message).toMatch(/skipped 1/);
  });

  test('indexer failure surfaces in result', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue({ count: 1, grouped: {} });
    mockIndexerInstance.backfill.mockResolvedValue({
      ok: false, reason: 'disabled',
    });

    const r = await tool.execute(
      { actions: [{ type: 'backfill-embeddings' }] },
      context,
    );

    expect(r.success).toBe(false);
    expect(r.message).toMatch(/disabled/);
  });
});
