/**
 * Fleet lease reaper — verifies the background loop actually drives
 * coordinator.reapExpiredLeases (start/stop/idempotency, immediate + interval
 * passes, and that a failing pass never throws out of the timer).
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const { reapSpy, staleSpy, sweepSpy } = vi.hoisted(() => ({
  reapSpy: vi.fn(),
  staleSpy: vi.fn(),
  sweepSpy: vi.fn(),
}));

vi.mock('./coordinator.js', () => ({
  reapExpiredLeases: reapSpy,
  reclaimStaleFactoryLeases: staleSpy,
  sweepFleetGarbage: sweepSpy,
}));

import {
  startLeaseReaper,
  stopLeaseReaper,
  isLeaseReaperRunning,
  getReaperStats,
} from './reaper.js';

const log = { info: vi.fn(), warn: vi.fn() };
const emptyGc = {
  leasesDeleted: 0,
  factoriesDeleted: 0,
  tokensDeleted: 0,
  jobsDeleted: 0,
  runsDeleted: 0,
  eventsDeleted: 0,
  artifactsDeleted: 0,
};

describe('fleet lease reaper', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    reapSpy.mockReset();
    staleSpy.mockReset();
    sweepSpy.mockReset();
    reapSpy.mockResolvedValue({ reaped: 0, jobIds: [] });
    staleSpy.mockResolvedValue({ reaped: 0, jobIds: [] });
    sweepSpy.mockResolvedValue({ ...emptyGc });
    log.info.mockReset();
    log.warn.mockReset();
  });

  afterEach(() => {
    stopLeaseReaper();
    vi.useRealTimers();
  });

  it('runs a pass immediately on start, then on each interval', async () => {
    startLeaseReaper(log);
    expect(isLeaseReaperRunning()).toBe(true);
    expect(reapSpy).toHaveBeenCalledTimes(1); // immediate boot pass

    await vi.advanceTimersByTimeAsync(30_000);
    expect(reapSpy).toHaveBeenCalledTimes(2);

    await vi.advanceTimersByTimeAsync(30_000);
    expect(reapSpy).toHaveBeenCalledTimes(3);
  });

  it('passes a fresh ISO timestamp to reapExpiredLeases', async () => {
    startLeaseReaper(log);
    expect(reapSpy).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/));
  });

  it('also reclaims stale-factory leases each pass', async () => {
    startLeaseReaper(log);
    await vi.advanceTimersByTimeAsync(0); // flush the boot pass (stale runs after an await)
    expect(staleSpy).toHaveBeenCalledTimes(1);
    await vi.advanceTimersByTimeAsync(30_000);
    expect(staleSpy).toHaveBeenCalledTimes(2);
  });

  it('logs a combined count when something was reclaimed', async () => {
    reapSpy.mockResolvedValueOnce({ reaped: 2, jobIds: ['fjob_a', 'fjob_b'] });
    staleSpy.mockResolvedValueOnce({ reaped: 1, jobIds: ['fjob_c'] });
    startLeaseReaper(log);
    await vi.advanceTimersByTimeAsync(0);
    expect(log.info).toHaveBeenCalledWith(expect.stringContaining('reclaimed 3 job(s)'));
  });

  it('runs the GC sweep on boot then throttles it (hourly, not every tick)', async () => {
    startLeaseReaper(log);
    await vi.advanceTimersByTimeAsync(0); // flush the boot pass (sweep runs after awaits)
    expect(sweepSpy).toHaveBeenCalledTimes(1); // boot sweep
    await vi.advanceTimersByTimeAsync(30_000); // a fast reclaim tick — no sweep
    expect(sweepSpy).toHaveBeenCalledTimes(1);
    await vi.advanceTimersByTimeAsync(60 * 60 * 1000); // cross the hourly boundary
    expect(sweepSpy).toHaveBeenCalledTimes(2);
  });

  it('is idempotent: a second start does not double-schedule', async () => {
    startLeaseReaper(log);
    startLeaseReaper(log);
    expect(reapSpy).toHaveBeenCalledTimes(1);
    await vi.advanceTimersByTimeAsync(30_000);
    expect(reapSpy).toHaveBeenCalledTimes(2); // one interval, not two
  });

  it('stop halts further passes and is idempotent', async () => {
    startLeaseReaper(log);
    stopLeaseReaper();
    expect(isLeaseReaperRunning()).toBe(false);
    await vi.advanceTimersByTimeAsync(90_000);
    expect(reapSpy).toHaveBeenCalledTimes(1); // only the boot pass ran
    expect(() => stopLeaseReaper()).not.toThrow();
  });

  it('accumulates reclaim + gc telemetry in getReaperStats', async () => {
    const before = getReaperStats().totals;
    reapSpy.mockResolvedValueOnce({ reaped: 2, jobIds: ['a', 'b'] });
    staleSpy.mockResolvedValueOnce({ reaped: 1, jobIds: ['c'] });
    sweepSpy.mockResolvedValueOnce({ ...emptyGc, leasesDeleted: 4, tokensDeleted: 2 });
    startLeaseReaper(log);
    await vi.advanceTimersByTimeAsync(0); // flush boot pass (reclaim + sweep)

    const s = getReaperStats();
    expect(s.running).toBe(true);
    expect(s.startedAt).toBeTruthy();
    expect(s.totals.expiredReclaimed - before.expiredReclaimed).toBe(2);
    expect(s.totals.staleReclaimed - before.staleReclaimed).toBe(1);
    expect(s.totals.leasesDeleted - before.leasesDeleted).toBe(4);
    expect(s.totals.tokensDeleted - before.tokensDeleted).toBe(2);
    expect(s.lastReclaimAt).toBeTruthy();
    expect(s.lastSweepAt).toBeTruthy();
  });

  it('swallows a failing pass and keeps the timer alive', async () => {
    reapSpy.mockRejectedValueOnce(new Error('cosmos unavailable'));
    startLeaseReaper(log);
    await vi.advanceTimersByTimeAsync(0); // let the rejected boot pass settle
    expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('reclaim pass failed'));

    // The interval is still scheduled and the next pass runs.
    reapSpy.mockResolvedValueOnce({ reaped: 0, jobIds: [] });
    await vi.advanceTimersByTimeAsync(30_000);
    expect(reapSpy).toHaveBeenCalledTimes(2);
  });
});
