/**
 * Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
 * validation, and cross-node orchestration shape across the fleet label routes:
 * the authoritative discovery reads (suggestions / match-preview / fleet-stop),
 * the bulk-assign orchestrator, and the per-node local-stop / local-assign
 * receivers.
 */
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';

let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let scopedAuthHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let scopedUserId: number;
let defaultNodeId: number;

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  ({ LicenseService } = await import('../services/LicenseService'));
  ({ DatabaseService } = await import('../services/DatabaseService'));
  const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
  authHeader = `Bearer ${token}`;
  const db = DatabaseService.getInstance();
  defaultNodeId = db.getDefaultNode()?.id ?? 1;
  const hash = await bcrypt.hash('password123', 1);
  scopedUserId = db.addUser({ username: 'fleet-scoped-operator', password_hash: hash, role: 'viewer' });
  db.addRoleAssignment({ user_id: scopedUserId, role: 'node-admin', resource_type: 'stack', resource_id: 'allowed-edit', node_id: defaultNodeId });
  db.addRoleAssignment({ user_id: scopedUserId, role: 'deployer', resource_type: 'stack', resource_id: 'allowed-deploy', node_id: defaultNodeId });
  const scopedUser = db.getUserByUsername('fleet-scoped-operator')!;
  scopedAuthHeader = `Bearer ${jwt.sign({ username: scopedUser.username, role: scopedUser.role, tv: scopedUser.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' })}`;
});

afterAll(() => {
  const db = DatabaseService.getInstance();
  db.deleteRoleAssignmentsByUser(scopedUserId);
  db.deleteUser(scopedUserId);
  cleanupTestDb(tmpDir);
});

function mockTier(tier: 'paid' | 'community') {
  vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}

function makeStack(name: string): void {
  const composeDir = process.env.COMPOSE_DIR as string;
  fs.mkdirSync(path.join(composeDir, name), { recursive: true });
  fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n');
}

describe('Fleet Actions endpoints require authentication', () => {
  it('POST /api/fleet/labels/bulk-assign returns 401 without auth', async () => {
    const res = await request(app).post('/api/fleet/labels/bulk-assign').send({ label: { name: 'x', color: 'teal' }, targets: [] });
    expect(res.status).toBe(401);
  });

  it('POST /api/fleet-actions/labels/local-assign returns 401 without auth', async () => {
    const res = await request(app).post('/api/fleet-actions/labels/local-assign').send({ label: { name: 'x', color: 'teal' }, stackNames: [] });
    expect(res.status).toBe(401);
  });

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

describe('Fleet Actions tier gating (Community + admin)', () => {
  afterEach(() => vi.restoreAllMocks());

  it('POST /api/fleet/labels/fleet-stop is reachable on community tier for admins', async () => {
    mockTier('community');
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'this-label-does-not-exist' });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(Array.isArray(res.body.results)).toBe(true);
  });

  it('POST /api/fleet/labels/bulk-assign is reachable on community tier for admins', async () => {
    mockTier('community');
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'community-ok', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['nope'] }] });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(Array.isArray(res.body.results)).toBe(true);
  });

  it('POST /api/fleet-actions/labels/local-assign is reachable on community tier for admins', async () => {
    mockTier('community');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'community-recv', color: 'teal' }, stackNames: [] });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(res.body).toEqual({ created: expect.any(Boolean), results: [] });
  });
});

describe('Fleet Actions input validation', () => {
  afterEach(() => vi.restoreAllMocks());

  it('POST /api/fleet/labels/fleet-stop rejects missing labelName', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({});
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/labelName/);
  });

  it('POST /api/fleet/labels/fleet-stop rejects whitespace-only labelName', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: '   ' });
    expect(res.status).toBe(400);
  });

  it('POST /api/fleet/labels/fleet-stop rejects a non-object target entry', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'prod', targets: ['oops'] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/each target must be an object/);
  });

  it('POST /api/fleet/labels/fleet-stop rejects a target with non-array stackNames', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'prod', targets: [{ nodeId: 1, stackNames: 'oops' }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/target.stackNames must be an array/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects an invalid label color', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'not-a-color' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/color/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects a missing label name', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { color: 'teal' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/label\.name/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects empty targets', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/targets/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects a non-integer nodeId', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 'abc', stackNames: ['x'] }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/nodeId/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects when all target groups are empty', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: [] }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/no target stacks/);
  });

  it('POST /api/fleet/labels/bulk-assign rejects an oversized total', async () => {
    const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: big }] });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/may not exceed/);
  });

  it('POST /api/fleet-actions/labels/local-assign rejects non-array stackNames', async () => {
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, stackNames: 'oops' });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/stackNames must be an array/);
  });

  it('POST /api/fleet-actions/labels/local-assign rejects an oversized payload', async () => {
    const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, stackNames: big });
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/may not exceed/);
  });
});

