/**
 * Fleet routes — Fastify inject, real coordinator/repo on the memory provider.
 * Auth + productId resolution are mocked (as in the items module routes test).
 */

import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryDatastoreProvider } from '@bytelyst/datastore';
import { _resetDatastoreProvider, setProvider } from '../../lib/datastore.js';

vi.mock('../../lib/auth.js', () => ({
  extractAuth: vi.fn(async () => ({ sub: 'user_1', role: 'admin' })),
}));
vi.mock('../../lib/request-context.js', () => ({
  getRequestProductId: () => 'lysnrai',
  requireProductAccess: async () => 'lysnrai',
}));

async function buildApp(): Promise<FastifyInstance> {
  const { fleetRoutes } = await import('./routes.js');
  // Fastify's default error handler maps a thrown ServiceError (which carries a
  // `.statusCode`) to the right HTTP status — same as the items routes test.
  const app = Fastify({ logger: false });
  await app.register(fleetRoutes, { prefix: '/api' });
  return app;
}

async function submit(app: FastifyInstance, body: Record<string, unknown>) {
  return app.inject({ method: 'POST', url: '/api/fleet/jobs', payload: body });
}

describe('fleetRoutes', () => {
  beforeEach(() => setProvider(new MemoryDatastoreProvider()));
  afterEach(() => {
    _resetDatastoreProvider();
    vi.clearAllMocks();
  });

  it('POST /fleet/jobs submits (201) and is idempotent (200 dedup)', async () => {
    const app = await buildApp();
    const r1 = await submit(app, { idempotencyKey: 'k1', bodyMd: '# task' });
    expect(r1.statusCode).toBe(201);
    expect(JSON.parse(r1.body).outcome).toBe('created');

    const r2 = await submit(app, { idempotencyKey: 'k1', bodyMd: '# task' });
    expect(r2.statusCode).toBe(200);
    expect(JSON.parse(r2.body).outcome).toBe('deduplicated');
  });

  it('POST /fleet/jobs rejects an invalid body (400)', async () => {
    const app = await buildApp();
    const res = await submit(app, { idempotencyKey: 'k1' }); // missing bodyMd
    expect(res.statusCode).toBe(400);
  });

  it('GET /fleet/queue-state (M0 gate) returns 0, then advances after a submit', async () => {
    const app = await buildApp();
    const before = await app.inject({ method: 'GET', url: '/api/fleet/queue-state' });
    expect(before.statusCode).toBe(200);
    const v0 = JSON.parse(before.body).version as number;
    expect(v0).toBe(0);

    await submit(app, { idempotencyKey: 'qs-1', bodyMd: '# task' });

    const after = await app.inject({ method: 'GET', url: '/api/fleet/queue-state' });
    const body = JSON.parse(after.body);
    expect(body.productId).toBe('lysnrai');
    expect(body.version).toBeGreaterThan(v0);
  });

  it('gated lifecycle via routes: submit -> claim -> building -> review -> approve -> ship -> metrics', async () => {
    const app = await buildApp();
    const sub = await submit(app, {
      idempotencyKey: 'gated-1',
      bodyMd: '# build feature',
      priority: 'high',
    });
    const jobId = JSON.parse(sub.body).job.id as string;

    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const epoch = JSON.parse(claim.body).job.leaseEpoch as number;

    const toBuilding = await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${jobId}`,
      payload: { leaseEpoch: epoch, stage: 'building' },
    });
    expect(toBuilding.statusCode).toBe(200);

    const reviewReq = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/review/request`,
      payload: { requiredApprovals: 1, reviewers: ['rev@x'] },
    });
    expect(reviewReq.statusCode).toBe(200);
    expect(JSON.parse(reviewReq.body).stage).toBe('review');

    const approve = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/review`,
      payload: { reviewer: 'rev@x', decision: 'approve' },
    });
    expect(approve.statusCode).toBe(200);
    expect(JSON.parse(approve.body).stage).toBe('testing');

    // ship from testing — the gap this closes (no lease holder after the gate)
    const ship = await app.inject({ method: 'POST', url: `/api/fleet/jobs/${jobId}/actions/ship` });
    expect(ship.statusCode).toBe(200);
    expect(JSON.parse(ship.body).stage).toBe('shipped');

    const metrics = await app.inject({ method: 'GET', url: '/api/fleet/metrics' });
    expect(JSON.parse(metrics.body).jobs.byStage.shipped).toBe(1);
  });

  it('GET /fleet/metrics/prom renders Prometheus exposition (admin)', async () => {
    const app = await buildApp();
    await submit(app, { idempotencyKey: 'prom-1', bodyMd: '# task' });
    const res = await app.inject({ method: 'GET', url: '/api/fleet/metrics/prom' });
    expect(res.statusCode).toBe(200);
    expect(res.headers['content-type']).toContain('text/plain; version=0.0.4');
    expect(res.body).toContain('# TYPE fleet_queue_depth gauge');
    expect(res.body).toContain('fleet_reaper_running');
    expect(res.body).toContain('fleet_engine_breaker_open_count');
  });

  it('release with insights records run cost/tokens (factory reporting)', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'ins-1', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;
    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const epoch = JSON.parse(claim.body).job.leaseEpoch as number;

    const rel = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/lease/release`,
      payload: {
        leaseEpoch: epoch,
        stage: 'shipped',
        insights: { model: 'claude-sonnet-4', tokensIn: 100, tokensOut: 20, costUsd: 0.01 },
        result: 'shipped',
      },
    });
    expect(rel.statusCode).toBe(200);

    const runs = await app.inject({ method: 'GET', url: `/api/fleet/jobs/${jobId}/runs` });
    const run = JSON.parse(runs.body).runs[0];
    expect(run.insights.costUsd).toBe(0.01);
    expect(run.insights.tokensIn).toBe(100);
    expect(run.result).toBe('shipped');
    expect(run.endedAt).toBeTruthy();
  });

  it('full lifecycle: submit -> list -> claim -> patch(fenced) -> renew -> release', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'k1', bodyMd: '# task', priority: 'high' });
    const jobId = JSON.parse(sub.body).job.id as string;

    const list = await app.inject({ method: 'GET', url: '/api/fleet/jobs?stage=queued' });
    expect(list.statusCode).toBe(200);
    expect(JSON.parse(list.body).jobs).toHaveLength(1);

    const claim = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    expect(claim.statusCode).toBe(200);
    const claimed = JSON.parse(claim.body);
    expect(claimed.claimed).toBe(true);
    expect(claimed.job.stage).toBe('assigned');
    const epoch = claimed.job.leaseEpoch as number;
    expect(epoch).toBe(1);

    // fenced: stale epoch rejected (409)
    const stale = await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${jobId}`,
      payload: { leaseEpoch: epoch - 1, stage: 'building' },
    });
    expect(stale.statusCode).toBe(409);

    // current epoch succeeds
    const patch = await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${jobId}`,
      payload: { leaseEpoch: epoch, stage: 'building' },
    });
    expect(patch.statusCode).toBe(200);
    expect(JSON.parse(patch.body).stage).toBe('building');

    const renew = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/lease/renew`,
      payload: { leaseEpoch: epoch, leaseSeconds: 600 },
    });
    expect(renew.statusCode).toBe(200);
    expect(JSON.parse(renew.body).renewals).toBe(1);

    const release = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/lease/release`,
      payload: { leaseEpoch: epoch, stage: 'review' },
    });
    expect(release.statusCode).toBe(200);
    expect(JSON.parse(release.body).status).toBe('released');

    const events = await app.inject({ method: 'GET', url: `/api/fleet/jobs/${jobId}/events` });
    const types = JSON.parse(events.body).events.map((e: { type: string }) => e.type);
    expect(types).toContain('submitted');
    expect(types).toContain('assigned');
  });

  it('GET /fleet/jobs/:id/events streams a snapshot over SSE then closes', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'sse1', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;

    // maxMs=0 => snapshot only, no follow loop, so inject resolves immediately.
    const res = await app.inject({
      method: 'GET',
      url: `/api/fleet/jobs/${jobId}/events/stream?maxMs=0`,
    });
    expect(res.statusCode).toBe(200);
    expect(res.headers['content-type']).toBe('text/event-stream');
    expect(res.payload).toContain('retry: 3000');
    expect(res.payload).toContain('event: fleet-event');
    expect(res.payload).toContain('id: 0');
    expect(res.payload).toContain('"type":"submitted"');
  });

  it('GET /fleet/jobs/:id/events/stream honours Last-Event-ID (no snapshot replay)', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'sse2', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;

    const res = await app.inject({
      method: 'GET',
      url: `/api/fleet/jobs/${jobId}/events/stream?maxMs=0`,
      headers: { 'last-event-id': '99' },
    });
    expect(res.statusCode).toBe(200);
    expect(res.payload).not.toContain('event: fleet-event');
    expect(res.payload).toContain('retry: 3000');
  });

  it('GET /fleet/jobs/:id/events/stream delivers events appended after connect', async () => {
    const repo = await import('./repository.js');
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'sse3', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;

    // Skip the snapshot (submitted=seq 0) and append a new event mid-stream; the
    // poll loop should pick it up and deliver it before the stream closes.
    const t = setTimeout(() => {
      void repo.appendEvent({
        jobId,
        productId: 'lysnrai',
        type: 'progress',
        data: { pct: 42 },
      });
    }, 40);

    const res = await app.inject({
      method: 'GET',
      url: `/api/fleet/jobs/${jobId}/events/stream?lastEventId=0&pollMs=25&maxMs=400`,
    });
    clearTimeout(t);
    expect(res.statusCode).toBe(200);
    expect(res.payload).toContain('"type":"progress"');
    expect(res.payload).toContain('id: 1');
    expect(res.payload).toContain(': keepalive');
  });

  it('GET /fleet/metrics returns jobs, factories, and alerts', async () => {
    const app = await buildApp();
    await submit(app, { idempotencyKey: 'm1', bodyMd: '# task' });

    const res = await app.inject({ method: 'GET', url: '/api/fleet/metrics' });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body);
    expect(body.productId).toBe('lysnrai');
    expect(body.jobs.queueDepth).toBe(1);
    expect(body.factories.total).toBe(0);
    // A queued job with no factory should raise the no_live_capacity alert.
    expect(body.alerts.some((a: { code: string }) => a.code === 'no_live_capacity')).toBe(true);
  });

  it('POST /fleet/jobs/:id/review approves through the human gate', async () => {
    const app = await buildApp();
    const job = JSON.parse(
      (await submit(app, { idempotencyKey: 'rev-route', bodyMd: '# task' })).body
    ).job;
    // Drive the job to building via claim + fenced patch.
    const claimRes = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const claim = JSON.parse(claimRes.body);
    await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${job.id}`,
      payload: { leaseEpoch: claim.job.leaseEpoch, stage: 'building' },
    });

    const reqRes = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${job.id}/review/request`,
      payload: { requiredApprovals: 1 },
    });
    expect(reqRes.statusCode).toBe(200);
    expect(JSON.parse(reqRes.body).stage).toBe('review');

    const approveRes = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${job.id}/review`,
      payload: { reviewer: 'alice@x.com', decision: 'approve' },
    });
    expect(approveRes.statusCode).toBe(200);
    const approved = JSON.parse(approveRes.body);
    expect(approved.gate).toBe('approved');
    expect(approved.stage).toBe('testing');
  });

  it('POST /fleet/jobs/:id/review/request rejects an unsatisfiable policy with 400', async () => {
    const app = await buildApp();
    const job = JSON.parse(
      (await submit(app, { idempotencyKey: 'rev-bad', bodyMd: '# task' })).body
    ).job;
    const claimRes = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });
    const claim = JSON.parse(claimRes.body);
    await app.inject({
      method: 'PATCH',
      url: `/api/fleet/jobs/${job.id}`,
      payload: { leaseEpoch: claim.job.leaseEpoch, stage: 'building' },
    });
    const res = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${job.id}/review/request`,
      payload: { requiredApprovals: 2, reviewers: ['only-one@x.com'] },
    });
    expect(res.statusCode).toBe(400);
  });

  it('POST /fleet/claim returns claimed:false when nothing is eligible', async () => {
    const app = await buildApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1' },
    });
    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.body).claimed).toBe(false);
  });

  it('POST /fleet/factories/heartbeat upserts a factory', async () => {
    const app = await buildApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      payload: { factoryId: 'fac_1', capabilities: ['os:mac'], health: 'ok' },
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body);
    expect(body.ok).toBe(true);
    expect(body.factory.factoryId).toBe('fac_1');
  });

  it('GET /fleet/jobs/:id returns 404 when missing', async () => {
    const app = await buildApp();
    const res = await app.inject({ method: 'GET', url: '/api/fleet/jobs/nope' });
    expect(res.statusCode).toBe(404);
  });

  it('operator action: requeue returns a claimed job to queued; unknown action is 400', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'k1', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;
    await app.inject({
      method: 'POST',
      url: '/api/fleet/claim',
      payload: { factoryId: 'fac_1', capabilities: [] },
    });

    const requeue = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/actions/requeue`,
    });
    expect(requeue.statusCode).toBe(200);
    expect(JSON.parse(requeue.body).stage).toBe('queued');

    const bad = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/actions/explode`,
    });
    expect(bad.statusCode).toBe(400);
  });

  it('GET /fleet/jobs/:id/explain returns a per-factory score breakdown', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'k1', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;
    await app.inject({
      method: 'POST',
      url: '/api/fleet/factories/heartbeat',
      payload: { factoryId: 'fac_1', capabilities: [], health: 'ok' },
    });

    const explain = await app.inject({
      method: 'GET',
      url: `/api/fleet/jobs/${jobId}/explain`,
    });
    expect(explain.statusCode).toBe(200);
    const body = JSON.parse(explain.body);
    expect(body.jobId).toBe(jobId);
    expect(body.factories).toHaveLength(1);
    expect(body.bestFactoryId).toBe('fac_1');

    const missing = await app.inject({ method: 'GET', url: '/api/fleet/jobs/nope/explain' });
    expect(missing.statusCode).toBe(404);
  });

  it('GET /fleet/budgets/:productId/burndown returns a gap-free daily series', async () => {
    const app = await buildApp();
    const res = await app.inject({
      method: 'GET',
      url: '/api/fleet/budgets/lysnrai/burndown?days=7',
    });
    expect(res.statusCode).toBe(200);
    const body = JSON.parse(res.body);
    expect(body.productId).toBe('lysnrai');
    expect(body.days).toHaveLength(7);
    expect(body.totalUsd).toBe(0);
  });

  // ── Phase 3 budgets: caller-product scoping (getRequestProductId mocked to 'lysnrai') ──
  it("budget routes work for the caller's own product", async () => {
    const app = await buildApp();
    const put = await app.inject({
      method: 'PUT',
      url: '/api/fleet/budgets/lysnrai',
      payload: { ceilingUsd: 100, window: 'monthly' },
    });
    expect(put.statusCode).toBe(200);
    const get = await app.inject({ method: 'GET', url: '/api/fleet/budgets/lysnrai' });
    expect(get.statusCode).toBe(200);
    expect(JSON.parse(get.body).productId).toBe('lysnrai');
  });

  it('budget routes reject a foreign productId with 403 (all 5 verbs, no cross-product access)', async () => {
    const app = await buildApp();
    const get = await app.inject({ method: 'GET', url: '/api/fleet/budgets/chronomind' });
    expect(get.statusCode).toBe(403);
    const burndown = await app.inject({
      method: 'GET',
      url: '/api/fleet/budgets/chronomind/burndown',
    });
    expect(burndown.statusCode).toBe(403);
    const put = await app.inject({
      method: 'PUT',
      url: '/api/fleet/budgets/chronomind',
      payload: { ceilingUsd: 999, window: 'monthly' },
    });
    expect(put.statusCode).toBe(403);
    const pause = await app.inject({ method: 'POST', url: '/api/fleet/budgets/chronomind/pause' });
    expect(pause.statusCode).toBe(403);
    const resume = await app.inject({
      method: 'POST',
      url: '/api/fleet/budgets/chronomind/resume',
    });
    expect(resume.statusCode).toBe(403);
  });

  it('POST /fleet/jobs/:id/pr/reconcile returns no_pr for a job without a PR, 404 for unknown', async () => {
    const app = await buildApp();
    const sub = await submit(app, { idempotencyKey: 'rec-1', bodyMd: '# task' });
    const jobId = JSON.parse(sub.body).job.id as string;

    const res = await app.inject({
      method: 'POST',
      url: `/api/fleet/jobs/${jobId}/pr/reconcile`,
    });
    expect(res.statusCode).toBe(200);
    expect(JSON.parse(res.body)).toMatchObject({ ok: true, updated: false, reason: 'no_pr' });

    const missing = await app.inject({
      method: 'POST',
      url: '/api/fleet/jobs/fjob_nope/pr/reconcile',
    });
    expect(missing.statusCode).toBe(404);
  });
});
