/**
 * Unit tests for ComposeService — subprocess handling, deploy/rollback,
 * registry auth temp dir management, and WebSocket output.
 */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import os from 'os';
import path from 'path';
import type WebSocket from 'ws';

// ── Hoisted mocks ──────────────────────────────────────────────────────

const {
  mockSpawn,
  mockGetContainersByStack, mockGetLegacyOrphanContainersByStack, mockRemoveContainers, mockListContainers,
  mockContainerInspect, mockContainerLogs,
  mockGetRegistries, mockResolveDockerConfig,
  mockBackupStackFiles, mockRestoreStackFiles,
  mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
  mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
  mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
  mockGetStackContent, mockGetEnvContent,
  mockLoadStackBuildServices,
  mockResolveMissingExternalNetworks,
  mockCreateNetwork,
  mockAddNotificationHistory,
  mockIsMeshStackEnabled,
  mockClassifyLegacyOrphansForUpdate,
  mockCaptureCandidate,
  mockMarkAcquired,
  mockHandoff,
  mockMarkReconciling,
  mockMarkImmediateVerified,
  mockAbandon,
  mockCompensateWithCandidate,
  mockBuildUnifiedHeldImagePredicate,
  mockGetRecovery,
  mockFsStat,
  mockCleanupDockerAuthTempDir,
  mockCreateDockerAuthTempDir,
  mockRecordRegistryDeliveryEvent,
  mockPreparedSourceFinalize,
} = vi.hoisted(() => {
  const mockCleanupDockerAuthTempDir = vi.fn();
  return {
  mockSpawn: vi.fn(),
  mockGetContainersByStack: vi.fn().mockResolvedValue([]),
  mockGetLegacyOrphanContainersByStack: vi.fn().mockResolvedValue([]),
  mockRemoveContainers: vi.fn().mockResolvedValue([]),
  mockListContainers: vi.fn().mockResolvedValue([]),
  mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
  mockContainerLogs: vi.fn().mockResolvedValue(Buffer.from('')),
  mockGetRegistries: vi.fn().mockReturnValue([]),
  mockResolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }),
  mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
  mockRestoreStackFiles: vi.fn().mockResolvedValue(undefined),
  mockGetComposeFilename: vi.fn().mockResolvedValue('compose.yaml'),
  mockGetOverrideFilename: vi.fn().mockResolvedValue(null),
  mockEnsureStackOverride: vi.fn().mockResolvedValue(null),
  mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
  mockWriteFileSync: vi.fn(),
  mockUnlinkSync: vi.fn(),
  mockRmdirSync: vi.fn(),
  mockGetGlobalSettings: vi.fn().mockReturnValue({}),
  mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
  mockGetBindMounts: vi.fn().mockResolvedValue(null),
  mockGetStackContent: vi.fn().mockResolvedValue(''),
  mockGetEnvContent: vi.fn().mockResolvedValue(''),
  mockLoadStackBuildServices: vi.fn().mockResolvedValue([]),
  mockResolveMissingExternalNetworks: vi.fn().mockResolvedValue({
    status: 'ok',
    autoCreateEnabled: false,
    stackName: 'my-stack',
    networks: [],
    declaredExternalCount: 0,
  }),
  mockCreateNetwork: vi.fn().mockResolvedValue({ id: 'net-1' }),
  mockAddNotificationHistory: vi.fn(),
  mockIsMeshStackEnabled: vi.fn().mockReturnValue(false),
  mockClassifyLegacyOrphansForUpdate: vi.fn().mockResolvedValue({ status: 'none' }),
  mockCaptureCandidate: vi.fn().mockResolvedValue({
    id: 'recovery-1',
    node_id: 1,
    stack_name: 'my-stack',
    status: 'candidate',
    phase: 'captured',
    override_path: '/test/compose/my-stack/.sencho-recovery-abc.yml',
  }),
  mockMarkAcquired: vi.fn().mockReturnValue(true),
  mockHandoff: vi.fn().mockReturnValue(true),
  mockMarkReconciling: vi.fn().mockReturnValue(true),
  mockMarkImmediateVerified: vi.fn().mockReturnValue(true),
  mockAbandon: vi.fn().mockResolvedValue(true),
  mockCompensateWithCandidate: vi.fn().mockResolvedValue(true),
  mockBuildUnifiedHeldImagePredicate: vi.fn().mockReturnValue(() => false),
  mockGetRecovery: vi.fn().mockReturnValue(undefined),
  mockFsStat: vi.fn().mockResolvedValue({ isDirectory: () => true }),
  mockCleanupDockerAuthTempDir,
  mockCreateDockerAuthTempDir: vi.fn(() => ({
    dirPath: '/tmp/sencho-docker-test',
    kind: 'local' as const,
    cleanup: mockCleanupDockerAuthTempDir,
  })),
  mockRecordRegistryDeliveryEvent: vi.fn(),
  mockPreparedSourceFinalize: vi.fn(),
  };
});

vi.mock('../helpers/dockerAuthTempDir', () => ({
  createDockerAuthTempDir: mockCreateDockerAuthTempDir,
}));

vi.mock('../helpers/registryDeliveryEvidence', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../helpers/registryDeliveryEvidence')>();
  return {
    ...actual,
    recordRegistryDeliveryEvent: (...args: unknown[]) => mockRecordRegistryDeliveryEvent(...args),
  };
});

vi.mock('../services/preparedSourceStore', () => ({
  PreparedSourceStore: {
    getInstance: () => ({
      finalize: (...args: unknown[]) => mockPreparedSourceFinalize(...args),
    }),
  },
}));

vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));

vi.mock('fs', () => ({
  default: {
    mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
    writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
    unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
    rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
    promises: { stat: (...args: unknown[]) => mockFsStat(...args) },
  },
  mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
  writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
  unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
  rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
  promises: { stat: (...args: unknown[]) => mockFsStat(...args) },
}));

vi.mock('../services/NodeRegistry', () => ({
  NodeRegistry: {
    getInstance: () => ({
      getDefaultNodeId: () => 1,
      getComposeDir: () => '/test/compose',
    }),
  },
}));

vi.mock('../services/DockerController', () => ({
  default: {
    getInstance: () => ({
      getContainersByStack: mockGetContainersByStack,
      getLegacyOrphanContainersByStack: mockGetLegacyOrphanContainersByStack,
      classifyLegacyOrphansForUpdate: mockClassifyLegacyOrphansForUpdate,
      removeContainers: mockRemoveContainers,
      pruneDanglingImages: mockPruneDanglingImages,
      createNetwork: mockCreateNetwork,
      getDocker: () => ({
        listContainers: mockListContainers,
        getContainer: () => ({
          inspect: mockContainerInspect,
          logs: mockContainerLogs,
        }),
      }),
    }),
  },
}));

vi.mock('../services/DatabaseService', () => ({
  DatabaseService: {
    getInstance: () => ({
      getRegistries: mockGetRegistries,
      getGlobalSettings: mockGetGlobalSettings,
      getGitSource: () => undefined,
      getStackProjectEnvFiles: () => [],
      addNotificationHistory: mockAddNotificationHistory,
      isMeshStackEnabled: (...args: unknown[]) => mockIsMeshStackEnabled(...args),
    }),
  },
}));

