/**
 * Tests for the Fleet Action card preview / estimate endpoints and the dry-run
 * flag added to the existing fleet-stop / fleet-prune routes.
 *
 * Covers:
 *   - POST /api/fleet/labels/match-preview (new): auth, tier, validation, real return shape.
 *   - POST /api/fleet/prune/estimate (new): auth, tier, validation, local + remote fan-out.
 *   - POST /api/fleet/labels/fleet-stop with dryRun: true: rehearses without invoking the destructive leaf.
 *   - POST /api/fleet/labels/fleet-prune with dryRun: true: rehearses without invoking pruneManagedOnly / pruneSystem.
 */
import { beforeAll, beforeEach, afterAll, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';

let mockFsStacks: string[] = [];
let mockFsStacksError: Error | null = null;
const pruneManagedOnly = vi.fn();
const pruneSystem = vi.fn();
const estimateManagedReclaim = vi.fn();
const estimateSystemReclaim = vi.fn();
const buildPrunePlan = vi.fn();
const executePrunePlan = vi.fn();
const getContainersByStack = vi.fn();
const stopContainer = vi.fn();
const restartContainer = vi.fn();
const invalidateNodeCaches = vi.fn();
const remoteSupportsCrossNodeRbac = vi.fn();

vi.mock('../helpers/remoteCapabilities', () => ({
  remoteSupportsCrossNodeRbac,
}));

vi.mock('../services/FileSystemService', () => ({
  FileSystemService: {
    getInstance: vi.fn(() => ({
      getStacks: vi.fn(async () => {
        if (mockFsStacksError) throw mockFsStacksError;
        return mockFsStacks;
      }),
    })),
  },
}));

vi.mock('../services/DockerController', () => ({
  default: {
    getInstance: vi.fn(() => ({
      pruneManagedOnly,
      pruneSystem,
      estimateManagedReclaim,
      estimateSystemReclaim,
      buildPrunePlan,
      executePrunePlan,
      getContainersByStack,
      stopContainer,
      restartContainer,
    })),
  },
}));

vi.mock('../helpers/cacheInvalidation', () => ({
  invalidateNodeCaches,
}));

let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let db: import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let activeBulkActions: typeof import('../routes/labels').activeBulkActions;
let labelCounter = 0;

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  ({ LicenseService } = await import('../services/LicenseService'));
  ({ activeBulkActions } = await import('../routes/labels'));
  const { DatabaseService } = await import('../services/DatabaseService');
  db = DatabaseService.getInstance();
  authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});

afterAll(() => cleanupTestDb(tmpDir));

beforeEach(() => {
  // restoreAllMocks resets spies but leaves call history on module-top vi.fn()
  // mocks intact; clearAllMocks zeroes that history so per-test call counts are
  // not polluted by earlier tests.
  vi.restoreAllMocks();
  vi.clearAllMocks();
  mockFsStacks = ['alpha', 'beta'];
  mockFsStacksError = null;
  pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 0 });
  pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 });
  estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
  estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 });
  buildPrunePlan.mockResolvedValue({
    nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0,
    fingerprint: 'empty-plan', createdAt: 1,
  });
  executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] });
  getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]);
  stopContainer.mockResolvedValue(undefined);
  restartContainer.mockResolvedValue(undefined);
  // Default: remotes are upgraded and honor the exact-stack allowlist. The
  // mixed-version cases below flip this to false to exercise the gate.
  remoteSupportsCrossNodeRbac.mockResolvedValue(true);
  activeBulkActions.clear();
  db.getDb().prepare('DELETE FROM stack_label_assignments').run();
  db.getDb().prepare('DELETE FROM stack_labels').run();
  // Suggestion tests seed node labels to prove they are excluded; clear them so
  // those rows do not leak into later assertions.
  db.getDb().prepare('DELETE FROM node_labels').run();
});

async function createAssignedLabel(name: string, stacks: string[]) {
  const created = await request(app)
    .post('/api/labels')
    .set('Authorization', authHeader)
    .send({ name: `${name}-${++labelCounter}`, color: 'teal' });
  expect(created.status).toBe(201);

  for (const stack of stacks) {
    const assigned = await request(app)
      .put(`/api/stacks/${stack}/labels`)
      .set('Authorization', authHeader)
      .send({ labelIds: [created.body.id] });
    expect(assigned.status).toBe(200);
  }

  return created.body as { id: number; node_id: number; name: string };
}

// Stub the control's server-side fan-out to a remote node. The remote's
// authoritative stack-label state is read live via /api/labels +
// /api/labels/assignments; this returns canned bodies for those two URLs and
// passes any other URL (e.g. the fleet-stop local-stop receiver) to `other`.
function mockRemoteLabelFetch(opts: {
  labels: { name: string; color?: string }[];
  assignments: Record<string, string[]>; // stackName -> label names
  other?: (url: string) => Response;
}) {
  return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters<typeof fetch>[0]) => {
    const url = String(input);
    if (url.endsWith('/api/labels')) {
      const body = opts.labels.map((l, i) => ({ id: i + 1, node_id: 9, name: l.name, color: l.color ?? 'teal' }));
      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
    }
    if (url.endsWith('/api/labels/assignments')) {
      const body: Record<string, { name: string; color: string }[]> = {};
      for (const [stack, names] of Object.entries(opts.assignments)) {
        body[stack] = names.map(n => ({ name: n, color: 'teal' }));
      }
      return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
    }
    return opts.other ? opts.other(url) : new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
  });
}

