/**
 * Fleet repository — CRUD round-trips, list filters, appendEvent ordering, and
 * the rev compare-and-swap. Runs on the in-memory datastore provider.
 */

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 type { FleetJobDoc } from './types.js';

const PID = 'lysnrai';
const now = '2026-05-30T00:00:00.000Z';

function jobDoc(over: Partial<FleetJobDoc> = {}): FleetJobDoc {
  return {
    id: 'fjob_1',
    productId: PID,
    stage: 'queued',
    idempotencyKey: 'task-1',
    contentHash: 'h1',
    bodyMd: '# task',
    manifestSnapshot: {
      priority: 'medium',
      capabilities: [],
      prefersEngine: [],
      allowedScope: [],
      deps: [],
    },
    priority: 'medium',
    priorityOrder: 2,
    capabilities: [],
    deps: [],
    kind: 'leaf',
    attempts: 0,
    leaseEpoch: 0,
    rev: 0,
    createdAt: now,
    updatedAt: now,
    ...over,
  };
}

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

  it('jobs: create / getById / list by stage + idempotencyKey', async () => {
    await repo.createJob(jobDoc({ id: 'fjob_1', idempotencyKey: 'a', stage: 'queued' }));
    await repo.createJob(jobDoc({ id: 'fjob_2', idempotencyKey: 'b', stage: 'blocked' }));

    expect((await repo.getJob('fjob_1', PID))?.idempotencyKey).toBe('a');
    expect(await repo.getJob('missing', PID)).toBeNull();

    const queued = await repo.listJobs({ productId: PID, stage: 'queued' });
    expect(queued.map(j => j.id)).toEqual(['fjob_1']);

    const byKey = await repo.findJobsByIdempotencyKey(PID, 'b');
    expect(byKey).toHaveLength(1);
    expect(byKey[0].id).toBe('fjob_2');
  });

  it('jobs: revUpdate is a compare-and-swap', async () => {
    await repo.createJob(jobDoc({ id: 'fjob_cas', rev: 0, stage: 'queued' }));

    const first = await repo.revUpdateJob('fjob_cas', PID, 0, { stage: 'assigned' });
    expect(first.ok).toBe(true);
    if (first.ok) expect(first.doc.rev).toBe(1);

    // a second writer using the stale rev (0) must conflict
    const stale = await repo.revUpdateJob('fjob_cas', PID, 0, { stage: 'building' });
    expect(stale.ok).toBe(false);
    if (!stale.ok) expect(stale.reason).toBe('conflict');

    const missing = await repo.revUpdateJob('nope', PID, 0, { stage: 'failed' });
    expect(missing.ok).toBe(false);
    if (!missing.ok) expect(missing.reason).toBe('not_found');
  });

  it('queue-state (M0 gate): version starts at 0, bumps on create + stage change', async () => {
    expect(await repo.getQueueVersion(PID)).toBe(0);

    // submit (createJob) bumps once
    await repo.createJob(jobDoc({ id: 'fjob_q', rev: 0, stage: 'queued' }));
    const afterCreate = await repo.getQueueVersion(PID);
    expect(afterCreate).toBe(1);

    // a successful stage-changing CAS bumps again
    const ok = await repo.revUpdateJob('fjob_q', PID, 0, { stage: 'assigned' });
    expect(ok.ok).toBe(true);
    expect(await repo.getQueueVersion(PID)).toBe(2);

    // a CAS WITHOUT a stage change (e.g. lease renewal) does NOT bump
    const noStage = await repo.revUpdateJob('fjob_q', PID, 1, { leaseEpoch: 5 });
    expect(noStage.ok).toBe(true);
    expect(await repo.getQueueVersion(PID)).toBe(2);

    // a CONFLICTING CAS (stale rev) does NOT bump
    const stale = await repo.revUpdateJob('fjob_q', PID, 0, { stage: 'building' });
    expect(stale.ok).toBe(false);
    expect(await repo.getQueueVersion(PID)).toBe(2);

    // the gate is scoped per product
    expect(await repo.getQueueVersion('other-product')).toBe(0);
  });

  it('runs: create + list ordered by attempt', async () => {
    await repo.createRun({
      id: 'r2',
      productId: PID,
      jobId: 'j',
      attempt: 2,
      engine: 'devin',
      startedAt: now,
      insights: {},
    });
    await repo.createRun({
      id: 'r1',
      productId: PID,
      jobId: 'j',
      attempt: 1,
      engine: 'devin',
      startedAt: now,
      insights: {},
    });
    const runs = await repo.listRunsByJob('j');
    expect(runs.map(r => r.attempt)).toEqual([1, 2]);
  });

  it('leases: create + revUpdate', async () => {
    await repo.createLease({
      id: 'j',
      productId: PID,
      jobId: 'j',
      leaseEpoch: 1,
      renewals: 0,
      status: 'held',
      rev: 0,
      updatedAt: now,
    });
    const res = await repo.revUpdateLease('j', 0, { renewals: 1 });
    expect(res.ok).toBe(true);
    if (res.ok) expect(res.doc.renewals).toBe(1);
    expect((await repo.getLease('j'))?.rev).toBe(1);
  });

  it('factories: upsert + list by productId', async () => {
    await repo.upsertFactory({
      id: 'fac_1',
      productId: PID,
      factoryId: 'fac_1',
      descriptor: {},
      capabilities: ['os:mac'],
      health: 'ok',
      load: 0,
      seatLimit: 1,
      lastHeartbeatAt: now,
    });
    await repo.upsertFactory({
      id: 'fac_1',
      productId: PID,
      factoryId: 'fac_1',
      descriptor: {},
      capabilities: ['os:mac'],
      health: 'degraded',
      load: 2,
      seatLimit: 1,
      lastHeartbeatAt: now,
    });
    const list = await repo.listFactories(PID);
    expect(list).toHaveLength(1);
    expect(list[0].health).toBe('degraded');
  });

  it('profiles: create + get', async () => {
    await repo.createProfile({
      id: 'prof_1',
      productId: PID,
      name: 'backend',
      version: 1,
      snapshot: { persona: 'x' },
      createdAt: now,
    });
    expect((await repo.getProfile('prof_1', PID))?.name).toBe('backend');
  });

  it('events: appendEvent yields an ordered, append-only stream', async () => {
    await repo.appendEvent({ jobId: 'j', productId: PID, type: 'submitted' });
    await repo.appendEvent({ jobId: 'j', productId: PID, type: 'assigned', actor: 'fac_1' });
    await repo.appendEvent({
      jobId: 'j',
      productId: PID,
      type: 'transition',
      data: { stage: 'building' },
    });
    const events = await repo.listEvents('j');
    expect(events.map(e => e.seq)).toEqual([0, 1, 2]);
    expect(events.map(e => e.type)).toEqual(['submitted', 'assigned', 'transition']);
  });

  it('artifacts: create + listByJob + get + delete (pointer only)', async () => {
    await repo.createArtifact({
      id: 'art_1',
      productId: PID,
      jobId: 'j',
      kind: 'coverage',
      blobKey: 'fleet/lysnrai/j/art_1-coverage',
      contentType: 'application/json',
      sizeBytes: 42,
      createdAt: now,
    });
    const arts = await repo.listArtifactsByJob('j', PID);
    expect(arts).toHaveLength(1);
    expect(arts[0].blobKey).toBe('fleet/lysnrai/j/art_1-coverage');

    // get is product-scoped (wrong product → null)
    expect((await repo.getArtifact('art_1', PID))?.contentType).toBe('application/json');
    expect(await repo.getArtifact('art_1', 'other-product')).toBeNull();

    // delete returns the removed doc and clears the partition
    const removed = await repo.deleteArtifact('art_1', PID);
    expect(removed?.id).toBe('art_1');
    expect(await repo.listArtifactsByJob('j', PID)).toHaveLength(0);
    expect(await repo.deleteArtifact('art_1', PID)).toBeNull();
  });
});