vi.mock('../services/RegistryService', () => ({
  RegistryService: {
    getInstance: () => ({
      resolveDockerConfig: mockResolveDockerConfig,
    }),
  },
}));

vi.mock('../services/FileSystemService', () => ({
  FileSystemService: {
    getInstance: () => ({
      backupStackFiles: mockBackupStackFiles,
      restoreStackFiles: mockRestoreStackFiles,
      getComposeFilename: mockGetComposeFilename,
      getOverrideFilename: mockGetOverrideFilename,
      getStackContent: mockGetStackContent,
      getEnvContent: mockGetEnvContent,
    }),
  },
}));

vi.mock('../services/LogFormatter', () => ({
  LogFormatter: { process: (line: string) => line },
}));

// runCommand and the deploy/update paths route through authoredComposeArgs, which
// resolves the (optional) mesh override. The hoisted mock defaults to "no override"
// so a single-file stack yields plain `docker compose <action>` args deterministically;
// individual tests set a path to exercise the mesh-injection branch.
vi.mock('../services/MeshService', () => ({
  MeshService: {
    getInstance: () => ({ ensureStackOverride: mockEnsureStackOverride }),
  },
}));

vi.mock('../services/recoveryHeldImages', () => ({
  buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate,
}));

vi.mock('../services/StackUpdateRecoveryService', () => ({
  StackUpdateRecoveryService: {
    getInstance: () => ({
      captureCandidate: mockCaptureCandidate,
      markAcquired: mockMarkAcquired,
      handoff: mockHandoff,
      markReconciling: mockMarkReconciling,
      markImmediateVerified: mockMarkImmediateVerified,
      abandon: mockAbandon,
      compensateWithCandidate: mockCompensateWithCandidate,
      get: mockGetRecovery,
      linkGateOrRetain: vi.fn(),
    }),
  },
}));


vi.mock('../services/SelfIdentityService', () => ({
  default: {
    getInstance: () => ({ getBindMounts: mockGetBindMounts }),
  },
}));

vi.mock('../services/ImageUpdateService', async (importOriginal) => ({
  ...(await importOriginal<typeof import('../services/ImageUpdateService')>()),
  loadStackBuildServices: (...args: unknown[]) => mockLoadStackBuildServices(...args),
}));

vi.mock('../services/network/resolveMissingExternalNetworks', () => ({
  resolveMissingExternalNetworks: (...args: unknown[]) => mockResolveMissingExternalNetworks(...args),
}));

import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import { DriftLedgerService } from '../services/DriftLedgerService';

const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
const originalSenchoMode = process.env.SENCHO_MODE;

/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
  const proc = new EventEmitter() as EventEmitter & {
    stdout: EventEmitter;
    stderr: EventEmitter;
    kill: ReturnType<typeof vi.fn>;
    killed: boolean;
  };
  proc.stdout = new EventEmitter();
  proc.stderr = new EventEmitter();
  proc.killed = false;
  proc.kill = vi.fn(() => {
    proc.killed = true;
    return true;
  });
  return proc;
}

/**
 * runCommand now awaits the authored compose args (mesh override resolution) before
 * spawning, so the child is created one microtask after the call. Tests that drive
 * the mock child's events must wait for the spawn first, or the event fires before
 * any listener is attached.
 */
async function waitForSpawn() {
  await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());
}

/** Sets up mockSpawn to auto-close with exit code 0 on next tick */
function setupAutoCloseSpawn(exitCode = 0) {
  mockSpawn.mockImplementation(() => {
    const proc = createMockProcess();
    // Emit close asynchronously (next microtask)
    Promise.resolve().then(() => proc.emit('close', exitCode));
    return proc;
  });
}

type MockWebSocket = EventEmitter & {
  readyState: number;
  send: ReturnType<typeof vi.fn>;
  close: ReturnType<typeof vi.fn>;
  OPEN: number;
} & WebSocket;

function createMockWs(): MockWebSocket {
  const ws = new EventEmitter() as MockWebSocket;
  Object.assign(ws, {
    readyState: 1,
    send: vi.fn(),
    close: vi.fn(),
    OPEN: 1,
  });
  return ws;
}

beforeEach(() => {
  vi.clearAllMocks();
  // clearAllMocks() clears call records but not implementations, so a mockResolvedValue
  // set by one test persists into the next. Re-assert the safe "no mesh override, no user
  // override, base = compose.yaml" baseline here so a stray override from an earlier test
  // cannot leak forward and add phantom -f flags.
  mockGetComposeFilename.mockResolvedValue('compose.yaml');
  mockGetOverrideFilename.mockResolvedValue(null);
  mockEnsureStackOverride.mockResolvedValue(null);
  mockGetBindMounts.mockResolvedValue(null);
  mockLoadStackBuildServices.mockResolvedValue([]);
  mockResolveMissingExternalNetworks.mockResolvedValue({
    status: 'ok',
    autoCreateEnabled: false,
    stackName: 'my-stack',
    networks: [],
    declaredExternalCount: 0,
  });
  mockCreateNetwork.mockResolvedValue({ id: 'net-1' });
  mockClassifyLegacyOrphansForUpdate.mockResolvedValue({ status: 'none' });
  mockCaptureCandidate.mockResolvedValue({
    id: 'recovery-1',
    node_id: 1,
    stack_name: 'my-stack',
    status: 'candidate',
    phase: 'captured',
    override_path: '/test/compose/my-stack/.sencho-recovery-abc.yml',
  });
  mockMarkAcquired.mockReturnValue(true);
  mockHandoff.mockReturnValue(true);
  mockMarkReconciling.mockReturnValue(true);
  mockMarkImmediateVerified.mockReturnValue(true);
  mockAbandon.mockResolvedValue(true);
  mockIsMeshStackEnabled.mockReturnValue(false);
  mockCompensateWithCandidate.mockResolvedValue(true);
  mockBuildUnifiedHeldImagePredicate.mockReturnValue(() => false);
  mockFsStat.mockResolvedValue({ isDirectory: () => true });
  delete process.env.SENCHO_MODE;
  vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
  vi.useRealTimers();
  if (originalComposeTimeout === undefined) {
    delete process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
  } else {
    process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = originalComposeTimeout;
  }
  if (originalStallTimeout === undefined) {
    delete process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
  } else {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = originalStallTimeout;
  }
  if (originalSenchoMode === undefined) {
    delete process.env.SENCHO_MODE;
  } else {
    process.env.SENCHO_MODE = originalSenchoMode;
  }
});

// ── runCommand ─────────────────────────────────────────────────────────

