/**
 * memoryTool.forget-stale tests.
 *
 * FadeMem-paper-aligned soft pruning (arXiv 2601.18642): the right
 * long-term answer to unbounded memory growth is age-based decay. This
 * suite pins the contract:
 *
 *   - Default ageDays threshold is 90.
 *   - Plan/* memories are SKIPPED unless includePlans:true (they're the
 *     active working plan, not stale notes).
 *   - dryRun:true returns the candidate list WITHOUT modifying state.
 *   - dryRun:false (default) deletes each candidate via memoryService.
 *   - Custom ageDays threshold is honoured.
 *   - Memories without createdAt are skipped (can't determine age).
 *   - Validation: ageDays must be positive when present.
 *   - Failed deletions are counted; the action does not throw.
 */

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

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 indexer modules — forget-stale doesn't use them directly
// (the cascade fires via memoryService.deleteMemory → tool's async hook
// → indexer.deleteOne), so we just need them to be no-op importable.
jest.unstable_mockModule('../../services/embeddings/indexers/memoryIndexer.js', () => ({
  MemoryIndexer: jest.fn(() => ({
    isEnabled: false,
    indexOne: jest.fn(),
    deleteOne: jest.fn(),
    indexMany: jest.fn(),
    backfill: jest.fn(),
    search: jest.fn(),
  })),
  memoryToText: () => '',
}));
jest.unstable_mockModule('../../services/embeddings/indexers/reminisceIndexer.js', () => ({
  ReminisceIndexer: jest.fn(() => ({ isEnabled: false })),
  messageToText: () => '',
  estimateMessageTokens: () => 0,
}));

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

const NOW = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;

function makeTool() {
  const tool = new MemoryTool({}, createMockLogger());
  // No aiService → indexer accessor returns null cleanly.
  tool.aiService = null;
  for (const fn of Object.values(mockMemoryService)) {
    if (typeof fn?.mockClear === 'function') fn.mockClear();
  }
  return tool;
}

/** Build a listMemories('full') response with the given memories grouped by date. */
function makeListing(memories) {
  const grouped = {};
  for (const m of memories) {
    const dateKey = (m.createdAt || new Date().toISOString()).slice(0, 10);
    (grouped[dateKey] = grouped[dateKey] || []).push(m);
  }
  return { count: memories.length, grouped };
}

const fixtures = {
  fresh:   { id: 'm-fresh',  title: 'fresh thought', createdAt: new Date(NOW - 5  * DAY_MS).toISOString() },
  middle:  { id: 'm-mid',    title: 'middle note',   createdAt: new Date(NOW - 60 * DAY_MS).toISOString() },
  old:     { id: 'm-old',    title: 'ancient note',  createdAt: new Date(NOW - 120 * DAY_MS).toISOString() },
  ancient: { id: 'm-anc',    title: 'very old',      createdAt: new Date(NOW - 365 * DAY_MS).toISOString() },
  planNew: { id: 'm-plan-1', title: 'plan/auth-refactor', createdAt: new Date(NOW - 5  * DAY_MS).toISOString() },
  planOld: { id: 'm-plan-2', title: 'plan/legacy-cleanup', createdAt: new Date(NOW - 200 * DAY_MS).toISOString() },
  noDate:  { id: 'm-no-date', title: 'orphan note' },          // no createdAt
};

describe('memoryTool.forget-stale — defaults', () => {
  test('default threshold = 90 days; identifies old + ancient', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.fresh, fixtures.middle, fixtures.old, fixtures.ancient,
    ]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true }] },
      { agentId: 'a1' },
    );
    expect(r.success).toBe(true);
    expect(r.thresholdDays).toBe(90);
    expect(r.candidates.map(c => c.id).sort()).toEqual(['m-anc', 'm-old']);
  });

  test('skips plan/* memories by default', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.old, fixtures.planOld, fixtures.ancient,
    ]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true }] },
      { agentId: 'a1' },
    );
    expect(r.candidates.map(c => c.id)).not.toContain('m-plan-2');
    expect(r.candidates.map(c => c.id).sort()).toEqual(['m-anc', 'm-old']);
  });

  test('includePlans:true sweeps plan/* memories too', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.planOld, fixtures.ancient,
    ]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true, includePlans: true }] },
      { agentId: 'a1' },
    );
    expect(r.candidates.map(c => c.id).sort()).toEqual(['m-anc', 'm-plan-2']);
  });

  test('skips memories without createdAt', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.old, fixtures.noDate,
    ]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true }] },
      { agentId: 'a1' },
    );
    expect(r.candidates.map(c => c.id)).toEqual(['m-old']);
  });
});

