/**
 * Fleet client unit tests — verifies correct URL construction,
 * method usage, and graceful degradation on errors.
 */

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

const { fetchSpy } = vi.hoisted(() => ({ fetchSpy: vi.fn() }));

vi.mock('@bytelyst/api-client', () => ({
  createApiClient: () => ({ fetch: fetchSpy }),
}));

import {
  listJobs,
  getJob,
  patchJob,
  operatorAction,
  requestReview,
  submitReview,
  getJobRuns,
  getJobEvents,
  getJobArtifacts,
  getJobDag,
  getJobExplain,
  listFactories,
  availableEnginesForProduct,
  getFleetMetrics,
  getBudget,
  getBudgetBurndown,
  upsertBudget,
  pauseBudget,
  resumeBudget,
  budgetUsagePct,
  parseSseFrames,
  subscribeJobEvents,
} from '@/lib/fleet-client';

describe('fleet-client', () => {
  beforeEach(() => {
    fetchSpy.mockReset();
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  describe('listJobs', () => {
    it('calls /jobs with query params', async () => {
      fetchSpy.mockResolvedValue({ jobs: [] });
      const res = await listJobs({ stage: 'queued', limit: 10 });
      expect(res.jobs).toEqual([]);
      expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining('/jobs'), expect.anything());
    });

    it('calls /jobs without params when none provided', async () => {
      fetchSpy.mockResolvedValue({ jobs: [{ id: 'j1' }] });
      const res = await listJobs();
      expect(res.jobs).toHaveLength(1);
    });
  });

  describe('getJob', () => {
    it('returns job on success', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
      const job = await getJob('j1');
      expect(job?.id).toBe('j1');
    });

    it('returns null on 404', async () => {
      fetchSpy.mockRejectedValue(new Error('404 Not Found'));
      const job = await getJob('missing');
      expect(job).toBeNull();
    });
  });

  describe('patchJob', () => {
    it('sends PATCH with correct body', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'shipped' });
      const res = await patchJob('j1', { leaseEpoch: 1, stage: 'shipped' });
      expect(res.stage).toBe('shipped');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1',
        expect.objectContaining({ method: 'PATCH' })
      );
    });

    it('forwards an optional checkpoint in the PATCH body', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'building' });
      await patchJob('j1', {
        leaseEpoch: 3,
        stage: 'building',
        checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
      });
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1',
        expect.objectContaining({
          method: 'PATCH',
          body: JSON.stringify({
            leaseEpoch: 3,
            stage: 'building',
            checkpoint: { wipBranch: 'feat/x', wipCommit: 'abc123' },
          }),
        })
      );
    });
  });

  describe('budgetUsagePct', () => {
    it('computes a clamped percentage for a normal ceiling', () => {
      expect(budgetUsagePct(25, 100)).toBe(25);
      expect(budgetUsagePct(150, 100)).toBe(100); // clamps over-budget to 100
    });

    it('returns 0 for a zero, missing, or non-finite ceiling (no NaN bar)', () => {
      expect(budgetUsagePct(10, 0)).toBe(0);
      expect(budgetUsagePct(0, 0)).toBe(0);
      expect(budgetUsagePct(10, NaN)).toBe(0);
      expect(budgetUsagePct(10, undefined as unknown as number)).toBe(0);
      expect(budgetUsagePct(10, -5)).toBe(0);
    });
  });

  describe('operatorAction', () => {
    it('sends POST to /jobs/:id/actions/:action', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'queued' });
      const res = await operatorAction('j1', 'requeue');
      expect(res.stage).toBe('queued');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1/actions/requeue',
        expect.objectContaining({ method: 'POST' })
      );
    });

    it('supports reject and cancel actions', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'dead_letter' });
      await operatorAction('j1', 'reject');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1/actions/reject',
        expect.objectContaining({ method: 'POST' })
      );
    });
  });

  describe('review gate', () => {
    it('requestReview POSTs the policy to /jobs/:id/review/request', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
      const res = await requestReview('j1', { requiredApprovals: 2, reviewers: ['a@x.com'] });
      expect(res.stage).toBe('review');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1/review/request',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ requiredApprovals: 2, reviewers: ['a@x.com'] }),
        })
      );
    });

    it('requestReview sends an empty object when no policy given', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'review' });
      await requestReview('j1');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1/review/request',
        expect.objectContaining({ method: 'POST', body: '{}' })
      );
    });

    it('submitReview POSTs the decision to /jobs/:id/review', async () => {
      fetchSpy.mockResolvedValue({ id: 'j1', stage: 'testing', gate: 'approved' });
      const res = await submitReview('j1', { reviewer: 'a@x.com', decision: 'approve' });
      expect(res.gate).toBe('approved');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/jobs/j1/review',
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify({ reviewer: 'a@x.com', decision: 'approve' }),
        })
      );
    });
  });

  describe('getJobRuns', () => {
    it('returns runs array', async () => {
      fetchSpy.mockResolvedValue({ runs: [{ id: 'r1', attempt: 1 }] });
      const res = await getJobRuns('j1');
      expect(res.runs).toHaveLength(1);
    });
  });

  describe('getJobEvents', () => {
    it('returns events array', async () => {
      fetchSpy.mockResolvedValue({ events: [{ id: 'e1', type: 'submitted' }] });
      const res = await getJobEvents('j1');
      expect(res.events).toHaveLength(1);
    });
  });

  describe('getJobArtifacts', () => {
    it('returns artifacts array', async () => {
      fetchSpy.mockResolvedValue({ artifacts: [] });
      const res = await getJobArtifacts('j1');
      expect(res.artifacts).toHaveLength(0);
    });
  });

  describe('getJobDag', () => {
    it('returns dag on success', async () => {
      fetchSpy.mockResolvedValue({ dag: { id: 'j1', children: [] } });
      const res = await getJobDag('j1');
      expect(res?.dag.id).toBe('j1');
    });

    it('returns null on 404 (leaf job with no children)', async () => {
      fetchSpy.mockRejectedValue(new Error('404 Not Found'));
      const res = await getJobDag('leaf');
      expect(res).toBeNull();
    });
  });

  describe('getJobExplain', () => {
    it('returns score breakdown on success', async () => {
      fetchSpy.mockResolvedValue({
        jobId: 'j1',
        stage: 'queued',
        weights: {},
        depsSatisfied: true,
        unmetDeps: [],
        factories: [{ factoryId: 'f1', eligible: true, ineligibleReasons: [], score: 3.2 }],
        bestFactoryId: 'f1',
      });
      const res = await getJobExplain('j1');
      expect(res?.bestFactoryId).toBe('f1');
      expect(fetchSpy).toHaveBeenCalledWith('/jobs/j1/explain', expect.anything());
    });

    it('returns null on 404', async () => {
      fetchSpy.mockRejectedValue(new Error('404 Not Found'));
      const res = await getJobExplain('missing');
      expect(res).toBeNull();
    });
  });

  describe('getFleetMetrics', () => {
    it('returns metrics on success', async () => {
      fetchSpy.mockResolvedValue({
        productId: 'lysnrai',
        generatedAt: new Date().toISOString(),
        jobs: {
          total: 1,
          byStage: {},
          queueDepth: 1,
          blocked: 0,
          active: 0,
          oldestQueuedAgeMs: null,
        },
        factories: {
          total: 0,
          live: 0,
          stale: 0,
          byHealth: { ok: 0, degraded: 0, down: 0 },
          seatsUsed: 0,
          seatsTotal: 0,
          utilizationPct: 0,
        },
        alerts: [],
      });
      const res = await getFleetMetrics();
      expect(res?.jobs.queueDepth).toBe(1);
      expect(fetchSpy).toHaveBeenCalledWith('/metrics', expect.anything());
    });

    it('returns null on 404', async () => {
      fetchSpy.mockRejectedValue(new Error('404 Not Found'));
      const res = await getFleetMetrics();
      expect(res).toBeNull();
    });
  });

  describe('listFactories', () => {
    it('returns factories on success', async () => {
      fetchSpy.mockResolvedValue({ factories: [{ id: 'f1' }] });
      const res = await listFactories();
      expect(res.factories).toHaveLength(1);
    });

    it('returns empty array on error (graceful degradation)', async () => {
      fetchSpy.mockRejectedValue(new Error('Network error'));
      const res = await listFactories();
      expect(res.factories).toEqual([]);
    });
  });

  describe('availableEnginesForProduct', () => {
    const fresh = () => new Date().toISOString();
    const stale = () => new Date(Date.now() - 120_000).toISOString(); // > 90s

    const factory = (over: Record<string, unknown>) => ({
      id: 'f',
      productId: 'lysnrai',
      factoryId: 'f',
      capabilities: [],
      health: 'ok',
      load: 0,
      seatLimit: 1,
      lastHeartbeatAt: fresh(),
      ...over,
    });

    it('collects engine:* caps from healthy, live factories', async () => {
      fetchSpy.mockResolvedValue({
        factories: [
          factory({ capabilities: ['os:mac', 'engine:devin', 'engine:claude'] }),
          factory({ capabilities: ['engine:copilot'] }),
        ],
      });
      const engines = await availableEnginesForProduct('lysnrai');
      expect(engines.sort()).toEqual(['claude', 'copilot', 'devin']);
    });

    it('ignores non-engine caps and unknown engines', async () => {
      fetchSpy.mockResolvedValue({
        factories: [factory({ capabilities: ['os:mac', 'has:git', 'engine:bogus'] })],
      });
      expect(await availableEnginesForProduct()).toEqual([]);
    });

    it('skips down factories', async () => {
      fetchSpy.mockResolvedValue({
        factories: [factory({ health: 'down', capabilities: ['engine:codex'] })],
      });
      expect(await availableEnginesForProduct()).toEqual([]);
    });

    it('skips stale (missed-heartbeat) factories even when health says ok', async () => {
      fetchSpy.mockResolvedValue({
        factories: [
          factory({ capabilities: ['engine:codex'], lastHeartbeatAt: stale() }),
          factory({ capabilities: ['engine:devin'], lastHeartbeatAt: fresh() }),
        ],
      });
      // codex's only host is stale → excluded; devin's host is fresh → kept.
      expect(await availableEnginesForProduct()).toEqual(['devin']);
    });

    it('returns empty (⇒ caller offers all) when the list call fails', async () => {
      fetchSpy.mockRejectedValue(new Error('Network error'));
      expect(await availableEnginesForProduct()).toEqual([]);
    });
  });

  describe('budget operations', () => {
    it('getBudget returns budget or null', async () => {
      fetchSpy.mockResolvedValue({ id: 'lysnrai', ceilingUsd: 100, spentUsd: 25 });
      const b = await getBudget('lysnrai');
      expect(b?.ceilingUsd).toBe(100);

      fetchSpy.mockRejectedValue(new Error('404'));
      const missing = await getBudget('unknown');
      expect(missing).toBeNull();
    });

    it('upsertBudget sends PUT', async () => {
      fetchSpy.mockResolvedValue({ id: 'p1', ceilingUsd: 50 });
      await upsertBudget('p1', 50, 'monthly');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/budgets/p1',
        expect.objectContaining({ method: 'PUT' })
      );
    });

    it('pauseBudget sends POST to /pause', async () => {
      fetchSpy.mockResolvedValue({ status: 'paused' });
      const res = await pauseBudget('p1');
      expect(res.status).toBe('paused');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/budgets/p1/pause',
        expect.objectContaining({ method: 'POST' })
      );
    });

    it('resumeBudget sends POST to /resume', async () => {
      fetchSpy.mockResolvedValue({ status: 'active' });
      const res = await resumeBudget('p1');
      expect(res.status).toBe('active');
      expect(fetchSpy).toHaveBeenCalledWith(
        '/budgets/p1/resume',
        expect.objectContaining({ method: 'POST' })
      );
    });

    it('getBudgetBurndown fetches the series with a days query', async () => {
      fetchSpy.mockResolvedValue({
        productId: 'p1',
        ceilingUsd: 50,
        window: 'monthly',
        totalUsd: 10,
        days: [{ date: '2024-01-01', costUsd: 10, cumulativeUsd: 10 }],
      });
      const res = await getBudgetBurndown('p1', 30);
      expect(res?.totalUsd).toBe(10);
      expect(fetchSpy).toHaveBeenCalledWith('/budgets/p1/burndown?days=30', expect.anything());
    });

    it('getBudgetBurndown returns null on 404', async () => {
      fetchSpy.mockRejectedValue(new Error('404 Not Found'));
      const res = await getBudgetBurndown('p1');
      expect(res).toBeNull();
    });
  });

  describe('parseSseFrames', () => {
    it('parses complete frames and skips keepalive comments', () => {
      const buf =
        'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n' +
        ': keepalive\n\n' +
        'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n';
      const { events, rest } = parseSseFrames(buf);
      expect(events).toHaveLength(2);
      expect(events[0]).toMatchObject({ id: '0', event: 'fleet-event' });
      expect(JSON.parse(events[0].data).type).toBe('submitted');
      expect(events[1].id).toBe('1');
      expect(rest).toBe('');
    });

    it('returns a trailing partial frame as rest', () => {
      const buf = 'id: 0\nevent: fleet-event\ndata: {"seq":0}\n\nid: 1\ndata: {"seq"';
      const { events, rest } = parseSseFrames(buf);
      expect(events).toHaveLength(1);
      expect(rest).toBe('id: 1\ndata: {"seq"');
    });
  });

  describe('subscribeJobEvents', () => {
    function streamResponse(chunks: string[]): Response {
      const encoder = new TextEncoder();
      const body = new ReadableStream<Uint8Array>({
        start(controller) {
          for (const c of chunks) controller.enqueue(encoder.encode(c));
          controller.close();
        },
      });
      return new Response(body, {
        status: 200,
        headers: { 'content-type': 'text/event-stream' },
      });
    }

    afterEach(() => {
      vi.unstubAllGlobals();
    });

    it('delivers parsed fleet-events to onEvent', async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValueOnce(
          streamResponse([
            'retry: 3000\n\n',
            'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
            ': keepalive\n\n',
            'id: 1\nevent: fleet-event\ndata: {"seq":1,"type":"progress"}\n\n',
          ])
        )
        // never resolves → prevents a tight reconnect loop after the first close
        .mockReturnValue(new Promise(() => {}));
      vi.stubGlobal('fetch', fetchMock);

      const received: number[] = [];
      await new Promise<void>(resolve => {
        const sub = subscribeJobEvents(
          'j1',
          {
            onEvent: e => {
              received.push(e.seq);
              if (received.length === 2) {
                sub.close();
                resolve();
              }
            },
          },
          { reconnectMs: 60_000 }
        );
      });

      expect(received).toEqual([0, 1]);
      expect(fetchMock).toHaveBeenCalledWith(
        '/api/fleet/jobs/j1/events/stream',
        expect.objectContaining({
          headers: expect.objectContaining({ accept: 'text/event-stream' }),
        })
      );
    });

    it('invokes onError and stops when the stream is unavailable', async () => {
      const fetchMock = vi.fn().mockResolvedValue(new Response('nope', { status: 500 }));
      vi.stubGlobal('fetch', fetchMock);

      const err = await new Promise<unknown>(resolve => {
        subscribeJobEvents('j1', { onEvent: () => {}, onError: resolve });
      });

      expect(err).toBeInstanceOf(Error);
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it('treats a terminal error frame as fatal (onError, no reconnect)', async () => {
      const fetchMock = vi
        .fn()
        .mockResolvedValueOnce(
          streamResponse([
            'id: 0\nevent: fleet-event\ndata: {"seq":0,"type":"submitted"}\n\n',
            'event: error\ndata: {"message":"stream interrupted"}\n\n',
          ])
        )
        .mockReturnValue(new Promise(() => {}));
      vi.stubGlobal('fetch', fetchMock);

      const seen: number[] = [];
      const err = await new Promise<unknown>(resolve => {
        subscribeJobEvents('j1', { onEvent: e => seen.push(e.seq), onError: resolve });
      });

      expect(seen).toEqual([0]);
      expect(err).toBeInstanceOf(Error);
      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it('sends Last-Event-ID header when resuming from a cursor', async () => {
      const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
      vi.stubGlobal('fetch', fetchMock);

      const sub = subscribeJobEvents('j1', { onEvent: () => {} }, { lastEventId: 5 });
      await Promise.resolve();
      sub.close();

      expect(fetchMock).toHaveBeenCalledWith(
        '/api/fleet/jobs/j1/events/stream',
        expect.objectContaining({ headers: expect.objectContaining({ 'last-event-id': '5' }) })
      );
    });
  });
});