describe('ComposeService - runCommand', () => {
  it('spawns docker compose with the correct action', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
  });

  it('resolves on exit code 0', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'start');
    await waitForSpawn();
    proc.emit('close', 0);

    await expect(promise).resolves.toBeUndefined();
  });

  it('rejects on non-zero exit code', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'stop');
    await waitForSpawn();
    proc.stderr.emit('data', Buffer.from('service not found'));
    proc.emit('close', 1);

    await expect(promise).rejects.toThrow('service not found');
  });

  it('redacts secrets from command failure errors', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'stop');
    await waitForSpawn();
    proc.stderr.emit('data', Buffer.from('token=abc123SECRET password=hunter2 Authorization: Bearer abc.def.ghi'));
    proc.emit('close', 1);

    await expect(promise).rejects.toThrow('token=[redacted]');
    await expect(promise).rejects.toThrow('password=[redacted]');
    await expect(promise).rejects.not.toThrow('abc.def.ghi');
  });

  it('sends output to WebSocket when provided', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);
    const ws = createMockWs();

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart', ws);
    await waitForSpawn();
    proc.stdout.emit('data', Buffer.from('Restarting...'));
    proc.emit('close', 0);
    await promise;

    expect(ws.send).toHaveBeenCalledWith('Restarting...');
  });

  it('kills and rejects commands that exceed the compose timeout', async () => {
    process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = '1000';
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    const expectation = expect(promise).rejects.toThrow('Command timed out after 1s');
    let settled = false;
    promise.finally(() => { settled = true; }).catch(() => undefined);
    await vi.advanceTimersByTimeAsync(1000);

    expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
    expect(settled).toBe(false);
    proc.emit('close', null);
    await expectation;
  });

  it('keeps the command running when the WebSocket disconnects (progress socket is output-only)', async () => {
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);
    const ws = createMockWs();

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart', ws);
    await waitForSpawn();
    // The deploy is owned by its HTTP request; closing the progress socket
    // (panel minimized, navigated away, connection blip) must not abort it.
    ws.emit('close');
    expect(proc.kill).not.toHaveBeenCalled();

    // The command still completes on its own exit, not the socket close.
    proc.emit('close', 0);
    await expect(promise).resolves.toBeUndefined();
  });

  it('rewrites ENOMEM spawn failures as host out-of-memory', async () => {
    const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(32 * 1024 * 1024);
    const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
    try {
      const proc = createMockProcess();
      mockSpawn.mockReturnValue(proc);
      const ws = createMockWs();

      const svc = ComposeService.getInstance(1);
      const promise = svc.runCommand('my-stack', 'restart', ws);
      await waitForSpawn();
      const err = Object.assign(new Error('spawn docker ENOMEM'), { code: 'ENOMEM' });
      proc.emit('error', err);

      await expect(promise).rejects.toThrow(/Out of memory while launching docker/);
      const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
      expect(sendCalls.some(msg => msg.includes('Out of memory while launching docker'))).toBe(true);
    } finally {
      freememSpy.mockRestore();
      totalmemSpy.mockRestore();
    }
  });

  it('rewrites ENOENT spawn failures as host OOM when free memory is below the floor', async () => {
    const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(32 * 1024 * 1024);
    const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
    try {
      const proc = createMockProcess();
      mockSpawn.mockReturnValue(proc);
      const ws = createMockWs();

      const svc = ComposeService.getInstance(1);
      const promise = svc.runCommand('my-stack', 'restart', ws);
      await waitForSpawn();
      const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
      proc.emit('error', err);

      await expect(promise).rejects.toThrow(/Out of memory while launching docker/);
      const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
      expect(sendCalls.some(msg => msg.includes('reported as ENOENT under memory pressure'))).toBe(true);
    } finally {
      freememSpy.mockRestore();
      totalmemSpy.mockRestore();
    }
  });

  it('preserves "Docker CLI unavailable" wording on healthy-memory ENOENT for docker', async () => {
    const freememSpy = vi.spyOn(os, 'freemem').mockReturnValue(2 * 1024 * 1024 * 1024);
    const totalmemSpy = vi.spyOn(os, 'totalmem').mockReturnValue(6612 * 1024 * 1024);
    try {
      const proc = createMockProcess();
      mockSpawn.mockReturnValue(proc);

      const svc = ComposeService.getInstance(1);
      const promise = svc.runCommand('my-stack', 'restart');
      await waitForSpawn();
      const err = Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' });
      proc.emit('error', err);

      await expect(promise).rejects.toThrow('Docker CLI unavailable on this node');
    } finally {
      freememSpy.mockRestore();
      totalmemSpy.mockRestore();
    }
  });
});

// ── authoredComposeArgs: mesh + user override ──────────────────────────

describe('ComposeService - authoredComposeArgs mesh override', () => {
  const MESH_OVERRIDE = '/app/data/mesh/overrides/1/my-stack.override.yml';

  it('preserves a user compose.override.yml between the base and the mesh override', async () => {
    // Single-file stack opted into mesh, with a hand-authored override on disk.
    mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
    mockGetComposeFilename.mockResolvedValue('compose.yaml');
    mockGetOverrideFilename.mockResolvedValue('compose.override.yml');

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    // The user override sits between the base and the mesh override as a bare basename
    // (resolved against the stack-dir cwd); only the mesh override is an absolute path.
    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', '-f', 'compose.yaml', '-f', 'compose.override.yml', '-f', MESH_OVERRIDE, 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
  });

  it('emits base + mesh override only when no user override exists', async () => {
    mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
    mockGetComposeFilename.mockResolvedValue('compose.yaml');
    mockGetOverrideFilename.mockResolvedValue(null);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
  });

  it('does not look up or emit a user override when mesh is disabled', async () => {
    // A user override on disk must not introduce -f flags for a non-mesh stack;
    // implicit compose discovery already resolves it when no -f is passed.
    mockEnsureStackOverride.mockResolvedValue(null);
    mockGetOverrideFilename.mockResolvedValue('compose.override.yml');

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
    // The override lookup is gated inside the mesh branch.
    expect(mockGetOverrideFilename).not.toHaveBeenCalled();
  });

  it('drops the user override and still deploys when the lookup throws', async () => {
    // A present override that cannot be resolved (e.g. EACCES) must not crash the
    // deploy: the mesh override still applies and the deploy proceeds without the
    // user override, with a warning logged.
    mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
    mockGetComposeFilename.mockResolvedValue('compose.yaml');
    mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', '-f', 'compose.yaml', '-f', MESH_OVERRIDE, 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('could not resolve user compose override'),
      expect.anything()
    );
    warnSpy.mockRestore();
  });

  it('aborts the deploy when the override lookup hits a containment-guard rejection', async () => {
    // A symlink-escape (or invalid-name) rejection from the override lookup is a hard
    // error: it must propagate and abort the deploy, never degrade to "no override".
    mockEnsureStackOverride.mockResolvedValue(MESH_OVERRIDE);
    mockGetComposeFilename.mockResolvedValue('compose.yaml');
    mockGetOverrideFilename.mockRejectedValue(Object.assign(new Error('symlink escape'), { code: 'SYMLINK_ESCAPE' }));

    const svc = ComposeService.getInstance(1);
    await expect(svc.runCommand('my-stack', 'restart')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
    // The error is thrown while building the args, before docker is ever spawned.
    expect(mockSpawn).not.toHaveBeenCalled();
  });

  it('leak sanity: a default single-file stack still emits no -f flags', async () => {
    // Proves the override-setting tests above do not leak through the shared mocks.
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    await waitForSpawn();
    proc.emit('close', 0);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'restart'],
      expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
    );
  });
});

// ── deployStack ────────────────────────────────────────────────────────


  it('fails closed when a mesh-enabled stack cannot generate its override', async () => {
    mockIsMeshStackEnabled.mockReturnValue(true);
    mockEnsureStackOverride.mockRejectedValue(new Error('mesh override write failed'));

    const svc = ComposeService.getInstance(1);
    await expect(svc.validateExactComposeInvocation('my-stack')).rejects.toThrow(/mesh override write failed/i);
    expect(mockSpawn).not.toHaveBeenCalled();
  });