describe('Fleet Actions orchestration shape', () => {
  afterEach(() => vi.restoreAllMocks());

  it('POST /api/fleet/labels/fleet-stop with unknown label returns matched:false per node', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'this-label-does-not-exist' });
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.results)).toBe(true);
    for (const row of res.body.results) {
      expect(row.matched).toBe(false);
      expect(row.stackResults).toEqual([]);
    }
  });

  it('POST /api/fleet/labels/bulk-assign reports an unknown node per-node without failing the request', async () => {
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['a', 'b'] }] });
    expect(res.status).toBe(200);
    expect(res.body.results).toHaveLength(1);
    const row = res.body.results[0];
    expect(row.reachable).toBe(false);
    expect(row.error).toBe('Unknown node');
    expect(row.stackResults).toEqual([
      { stackName: 'a', success: false, error: 'Unknown node' },
      { stackName: 'b', success: false, error: 'Unknown node' },
    ]);
  });
});

describe('Fleet Actions authorize every target before mutation', () => {
  afterEach(() => vi.restoreAllMocks());

  it('rejects fleet stop when any confirmed stack lacks stack:deploy', async () => {
    mockTier('paid');
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', scopedAuthHeader)
      .send({
        labelName: 'prod',
        targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-deploy', 'denied-deploy'] }],
      });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
  });

  it('does not create a label when bulk assign contains a denied stack', async () => {
    mockTier('paid');
    const labelName = 'atomic-bulk-denied';
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', scopedAuthHeader)
      .send({
        label: { name: labelName, color: 'teal' },
        targets: [{ nodeId: defaultNodeId, stackNames: ['allowed-edit', 'denied-edit'] }],
      });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
    expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
  });

  it('does not create a label when a local receiver target is denied', async () => {
    mockTier('paid');
    const labelName = 'atomic-local-denied';
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', scopedAuthHeader)
      .send({ label: { name: labelName, color: 'teal' }, stackNames: ['allowed-edit', 'denied-edit'] });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
    expect(DatabaseService.getInstance().getLabels(defaultNodeId).some(label => label.name === labelName)).toBe(false);
  });

  it('rejects local stop before work when any confirmed stack is denied', async () => {
    mockTier('paid');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', scopedAuthHeader)
      .send({ labelName: 'prod', stackNames: ['allowed-deploy', 'denied-deploy'] });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('PERMISSION_DENIED');
  });
});

// The per-node local-stop receiver is what a control instance calls on each
// remote during a fleet-wide stop. It must be reachable on every license (only
// admin-gated): the original fleet-stop fan-out hit the paid /api/labels/:id/action
// and 403'd on Community remotes. These tests lock that behavior in.
describe('local-stop receiver auth + tier', () => {
  afterEach(() => vi.restoreAllMocks());

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

  it('is reachable on community tier for admins and never returns PAID_REQUIRED', async () => {
    mockTier('community');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'this-label-does-not-exist' });
    expect(res.status).toBe(200);
    expect(res.body.code).not.toBe('PAID_REQUIRED');
    expect(res.body).toEqual({ matched: false, results: [] });
  });

  it('rejects missing labelName', async () => {
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({});
    expect(res.status).toBe(400);
    expect(res.body.error).toMatch(/labelName/);
  });

  it('rejects whitespace-only labelName', async () => {
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: '   ' });
    expect(res.status).toBe(400);
  });
});

