/**
 * Fleet cleanup/recovery — stale-factory lease reclaim + garbage-collection sweep.
 * Memory provider; deterministic (no real clock — `now` is injected).
 */

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';
import * as repo from './repository.js';
import * as coord from './coordinator.js';
import {
  FleetJobDocSchema,
  type FleetJobDoc,
  type FleetLeaseDoc,
  type SubmitJobInput,
} from './types.js';

const PID = 'lysnrai';

function input(over: Partial<SubmitJobInput> = {}): SubmitJobInput {
  return {
    idempotencyKey: 'task-1',
    bodyMd: '# do the thing',
    priority: 'medium',
    capabilities: [],
    prefersEngine: [],
    allowedScope: [],
    deps: [],
    kind: 'leaf',
    ...over,
  };
}

const factory = (over = {}) => ({
  productId: PID,
  factoryId: 'fac_1',
  capabilities: [] as string[],
  leaseSeconds: 900,
  ...over,
});

describe('fleet cleanup — reclaimStaleFactoryLeases', () => {
  beforeEach(() => setProvider(new MemoryDatastoreProvider()));
  afterEach(() => _resetDatastoreProvider());

  it('reclaims a held lease whose holder factory stopped heartbeating', async () => {
    const { job } = await coord.submitJob(PID, input());
    await coord.heartbeat({ productId: PID, factoryId: 'fac_1', capabilities: [], load: 0 });
    const claim = await coord.claimNextJob(factory());
    expect(claim?.job.stage).toBe('assigned');

    // Factory goes silent: rewrite its heartbeat to ~5 minutes ago.
    const fac = await repo.getFactory('fac_1', PID);
    await repo.upsertFactory({
      ...fac!,
      lastHeartbeatAt: new Date(Date.now() - 5 * 60_000).toISOString(),
    });

    const res = await coord.reclaimStaleFactoryLeases(Date.now());
    expect(res.reaped).toBe(1);
    expect(res.jobIds).toEqual([job.id]);

    const after = await repo.getJob(job.id, PID);
    expect(after?.stage).toBe('queued'); // returned to the queue
    expect(after?.leaseEpoch).toBe(claim!.job.leaseEpoch + 1); // zombie fenced
    expect((await repo.getLease(job.id))?.status).toBe('expired');
  });

  it('reclaims a held lease whose holder factory never registered (vanished)', async () => {
    const { job } = await coord.submitJob(PID, input());
    await coord.claimNextJob(factory()); // no heartbeat ⇒ no factory doc
    const res = await coord.reclaimStaleFactoryLeases(Date.now());
    expect(res.reaped).toBe(1);
    expect((await repo.getJob(job.id, PID))?.stage).toBe('queued');
  });

  it('leaves a fresh factory\u2019s lease untouched', async () => {
    await coord.submitJob(PID, input());
    await coord.heartbeat({ productId: PID, factoryId: 'fac_1', capabilities: [], load: 0 });
    const claim = await coord.claimNextJob(factory());
    const res = await coord.reclaimStaleFactoryLeases(Date.now());
    expect(res.reaped).toBe(0);
    expect((await repo.getJob(claim!.job.id, PID))?.stage).toBe('assigned');
  });
});