describe('ComposeService - deployStack', () => {
  it('blocks a pilot deploy with relative binds before replacing containers when path mapping differs', async () => {
    process.env.SENCHO_MODE = 'pilot';
    const composeDir = path.resolve('/test/compose');
    mockGetBindMounts.mockResolvedValue([
      { source: '/opt/docker/sencho', destination: composeDir },
    ]);
    const configProc = createMockProcess();
    mockSpawn.mockReturnValue(configProc);

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack').then(() => null, (error: Error) => error);
    await waitForSpawn();
    configProc.stdout.emit('data', Buffer.from(JSON.stringify({
      name: 'my-stack',
      services: {
        db: {
          image: 'postgres:17',
          volumes: [{
            type: 'bind',
            source: path.join(composeDir, 'my-stack', 'data'),
            target: '/var/lib/postgresql/data',
          }],
        },
      },
    })));
    configProc.emit('close', 0);

    const error = await result;
    expect(error?.message).toContain('1:1');
    expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
    expect(mockRemoveContainers).not.toHaveBeenCalled();
    expect(mockSpawn).toHaveBeenCalledTimes(1);
  });

  it('allows a pilot deploy that uses only absolute external bind sources', async () => {
    process.env.SENCHO_MODE = 'pilot';
    const composeDir = path.resolve('/test/compose');
    mockGetBindMounts.mockResolvedValue([
      { source: '/opt/docker/sencho', destination: composeDir },
    ]);
    let spawnCount = 0;
    mockSpawn.mockImplementation(() => {
      spawnCount += 1;
      const proc = createMockProcess();
      Promise.resolve().then(() => {
        if (spawnCount === 1) {
          proc.stdout.emit('data', Buffer.from(JSON.stringify({
            name: 'my-stack',
            services: {
              db: {
                image: 'postgres:17',
                volumes: [{
                  type: 'bind',
                  source: '/srv/postgres/data',
                  target: '/var/lib/postgresql/data',
                }],
              },
            },
          })));
        }
        proc.emit('close', 0);
      });
      return proc;
    });
    mockListContainers.mockResolvedValue([]);

    const promise = ComposeService.getInstance(1).deployStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);

    await expect(promise).resolves.toEqual({ recoveryId: null, deployedGenerationId: null });
    expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
  });

  it('blocks a pilot service restore with relative binds before Compose up', async () => {
    process.env.SENCHO_MODE = 'pilot';
    const composeDir = path.resolve('/test/compose');
    mockGetBindMounts.mockResolvedValue([
      { source: '/opt/docker/sencho', destination: composeDir },
    ]);
    const configProc = createMockProcess();
    mockSpawn.mockReturnValue(configProc);

    const svc = ComposeService.getInstance(1);
    const result = svc.recreateServiceFromLocal('my-stack', 'db').then(() => null, (error: Error) => error);
    await waitForSpawn();
    configProc.stdout.emit('data', Buffer.from(JSON.stringify({
      name: 'my-stack',
      services: {
        db: {
          image: 'postgres:17',
          volumes: [{
            type: 'bind',
            source: path.join(composeDir, 'my-stack', 'data'),
            target: '/var/lib/postgresql/data',
          }],
        },
      },
    })));
    configProc.emit('close', 0);

    const error = await result;
    expect(error?.message).toContain('1:1');
    expect(mockSpawn).toHaveBeenCalledTimes(1);
  });

  it('does not remove compose-managed containers before deploy (#1565)', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetLegacyOrphanContainersByStack.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
    expect(mockRemoveContainers).not.toHaveBeenCalled();
    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'up', '-d', '--remove-orphans'],
      expect.any(Object),
    );
  });

  it('removes legacy orphan containers when compose ps is empty', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetLegacyOrphanContainersByStack.mockResolvedValue([
      { Id: 'legacy-c1' },
      { Id: 'legacy-c2' },
    ]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockRemoveContainers).toHaveBeenCalledWith(['legacy-c1', 'legacy-c2']);
    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'up', '-d', '--remove-orphans'],
      expect.any(Object),
    );
  });

  it('runs docker compose up -d --remove-orphans', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');

    // Advance past the 3s health probe timeout
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'up', '-d', '--remove-orphans'],
      expect.any(Object)
    );
  });

  it('captures recovery generation when atomic=true', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack', undefined, true);

    await vi.advanceTimersByTimeAsync(3100);
    const result = await promise;

    expect(result).toEqual({ recoveryId: 'recovery-1', deployedGenerationId: null });
    expect(mockCaptureCandidate).toHaveBeenCalledWith(expect.objectContaining({
      stackName: 'my-stack',
      operationKind: 'deployment',
      createdBy: 'atomic-deploy',
    }));
    expect(mockHandoff).toHaveBeenCalled();
    expect(mockMarkImmediateVerified).toHaveBeenCalledWith('recovery-1');
  });

  it('blocks deploy before backup when missing external networks need a prompt', async () => {
    const { MissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError');
    mockResolveMissingExternalNetworks.mockResolvedValue({
      status: 'ok',
      autoCreateEnabled: false,
      stackName: 'my-stack',
      networks: [{
        name: 'arr-net',
        keys: ['arr'],
        declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
        safe: true,
        unsupportedFeatures: [],
        creationSpec: { driver: 'bridge', options: 'default' },
      }],
      declaredExternalCount: 1,
    });

    const svc = ComposeService.getInstance(1);
    await expect(svc.deployStack('my-stack', undefined, true)).rejects.toBeInstanceOf(MissingExternalNetworksError);
    expect(mockCaptureCandidate).not.toHaveBeenCalled();
    expect(mockSpawn).not.toHaveBeenCalled();
    expect(mockAddNotificationHistory).not.toHaveBeenCalled();
  });

  it('auto-creates safe missing networks and records history-only activity', async () => {
    const missing = [{
      name: 'arr-net',
      keys: ['arr'],
      declarations: [{ key: 'arr', driverKind: 'bridge' as const, unsupportedFeatures: [] as const }],
      safe: true,
      unsupportedFeatures: [] as const,
      creationSpec: { driver: 'bridge' as const, options: 'default' as const },
    }];
    mockResolveMissingExternalNetworks
      .mockResolvedValueOnce({
        status: 'ok',
        autoCreateEnabled: true,
        stackName: 'my-stack',
        networks: missing,
        declaredExternalCount: 1,
      })
      .mockResolvedValueOnce({
        status: 'ok',
        autoCreateEnabled: true,
        stackName: 'my-stack',
        networks: [],
        declaredExternalCount: 1,
      });
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack(
      'my-stack',
      undefined,
      true,
      { source: 'scheduler', actor: 'system:scheduler' },
    );
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockCreateNetwork).toHaveBeenCalledWith({ Name: 'arr-net', Driver: 'bridge' });
    expect(mockAddNotificationHistory).toHaveBeenCalledWith(
      1,
      expect.objectContaining({
        category: 'network_auto_created',
        level: 'info',
        actor_username: 'system:scheduler',
        stack_name: 'my-stack',
      }),
    );
    expect(mockCaptureCandidate).toHaveBeenCalled();
  });

  it('does not wrap a missing-external prompt in ComposeRollbackError when atomic', async () => {
    mockResolveMissingExternalNetworks.mockResolvedValue({
      status: 'ok',
      autoCreateEnabled: false,
      stackName: 'my-stack',
      networks: [{
        name: 'arr-net',
        keys: ['arr'],
        declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
        safe: true,
        unsupportedFeatures: [],
        creationSpec: { driver: 'bridge', options: 'default' },
      }],
      declaredExternalCount: 1,
    });

    const svc = ComposeService.getInstance(1);
    const error = await svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
    expect(getComposeRollbackInfo(error)).toBeNull();
    expect(error?.name).toBe('MissingExternalNetworksError');
  });

  it('aborts atomic deploy before docker side effects when capture fails', async () => {
    mockCaptureCandidate.mockRejectedValueOnce(new Error('disk full'));

    const svc = ComposeService.getInstance(1);

    await expect(svc.deployStack('my-stack', undefined, true)).rejects.toThrow('disk full');
    expect(mockSpawn).not.toHaveBeenCalled();
    expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
  });

  it('throws sanitized CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([{
      Id: 'crashed-c1',
      State: 'exited',
      Labels: { 'com.docker.compose.project': 'my-stack' },
    }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockContainerLogs.mockResolvedValue(Buffer.from('SECRET_TOKEN=leaked'));

    const svc = ComposeService.getInstance(1);
    // Attach catch handler immediately so rejection is never "unhandled"
    const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('CONTAINER_CRASHED');
    expect(error!.message).not.toContain('SECRET_TOKEN');
    expect(mockContainerLogs).not.toHaveBeenCalled();
  });

  it('rolls back on failure when atomic=true', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([{
      Id: 'crashed-c1',
      State: 'exited',
      Labels: { 'com.docker.compose.project': 'my-stack' },
    }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockContainerLogs.mockResolvedValue(Buffer.from('Error'));

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('CONTAINER_CRASHED');
    expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: true });
    expect(mockCompensateWithCandidate).toHaveBeenCalledWith('recovery-1', expect.any(Function));
  });

  it('reports rollback failure when atomic restore fails', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([{
      Id: 'crashed-c1',
      State: 'exited',
      Labels: { 'com.docker.compose.project': 'my-stack' },
    }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockCompensateWithCandidate.mockResolvedValueOnce(false);

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: false });
  });

  it('wraps a missing hold tag as rolledBack=false without replacing the original error', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([{
      Id: 'crashed-c1',
      State: 'exited',
      Labels: { 'com.docker.compose.project': 'my-stack' },
    }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockCompensateWithCandidate.mockRejectedValueOnce(
      Object.assign(new Error('Held recovery image is missing'), { code: 'HELD_IMAGE_MISSING' }),
    );

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('CONTAINER_CRASHED');
    expect(getComposeRollbackInfo(error)).toEqual({ attempted: true, rolledBack: false });
  });

  it('does not roll back when atomic=false', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([{
      Id: 'crashed-c1',
      State: 'exited',
    }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockContainerLogs.mockResolvedValue(Buffer.from('Error'));

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack', undefined, false).then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('CONTAINER_CRASHED');
    expect(mockRestoreStackFiles).not.toHaveBeenCalled();
  });
});