describe('local-stop behavior', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let nodeId: number;

  beforeAll(async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const { NodeRegistry } = await import('../services/NodeRegistry');
    db = DatabaseService.getInstance();
    nodeId = NodeRegistry.getInstance().getDefaultNodeId();
  });

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

  it('matched:true with empty results when the label exists but has no stacks', async () => {
    db.createLabel(nodeId, 'no-stacks-label', 'slate');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'no-stacks-label' });
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ matched: true, results: [] });
  });

  it('reports per-stack lock contention when a bulk action is already running on the node', async () => {
    const label = db.createLabel(nodeId, 'busy-label', 'slate');
    db.setStackLabels('busy-stack', nodeId, [label.id]);
    const { activeBulkActions } = await import('../routes/labels');
    activeBulkActions.add(`bulk:${nodeId}`);
    try {
      const res = await request(app)
        .post('/api/fleet-actions/labels/local-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'busy-label' });
      expect(res.status).toBe(200);
      expect(res.body.matched).toBe(true);
      expect(res.body.results).toEqual([
        { stackName: 'busy-stack', success: false, error: 'A bulk action is already running on this node' },
      ]);
    } finally {
      activeBulkActions.delete(`bulk:${nodeId}`);
    }
  });

  it('reports every confirmed stack under lock contention, including one that lost its label', async () => {
    const label = db.createLabel(nodeId, 'contend-label', 'slate');
    db.setStackLabels('contend-kept', nodeId, [label.id]);
    // contend-lost was confirmed in the preview but no longer carries the label.
    const { activeBulkActions } = await import('../routes/labels');
    activeBulkActions.add(`bulk:${nodeId}`);
    try {
      const res = await request(app)
        .post('/api/fleet-actions/labels/local-stop')
        .set('Authorization', authHeader)
        .send({ labelName: 'contend-label', stackNames: ['contend-kept', 'contend-lost'] });
      expect(res.status).toBe(200);
      // Both confirmed stacks surface as contention failures; the lost one is
      // not dropped (which would read as "no stacks assigned"), and the result
      // is never empty when stacks were confirmed.
      expect(res.body.results).toHaveLength(2);
      const byName = Object.fromEntries(res.body.results.map((r: { stackName: string }) => [r.stackName, r]));
      expect(byName['contend-kept'].error).toMatch(/already running/);
      expect(byName['contend-lost'].error).toMatch(/already running/);
    } finally {
      activeBulkActions.delete(`bulk:${nodeId}`);
    }
  });

  it('dry run returns dryRun:true per on-disk stack without touching Docker', async () => {
    makeStack('dry-stack');
    const label = db.createLabel(nodeId, 'dry-label', 'slate');
    db.setStackLabels('dry-stack', nodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'dry-label', dryRun: true });
    expect(res.status).toBe(200);
    expect(res.body.matched).toBe(true);
    expect(res.body.results).toEqual([{ stackName: 'dry-stack', success: true, dryRun: true }]);
  });

  it('filters out assigned stacks that are not present on disk', async () => {
    const label = db.createLabel(nodeId, 'ghost-label', 'slate');
    db.setStackLabels('ghost-stack', nodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'ghost-label' });
    expect(res.status).toBe(200);
    expect(res.body).toEqual({ matched: true, results: [] });
  });

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

  it('stops only the stacks in the stackNames allowlist, not every labelled stack', async () => {
    makeStack('allow-a');
    makeStack('allow-b');
    const label = db.createLabel(nodeId, 'allow-label', 'slate');
    db.setStackLabels('allow-a', nodeId, [label.id]);
    db.setStackLabels('allow-b', nodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'allow-label', dryRun: true, stackNames: ['allow-a'] });
    expect(res.status).toBe(200);
    // allow-b carries the label too but was not confirmed, so it is excluded.
    expect(res.body.results).toEqual([{ stackName: 'allow-a', success: true, dryRun: true }]);
  });

  it('reports a confirmed stack that no longer carries the label as a failure', async () => {
    makeStack('drop-kept');
    makeStack('drop-gone');
    const label = db.createLabel(nodeId, 'drop-label', 'teal');
    db.setStackLabels('drop-kept', nodeId, [label.id]);
    // drop-gone was confirmed but does not carry the label at execution time.
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'drop-label', dryRun: true, stackNames: ['drop-kept', 'drop-gone'] });
    expect(res.status).toBe(200);
    const byName = Object.fromEntries(res.body.results.map((r: { stackName: string }) => [r.stackName, r]));
    expect(byName['drop-kept']).toEqual({ stackName: 'drop-kept', success: true, dryRun: true });
    // The confirmed stack that fell out of scope is reported, not silently dropped.
    expect(byName['drop-gone']).toEqual({ stackName: 'drop-gone', success: false, error: 'No longer carries this label' });
  });

  it('reports a confirmed labelled stack that is missing on disk as a failure', async () => {
    const label = db.createLabel(nodeId, 'ghostdisk-label', 'rose');
    db.setStackLabels('ghostdisk-stack', nodeId, [label.id]); // labelled but never created on disk
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'ghostdisk-label', dryRun: true, stackNames: ['ghostdisk-stack'] });
    expect(res.status).toBe(200);
    expect(res.body.results).toEqual([{ stackName: 'ghostdisk-stack', success: false, error: 'Stack not found on this node' }]);
  });

  it('reports one failure per confirmed stack when the label no longer exists', async () => {
    // The label vanished between preview and execution. With a confirmed
    // allowlist the receiver must return one result per confirmed stack (so the
    // control's exact-membership check sees a complete set), not an empty body.
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'no-such-label', dryRun: true, stackNames: ['gone-a', 'gone-b'] });
    expect(res.status).toBe(200);
    expect(res.body.matched).toBe(false);
    expect(res.body.results).toEqual([
      { stackName: 'gone-a', success: false, error: 'No longer carries this label' },
      { stackName: 'gone-b', success: false, error: 'No longer carries this label' },
    ]);
  });
});

