// fleet-dash.test.mjs — dependency-light unit tests for the fleet-mode dashboard
// adapter. Uses node:assert only (no test framework), matching the repo style.
// Run: `node fleet-dash.test.mjs`  (also wired into selftest.sh).
//
// These tests prove the dashboard's CONTRACT ASSUMPTIONS against the /fleet API
// (request shaping, response mapping, graceful degradation, action semantics)
// via an injected fetch stub. They do NOT prove live server compatibility.

import assert from 'node:assert/strict';
import {
  fleetConfig,
  mapStage,
  toBoard,
  fleetFetch,
  fetchBoard,
  fetchEvents,
  formatEvent,
  jobAction,
} from './fleet-dash.mjs';

let passed = 0;
const t = (name, fn) => {
  try {
    const r = fn();
    if (r && typeof r.then === 'function') {
      return r.then(
        () => { passed += 1; },
        (e) => { console.error(`  ✗ ${name}\n    ${e && e.message}`); process.exitCode = 1; },
      );
    }
    passed += 1;
  } catch (e) {
    console.error(`  ✗ ${name}\n    ${e && e.message}`);
    process.exitCode = 1;
  }
  return undefined;
};

// A recording fetch stub. `routes` maps a matcher → {status, body} (or a fn).
function makeFetch(routes) {
  const calls = [];
  const fetchImpl = async (url, opts = {}) => {
    calls.push({ url, opts, headers: opts.headers || {}, method: opts.method || 'GET' });
    let entry = routes[url];
    if (!entry) {
      // try suffix match (path only)
      const key = Object.keys(routes).find((k) => url.endsWith(k));
      entry = key ? routes[key] : undefined;
    }
    if (typeof entry === 'function') entry = entry({ url, opts });
    if (entry === undefined) return mkRes(404, '{}');
    if (entry.throw) throw Object.assign(new Error(entry.throw), { name: entry.name || 'Error' });
    return mkRes(entry.status ?? 200, entry.body ?? '');
  };
  fetchImpl.calls = calls;
  return fetchImpl;
}
const mkRes = (status, body) => ({
  ok: status >= 200 && status < 300,
  status,
  text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
});

const CFG = { enabled: true, ok: true, api: 'http://svc/api', token: 'tok', productId: 'prodX', missing: [] };