describe('memoryTool.forget-stale — dryRun', () => {
  test('dryRun:true does not call deleteMemory', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([fixtures.old]));
    await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true }] },
      { agentId: 'a1' },
    );
    expect(mockMemoryService.deleteMemory).not.toHaveBeenCalled();
  });

  test('dryRun:true returns the candidate list with age info', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([fixtures.old]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true }] },
      { agentId: 'a1' },
    );
    expect(r.dryRun).toBe(true);
    expect(r.candidates[0]).toMatchObject({ id: 'm-old' });
    expect(typeof r.candidates[0].ageDays).toBe('number');
    expect(r.candidates[0].ageDays).toBeGreaterThanOrEqual(119);
    expect(r.candidates[0].ageDays).toBeLessThanOrEqual(121);
  });
});

describe('memoryTool.forget-stale — actual deletion', () => {
  test('default (dryRun=false): calls deleteMemory for each candidate', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.old, fixtures.ancient, fixtures.fresh,
    ]));
    mockMemoryService.deleteMemory.mockResolvedValue(true);
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale' }] },
      { agentId: 'a1' },
    );
    expect(mockMemoryService.deleteMemory).toHaveBeenCalledTimes(2);
    expect(mockMemoryService.deleteMemory).toHaveBeenCalledWith('a1', 'm-old');
    expect(mockMemoryService.deleteMemory).toHaveBeenCalledWith('a1', 'm-anc');
    expect(r.forgotten).toBe(2);
    expect(r.failed).toBe(0);
    expect(r.success).toBe(true);
  });

  test('reports failures cleanly when deleteMemory returns false', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([fixtures.old, fixtures.ancient]));
    mockMemoryService.deleteMemory
      .mockResolvedValueOnce(true)
      .mockResolvedValueOnce(false);
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale' }] },
      { agentId: 'a1' },
    );
    expect(r.forgotten).toBe(1);
    expect(r.failed).toBe(1);
    expect(r.success).toBe(false);
  });

  test('does not throw when deleteMemory rejects', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([fixtures.old]));
    mockMemoryService.deleteMemory.mockRejectedValueOnce(new Error('disk full'));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale' }] },
      { agentId: 'a1' },
    );
    expect(r.forgotten).toBe(0);
    expect(r.failed).toBe(1);
  });

  test('no candidates → clean no-op message', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([fixtures.fresh]));
    const r = await tool.execute(
      { actions: [{ type: 'forget-stale' }] },
      { agentId: 'a1' },
    );
    expect(r.forgotten).toBe(0);
    expect(r.failed).toBe(0);
    expect(r.message).toMatch(/No memories older/);
  });
});

describe('memoryTool.forget-stale — threshold tuning', () => {
  test('custom ageDays narrows / widens the set', async () => {
    const tool = makeTool();
    mockMemoryService.listMemories.mockResolvedValue(makeListing([
      fixtures.fresh, fixtures.middle, fixtures.old, fixtures.ancient,
    ]));
    // ageDays=30 sweeps middle/old/ancient
    const r1 = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true, ageDays: 30 }] },
      { agentId: 'a1' },
    );
    expect(r1.candidates.map(c => c.id).sort()).toEqual(['m-anc', 'm-mid', 'm-old']);

    // ageDays=200 only catches ancient
    const r2 = await tool.execute(
      { actions: [{ type: 'forget-stale', dryRun: true, ageDays: 200 }] },
      { agentId: 'a1' },
    );
    expect(r2.candidates.map(c => c.id)).toEqual(['m-anc']);
  });
});

describe('memoryTool.forget-stale — validation', () => {
  test('ageDays must be positive when supplied', () => {
    const tool = makeTool();
    expect(tool.customValidateParameters({
      actions: [{ type: 'forget-stale', ageDays: 0 }],
    }).valid).toBe(false);
    expect(tool.customValidateParameters({
      actions: [{ type: 'forget-stale', ageDays: -5 }],
    }).valid).toBe(false);
    expect(tool.customValidateParameters({
      actions: [{ type: 'forget-stale', ageDays: 'lots' }],
    }).valid).toBe(false);
    expect(tool.customValidateParameters({
      actions: [{ type: 'forget-stale', ageDays: 30 }],
    }).valid).toBe(true);
    expect(tool.customValidateParameters({
      actions: [{ type: 'forget-stale' }],         // ageDays absent → ok (default)
    }).valid).toBe(true);
  });
});