// Orchestrator-level binding: the control sends the exact node + stack list the
// operator confirmed, and the fan-out must neither widen the stack set nor drop
// a confirmed node that has since disappeared from the registry.
describe('fleet-stop confirmed-target binding', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let localNodeId: number;

  beforeAll(async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const { NodeRegistry } = await import('../services/NodeRegistry');
    db = DatabaseService.getInstance();
    localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
  });

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

  it('stops only the confirmed stacks even when others share the label', async () => {
    makeStack('bind-a');
    makeStack('bind-late');
    const label = db.createLabel(localNodeId, 'bind-label', 'teal');
    db.setStackLabels('bind-a', localNodeId, [label.id]);
    // bind-late carries the label but was not in the confirmed preview (it
    // stands in for a stack labelled between preview and execution).
    db.setStackLabels('bind-late', localNodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'bind-label', dryRun: true, targets: [{ nodeId: localNodeId, stackNames: ['bind-a'] }] });
    expect(res.status).toBe(200);
    const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
    expect(local.matched).toBe(true);
    expect(local.stackResults.map((s: { stackName: string }) => s.stackName)).toEqual(['bind-a']);
  });

  it('surfaces a confirmed node that no longer exists as an unreachable failure', async () => {
    makeStack('mix-a');
    const label = db.createLabel(localNodeId, 'mix-label', 'rose');
    db.setStackLabels('mix-a', localNodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({
        labelName: 'mix-label',
        dryRun: true,
        targets: [
          { nodeId: localNodeId, stackNames: ['mix-a'] },
          { nodeId: 999999, stackNames: ['ghost'] },
        ],
      });
    expect(res.status).toBe(200);
    const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
    const missing = res.body.results.find((r: { nodeId: number }) => r.nodeId === 999999);
    expect(local.stackResults.map((s: { stackName: string }) => s.stackName)).toEqual(['mix-a']);
    expect(missing).toBeTruthy();
    expect(missing.reachable).toBe(false);
    expect(missing.error).toMatch(/no longer exists/i);
    // The missing node's confirmed stacks are marked failed so the run reports
    // as partial rather than a clean stop of the surviving node alone.
    expect(missing.stackResults).toEqual([{ stackName: 'ghost', success: false, error: 'Node no longer exists' }]);
  });

  it('without targets, scans the whole label (no per-stack binding)', async () => {
    makeStack('nobind-a');
    makeStack('nobind-b');
    const label = db.createLabel(localNodeId, 'nobind-label', 'amber');
    db.setStackLabels('nobind-a', localNodeId, [label.id]);
    db.setStackLabels('nobind-b', localNodeId, [label.id]);
    const res = await request(app)
      .post('/api/fleet/labels/fleet-stop')
      .set('Authorization', authHeader)
      .send({ labelName: 'nobind-label', dryRun: true });
    expect(res.status).toBe(200);
    const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
    expect(local.stackResults.map((s: { stackName: string }) => s.stackName).sort()).toEqual(['nobind-a', 'nobind-b']);
  });
});