await (async () => {
  // ── fleetConfig ──
  t('fleetConfig: AQ_FLEET_DASH unset ⇒ disabled', () => {
    const c = fleetConfig({});
    assert.equal(c.enabled, false);
    assert.equal(c.ok, false);
  });
  t('fleetConfig: enabled but missing config ⇒ not ok, lists missing', () => {
    const c = fleetConfig({ AQ_FLEET_DASH: '1' });
    assert.equal(c.enabled, true);
    assert.equal(c.ok, false);
    assert.deepEqual(c.missing.sort(), ['AQ_FLEET_API', 'AQ_FLEET_TOKEN', 'AQ_PRODUCT_ID'].sort());
  });
  t('fleetConfig: enabled + complete ⇒ ok, trims trailing slash', () => {
    const c = fleetConfig({ AQ_FLEET_DASH: '1', AQ_FLEET_API: 'http://svc/api/', AQ_FLEET_TOKEN: 'k', AQ_PRODUCT_ID: 'p' });
    assert.equal(c.ok, true);
    assert.equal(c.api, 'http://svc/api');
  });

  // ── mapStage ──
  t('mapStage: fleet stages collapse to board buckets', () => {
    assert.equal(mapStage('queued'), 'inbox');
    assert.equal(mapStage('assigned'), 'building');
    assert.equal(mapStage('building'), 'building');
    assert.equal(mapStage('review'), 'review');
    assert.equal(mapStage('testing'), 'testing');
    assert.equal(mapStage('shipped'), 'shipped');
    assert.equal(mapStage('failed'), 'failed');
    assert.equal(mapStage('dead_letter'), 'failed');
    assert.equal(mapStage('weird'), 'inbox');
  });

  // ── toBoard ──
  t('toBoard: counts, actionable items, running, recent', () => {
    const jobs = [
      { id: 'a', stage: 'queued', priority: 'high', capabilities: ['os:any'] },
      { id: 'b', stage: 'building', priority: 'critical', factoryId: 'mac-1', leaseEpoch: 3 },
      { id: 'c', stage: 'review', updatedAt: '2026-01-01T00:00:02Z' },
      { id: 'd', stage: 'testing' },
      { id: 'e', stage: 'shipped', updatedAt: '2026-01-01T00:00:09Z' },
      { id: 'f', stage: 'failed', updatedAt: '2026-01-01T00:00:05Z' },
      { id: 'g', stage: 'dead_letter', updatedAt: '2026-01-01T00:00:01Z' },
    ];
    const b = toBoard({ jobs });
    assert.equal(b.counts.inbox, 1);
    assert.equal(b.counts.building, 1);
    assert.equal(b.counts.review, 1);
    assert.equal(b.counts.testing, 1);
    assert.equal(b.counts.shipped, 1);
    assert.equal(b.counts.failed, 2); // failed + dead_letter
    // running = assigned/building only
    assert.deepEqual(b.running.map((x) => x.id), ['b']);
    assert.equal(b.running[0].fleetStage, 'building');
    assert.equal(b.running[0].factoryId, 'mac-1');
    // actionable items exclude building/shipped, ordered review<testing<failed<inbox
    assert.deepEqual(b.items.map((x) => x.id), ['c', 'd', 'f', 'g', 'a']);
    // item.stage is the bucket (so dashboard gate()/STAGE_TAG reuse works)
    assert.equal(b.items[0].stage, 'review');
    assert.equal(b.items[4].stage, 'inbox');
    // recent = shipped+failed, newest first, capped at 5
    assert.deepEqual(b.recent.map((x) => x.id), ['e', 'f', 'g']);
  });

  // ── fleetFetch: headers + product scoping on every request ──
  await t('fleetFetch: sends bearer + X-Product-Id; parses JSON', async () => {
    const f = makeFetch({ '/fleet/jobs': { status: 200, body: { jobs: [] } } });
    const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
    assert.equal(r.ok, true);
    assert.deepEqual(r.json, { jobs: [] });
    const h = f.calls[0].headers;
    assert.equal(h.Authorization, 'Bearer tok');
    assert.equal(h['X-Product-Id'], 'prodX');
    assert.equal(f.calls[0].url, 'http://svc/api/fleet/jobs');
  });
  await t('fleetFetch: network error ⇒ ok:false with message (no throw)', async () => {
    const f = makeFetch({ '/fleet/jobs': { throw: 'boom' } });
    const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
    assert.equal(r.ok, false);
    assert.equal(r.status, 0);
    assert.match(r.error, /boom/);
  });
  await t('fleetFetch: abort ⇒ timeout error', async () => {
    const f = makeFetch({ '/fleet/jobs': { throw: 'aborted', name: 'AbortError' } });
    const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
    assert.equal(r.ok, false);
    assert.equal(r.error, 'timeout');
  });
  await t('fleetFetch: non-JSON 500 body ⇒ ok:false, json null', async () => {
    const f = makeFetch({ '/fleet/jobs': { status: 500, body: '<html>err</html>' } });
    const r = await fleetFetch(CFG, '/fleet/jobs', {}, f);
    assert.equal(r.ok, false);
    assert.equal(r.status, 500);
    assert.equal(r.json, null);
  });

  // ── fetchBoard: assembly + degradation ──
  await t('fetchBoard: jobs + metrics + factories assembled', async () => {
    const f = makeFetch({
      '/fleet/jobs': { body: { jobs: [{ id: 'a', stage: 'review' }] } },
      '/fleet/metrics': { body: { utilizationPct: 50, alerts: [] } },
      '/fleet/factories': { body: { factories: [{ factoryId: 'mac-1', health: 'ok' }] } },
    });
    const r = await fetchBoard(CFG, f);
    assert.equal(r.ok, true);
    assert.equal(r.board.items.length, 1);
    assert.equal(r.board.metrics.utilizationPct, 50);
    assert.equal(r.board.factories.length, 1);
  });
  await t('fetchBoard: factories 404 ⇒ degrades to []', async () => {
    const f = makeFetch({
      '/fleet/jobs': { body: { jobs: [] } },
      '/fleet/metrics': { body: {} },
      '/fleet/factories': { status: 404, body: {} },
    });
    const r = await fetchBoard(CFG, f);
    assert.equal(r.ok, true);
    assert.deepEqual(r.board.factories, []);
  });
  await t('fetchBoard: factories 501 ⇒ degrades to []', async () => {
    const f = makeFetch({
      '/fleet/jobs': { body: { jobs: [] } },
      '/fleet/metrics': { body: {} },
      '/fleet/factories': { status: 501, body: {} },
    });
    const r = await fetchBoard(CFG, f);
    assert.equal(r.ok, true);
    assert.deepEqual(r.board.factories, []);
  });
  await t('fetchBoard: metrics failure ⇒ board still ok, metrics null', async () => {
    const f = makeFetch({
      '/fleet/jobs': { body: { jobs: [] } },
      '/fleet/metrics': { status: 500, body: 'oops' },
      '/fleet/factories': { body: { factories: [] } },
    });
    const r = await fetchBoard(CFG, f);
    assert.equal(r.ok, true);
    assert.equal(r.board.metrics, null);
  });
  await t('fetchBoard: jobs failure ⇒ board fails with error', async () => {
    const f = makeFetch({ '/fleet/jobs': { status: 503, body: '{}' } });
    const r = await fetchBoard(CFG, f);
    assert.equal(r.ok, false);
    assert.match(r.error, /503/);
  });

  // ── events ──
  t('formatEvent: renders type + actor + data', () => {
    const line = formatEvent({ type: 'claimed', actor: 'mac-1', at: '2026-01-01T00:00:00Z', data: { leaseEpoch: 2 } });
    assert.match(line, /claimed/);
    assert.match(line, /mac-1/);
    assert.match(line, /leaseEpoch/);
  });
  await t('fetchEvents: maps events to lines', async () => {
    const f = makeFetch({
      '/events': { body: { events: [{ type: 'queued', at: '2026-01-01T00:00:00Z', data: {} }, { type: 'claimed', data: {} }] } },
    });
    const r = await fetchEvents(CFG, 'job-1', f);
    assert.equal(r.ok, true);
    assert.equal(r.lines.length, 2);
    assert.match(r.lines[1], /claimed/);
    assert.match(f.calls[0].url, /\/fleet\/jobs\/job-1\/events$/);
  });
  await t('fetchEvents: failure ⇒ ok:false, empty lines', async () => {
    const f = makeFetch({ '/events': { status: 500, body: 'x' } });
    const r = await fetchEvents(CFG, 'job-1', f);
    assert.equal(r.ok, false);
    assert.deepEqual(r.lines, []);
  });

  // ── jobAction ──
  await t('jobAction: ship re-GETs fresh leaseEpoch then PATCHes shipped', async () => {
    let patchBody = null;
    const f = makeFetch({
      'http://svc/api/fleet/jobs/j1': ({ opts }) => {
        if ((opts.method || 'GET') === 'PATCH') { patchBody = JSON.parse(opts.body); return { status: 200, body: { id: 'j1', stage: 'shipped' } }; }
        return { status: 200, body: { id: 'j1', stage: 'testing', leaseEpoch: 7 } }; // fresh epoch
      },
    });
    const r = await jobAction(CFG, { id: 'j1', leaseEpoch: 2 /* stale */ }, 'ship', f);
    assert.equal(r.ok, true);
    assert.equal(patchBody.stage, 'shipped');
    assert.equal(patchBody.leaseEpoch, 7); // used the freshly-fetched epoch, not the stale 2
  });
  await t('jobAction: ship 409 ⇒ actionable fenced message', async () => {
    const f = makeFetch({
      'http://svc/api/fleet/jobs/j1': ({ opts }) => (opts.method === 'PATCH'
        ? { status: 409, body: '{}' }
        : { status: 200, body: { id: 'j1', leaseEpoch: 7 } }),
    });
    const r = await jobAction(CFG, { id: 'j1' }, 'ship', f);
    assert.equal(r.ok, false);
    assert.match(r.message, /fenced|refresh/i);
  });
  await t('jobAction: requeue ⇒ POST /actions/requeue', async () => {
    const f = makeFetch({ '/fleet/jobs/j1/actions/requeue': { status: 200, body: { id: 'j1', stage: 'queued' } } });
    const r = await jobAction(CFG, { id: 'j1' }, 'requeue', f);
    assert.equal(r.ok, true);
    assert.equal(f.calls[0].method, 'POST');
    assert.match(f.calls[0].url, /\/actions\/requeue$/);
  });
  await t('jobAction: reject 409 ⇒ conflict message', async () => {
    const f = makeFetch({ '/fleet/jobs/j1/actions/reject': { status: 409, body: '{}' } });
    const r = await jobAction(CFG, { id: 'j1' }, 'reject', f);
    assert.equal(r.ok, false);
    assert.match(r.message, /conflict|terminal|refresh/i);
  });
  t('jobAction: promote ⇒ explicitly unavailable in fleet mode', async () => {
    return jobAction(CFG, { id: 'j1' }, 'promote', makeFetch({})).then((r) => {
      assert.equal(r.ok, false);
      assert.match(r.message, /promote/i);
    });
  });
})();

// Summary line (selftest greps for PASS).
process.on('exit', () => {
  if (process.exitCode && process.exitCode !== 0) {
    console.error('fleet-dash.test FAIL');
  } else {
    console.log(`fleet-dash.test PASS (${passed} assertions)`);
  }
});