describe('POST /api/fleet/labels/match-preview', () => {
  it('returns 401 without auth', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/match-preview')
      .send({ labelName: 'x' });
    expect(res.status).toBe(401);
  });

  it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
    vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
    const res = await request(app)
      .post('/api/fleet/labels/match-preview')
      .set('Authorization', authHeader)
      .send({ labelName: 'does-not-exist' });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(res.body.matchedNodes).toBe(0);
  });

  it('returns 400 when labelName is missing or empty', async () => {
    const a = await request(app)
      .post('/api/fleet/labels/match-preview')
      .set('Authorization', authHeader)
      .send({});
    expect(a.status).toBe(400);
    const b = await request(app)
      .post('/api/fleet/labels/match-preview')
      .set('Authorization', authHeader)
      .send({ labelName: '   ' });
    expect(b.status).toBe(400);
  });

  it('returns matched counts and per-node stack lists for a real label', async () => {
    const label = await createAssignedLabel('preview', ['alpha', 'beta']);
    const res = await request(app)
      .post('/api/fleet/labels/match-preview')
      .set('Authorization', authHeader)
      .send({ labelName: label.name });
    expect(res.status).toBe(200);
    expect(res.body.matchedNodes).toBe(1);
    expect(res.body.matchedStacks).toBe(2);
    expect(res.body.perNode).toHaveLength(1);
    expect(res.body.perNode[0].stackCount).toBe(2);
    expect(res.body.perNode[0].stackNames.sort()).toEqual(['alpha', 'beta']);
  });

  it('returns zero counts for an unknown label without erroring', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/match-preview')
      .set('Authorization', authHeader)
      .send({ labelName: 'does-not-exist' });
    expect(res.status).toBe(200);
    expect(res.body.matchedNodes).toBe(0);
    expect(res.body.matchedStacks).toBe(0);
  });

  it('includes remote stacks grouped per node, drops stale local stacks, and flags reachability', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    const local = db.createLabel(localId, 'media', 'teal');
    db.setStackLabels('alpha', localId, [local.id]);
    // A stale local assignment to a stack that no longer exists on disk
    // (not in mockFsStacks) must be filtered out, not counted.
    db.setStackLabels('media-ghost', localId, [local.id]);
    const remoteId = db.addNode({
      name: 'media-remote', type: 'remote', api_url: 'http://media.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      // The control DB holds no 'media' row for the remote; its stacks come live.
      mockRemoteLabelFetch({ labels: [{ name: 'media' }], assignments: { sonarr: ['media'], radarr: ['media'] } });
      const res = await request(app)
        .post('/api/fleet/labels/match-preview')
        .set('Authorization', authHeader)
        .send({ labelName: 'media' });
      expect(res.status).toBe(200);
      // 1 live local stack (the ghost is dropped) + 2 remote stacks.
      expect(res.body.matchedStacks).toBe(3);
      expect(res.body.matchedNodes).toBe(2);
      expect(res.body.unreachableNodes).toBe(0);
      const localRow = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === localId);
      expect(localRow.stackCount).toBe(1);
      expect(localRow.stackNames).toEqual(['alpha']);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(true);
      expect(remote.labelExists).toBe(true);
      expect(remote.stackNames.sort()).toEqual(['radarr', 'sonarr']);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('distinguishes label-exists-no-stacks from an unreachable remote', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    // Local carries the label but assigns no stacks to it.
    db.createLabel(localId, 'empty-label', 'teal');
    const remoteId = db.addNode({
      name: 'unreach-remote', type: 'remote', api_url: 'http://unreach.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
      const res = await request(app)
        .post('/api/fleet/labels/match-preview')
        .set('Authorization', authHeader)
        .send({ labelName: 'empty-label' });
      expect(res.status).toBe(200);
      expect(res.body.unreachableNodes).toBe(1);
      const localRow = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === localId);
      expect(localRow.reachable).toBe(true);
      expect(localRow.labelExists).toBe(true);
      expect(localRow.stackCount).toBe(0);
      const remoteRow = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(false);
      expect(remoteRow.error).toMatch(/ECONNREFUSED|reach/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('surfaces the remote error body when it serves labels but rejects assignments', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    db.setStackLabels('alpha', localId, [db.createLabel(localId, 'ok-label', 'teal').id]);
    const remoteId = db.addNode({
      name: 'split-remote', type: 'remote', api_url: 'http://split.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters<typeof fetch>[0]) => {
        const url = String(input);
        if (url.endsWith('/api/labels')) {
          return new Response(JSON.stringify([]), { status: 200, headers: { 'content-type': 'application/json' } });
        }
        // /api/labels/assignments rejects with a real error body.
        return new Response(JSON.stringify({ error: 'token expired' }), { status: 403, headers: { 'content-type': 'application/json' } });
      });
      const res = await request(app)
        .post('/api/fleet/labels/match-preview')
        .set('Authorization', authHeader)
        .send({ labelName: 'ok-label' });
      expect(res.status).toBe(200);
      const remoteRow = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(false);
      // The remote's own message is surfaced, not a bare status number.
      expect(remoteRow.error).toBe('token expired');
    } finally {
      db.deleteNode(remoteId);
    }
  });
});

describe('GET /api/fleet/labels/suggestions', () => {
  it('returns 401 without auth', async () => {
    const res = await request(app).get('/api/fleet/labels/suggestions');
    expect(res.status).toBe(401);
  });

  it('allows a viewer with node:read to load suggestions', async () => {
    const viewerName = `viewer-sugg-${++labelCounter}`;
    db.addUser({ username: viewerName, password_hash: 'x', role: 'viewer' });
    const viewerAuth = `Bearer ${jwt.sign({ username: viewerName }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
    const res = await request(app)
      .get('/api/fleet/labels/suggestions')
      .set('Authorization', viewerAuth);
    expect(res.status).toBe(200);
  });

  it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
    vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
    const res = await request(app)
      .get('/api/fleet/labels/suggestions')
      .set('Authorization', authHeader);
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(Array.isArray(res.body.suggestions)).toBe(true);
  });

  it('surfaces remote-only stack labels and aggregates shared names across nodes', async () => {
    const localNode = db.getNodes().find(n => n.is_default)!;
    const remoteId = db.addNode({
      name: 'sugg-remote', type: 'remote', api_url: 'http://sugg.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      // Local node: 'shared-prod' on stack 'alpha' (alpha is in mockFsStacks).
      const local = db.createLabel(localNode.id, 'shared-prod', 'teal');
      db.setStackLabels('alpha', localNode.id, [local.id]);
      // Remote node: queried live. It carries 'shared-prod' on TWO stacks plus a
      // remote-only 'edge'. NONE of this is mirrored in the control DB. The
      // asymmetric remote count (2) makes a sum-vs-concatenate bug observable.
      mockRemoteLabelFetch({
        labels: [{ name: 'shared-prod' }, { name: 'edge' }],
        assignments: { sonarr: ['shared-prod'], lidarr: ['shared-prod'], radarr: ['edge'] },
      });

      const res = await request(app)
        .get('/api/fleet/labels/suggestions')
        .set('Authorization', authHeader);
      expect(res.status).toBe(200);

      const names = res.body.suggestions.map((s: { name: string }) => s.name);
      expect(names).toContain('shared-prod');
      // Remote-only label is visible despite no control-DB row.
      expect(names).toContain('edge');
      // Sorted by name.
      expect(names).toEqual([...names].sort((a: string, b: string) => a.localeCompare(b)));
      expect(res.body.partial).toBe(false);
      expect(res.body.unreachableNodes).toBe(0);

      const shared = res.body.suggestions.find((s: { name: string }) => s.name === 'shared-prod');
      expect(shared.scope).toBe('stack');
      expect(shared.nodeCount).toBe(2);
      // 1 local stack + 2 remote stacks, summed (not deduped to the node count).
      expect(shared.stackCount).toBe(3);
      expect([...shared.nodes].sort()).toEqual([localNode.name, 'sugg-remote'].sort());

      const edge = res.body.suggestions.find((s: { name: string }) => s.name === 'edge');
      expect(edge.nodeCount).toBe(1);
      expect(edge.stackCount).toBe(1);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('never folds node labels into the picker even when one shares a stack-label name', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    const remoteId = db.addNode({
      name: 'collision-remote', type: 'remote', api_url: 'http://collision.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      // A stack label 'prod' lives on the local node...
      const stackLabel = db.createLabel(localId, 'prod', 'teal');
      db.setStackLabels('alpha', localId, [stackLabel.id]);
      // ...while a node label of the same name lives on the remote node. The
      // remote's stack-label endpoint (/api/labels) never returns node labels.
      db.addNodeLabel(remoteId, 'prod');
      mockRemoteLabelFetch({ labels: [], assignments: {} });

      const res = await request(app)
        .get('/api/fleet/labels/suggestions')
        .set('Authorization', authHeader);
      expect(res.status).toBe(200);

      const prod = res.body.suggestions.find((s: { name: string }) => s.name === 'prod');
      // The node label must not inflate the counts: only the local stack label counts.
      expect(prod.nodeCount).toBe(1);
      expect(prod.stackCount).toBe(1);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('excludes labels whose only assigned stacks no longer exist on disk', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    const ghost = db.createLabel(localId, 'ghost-only', 'teal');
    // 'vanished-stack' is not in mockFsStacks, so the fs-present filter drops it,
    // leaving the label with zero matching stacks fleet-wide.
    db.setStackLabels('vanished-stack', localId, [ghost.id]);

    const res = await request(app)
      .get('/api/fleet/labels/suggestions')
      .set('Authorization', authHeader);
    expect(res.status).toBe(200);
    const names = res.body.suggestions.map((s: { name: string }) => s.name);
    expect(names).not.toContain('ghost-only');
  });

  it('reports unreachable remotes as partial without dropping reachable suggestions', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    const local = db.createLabel(localId, 'live-label', 'teal');
    db.setStackLabels('alpha', localId, [local.id]);
    const remoteId = db.addNode({
      name: 'down-remote', type: 'remote', api_url: 'http://down.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
      const res = await request(app)
        .get('/api/fleet/labels/suggestions')
        .set('Authorization', authHeader);
      expect(res.status).toBe(200);
      expect(res.body.partial).toBe(true);
      expect(res.body.unreachableNodes).toBe(1);
      const names = res.body.suggestions.map((s: { name: string }) => s.name);
      expect(names).toContain('live-label');
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('fails closed: a remote returning a malformed label body is marked unreachable', async () => {
    const localId = db.getNodes().find(n => n.is_default)!.id;
    const local = db.createLabel(localId, 'safe-label', 'teal');
    db.setStackLabels('alpha', localId, [local.id]);
    const remoteId = db.addNode({
      name: 'garbage-remote', type: 'remote', api_url: 'http://garbage.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: Parameters<typeof fetch>[0]) => {
        const url = String(input);
        if (url.endsWith('/api/labels')) {
          // Not an array: the fail-closed parser must reject the whole node.
          return new Response(JSON.stringify({ not: 'an array' }), { status: 200, headers: { 'content-type': 'application/json' } });
        }
        return new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } });
      });
      const res = await request(app)
        .get('/api/fleet/labels/suggestions')
        .set('Authorization', authHeader);
      expect(res.status).toBe(200);
      expect(res.body.partial).toBe(true);
      expect(res.body.unreachableNodes).toBe(1);
      // The garbage remote contributes nothing; the local label still surfaces.
      const names = res.body.suggestions.map((s: { name: string }) => s.name);
      expect(names).toContain('safe-label');
    } finally {
      db.deleteNode(remoteId);
    }
  });
});

describe('POST /api/fleet/prune/estimate', () => {
  it('returns 401 without auth', async () => {
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .send({ targets: ['images'], scope: 'managed' });
    expect(res.status).toBe(401);
  });

  it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
    vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images'], scope: 'managed' });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(res.body).toHaveProperty('totalBytes');
  });

  it('returns 400 when targets is empty', async () => {
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: [], scope: 'managed' });
    expect(res.status).toBe(400);
  });

  it('aggregates per-node reclaimable bytes from estimateManagedReclaim on the local node', async () => {
    estimateManagedReclaim.mockImplementation(async (target: string) =>
      ({ reclaimableBytes: target === 'images' ? 4096 : 256 }));
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images', 'volumes'], scope: 'managed' });
    expect(res.status).toBe(200);
    expect(res.body.totalBytes).toBe(4096 + 256);
    expect(res.body.perNode).toHaveLength(1);
    expect(res.body.perNode[0].reachable).toBe(true);
    expect(res.body.perNode[0].reclaimableBytes).toBe(4096 + 256);
    expect(pruneManagedOnly).not.toHaveBeenCalled();
    expect(pruneSystem).not.toHaveBeenCalled();
  });

  it('uses estimateSystemReclaim when scope is "all"', async () => {
    estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 1024 });
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images'], scope: 'all' });
    expect(res.status).toBe(200);
    expect(estimateManagedReclaim).not.toHaveBeenCalled();
    expect(estimateSystemReclaim).toHaveBeenCalled();
    expect(res.body.perNode[0].reclaimableBytes).toBe(1024);
  });

  it('marks a remote node unreachable when its estimate endpoint is down', async () => {
    const remoteId = db.addNode({
      name: 'remote-est',
      type: 'remote',
      api_url: 'http://remote-est.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(false);
      expect(remote.error).toMatch(/ECONNREFUSED/);
      expect(remote.reclaimableBytes).toBe(0);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('keeps successful remote bytes when one per-target estimate fails', async () => {
    const remoteId = db.addNode({
      name: 'remote-partial',
      type: 'remote',
      api_url: 'http://remote-partial.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
        const body = JSON.parse(String(init?.body ?? '{}')) as { target?: string };
        if (body.target === 'images') {
          return new Response(JSON.stringify({ reclaimableBytes: 42 }), {
            status: 200,
            headers: { 'Content-Type': 'application/json' },
          });
        }
        return new Response(JSON.stringify({ error: 'Docker daemon is busy', code: 'docker_df_slow' }), {
          status: 503,
          headers: { 'Content-Type': 'application/json' },
        });
      });
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images', 'volumes'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(true);
      expect(remote.partial).toBe(true);
      expect(remote.reclaimableBytes).toBe(42);
      expect(remote.error).toMatch(/Docker daemon is busy|503/);
      expect(res.body.totalBytes).toBeGreaterThanOrEqual(42);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('rejects a negative remote reclaimableBytes without folding it into the totals', async () => {
    estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
    const remoteId = db.addNode({
      name: 'remote-negative',
      type: 'remote',
      api_url: 'http://remote-negative.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
        JSON.stringify({ reclaimableBytes: -5 }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ));
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(false);
      expect(remote.partial).toBeUndefined();
      expect(remote.reclaimableBytes).toBe(0);
      expect(remote.error).toBe('Invalid response from remote node');
      expect(res.body.totalBytes).toBe(0);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('rejects a negative remote target without discarding a successful sibling target', async () => {
    estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
    const remoteId = db.addNode({
      name: 'remote-negative-partial',
      type: 'remote',
      api_url: 'http://remote-negative-partial.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
        const body = JSON.parse(String(init?.body ?? '{}')) as { target?: string };
        return new Response(
          JSON.stringify({ reclaimableBytes: body.target === 'images' ? 42 : -5 }),
          { status: 200, headers: { 'Content-Type': 'application/json' } },
        );
      });
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images', 'volumes'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(true);
      expect(remote.partial).toBe(true);
      expect(remote.reclaimableBytes).toBe(42);
      expect(remote.error).toBe('Invalid response from remote node');
      expect(res.body.totalBytes).toBe(42);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('rejects a non-finite remote reclaimableBytes value', async () => {
    estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
    const remoteId = db.addNode({
      name: 'remote-infinity',
      type: 'remote',
      api_url: 'http://remote-infinity.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      // 1e999 overflows JSON.parse to Infinity; JSON.stringify would emit null
      // instead, so the raw body must be provided verbatim.
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
        '{"reclaimableBytes":1e999}',
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ));
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(false);
      expect(remote.partial).toBeUndefined();
      expect(remote.reclaimableBytes).toBe(0);
      expect(remote.error).toBe('Invalid response from remote node');
      expect(res.body.totalBytes).toBe(0);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('accepts a zero remote reclaimableBytes as a valid estimate', async () => {
    estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
    const remoteId = db.addNode({
      name: 'remote-zero',
      type: 'remote',
      api_url: 'http://remote-zero.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
        JSON.stringify({ reclaimableBytes: 0 }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ));
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images'], scope: 'managed' });
      expect(res.status).toBe(200);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(true);
      expect(remote.partial).toBeUndefined();
      expect(remote.reclaimableBytes).toBe(0);
      expect(remote.error).toBeUndefined();
      expect(res.body.totalBytes).toBe(0);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('keeps successful local bytes when a later target rejects without timing out', async () => {
    estimateManagedReclaim.mockImplementation(async (target: string) => {
      if (target === 'images') return { reclaimableBytes: 4096 };
      throw new Error('enumeration failed');
    });
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images', 'volumes'], scope: 'managed' });
    expect(res.status).toBe(200);
    expect(res.body.perNode[0].reachable).toBe(true);
    expect(res.body.perNode[0].partial).toBe(true);
    expect(res.body.perNode[0].reclaimableBytes).toBe(4096);
    expect(res.body.perNode[0].error).toMatch(/enumeration failed/);
    expect(res.body.totalBytes).toBe(4096);
  });

  it('marks the local node unreachable when DockerController init fails without 500ing the fleet', async () => {
    const remoteId = db.addNode({
      name: 'remote-survives-init',
      type: 'remote',
      api_url: 'http://remote-survives.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      const DockerController = (await import('../services/DockerController')).default;
      vi.mocked(DockerController.getInstance).mockImplementationOnce(() => {
        throw new Error('Node with id 1 not found');
      });
      vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
        JSON.stringify({ reclaimableBytes: 99 }),
        { status: 200, headers: { 'Content-Type': 'application/json' } },
      ));
      const res = await request(app)
        .post('/api/fleet/prune/estimate')
        .set('Authorization', authHeader)
        .send({ targets: ['images'], scope: 'managed' });
      expect(res.status).toBe(200);
      const local = res.body.perNode.find((n: { nodeId: number; nodeName: string }) => n.nodeName !== 'remote-survives-init');
      expect(local.reachable).toBe(false);
      expect(local.reclaimableBytes).toBe(0);
      expect(local.error).toMatch(/not found|Failed to estimate locally/);
      const remote = res.body.perNode.find((n: { nodeId: number }) => n.nodeId === remoteId);
      expect(remote.reachable).toBe(true);
      expect(remote.reclaimableBytes).toBe(99);
      expect(res.body.totalBytes).toBe(99);
    } finally {
      db.deleteNode(remoteId);
    }
  });
});

describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
  // Guards the C-1 fix: the remote fan-out must target the admin-only
  // /api/fleet-actions/labels/local-stop receiver (reachable on every license),
  // never the paid /api/labels/:id/action it used to call, which 403'd on
  // Community remotes.
  it('fans out to the admin-only local-stop receiver, never the paid per-label action route', async () => {
    const remoteId = db.addNode({
      name: 'remote-stop',
      type: 'remote',
      api_url: 'http://remote-stop.example:1852',
      api_token: 'tok',
      compose_dir: '/app/compose',
      is_default: false,
    });
    try {
      const label = db.createLabel(remoteId, `remote-c1-${++labelCounter}`, 'teal');
      db.setStackLabels('alpha', remoteId, [label.id]);
      const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true,
        status: 200,
        json: async () => ({ matched: true, results: [{ stackName: 'alpha', success: true }] }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name });

      expect(res.status).toBe(200);
      const urls = fetchSpy.mock.calls.map(c => String(c[0]));
      expect(urls.some(u => u.endsWith('/api/fleet-actions/labels/local-stop'))).toBe(true);
      expect(urls.some(u => u.includes('/api/labels/'))).toBe(false);

      const call = fetchSpy.mock.calls.find(c => String(c[0]).endsWith('/api/fleet-actions/labels/local-stop'));
      expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({ labelName: label.name, dryRun: false });

      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.stackResults).toEqual([{ stackName: 'alpha', success: true }]);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('honors the remote matched:false flag over the control mirror (mirror skew)', async () => {
    const remoteId = db.addNode({
      name: 'remote-skew', type: 'remote', api_url: 'http://remote-skew.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      // The control mirror believes the remote carries this label + stack...
      const label = db.createLabel(remoteId, `remote-skew-${++labelCounter}`, 'teal');
      db.setStackLabels('alpha', remoteId, [label.id]);
      // ...but the remote authoritatively reports it has no such label.
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200, json: async () => ({ matched: false, results: [] }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name });

      expect(res.status).toBe(200);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.matched).toBe(false);
      expect(remoteRow.stackResults).toEqual([]);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('accepts a legitimate empty stop (matched:true, results:[]) without flagging it malformed', async () => {
    const remoteId = db.addNode({
      name: 'remote-empty', type: 'remote', api_url: 'http://remote-empty.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const label = db.createLabel(remoteId, `remote-empty-${++labelCounter}`, 'teal');
      db.setStackLabels('alpha', remoteId, [label.id]);
      // The local-stop receiver emits exactly this when the label exists with no
      // assigned stacks. The guard must let it through, not over-reject it.
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200, json: async () => ({ matched: true, results: [] }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name });

      expect(res.status).toBe(200);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(true);
      expect(remoteRow.matched).toBe(true);
      expect(remoteRow.stackResults).toEqual([]);
      expect(remoteRow.error).toBeUndefined();
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('fails a node whose 200 body has a non-array results, never rendering it as zero-stack success', async () => {
    const remoteId = db.addNode({
      name: 'remote-malformed', type: 'remote', api_url: 'http://remote-malformed.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const label = db.createLabel(remoteId, `remote-malformed-${++labelCounter}`, 'teal');
      db.setStackLabels('alpha', remoteId, [label.id]);
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200, json: async () => ({ matched: true, results: 'not-an-array' }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name });

      expect(res.status).toBe(200);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      // A malformed contract is a node failure: reachable:false + error, not the
      // matched:true/empty-results shape the UI reads as a successful no-op.
      expect(remoteRow.reachable).toBe(false);
      expect(remoteRow.matched).toBe(false);
      expect(remoteRow.stackResults).toEqual([]);
      expect(remoteRow.error).toMatch(/malformed/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('fails a node whose 200 body omits the matched flag instead of defaulting it to true', async () => {
    const remoteId = db.addNode({
      name: 'remote-no-matched', type: 'remote', api_url: 'http://remote-no-matched.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const label = db.createLabel(remoteId, `remote-no-matched-${++labelCounter}`, 'teal');
      db.setStackLabels('alpha', remoteId, [label.id]);
      // The previous default (`matched: remote.matched ?? true`) turned this body
      // into a matched:true/zero-stack success; the guard must fail the node.
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200, json: async () => ({ results: [] }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name });

      expect(res.status).toBe(200);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(false);
      expect(remoteRow.matched).toBe(false);
      expect(remoteRow.error).toMatch(/malformed/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('calls the remote local-stop even when the control DB has no remote label row', async () => {
    const remoteId = db.addNode({
      name: 'remote-nomirror', type: 'remote', api_url: 'http://remote-nomirror.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      // Deliberately seed NO label/assignment for the remote in the control DB.
      // The old mirror pre-check would have skipped this node entirely.
      const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200,
        json: async () => ({ matched: true, results: [{ stackName: 'remote-stack', success: true }] }),
      } as unknown as Response);

      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'remote-only-label' });

      expect(res.status).toBe(200);
      const urls = fetchSpy.mock.calls.map(c => String(c[0]));
      expect(urls.some(u => u.endsWith('/api/fleet-actions/labels/local-stop'))).toBe(true);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(true);
      expect(remoteRow.matched).toBe(true);
      expect(remoteRow.stackResults).toEqual([{ stackName: 'remote-stack', success: true }]);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('reports an unreachable remote at the node level without blocking the local stop', async () => {
    const localLabel = await createAssignedLabel('local-live', ['alpha']);
    const remoteId = db.addNode({
      name: 'remote-down', type: 'remote', api_url: 'http://remote-down.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: localLabel.name });

      expect(res.status).toBe(200);
      const localResult = res.body.results.find((r: { nodeId: number }) => r.nodeId === localLabel.node_id);
      expect(localResult.reachable).toBe(true);
      expect(localResult.matched).toBe(true);
      expect(localResult.stackResults).toEqual([{ stackName: 'alpha', success: true }]);
      const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(remoteRow.reachable).toBe(false);
      expect(remoteRow.matched).toBe(false);
      expect(remoteRow.stackResults).toEqual([]);
      expect(remoteRow.error).toMatch(/ECONNREFUSED|reach/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });
});

describe('POST /api/fleet/labels/fleet-stop confirmed-target allowlist', () => {
  // Guards the drift fix: the real stop carries the exact node + stack list the
  // operator confirmed in the preview. A node that was unreachable then and
  // reconnects cannot enter the fan-out, and a stack labelled after the preview
  // is not stopped (the per-node receiver intersects against the sent stacks).
  it('contacts a confirmed remote and forwards its confirmed stacks, excluding the unconfirmed local node', async () => {
    const remoteId = db.addNode({
      name: 'confirmed-remote', type: 'remote', api_url: 'http://confirmed.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200, json: async () => ({ matched: true, results: [{ stackName: 'alpha', success: true }] }),
      } as unknown as Response);
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
      expect(res.status).toBe(200);
      // Only the confirmed remote is in the fan-out: its local-stop receiver is
      // contacted and the unconfirmed local node is absent from the results.
      expect(res.body.results).toHaveLength(1);
      expect(res.body.results[0].nodeId).toBe(remoteId);
      const stopCall = fetchSpy.mock.calls.find(c => String(c[0]).endsWith('/api/fleet-actions/labels/local-stop'));
      expect(stopCall).toBeTruthy();
      // The confirmed stacks are forwarded so the remote binds its stop to them.
      expect(JSON.parse((stopCall![1] as RequestInit).body as string)).toMatchObject({ labelName: 'any-label', stackNames: ['alpha'] });
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('excludes an otherwise-reachable remote that is not in the allowlist', async () => {
    const label = await createAssignedLabel('confirmed-only', ['alpha']);
    const localId = label.node_id;
    const remoteId = db.addNode({
      name: 'excluded-remote', type: 'remote', api_url: 'http://excluded.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const fetchSpy = vi.spyOn(globalThis, 'fetch');
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: label.name, targets: [{ nodeId: localId, stackNames: ['alpha'] }] });
      expect(res.status).toBe(200);
      // The excluded remote has a proxy target and would otherwise be contacted;
      // the allowlist keeps it out of execution entirely.
      expect(res.body.results).toHaveLength(1);
      expect(res.body.results[0].nodeId).toBe(localId);
      expect(res.body.results.some((r: { nodeId: number }) => r.nodeId === remoteId)).toBe(false);
      expect(fetchSpy).not.toHaveBeenCalled();
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('treats empty targets as zero target nodes, stopping nothing', async () => {
    const label = await createAssignedLabel('empty-allow', ['alpha']);
    const fetchSpy = vi.spyOn(globalThis, 'fetch');
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: label.name, targets: [] });
    expect(res.status).toBe(200);
    // Empty targets filter to no nodes: a fail-safe no-op rather than a
    // full-fleet stop. The local node is not acted on and no remote is contacted.
    expect(res.body.results).toEqual([]);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('rejects a non-array targets with 400', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'whatever', targets: 'oops' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/targets/);
  });

  it('rejects a non-integer target.nodeId with 400', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'whatever', targets: [{ nodeId: 2.5, stackNames: ['x'] }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/nodeId/);
  });

  it('refuses a real stop to a remote lacking cross-node-rbac and reports it as needing upgrade', async () => {
    remoteSupportsCrossNodeRbac.mockResolvedValue(false);
    const remoteId = db.addNode({
      name: 'old-remote', type: 'remote', api_url: 'http://old.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      const fetchSpy = vi.spyOn(globalThis, 'fetch');
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
      expect(res.status).toBe(200);
      const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(node.reachable).toBe(false);
      expect(node.error).toMatch(/upgrade/i);
      expect(node.stackResults).toEqual([{ stackName: 'alpha', success: false, error: 'Node must be upgraded to honor an exact-stack stop' }]);
      // The destructive local-stop receiver is never contacted on an old remote.
      expect(fetchSpy.mock.calls.some(c => String(c[0]).endsWith('/api/fleet-actions/labels/local-stop'))).toBe(false);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('fails a node whose stop results include a stack outside the confirmed set', async () => {
    remoteSupportsCrossNodeRbac.mockResolvedValue(true);
    const remoteId = db.addNode({
      name: 'lying-remote', type: 'remote', api_url: 'http://lying.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200,
        json: async () => ({
          matched: true,
          results: [
            { stackName: 'alpha', success: true },
            { stackName: 'unconfirmed-extra', success: true },
          ],
        }),
      } as unknown as Response);
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
      expect(res.status).toBe(200);
      const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(node.reachable).toBe(false);
      expect(node.error).toMatch(/exactly the confirmed stacks/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('fails a node whose stop results omit a confirmed stack (partial result)', async () => {
    remoteSupportsCrossNodeRbac.mockResolvedValue(true);
    const remoteId = db.addNode({
      name: 'dropping-remote', type: 'remote', api_url: 'http://dropping.example:1852',
      api_token: 'tok', compose_dir: '/app/compose', is_default: false,
    });
    try {
      vi.spyOn(globalThis, 'fetch').mockResolvedValue({
        ok: true, status: 200,
        // Two stacks confirmed, but the remote reports only one.
        json: async () => ({ matched: true, results: [{ stackName: 'alpha', success: true }] }),
      } as unknown as Response);
      const res = await request(app)
        .post('/api/fleet/labels/fleet-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha', 'beta'] }] });
      expect(res.status).toBe(200);
      const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
      expect(node.reachable).toBe(false);
      expect(node.error).toMatch(/exactly the confirmed stacks/i);
    } finally {
      db.deleteNode(remoteId);
    }
  });

  it('local exception path reports every confirmed stack, including one that lost its label', async () => {
    // runLocalLabelStop throws (filesystem read fails) after the label is
    // resolved; the catch must report the full confirmed set, not the current
    // assignment, so a confirmed stack that lost its label still surfaces.
    const localNodeId = db.getNodes().find(n => n.type === 'local')!.id;
    const label = await createAssignedLabel('local-throw', ['alpha']); // only alpha is still assigned
    expect(label.node_id).toBe(localNodeId);
    mockFsStacksError = new Error('compose dir unreadable');
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: label.name, targets: [{ nodeId: localNodeId, stackNames: ['alpha', 'lost-label-stack'] }] });
    expect(res.status).toBe(200);
    const node = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
    const byName = Object.fromEntries(node.stackResults.map((s: { stackName: string }) => [s.stackName, s]));
    // Both confirmed stacks are failed; the one that lost its label is not dropped.
    expect(byName['alpha']).toMatchObject({ success: false });
    expect(byName['lost-label-stack']).toMatchObject({ success: false });
    expect(node.stackResults).toHaveLength(2);
  });
});

describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => {
  it('marks each stack dryRun: true and does not invoke containerActionForStack', async () => {
    const label = await createAssignedLabel('dry-stop', ['alpha', 'beta']);
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: label.name, dryRun: true });
    expect(res.status).toBe(200);
    expect(res.body.results).toHaveLength(1);
    const node = res.body.results[0];
    expect(node.matched).toBe(true);
    expect(node.stackResults).toHaveLength(2);
    for (const stack of node.stackResults) {
      expect(stack.success).toBe(true);
      expect(stack.dryRun).toBe(true);
    }
    // containerActionForStack walks DockerController.stopContainer / restartContainer
    // internally; if dry-run incorrectly invoked it, those mocks would record calls.
    expect(stopContainer).not.toHaveBeenCalled();
    expect(restartContainer).not.toHaveBeenCalled();
    // Dry run must not bust caches.
    expect(invalidateNodeCaches).not.toHaveBeenCalled();
  });

  it('still runs the real action when dryRun is omitted', async () => {
    const label = await createAssignedLabel('real-stop', ['alpha']);
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: label.name });
    expect(res.status).toBe(200);
    // The destructive path invokes containerActionForStack, which in turn calls
    // DockerController.stopContainer for each container on the matched stack.
    expect(stopContainer).toHaveBeenCalled();
    // The destructive path should also have invalidated the local node's cache.
    expect(invalidateNodeCaches).toHaveBeenCalled();
  });
});

describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => {
  it('returns one multi-target itemized plan without calling prune methods', async () => {
    buildPrunePlan.mockResolvedValue({
      nodeId: 1,
      scope: 'managed',
      targets: ['volumes', 'images'],
      items: [
        { target: 'volumes', id: 'data', name: 'data', sizeBytes: 512, managed: true, reason: 'unused' },
        { target: 'images', id: 'image', name: 'app:latest', sizeBytes: 1536, managed: true, reason: 'unused' },
      ],
      reclaimableBytes: 2048,
      fingerprint: 'itemized-plan',
      createdAt: 1,
    });
    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true });
    expect(res.status).toBe(200);
    const node = res.body.results[0];
    expect(node.reachable).toBe(true);
    expect(node.fingerprint).toBe('itemized-plan');
    expect(node.items).toHaveLength(2);
    expect(node.targets).toHaveLength(2);
    expect(node.targets).toEqual([
      { target: 'images', success: true, reclaimedBytes: 1536, dryRun: true },
      { target: 'volumes', success: true, reclaimedBytes: 512, dryRun: true },
    ]);
    expect(pruneManagedOnly).not.toHaveBeenCalled();
    expect(pruneSystem).not.toHaveBeenCalled();
    expect(buildPrunePlan).toHaveBeenCalledTimes(1);
    expect(invalidateNodeCaches).not.toHaveBeenCalled();
  });

  it('loads known stacks and builds attribution even when scope is "all"', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({ targets: ['images'], scope: 'all', dryRun: true });
    expect(res.status).toBe(200);
    expect(estimateManagedReclaim).not.toHaveBeenCalled();
    expect(estimateSystemReclaim).not.toHaveBeenCalled();
    expect(buildPrunePlan).toHaveBeenCalledWith(['images'], 'all', ['alpha', 'beta'], expect.any(Number), expect.any(Function));
    expect(pruneManagedOnly).not.toHaveBeenCalled();
    expect(pruneSystem).not.toHaveBeenCalled();
  });

  it('requires reviewed roster and fingerprints when dryRun is omitted', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({ targets: ['images'], scope: 'managed' });
    expect(res.status).toBe(400);
    expect(pruneManagedOnly).not.toHaveBeenCalled();
    expect(executePrunePlan).not.toHaveBeenCalled();
    expect(estimateManagedReclaim).not.toHaveBeenCalled();
  });

  it('invalidates local caches only after an item is removed', async () => {
    const local = db.getNodes().find((node) => node.type === 'local')!;
    buildPrunePlan.mockResolvedValue({
      nodeId: local.id,
      scope: 'managed',
      targets: ['images'],
      items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused' }],
      reclaimableBytes: 0,
      fingerprint: 'reviewed-plan',
      createdAt: 1,
    });
    executePrunePlan.mockResolvedValue({
      success: true,
      reclaimedBytes: 0,
      mutated: true,
      outcomes: [{ target: 'images', id: 'image', status: 'removed' }],
    });
    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({
        targets: ['images'],
        scope: 'managed',
        dryRun: false,
        reviewedNodes: [{ nodeId: local.id, reachable: true }],
        plans: [{ nodeId: local.id, fingerprint: 'reviewed-plan' }],
      });
    expect(res.status).toBe(200);
    expect(executePrunePlan).toHaveBeenCalledTimes(1);
    expect(invalidateNodeCaches).toHaveBeenCalledWith(local.id);
  });

  it('does not invalidate local caches for empty, skipped, or failed outcomes', async () => {
    const local = db.getNodes().find((node) => node.type === 'local')!;
    const cases = [
      { items: [], outcomes: [] },
      {
        items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }],
        outcomes: [{ target: 'images', id: 'image', status: 'skipped', reason: 'became active' }],
      },
      {
        items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }],
        outcomes: [{ target: 'images', id: 'image', status: 'failed', error: 'remove failed' }],
      },
    ] as const;
    for (const [index, testCase] of cases.entries()) {
      const fingerprint = `reviewed-plan-${index}`;
      buildPrunePlan.mockResolvedValue({
        nodeId: local.id, scope: 'managed', targets: ['images'], items: [...testCase.items],
        reclaimableBytes: 0, fingerprint, createdAt: 1,
      });
      executePrunePlan.mockResolvedValue({
        success: true,
        reclaimedBytes: 0,
        mutated: false,
        outcomes: [...testCase.outcomes],
      });
      invalidateNodeCaches.mockClear();
      const res = await request(app)
        .post('/api/fleet/labels/fleet-prune')
        .set('Authorization', authHeader)
        .send({
          targets: ['images'], scope: 'managed', dryRun: false,
          reviewedNodes: [{ nodeId: local.id, reachable: true }],
          plans: [{ nodeId: local.id, fingerprint }],
        });
      expect(res.status).toBe(200);
      expect(invalidateNodeCaches).not.toHaveBeenCalled();
    }
  });
});