describe('local-assign receiver behavior', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let nodeId: number;

  beforeAll(async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const { NodeRegistry } = await import('../services/NodeRegistry');
    db = DatabaseService.getInstance();
    nodeId = NodeRegistry.getInstance().getDefaultNodeId();
  });

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

  it('creates the label when missing and assigns it to the stack', async () => {
    makeStack('recv-create');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'recv-created', color: 'blue' }, stackNames: ['recv-create'] });
    expect(res.status).toBe(200);
    expect(res.body.created).toBe(true);
    expect(res.body.results).toEqual([{ stackName: 'recv-create', success: true }]);
    const created = db.getLabels(nodeId).find(l => l.name === 'recv-created');
    expect(created).toBeTruthy();
    expect(db.getLabelsForStacks(nodeId)['recv-create'].map(l => l.name)).toContain('recv-created');
  });

  it('reuses an existing label (created:false) and preserves existing assignments', async () => {
    makeStack('recv-reuse');
    const existing = db.createLabel(nodeId, 'recv-existing', 'green');
    db.setStackLabels('recv-reuse', nodeId, [existing.id]);
    db.createLabel(nodeId, 'recv-reused', 'purple');
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'recv-reused', color: 'purple' }, stackNames: ['recv-reuse'] });
    expect(res.status).toBe(200);
    expect(res.body.created).toBe(false);
    const names = db.getLabelsForStacks(nodeId)['recv-reuse'].map(l => l.name).sort();
    expect(names).toEqual(['recv-existing', 'recv-reused']);
  });

  it('reports a per-stack error for a stack that is not on disk', async () => {
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'recv-ghost', color: 'rose' }, stackNames: ['recv-not-on-disk'] });
    expect(res.status).toBe(200);
    expect(res.body.results).toEqual([{ stackName: 'recv-not-on-disk', success: false, error: 'Stack not found' }]);
  });

  it('fails all stacks with the cap message when the node is at the label limit', async () => {
    makeStack('recv-cap');
    vi.spyOn(db, 'getLabelCount').mockReturnValue(50);
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'recv-over-cap', color: 'teal' }, stackNames: ['recv-cap'] });
    expect(res.status).toBe(200);
    expect(res.body.created).toBe(false);
    expect(res.body.results[0]).toMatchObject({ stackName: 'recv-cap', success: false });
    expect(res.body.results[0].error).toMatch(/Maximum of 50/);
  });

  it('reuses an existing label after a concurrent-create unique violation', async () => {
    makeStack('recv-race');
    // First read sees no label (so a create is attempted); create throws a unique
    // violation as if another request won the race; the re-fetch then finds it.
    const raced = { id: 9991, node_id: nodeId, name: 'recv-raced', color: 'teal' as const };
    vi.spyOn(db, 'getLabels').mockReturnValueOnce([]).mockReturnValue([raced]);
    const uniqueErr = Object.assign(new Error('UNIQUE constraint failed'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
    vi.spyOn(db, 'createLabel').mockImplementation(() => { throw uniqueErr; });
    const addSpy = vi.spyOn(db, 'addStackLabels').mockImplementation(() => {});
    const res = await request(app)
      .post('/api/fleet-actions/labels/local-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'recv-raced', color: 'teal' }, stackNames: ['recv-race'] });
    expect(res.status).toBe(200);
    expect(res.body.created).toBe(false);
    expect(res.body.results).toEqual([{ stackName: 'recv-race', success: true }]);
    expect(addSpy).toHaveBeenCalledWith('recv-race', nodeId, [9991]);
  });
});

describe('DatabaseService.addStackLabels', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let nodeId: number;

  beforeAll(async () => {
    db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
    nodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
  });

  it('adds labels while preserving existing assignments', () => {
    const a = db.createLabel(nodeId, 'add-pre-a', 'teal');
    const b = db.createLabel(nodeId, 'add-pre-b', 'blue');
    db.setStackLabels('add-pre-stack', nodeId, [a.id]);
    db.addStackLabels('add-pre-stack', nodeId, [b.id]);
    const names = db.getLabelsForStacks(nodeId)['add-pre-stack'].map(l => l.name).sort();
    expect(names).toEqual(['add-pre-a', 'add-pre-b']);
  });

  it('is idempotent on re-add (no duplicate row, no throw)', () => {
    const a = db.createLabel(nodeId, 'idem-a', 'teal');
    db.addStackLabels('idem-stack', nodeId, [a.id]);
    db.addStackLabels('idem-stack', nodeId, [a.id]);
    expect(db.getLabelsForStacks(nodeId)['idem-stack'].filter(l => l.name === 'idem-a')).toHaveLength(1);
  });

  it('throws when a label id does not belong to the node', () => {
    expect(() => db.addStackLabels('bad-id-stack', nodeId, [999999])).toThrow(/invalid for this node/);
  });

  it('no-ops on an empty id list', () => {
    expect(() => db.addStackLabels('empty-id-stack', nodeId, [])).not.toThrow();
    expect(db.getLabelsForStacks(nodeId)['empty-id-stack']).toBeUndefined();
  });
});