// ── updateStack: build-aware ───────────────────────────────────────────

describe('ComposeService - updateStack build-aware', () => {
  it('runs build --pull, pull --ignore-buildable, and up when build services exist', async () => {
    mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
    expect(spawnArgs.some(args => args.includes('build') && args.includes('--pull'))).toBe(true);
    expect(spawnArgs.some(args => args.includes('pull') && args.includes('--ignore-buildable'))).toBe(true);
    expect(spawnArgs.some(args => args.includes('up') && args.includes('-d'))).toBe(true);
  });

  it('runs plain pull + up when no build services exist', async () => {
    mockLoadStackBuildServices.mockResolvedValueOnce([]);
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
    expect(spawnArgs.some(args => args.includes('build'))).toBe(false);
    expect(spawnArgs.some(args => args.includes('pull') && !args.includes('--ignore-buildable'))).toBe(true);
  });

  it('abandons recovery and leaves runtime untouched when acquire fails before handoff', async () => {
    mockLoadStackBuildServices.mockResolvedValueOnce(['app']);
    let spawnCount = 0;
    mockSpawn.mockImplementation(() => {
      spawnCount += 1;
      const proc = createMockProcess();
      Promise.resolve().then(() => proc.emit('close', spawnCount === 1 ? 1 : 0));
      return proc;
    });
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
    await vi.advanceTimersByTimeAsync(3100);
    const error = await result;

    expect(error).not.toBeNull();
    expect(mockAbandon).toHaveBeenCalledWith('recovery-1');
    expect(mockHandoff).not.toHaveBeenCalled();
    expect(mockRemoveContainers).not.toHaveBeenCalled();
    expect(mockCompensateWithCandidate).not.toHaveBeenCalled();
    const spawnArgs = mockSpawn.mock.calls.map(c => c[1] as string[]);
    expect(spawnArgs.some(args => args.includes('up'))).toBe(false);
  });
});

// ── updateStack: prune-on-update ───────────────────────────────────────

describe('ComposeService - updateStack prune-on-update', () => {
  it('prunes dangling images after a successful update when prune_on_update=1', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
    mockPruneDanglingImages.mockResolvedValue({ reclaimedBytes: 2_097_152 });

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockPruneDanglingImages).toHaveBeenCalledTimes(1);
  });

  it('streams a reclaim figure only when the daemon reports bytes', async () => {
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
    const ws = createMockWs();

    // The containerd image store reports SpaceReclaimed=0 even when it removes
    // images, so a zero figure must be omitted rather than shown as "0.0 MB".
    setupAutoCloseSpawn();
    mockPruneDanglingImages.mockResolvedValueOnce({ reclaimedBytes: 0 });
    let p = ComposeService.getInstance(1).updateStack('my-stack', ws);
    await vi.advanceTimersByTimeAsync(3100);
    await p;
    expect(ws.send).toHaveBeenCalledWith('=== Pruned dangling images ===\n');

    ws.send.mockClear();
    setupAutoCloseSpawn();
    mockPruneDanglingImages.mockResolvedValueOnce({ reclaimedBytes: 5 * 1024 * 1024 });
    p = ComposeService.getInstance(1).updateStack('my-stack', ws);
    await vi.advanceTimersByTimeAsync(3100);
    await p;
    expect(ws.send).toHaveBeenCalledWith('=== Pruned dangling images · reclaimed 5.0 MB ===\n');
  });

  it('does not prune when prune_on_update=0', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '0' });

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockPruneDanglingImages).not.toHaveBeenCalled();
  });

  it('does not prune when the setting key is absent (fail-safe for un-backfilled DBs)', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({});

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockPruneDanglingImages).not.toHaveBeenCalled();
  });

  it('does not prune when the update itself fails (prune is success-only)', async () => {
    setupAutoCloseSpawn();
    // A container that exited non-zero makes the post-update health probe throw,
    // so control never reaches the prune block that follows it.
    mockListContainers.mockResolvedValue([{ Id: 'c1', State: 'exited' }]);
    mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    // Attach the rejection expectation before advancing timers so the throw
    // (which fires mid-advance) is never momentarily unhandled.
    const rejection = expect(promise).rejects.toThrow();
    await vi.advanceTimersByTimeAsync(3100);
    await rejection;

    expect(mockPruneDanglingImages).not.toHaveBeenCalled();
  });

  it('does not roll back an atomic update when the post-update prune throws', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
    mockPruneDanglingImages.mockRejectedValueOnce(new Error('docker busy'));

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack', undefined, true); // atomic
    await vi.advanceTimersByTimeAsync(3100);

    // The update already succeeded before the prune ran, so a prune failure
    // must neither reject nor trigger the atomic restore.
    await expect(promise).resolves.toEqual({ recoveryId: 'recovery-1', deployedGenerationId: null });
    expect(mockRestoreStackFiles).not.toHaveBeenCalled();
  });

  it('does not fail the update when the prune throws', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({ prune_on_update: '1' });
    mockPruneDanglingImages.mockRejectedValueOnce(new Error('docker busy'));

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);

    await expect(promise).resolves.toEqual({ recoveryId: 'recovery-1', deployedGenerationId: null });
  });
});