describe('fleet cleanup — sweepFleetGarbage', () => {
  beforeEach(() => setProvider(new MemoryDatastoreProvider()));
  afterEach(() => _resetDatastoreProvider());

  const now = Date.now();

  /** Seed a terminal job directly (the repo write paths force updatedAt=now, so we
   *  go through createJob to control the backdated updatedAt the GC keys on). */
  async function seedTerminalJob(
    id: string,
    ageMs: number,
    stage = 'shipped'
  ): Promise<FleetJobDoc> {
    const at = new Date(now - ageMs).toISOString();
    const doc = FleetJobDocSchema.parse({
      id,
      productId: PID,
      stage,
      idempotencyKey: id,
      contentHash: 'h',
      bodyMd: '# done',
      manifestSnapshot: {
        priority: 'medium',
        capabilities: [],
        prefersEngine: [],
        allowedScope: [],
        deps: [],
      },
      priority: 'medium',
      priorityOrder: 2,
      capabilities: [],
      deps: [],
      kind: 'leaf',
      attempts: 1,
      leaseEpoch: 0,
      rev: 0,
      createdAt: at,
      updatedAt: at,
    });
    return repo.createJob(doc);
  }

  async function seedFinishedLease(jobId: string, status: 'expired' | 'released', ageMs: number) {
    const doc: FleetLeaseDoc = {
      id: jobId,
      productId: PID,
      jobId,
      status,
      leaseEpoch: 1,
      renewals: 0,
      rev: 0,
      updatedAt: new Date(now - ageMs).toISOString(),
    };
    await repo.createLease(doc);
  }

  it('deletes finished leases older than the TTL, keeps recent ones', async () => {
    await seedFinishedLease('old', 'released', 48 * 60 * 60 * 1000); // 2 days
    await seedFinishedLease('recent', 'expired', 60_000); // 1 min

    const res = await coord.sweepFleetGarbage({ now });
    expect(res.leasesDeleted).toBe(1);
    expect(await repo.getLease('old')).toBeNull();
    expect(await repo.getLease('recent')).not.toBeNull();
  });

  it('deletes long-dead factory docs, keeps live ones', async () => {
    await repo.upsertFactory({
      id: 'dead',
      productId: PID,
      factoryId: 'dead',
      descriptor: {},
      capabilities: [],
      health: 'ok',
      load: 0,
      seatLimit: 1,
      lastHeartbeatAt: new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(), // 10 days
    });
    await repo.upsertFactory({
      id: 'live',
      productId: PID,
      factoryId: 'live',
      descriptor: {},
      capabilities: [],
      health: 'ok',
      load: 0,
      seatLimit: 1,
      lastHeartbeatAt: new Date(now).toISOString(),
    });

    const res = await coord.sweepFleetGarbage({ now });
    expect(res.factoriesDeleted).toBe(1);
    expect(await repo.getFactory('dead', PID)).toBeNull();
    expect(await repo.getFactory('live', PID)).not.toBeNull();
  });

  it('never prunes terminal jobs/history by default (jobRetentionMs unset)', async () => {
    const job = await seedTerminalJob('jt_old', 365 * 24 * 60 * 60 * 1000); // 1 year
    const res = await coord.sweepFleetGarbage({ now });
    expect(res.jobsDeleted).toBe(0);
    expect(await repo.getJob(job.id, PID)).not.toBeNull();
  });

  it('prunes old terminal jobs and cascades runs/events when retention is set', async () => {
    const job = await seedTerminalJob('jt_40d', 40 * 24 * 60 * 60 * 1000); // 40 days
    await repo.createRun({
      id: 'run_1',
      productId: PID,
      jobId: job.id,
      attempt: 1,
      engine: 'devin',
      startedAt: new Date(now).toISOString(),
      insights: {},
    });
    await repo.appendEvent({ jobId: job.id, productId: PID, type: 'submitted' });

    const res = await coord.sweepFleetGarbage({
      now,
      jobRetentionMs: 30 * 24 * 60 * 60 * 1000, // 30-day retention
    });
    expect(res.jobsDeleted).toBe(1);
    expect(res.runsDeleted).toBe(1);
    expect(res.eventsDeleted).toBeGreaterThanOrEqual(1);
    expect(await repo.getJob(job.id, PID)).toBeNull();
    expect(await repo.listRunsByJob(job.id)).toHaveLength(0);
    expect(await repo.listEvents(job.id)).toHaveLength(0);
  });

  it('keeps terminal jobs newer than the retention window', async () => {
    const job = await seedTerminalJob('jt_5d', 5 * 24 * 60 * 60 * 1000); // 5 days
    const res = await coord.sweepFleetGarbage({
      now,
      jobRetentionMs: 30 * 24 * 60 * 60 * 1000,
    });
    expect(res.jobsDeleted).toBe(0);
    expect(await repo.getJob(job.id, PID)).not.toBeNull();
  });
});