describe('bulk-assign orchestrator: local node', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let nodeId: number;

  beforeAll(async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const { NodeRegistry } = await import('../services/NodeRegistry');
    db = DatabaseService.getInstance();
    nodeId = NodeRegistry.getInstance().getDefaultNodeId();
  });

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

  it('creates the label on the local node and assigns it, preserving existing labels', async () => {
    makeStack('orch-local');
    const existing = db.createLabel(nodeId, 'orch-existing', 'amber');
    db.setStackLabels('orch-local', nodeId, [existing.id]);
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'orch-media', color: 'teal' }, targets: [{ nodeId, stackNames: ['orch-local'] }] });
    expect(res.status).toBe(200);
    expect(res.body.results).toHaveLength(1);
    const row = res.body.results[0];
    expect(row.reachable).toBe(true);
    expect(row.created).toBe(true);
    expect(row.stackResults).toEqual([{ stackName: 'orch-local', success: true }]);
    const names = db.getLabelsForStacks(nodeId)['orch-local'].map(l => l.name).sort();
    expect(names).toEqual(['orch-existing', 'orch-media']);
  });

  it('reuses an existing local label (created:false)', async () => {
    makeStack('orch-reuse');
    db.createLabel(nodeId, 'orch-reused', 'cyan');
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'orch-reused', color: 'cyan' }, targets: [{ nodeId, stackNames: ['orch-reuse'] }] });
    expect(res.status).toBe(200);
    expect(res.body.results[0].created).toBe(false);
    expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-reuse', success: true }]);
  });

  it('dedupes repeated stack names within a target', async () => {
    makeStack('orch-dedupe');
    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'orch-dd', color: 'pink' }, targets: [{ nodeId, stackNames: ['orch-dedupe', 'orch-dedupe'] }] });
    expect(res.status).toBe(200);
    expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-dedupe', success: true }]);
  });
});