// ── withRegistryAuth ───────────────────────────────────────────────────

describe('ComposeService - drift reconcile hook', () => {
  it('reconciles the drift ledger after a successful update', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({});
    const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });

    const promise = ComposeService.getInstance(1).updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(spy).toHaveBeenCalledWith(1, 'my-stack');
    spy.mockRestore();
  });

  it('reconciles the drift ledger after a successful deploy', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({});
    const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });

    const promise = ComposeService.getInstance(1).deployStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(spy).toHaveBeenCalledWith(1, 'my-stack');
    spy.mockRestore();
  });

  it('does not reconcile the ledger when a deploy fails', async () => {
    setupAutoCloseSpawn(1); // non-zero exit => the deploy rejects before the post-success hook
    mockListContainers.mockResolvedValue([]);
    mockGetGlobalSettings.mockReturnValue({});
    const spy = vi.spyOn(DriftLedgerService.getInstance(), 'reconcileStack').mockResolvedValue({ detected: 0, resolved: 0 });

    const result = await ComposeService.getInstance(1).deployStack('my-stack').then(() => null, (e: Error) => e);
    expect(result).toBeInstanceOf(Error);
    expect(spy).not.toHaveBeenCalled();
    spy.mockRestore();
  });
});

describe('ComposeService - withRegistryAuth', () => {
  it('passes default env when no registries configured', async () => {
    mockGetRegistries.mockReturnValue([]);
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');

    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockMkdtempSync).not.toHaveBeenCalled();
  });

  it('creates temp config dir when registries exist', async () => {
    mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com', username: 'user', password: 'pass' }]);
    mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } }, warnings: [] });
    mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'test-delivery-source' });
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');

    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockCreateDockerAuthTempDir).toHaveBeenCalled();
    expect(mockCleanupDockerAuthTempDir).toHaveBeenCalled();
  });

  it('surfaces resolveDockerConfig warnings to the WebSocket output', async () => {
    mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
    mockResolveDockerConfig.mockResolvedValue({
      config: { auths: {} },
      warnings: ['Registry "broken" credentials unavailable: bad key'],
    });
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    const ws = createMockWs();

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack', ws);

    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    const sendCalls = ws.send.mock.calls.map(c => c[0]);
    expect(sendCalls.some((msg: string) => msg.includes('[Sencho] Warning') && msg.includes('bad key'))).toBe(true);
  });

  it('cleans up temp dir even on command failure', async () => {
    mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
    mockResolveDockerConfig.mockResolvedValue({ config: { auths: { 'registry.example.com': { auth: 'dGVzdA==' } } }, warnings: [] });
    mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'test-delivery-source' });

    // Make spawn fail
    mockSpawn.mockImplementation(() => {
      const proc = createMockProcess();
      Promise.resolve().then(() => {
        proc.stderr.emit('data', Buffer.from('pull failed'));
        proc.emit('close', 1);
      });
      return proc;
    });

    const svc = ComposeService.getInstance(1);
    const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);

    await vi.runAllTimersAsync();
    const error = await result;
    expect(error).not.toBeNull();
    expect(mockCleanupDockerAuthTempDir).toHaveBeenCalled();
  });
});

describe('ComposeService - registry delivery cleanup logging', () => {
  const deliveryEnvelope = {
    attestation: 'test-token',
    auths: [],
    notAfter: Date.now() + 60_000,
    deliverySourceId: 'delivery-src-1',
  };

  async function deployWithDeliveryContext(): Promise<void> {
    const { runWithRegistryDeliveryContext } = await import('../helpers/registryDeliveryContext');
    const svc = ComposeService.getInstance(1);
    await runWithRegistryDeliveryContext({
      envelope: deliveryEnvelope,
      nodeId: 1,
      stack: 'my-stack',
      stage: 'stack-deploy',
      seamResult: { auths: { 'registry.example.com': { auth: 'dGVzdA==' } }, prepId: 'prep-1' },
      seamSettled: true,
    }, async () => {
      const promise = svc.deployStack('my-stack');
      await vi.advanceTimersByTimeAsync(3100);
      await promise;
    });
  }

  beforeEach(() => {
    mockGetRegistries.mockReturnValue([]);
    mockGetGlobalSettings.mockReturnValue({ delivery_source_id: 'delivery-src-1' });
    mockCleanupDockerAuthTempDir.mockReset();
    mockCleanupDockerAuthTempDir.mockImplementation(() => undefined);
    mockRecordRegistryDeliveryEvent.mockReset();
    mockPreparedSourceFinalize.mockReset();
    mockPreparedSourceFinalize.mockImplementation(() => undefined);
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
  });

  it('logs cleanup failure when evidence records successfully', async () => {
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
    mockCleanupDockerAuthTempDir.mockImplementation(() => {
      throw new Error('cleanup failed');
    });

    await deployWithDeliveryContext();

    expect(mockRecordRegistryDeliveryEvent).toHaveBeenCalledWith(expect.objectContaining({
      eventType: 'cleanup_failed',
    }));
    expect(errorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Registry delivery temp dir cleanup failed'),
      expect.any(String),
      'cleanup failed',
    );
    errorSpy.mockRestore();
  });

  it('logs both cleanup and evidence failures without changing deploy success', async () => {
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
    mockCleanupDockerAuthTempDir.mockImplementation(() => {
      throw new Error('cleanup failed');
    });
    mockRecordRegistryDeliveryEvent.mockImplementation(() => {
      throw new Error('evidence failed');
    });

    await deployWithDeliveryContext();

    expect(errorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Registry delivery cleanup and evidence both failed'),
      expect.any(String),
      'evidence failed',
      'cleanup failed',
    );
    errorSpy.mockRestore();
  });

  it('logs prepared-source finalization failure without changing deploy success', async () => {
    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
    mockPreparedSourceFinalize.mockImplementation(() => {
      throw new Error('finalize failed');
    });

    await deployWithDeliveryContext();

    expect(errorSpy).toHaveBeenCalledWith(
      expect.stringContaining('Registry delivery prepared-source finalize failed'),
      'prep-1',
      'finalize failed',
    );
    errorSpy.mockRestore();
  });
});

// ── runDown ────────────────────────────────────────────────────────────

