/**
 * Fleet retry / dead-letter automation — enforces job.retry on a failed attempt:
 * auto-requeue (with backoff) while attempts remain, dead-letter when exhausted,
 * plain `failed` when there's no policy or the result isn't retryable.
 */

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 { parseBackoffMs, decideFailureOutcome } from './coordinator.js';
import type { FleetJobDoc, 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,
});

/** Claim the queued job (assigns it + creates the lease) and return its leaseEpoch. */
async function claim() {
  const c = await coord.claimNextJob(factory());
  if (!c) throw new Error('expected a claim');
  return c;
}

describe('parseBackoffMs', () => {
  it('parses seconds/minutes/hours and bare numbers', () => {
    expect(parseBackoffMs('30s', 1)).toBe(30_000);
    expect(parseBackoffMs('5m', 1)).toBe(5 * 60_000);
    expect(parseBackoffMs('1h', 1)).toBe(60 * 60_000);
    expect(parseBackoffMs('45', 1)).toBe(45_000); // bare ⇒ seconds
    expect(parseBackoffMs('250ms', 1)).toBe(250);
  });
  it('grows exponentially (capped at 1h) and defaults to 0', () => {
    expect(parseBackoffMs('exp', 1)).toBe(30_000);
    expect(parseBackoffMs('exp', 2)).toBe(60_000);
    expect(parseBackoffMs('exp', 99)).toBe(60 * 60 * 1000); // capped
    expect(parseBackoffMs(undefined, 1)).toBe(0);
    expect(parseBackoffMs('garbage', 1)).toBe(0);
  });
});

describe('decideFailureOutcome (pure)', () => {
  const base = { attempts: 1 } as FleetJobDoc;
  it('no policy ⇒ failed', () => {
    expect(decideFailureOutcome({ ...base } as FleetJobDoc, 'timeout').kind).toBe('failed');
  });
  it('non-retryable result ⇒ failed even with a policy', () => {
    const job = { ...base, retry: { max: 3, on: ['timeout'] } } as FleetJobDoc;
    expect(decideFailureOutcome(job, 'no_engine').kind).toBe('failed');
    expect(decideFailureOutcome(job, 'capability_mismatch').kind).toBe('failed');
  });
  it('matching class with attempts left ⇒ retry', () => {
    const job = { ...base, attempts: 1, retry: { max: 3, on: ['timeout'] } } as FleetJobDoc;
    expect(decideFailureOutcome(job, 'timeout').kind).toBe('retry');
  });
  it('matching class but attempts exhausted ⇒ dead_letter', () => {
    const job = { ...base, attempts: 3, retry: { max: 3, on: ['timeout'] } } as FleetJobDoc;
    expect(decideFailureOutcome(job, 'timeout').kind).toBe('dead_letter');
  });
  it("generic 'failed' matches agent_error/crash classes", () => {
    const job = { ...base, attempts: 1, retry: { max: 2, on: ['agent_error'] } } as FleetJobDoc;
    expect(decideFailureOutcome(job, 'failed').kind).toBe('retry');
  });
});