describe('bulk-assign orchestrator: remote fan-out', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;

  beforeAll(async () => {
    db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
    ({ NodeRegistry } = await import('../services/NodeRegistry'));
  });

  afterEach(() => {
    vi.restoreAllMocks();
    for (const n of db.getNodes().filter(n => n.type === 'remote')) db.deleteNode(n.id);
  });

  function addRemote(name: string, mode: 'proxy' | 'pilot_agent' = 'proxy'): number {
    return db.addNode({
      name, type: 'remote', mode,
      compose_dir: '/tmp', is_default: false,
      api_url: mode === 'proxy' ? 'https://remote.example.com:1852' : '',
      api_token: mode === 'proxy' ? 'remote-tok' : '',
    });
  }

  it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
    const remoteId = addRemote('assign-remote-ok');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
      JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(true);
    expect(row.created).toBe(true);
    expect(row.stackResults).toEqual([{ stackName: 'r1', success: true }]);

    expect(fetchMock).toHaveBeenCalledTimes(1);
    const call = fetchMock.mock.calls[0];
    expect(String(call[0])).toBe('https://remote.example.com:1852/api/fleet-actions/labels/local-assign');
    const init = call[1] as { method: string; headers: Record<string, string>; body: string };
    expect(init.method).toBe('POST');
    expect(init.headers['Authorization']).toBe('Bearer remote-tok');
    expect(JSON.parse(init.body)).toEqual({ label: { name: 'media', color: 'teal' }, stackNames: ['r1'] });
  });

  it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
    const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '', trustedLoopback: true });
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
      JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['p1'] }] });

    expect(res.status).toBe(200);
    const init = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
    expect(init.headers).not.toHaveProperty('Authorization');
  });

  it('reports no-proxy-target as a per-node failure without blocking the request', async () => {
    const remoteId = addRemote('assign-remote-down', 'pilot_agent');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
    const fetchSpy = vi.spyOn(globalThis, 'fetch');

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.error).toBeTruthy();
    expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: row.error }]);
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
    const remoteId = addRemote('assign-remote-404');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.error).toMatch(/404/);
    expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
  });

  it('reports a transport failure as a per-node failure', async () => {
    const remoteId = addRemote('assign-remote-transport');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
  });

  it('reports a malformed 200 body as a per-node failure', async () => {
    const remoteId = addRemote('assign-remote-malformed');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
      JSON.stringify({ created: true, results: 'not-an-array' }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.error).toMatch(/malformed/);
    expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
  });

  it('fails a node whose 200 body returns an empty results array for a non-empty request', async () => {
    const remoteId = addRemote('assign-remote-empty');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    // A well-shaped { created, results } body whose results are empty used to
    // pass the bare Array.isArray check and read as a successful zero-stack
    // assign. Membership validation must fail the node instead.
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
      JSON.stringify({ created: true, results: [] }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.error).toMatch(/malformed/);
    expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
  });

  it('fails a node whose results omit one of the requested stacks', async () => {
    const remoteId = addRemote('assign-remote-partial');
    vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok', trustedLoopback: false });
    // Two stacks requested, only one row returned: a partial body the control
    // must not accept as a clean assign of the covered stack alone.
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
      JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ));

    const res = await request(app)
      .post('/api/fleet/labels/bulk-assign')
      .set('Authorization', authHeader)
      .send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1', 'r2'] }] });

    expect(res.status).toBe(200);
    const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
    expect(row.reachable).toBe(false);
    expect(row.error).toMatch(/malformed/);
    expect(row.stackResults).toEqual([
      { stackName: 'r1', success: false, error: 'Remote returned a malformed response' },
      { stackName: 'r2', success: false, error: 'Remote returned a malformed response' },
    ]);
  });
});

describe('fleet-stop degrades the local leg per-node instead of failing the whole fan-out', () => {
  let db: import('../services/DatabaseService').DatabaseService;
  let nodeId: number;

  beforeAll(async () => {
    const { DatabaseService } = await import('../services/DatabaseService');
    const { NodeRegistry } = await import('../services/NodeRegistry');
    db = DatabaseService.getInstance();
    nodeId = NodeRegistry.getInstance().getDefaultNodeId();
  });

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

  it('returns 200 with per-stack errors when the control filesystem read throws', async () => {
    const label = db.createLabel(nodeId, 'degrade-label', 'slate');
    db.setStackLabels('degrade-stack', nodeId, [label.id]);
    const { FileSystemService } = await import('../services/FileSystemService');
    vi.spyOn(FileSystemService.prototype, 'getStacks').mockRejectedValue(new Error('compose dir unreadable'));

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

    expect(res.status).toBe(200);
    const localRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === nodeId);
    expect(localRow.matched).toBe(true);
    expect(localRow.stackResults).toEqual([
      { stackName: 'degrade-stack', success: false, error: 'compose dir unreadable' },
    ]);
  });
});