describe('ComposeService - runDown', () => {
  it('runs plain docker compose down by default', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.runDown('my-stack');

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down'],
      expect.any(Object)
    );
  });

  it('runs docker compose down --volumes when removeVolumes is true', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.runDown('my-stack', { removeVolumes: true });

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down', '--volumes'],
      expect.any(Object)
    );
  });

  it('does not pass --volumes when removeVolumes is false', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.runDown('my-stack', { removeVolumes: false });

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down'],
      expect.any(Object)
    );
  });
});

// ── downStack ──────────────────────────────────────────────────────────

describe('ComposeService - downStack', () => {
  it('runs docker compose down with volumes and remove-orphans when removeVolumes is true', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.downStack('my-stack', { removeVolumes: true });

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down', '--volumes', '--remove-orphans'],
      expect.any(Object)
    );
  });

  it('runs docker compose down without --volumes when removeVolumes is false', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.downStack('my-stack', { removeVolumes: false });

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down', '--remove-orphans'],
      expect.any(Object)
    );
  });

  it('defaults to no --volumes when options are omitted (safe default)', async () => {
    setupAutoCloseSpawn();

    const svc = ComposeService.getInstance(1);
    await svc.downStack('my-stack');

    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['compose', 'down', '--remove-orphans'],
      expect.any(Object)
    );
  });

  it('resolves even when command fails (throwOnError=false)', async () => {
    mockSpawn.mockImplementation(() => {
      const proc = createMockProcess();
      Promise.resolve().then(() => proc.emit('close', 1));
      return proc;
    });

    const svc = ComposeService.getInstance(1);
    await expect(svc.downStack('my-stack')).resolves.toBeUndefined();
  });
});

// ── stall (idle-output) backstop ───────────────────────────────────────

describe('ComposeService - idle-output stall backstop', () => {
  it('terminates a silent update step and rejects with STACK_STALLED_OUTPUT', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    // The pull emits nothing; the idle backstop should fire after 1s.
    const result = svc.updateStack('my-stack').then(() => null, (e: Error) => e);
    await vi.advanceTimersByTimeAsync(1000);

    expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
    proc.emit('close', null);
    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('STACK_STALLED_OUTPUT');
  });

  it('sends a stalled marker to the WebSocket before terminating', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);
    const ws = createMockWs();

    const svc = ComposeService.getInstance(1);
    const result = svc.updateStack('my-stack', ws).then(() => null, (e: Error) => e);
    await vi.advanceTimersByTimeAsync(1000);
    proc.emit('close', null);
    await result;

    const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
    expect(sendCalls.some(msg => msg.includes('appears stalled and was stopped'))).toBe(true);
  });

  it('does not stall when output keeps arriving within the idle window', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
    mockListContainers.mockResolvedValue([]);
    const proc = createMockProcess();
    // deployStack now spawns a second child (exposure refresh via renderConfig)
    // after the up command closes. Return the controlled proc for the up spawn,
    // and a fresh auto-closing proc for the config spawn so the test does not
    // hang on the already-closed proc.
    let spawnCount = 0;
    mockSpawn.mockImplementation(() => {
      spawnCount += 1;
      if (spawnCount === 1) return proc;
      const configProc = createMockProcess();
      Promise.resolve().then(() => configProc.emit('close', 0));
      return configProc;
    });

    const svc = ComposeService.getInstance(1);
    // deployStack spawns a single `up`; emit output every 600ms (< 1s window)
    // so the idle timer resets and never fires.
    const promise = svc.deployStack('my-stack');
    await vi.advanceTimersByTimeAsync(600);
    proc.stdout.emit('data', Buffer.from('pulling layer a...'));
    await vi.advanceTimersByTimeAsync(600);
    proc.stdout.emit('data', Buffer.from('pulling layer b...'));
    await vi.advanceTimersByTimeAsync(600);
    expect(proc.kill).not.toHaveBeenCalled();

    proc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(3100); // health probe
    await promise;
    expect(proc.kill).not.toHaveBeenCalled();
  });

  it('does not arm the idle backstop for runCommand (down/restart/stop stay silent-safe)', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    const svc = ComposeService.getInstance(1);
    const promise = svc.runCommand('my-stack', 'restart');
    // A silent restart longer than the stall window must not be killed: the
    // idle backstop is only armed for deploy/update compose steps.
    await vi.advanceTimersByTimeAsync(1500);
    expect(proc.kill).not.toHaveBeenCalled();

    proc.emit('close', 0);
    await promise;
  });

  it('terminates compose when the registry delivery abort signal fires', async () => {
    const { runWithRegistryDeliveryContext } = await import('../helpers/registryDeliveryContext');
    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);
    const abortController = new AbortController();

    const svc = ComposeService.getInstance(1);
    const result = runWithRegistryDeliveryContext(
      {
        envelope: {
          attestation: 'unused',
          auths: [],
          notAfter: Date.now() + 60_000,
          deliverySourceId: 'test',
        },
        nodeId: 1,
        stack: 'my-stack',
        stage: 'stack-deploy',
        abortSignal: abortController.signal,
      },
      () => svc.runCommand('my-stack', 'restart'),
    ).then(() => null, (e: Error) => e);

    await waitForSpawn();
    abortController.abort();
    proc.emit('close', null);

    const error = await result;
    expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
    expect(error).not.toBeNull();
    expect((error as Error).message).toContain('OPERATION_ABORTED');
  });

  it('falls back to the default stall window when the env value is invalid', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '0'; // invalid → default (10min)
    mockListContainers.mockResolvedValue([]);
    const proc = createMockProcess();
    // Same pattern as the stall test above: the second spawn (exposure refresh)
    // needs its own auto-closing proc so deployStack can resolve after close.
    let spawnCount = 0;
    mockSpawn.mockImplementation(() => {
      spawnCount += 1;
      if (spawnCount === 1) return proc;
      const configProc = createMockProcess();
      Promise.resolve().then(() => configProc.emit('close', 0));
      return configProc;
    });

    const svc = ComposeService.getInstance(1);
    const promise = svc.deployStack('my-stack');
    // Far below the 10-minute default: a '0' that leaked through would fire at 0ms.
    await vi.advanceTimersByTimeAsync(2000);
    expect(proc.kill).not.toHaveBeenCalled();

    proc.emit('close', 0);
    await vi.advanceTimersByTimeAsync(3100); // health probe
    await promise;
  });

  it('surfaces STACK_STALLED_OUTPUT on acquire stall without runtime compensation', async () => {
    process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
    const pullProc = createMockProcess();
    mockSpawn.mockImplementation(() => pullProc);

    const svc = ComposeService.getInstance(1);
    const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
    await vi.advanceTimersByTimeAsync(1000); // idle backstop fires on the silent pull
    pullProc.emit('close', null);
    await vi.runAllTimersAsync();

    const error = await result;
    expect(error).not.toBeNull();
    expect(error!.message).toContain('STACK_STALLED_OUTPUT');
    // Acquire failure is pre-handoff: abandon candidate, do not wrap as ComposeRollbackError.
    expect(getComposeRollbackInfo(error)).toBeNull();
    expect(mockAbandon).toHaveBeenCalledWith('recovery-1');
    expect(mockCompensateWithCandidate).not.toHaveBeenCalled();
  });
});

// ── streamLogs ─────────────────────────────────────────────────────────