describe('fleet retry — releaseLease enforcement', () => {
  beforeEach(() => setProvider(new MemoryDatastoreProvider()));
  afterEach(() => _resetDatastoreProvider());

  it('requeues a retryable failure and sets a backoff', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 3, on: ['timeout'] } }));
    const c = await claim();
    const res = await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'failed', {
      result: 'timeout',
    });
    expect(res.ok).toBe(true);

    const after = await repo.getJob(job.id, PID);
    expect(after?.stage).toBe('queued'); // back in the queue, not failed
    // No backoff configured ⇒ retryNotBefore is ~now (not in the future) ⇒ claimable.
    expect(Date.parse(after!.retryNotBefore!)).toBeLessThanOrEqual(Date.now());
  });

  it('honors backoff: a requeued job is not claimable until retryNotBefore', async () => {
    const { job } = await coord.submitJob(
      PID,
      input({ retry: { max: 3, on: ['timeout'], backoff: '1h' } })
    );
    const c = await claim();
    await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'failed', { result: 'timeout' });

    const after = await repo.getJob(job.id, PID);
    expect(after?.stage).toBe('queued');
    expect(after?.retryNotBefore).toBeTruthy();
    expect(Date.parse(after!.retryNotBefore!)).toBeGreaterThan(Date.now());

    // The scheduler must NOT hand it out while backing off.
    expect(await coord.claimNextJob(factory())).toBeNull();
  });

  it('dead-letters once attempts are exhausted', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 1, on: ['timeout'] } }));
    const c = await claim(); // attempts becomes 1 (== max)
    await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'failed', { result: 'timeout' });

    const after = await repo.getJob(job.id, PID);
    expect(after?.stage).toBe('dead_letter');
  });

  it('a non-retryable failure stays failed (no policy)', async () => {
    const { job } = await coord.submitJob(PID, input());
    const c = await claim();
    await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'failed', { result: 'failed' });
    expect((await repo.getJob(job.id, PID))?.stage).toBe('failed');
  });

  it('a successful release is unaffected by retry logic', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 3, on: ['timeout'] } }));
    const c = await claim();
    await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'review', { result: 'review' });
    expect((await repo.getJob(job.id, PID))?.stage).toBe('review');
  });

  it('an immediate-backoff retry is claimable again on the next attempt', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 3, on: ['timeout'] } }));
    const c1 = await claim();
    await coord.releaseLease(job.id, PID, c1.job.leaseEpoch, 'failed', { result: 'timeout' });
    // backoff unset ⇒ retryNotBefore ~= now ⇒ immediately re-claimable
    const c2 = await coord.claimNextJob(factory());
    expect(c2?.job.id).toBe(job.id);
    expect(c2?.job.attempts).toBe(2);
  });

  it('operator redrive grants a dead_letter job a FRESH retry budget (queued, no backoff)', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 2, on: ['timeout'] } }));
    // Burn the whole budget: two attempts → dead_letter.
    const c1 = await claim();
    await coord.releaseLease(job.id, PID, c1.job.leaseEpoch, 'failed', { result: 'timeout' });
    const c2 = await coord.claimNextJob(factory());
    await coord.releaseLease(job.id, PID, c2!.job.leaseEpoch, 'failed', { result: 'timeout' });
    expect((await repo.getJob(job.id, PID))?.stage).toBe('dead_letter');

    const res = await coord.operatorAction(job.id, PID, 'redrive');
    expect(res.ok).toBe(true);
    const after = await repo.getJob(job.id, PID);
    expect(after?.stage).toBe('queued');
    expect(after?.attemptsBase).toBe(after?.attempts); // budget anchored to now
    expect(after?.retryNotBefore).toBeUndefined(); // claimable immediately

    // Fresh budget: the next failure RETRIES (used 1 < max 2) instead of dead-lettering,
    // and the re-claim doesn't collide on run id (attempts stays monotonic).
    const c3 = await coord.claimNextJob(factory());
    expect(c3?.job.id).toBe(job.id);
    expect(c3!.job.attempts).toBe(3); // monotonic
    await coord.releaseLease(job.id, PID, c3!.job.leaseEpoch, 'failed', { result: 'timeout' });
    expect((await repo.getJob(job.id, PID))?.stage).toBe('queued');
  });

  it('a plain requeue leaves the budget exhausted (re-dead-letters); redrive resets it', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 1, on: ['timeout'] } }));
    const c1 = await claim();
    await coord.releaseLease(job.id, PID, c1.job.leaseEpoch, 'failed', { result: 'timeout' });
    // requeue: budget still exhausted → one more attempt re-dead-letters.
    await coord.operatorAction(job.id, PID, 'requeue');
    const c2 = await coord.claimNextJob(factory());
    await coord.releaseLease(job.id, PID, c2!.job.leaseEpoch, 'failed', { result: 'timeout' });
    expect((await repo.getJob(job.id, PID))?.stage).toBe('dead_letter');
    // redrive: anchors the budget so the next attempt is fresh again.
    await coord.operatorAction(job.id, PID, 'redrive');
    const j = await repo.getJob(job.id, PID);
    expect(j?.attemptsBase).toBe(j?.attempts);
    expect(j?.stage).toBe('queued');
  });

  it('fleetMetrics raises a dead_letter alert when jobs are dead-lettered', async () => {
    const { job } = await coord.submitJob(PID, input({ retry: { max: 1, on: ['timeout'] } }));
    const c = await claim();
    await coord.releaseLease(job.id, PID, c.job.leaseEpoch, 'failed', { result: 'timeout' });
    const metrics = await coord.fleetMetrics(PID);
    expect(metrics.jobs.byStage.dead_letter).toBe(1);
    expect(metrics.alerts.map(a => a.code)).toContain('dead_letter');
  });
});