describe('ComposeService - streamLogs', () => {
  it('emits a normalized container name prefix for each container', async () => {
    mockGetContainersByStack.mockResolvedValue([
      { Names: ['/mystack-redis-1'], State: 'running', Id: 'abc123' },
      { Names: ['/mystack-api-1'], State: 'running', Id: 'def456' },
    ]);

    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    const proc1 = createMockProcess();
    const proc2 = createMockProcess();
    mockSpawn
      .mockReturnValueOnce(proc1)
      .mockReturnValueOnce(proc2);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalledTimes(2));

    // Emit stdout from each container.
    proc1.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z redis log line\n'));
    proc2.stdout.emit('data', Buffer.from('2024-01-01T00:00:01Z api response\n'));

    const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
    const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);

    expect(sentLines).toContain('redis | 2024-01-01T00:00:00Z redis log line');
    expect(sentLines).toContain('api | 2024-01-01T00:00:01Z api response');
  });

  it('prefixes flushBuffer trailing line on child close', async () => {
    mockGetContainersByStack.mockResolvedValue([
      { Names: ['/mystack-web-1'], State: 'running', Id: 'ghi789' },
    ]);

    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());

    // Emit a line without a trailing newline, then close.
    proc.stdout.emit('data', Buffer.from('trailing content'));
    proc.emit('close', 0);

    const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
    const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);

    expect(sentLines).toContain('web | trailing content');
  });

  it('joins chunk-split lines and prefixes once', async () => {
    mockGetContainersByStack.mockResolvedValue([
      { Names: ['/mystack-db-1'], State: 'running', Id: 'jkl012' },
    ]);

    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());

    // Split a single line across two data events.
    proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z partial '));
    proc.stdout.emit('data', Buffer.from('end of line\n'));

    const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
    const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);

    expect(sentLines).toContain('db | 2024-01-01T00:00:00Z partial end of line');
  });

  it('normalizes dotted container names', async () => {
    mockGetContainersByStack.mockResolvedValue([
      { Names: ['/mystack-api.v1-1'], State: 'running', Id: 'mno345' },
    ]);

    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());

    proc.stdout.emit('data', Buffer.from('2024-01-01T00:00:00Z started\n'));

    const calls = (ws.send as ReturnType<typeof vi.fn>).mock.calls as string[][];
    const sentLines = calls.flatMap(c => c[0].split('\r\n')).filter(Boolean);

    // normalizeContainerName strips stack prefix and -1 replica suffix, leaving 'api.v1'.
    expect(sentLines).toContain('api.v1 | 2024-01-01T00:00:00Z started');
  });

  it('passes raw container name to docker logs, not normalized name', async () => {
    mockGetContainersByStack.mockResolvedValue([
      { Names: ['/mystack-redis-1'], State: 'running', Id: 'pqr678' },
    ]);

    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    const proc = createMockProcess();
    mockSpawn.mockReturnValue(proc);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockSpawn).toHaveBeenCalled());

    // Verify that docker logs uses the raw name, not the normalized one.
    expect(mockSpawn).toHaveBeenCalledWith(
      'docker',
      ['logs', '-f', '-t', '--tail', '100', 'mystack-redis-1'],
      expect.anything(),
    );
  });

  it('does not reschedule when the stack directory is gone', async () => {
    mockFsStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(ws.send).toHaveBeenCalled());

    const sent = (ws.send as ReturnType<typeof vi.fn>).mock.calls
      .flatMap((c) => String(c[0]).split(/\r?\n/))
      .filter(Boolean);
    expect(sent.some((line) => line.includes('Stack directory is gone'))).toBe(true);
    expect(mockGetContainersByStack).not.toHaveBeenCalled();
    expect(ws.send).toHaveBeenCalledTimes(1);

    await vi.advanceTimersByTimeAsync(4000);
    expect(mockGetContainersByStack).not.toHaveBeenCalled();
    expect(ws.send).toHaveBeenCalledTimes(1);
    expect(ws.close).not.toHaveBeenCalled();
  });

  it('retries every 2s when the stack dir exists and no containers are found', async () => {
    mockGetContainersByStack.mockResolvedValue([]);
    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockGetContainersByStack).toHaveBeenCalledTimes(1));

    await vi.advanceTimersByTimeAsync(2000);
    expect(mockGetContainersByStack).toHaveBeenCalledTimes(2);

    const sent = (ws.send as ReturnType<typeof vi.fn>).mock.calls
      .flatMap((c) => String(c[0]))
      .join('');
    expect(sent).toContain('No containers found. Waiting for activity');
  });

  it('stops retrying after the websocket closes', async () => {
    mockGetContainersByStack.mockResolvedValue([]);
    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockGetContainersByStack).toHaveBeenCalledTimes(1));

    ws.readyState = 3;
    ws.emit('close');
    await vi.advanceTimersByTimeAsync(4000);
    expect(mockGetContainersByStack).toHaveBeenCalledTimes(1);
  });

  it('stops the empty-container retry after the stack directory disappears', async () => {
    mockGetContainersByStack.mockResolvedValue([]);
    const ws = createMockWs();
    const svc = ComposeService.getInstance(1);

    svc.streamLogs('mystack', ws);
    await vi.waitFor(() => expect(mockGetContainersByStack).toHaveBeenCalledTimes(1));

    mockFsStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
    await vi.advanceTimersByTimeAsync(2000);

    expect(mockGetContainersByStack).toHaveBeenCalledTimes(1);
    const sent = (ws.send as ReturnType<typeof vi.fn>).mock.calls
      .flatMap((c) => String(c[0]))
      .join('');
    expect(sent).toContain('No containers found. Waiting for activity');
    expect(sent).toContain('Stack directory is gone');
    expect(ws.close).not.toHaveBeenCalled();

    const sendsAfterGone = (ws.send as ReturnType<typeof vi.fn>).mock.calls.length;
    await vi.advanceTimersByTimeAsync(4000);
    expect(mockGetContainersByStack).toHaveBeenCalledTimes(1);
    expect(ws.send).toHaveBeenCalledTimes(sendsAfterGone);
    expect(ws.close).not.toHaveBeenCalled();
  });
});


describe('ComposeService - updateStack safe ordering', () => {
  it('never broad-removes Compose-managed containers before up', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockGetContainersByStack.mockResolvedValue([{ Id: 'should-not-remove' }]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockGetContainersByStack).not.toHaveBeenCalled();
    expect(mockRemoveContainers).not.toHaveBeenCalled();
    expect(mockCaptureCandidate).toHaveBeenCalled();
    expect(mockClassifyLegacyOrphansForUpdate).toHaveBeenCalledWith('my-stack');
    expect(mockHandoff).toHaveBeenCalled();
  });

  it('removes only classified orphans after handoff', async () => {
    setupAutoCloseSpawn();
    mockListContainers.mockResolvedValue([]);
    mockClassifyLegacyOrphansForUpdate.mockResolvedValueOnce({
      status: 'orphans',
      ids: ['orphan-1'],
    });
    mockRemoveContainers.mockResolvedValueOnce([{ id: 'orphan-1', success: true }]);

    const svc = ComposeService.getInstance(1);
    const promise = svc.updateStack('my-stack');
    await vi.advanceTimersByTimeAsync(3100);
    await promise;

    expect(mockHandoff).toHaveBeenCalled();
    expect(mockRemoveContainers).toHaveBeenCalledWith(['orphan-1']);
  });
});
