/**
 * Unit tests for DockerController — validateApiData, state-safe container ops,
 * disk usage, classified resources, orphan detection, and error paths.
 */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

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

const { mockDocker, composeDirRef, mockExecFileAsync } = vi.hoisted(() => {
  const mockDocker = {
    df: vi.fn(),
    listImages: vi.fn().mockResolvedValue([]),
    listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
    listNetworks: vi.fn().mockResolvedValue([]),
    listContainers: vi.fn().mockResolvedValue([]),
    getContainer: vi.fn(),
    getImage: vi.fn(),
    getVolume: vi.fn(),
    getNetwork: vi.fn(),
    pruneContainers: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
    pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
    pruneNetworks: vi.fn().mockResolvedValue({}),
    pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
    createNetwork: vi.fn(),
  };
  return {
    mockDocker,
    composeDirRef: { current: '/test/compose' },
    mockExecFileAsync: vi.fn(),
  };
});

vi.mock('../services/NodeRegistry', () => ({
  NodeRegistry: {
    getInstance: () => ({
      getDocker: () => mockDocker,
      getDefaultNodeId: () => 1,
      getComposeDir: () => composeDirRef.current,
    }),
  },
}));

vi.mock('../services/DatabaseService', () => ({
  DatabaseService: {
    getInstance: () => ({
      getGitSource: () => undefined,
      getStackProjectEnvFiles: () => [],
    }),
  },
}));

// Prevent COMPOSE_DIR related issues
vi.mock('child_process', () => ({
  exec: vi.fn(),
  execFile: vi.fn(),
}));
vi.mock('util', () => ({
  promisify: () => mockExecFileAsync,
}));

import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController';
import { CacheService } from '../services/CacheService';
import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService';
import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';

beforeEach(() => {
  vi.clearAllMocks();
  composeDirRef.current = '/test/compose';
  mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' });
  // Empty held sets by default so the held-image exclusion paths (getDiskUsage
  // with excludeHeldImages, estimateManagedReclaim) are hermetic. The
  // DatabaseService mock above exposes none of the recovery-row queries, so
  // an unspied getHeldImageIds would return null and the fail-closed
  // predicate would treat every image as held. Tests that need specific
  // holds re-spy after this beforeEach runs.
  vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
  vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
  vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => true } as Awaited<ReturnType<typeof fs.stat>>);
});

/** Hold specific images via the stack-side recovery service for one test. */
const holdStackImages = (ids: string[]) => {
  vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(ids));
};

// ── validateApiData ────────────────────────────────────────────────────

describe('DockerController - validateApiData', () => {
  it('throws when response is a string (HTML from wrong port)', async () => {
    mockDocker.listImages.mockResolvedValue('<html>Not Docker</html>');

    const dc = DockerController.getInstance(1);
    await expect(dc.getImages()).rejects.toThrow('Invalid response from Docker API');
  });

  it('passes through valid object data', async () => {
    const imageData = [{ Id: 'sha256:abc', RepoTags: ['nginx:latest'], Size: 100 }];
    mockDocker.listImages.mockResolvedValue(imageData);

    const dc = DockerController.getInstance(1);
    const result = await dc.getImages();
    expect(result).toEqual(imageData);
  });
});

// ── State-safe container operations ────────────────────────────────────

describe('DockerController - startContainer', () => {
  it('starts a container successfully', async () => {
    const mockStart = vi.fn().mockResolvedValue(undefined);
    mockDocker.getContainer.mockReturnValue({ start: mockStart });

    const dc = DockerController.getInstance(1);
    await dc.startContainer('abc123');

    expect(mockStart).toHaveBeenCalled();
  });

  it('silently ignores 304 already-started error', async () => {
    const mockStart = vi.fn().mockRejectedValue({ statusCode: 304 });
    mockDocker.getContainer.mockReturnValue({ start: mockStart });

    const dc = DockerController.getInstance(1);
    await expect(dc.startContainer('abc123')).resolves.toBeUndefined();
  });

  it('rethrows other errors', async () => {
    const mockStart = vi.fn().mockRejectedValue(new Error('container not found'));
    mockDocker.getContainer.mockReturnValue({ start: mockStart });

    const dc = DockerController.getInstance(1);
    await expect(dc.startContainer('abc123')).rejects.toThrow('container not found');
  });
});

describe('DockerController - stopContainer', () => {
  it('stops a container successfully', async () => {
    const mockStop = vi.fn().mockResolvedValue(undefined);
    mockDocker.getContainer.mockReturnValue({ stop: mockStop });

    const dc = DockerController.getInstance(1);
    await dc.stopContainer('abc123');

    expect(mockStop).toHaveBeenCalled();
  });

  it('silently ignores 304 already-stopped error', async () => {
    const mockStop = vi.fn().mockRejectedValue({ statusCode: 304 });
    mockDocker.getContainer.mockReturnValue({ stop: mockStop });

    const dc = DockerController.getInstance(1);
    await expect(dc.stopContainer('abc123')).resolves.toBeUndefined();
  });

  it('rethrows other errors', async () => {
    const err = new Error('permission denied');
    const mockStop = vi.fn().mockRejectedValue(err);
    mockDocker.getContainer.mockReturnValue({ stop: mockStop });

    const dc = DockerController.getInstance(1);
    await expect(dc.stopContainer('abc123')).rejects.toThrow('permission denied');
  });
});

// ── removeContainers ───────────────────────────────────────────────────

describe('DockerController - removeContainers', () => {
  it('removes multiple containers and returns results', async () => {
    const mockRemove = vi.fn().mockResolvedValue(undefined);
    mockDocker.getContainer.mockReturnValue({ remove: mockRemove });

    const dc = DockerController.getInstance(1);
    const results = await dc.removeContainers(['c1', 'c2']);

    expect(results).toEqual([
      { id: 'c1', success: true },
      { id: 'c2', success: true },
    ]);
  });

  it('returns failure result for containers that cannot be removed', async () => {
    let callCount = 0;
    mockDocker.getContainer.mockImplementation(() => ({
      remove: vi.fn().mockImplementation(() => {
        callCount++;
        if (callCount === 2) throw new Error('in use');
        return Promise.resolve();
      }),
    }));

    const dc = DockerController.getInstance(1);
    const results = await dc.removeContainers(['c1', 'c2']);

    expect(results[0]).toEqual({ id: 'c1', success: true });
    expect(results[1]).toMatchObject({ id: 'c2', success: false, error: 'in use' });
  });
});

// ── getDiskUsage ───────────────────────────────────────────────────────

describe('DockerController - getDiskUsage', () => {
  it('calculates reclaimable space correctly', async () => {
    mockDocker.df.mockResolvedValue({
      LayersSize: 800,                                  // total image-layer bytes on disk
      Images: [
        { Id: 'img1', Containers: 0, Size: 500, SharedSize: 0 },  // reclaimable (unused)
        { Id: 'img2', Containers: 1, Size: 300, SharedSize: 0 },  // not reclaimable (in use); used = 300
      ],
      Containers: [
        { State: 'running', SizeRw: 100 },              // not reclaimable (running)
        { State: 'exited', SizeRw: 200 },                // reclaimable (stopped)
      ],
      Volumes: [
        { UsageData: { RefCount: 0, Size: 400 } },      // reclaimable (unused)
        { UsageData: { RefCount: 1, Size: 300 } },      // not reclaimable (in use)
      ],
      BuildCache: [
        { ID: 'bc1', InUse: false, Size: 600 },          // reclaimable
        { ID: 'bc2', InUse: true, Size: 250 },           // not reclaimable
      ],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // 800 LayersSize - 300 used-by-in-use-image = 500 reclaimable
    expect(usage.reclaimableImages).toBe(500);
    expect(usage.reclaimableImageCount).toBe(1);
    expect(usage.reclaimableContainers).toBe(200);
    expect(usage.reclaimableVolumes).toBe(400);
    expect(usage.reclaimableBuildCache).toBe(600);
    expect(usage.reclaimableBuildCacheCount).toBe(1);
  });

  it('uses daemon-reported ImageUsage.Reclaimable when present (API v1.44+)', async () => {
    // Modern Docker daemons compute Reclaimable server-side and ship the exact
    // value `docker system df` displays. Trust it over any client-side fallback.
    mockDocker.df.mockResolvedValue({
      LayersSize: 10_000_000_000,
      ImageUsage: { Reclaimable: 7_837_305_085, TotalSize: 10_000_000_000, ActiveCount: 11, TotalCount: 33 },
      Images: [
        { Id: 'a', Containers: 0, Size: 5_000_000_000, SharedSize: 0 },  // a fallback formula would say 5G
        { Id: 'b', Containers: 1, Size: 3_000_000_000, SharedSize: 0 },
      ],
      Containers: [],
      Volumes: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableImages).toBe(7_837_305_085);
    expect(usage.reclaimableImageCount).toBe(1);
  });

  it('does not double-count shared layers across unused images', async () => {
    // Three images sharing a 400MB base layer: two unused (800MB virtual each),
    // one in-use (600MB virtual). Total on-disk: 1GB. The in-use image holds
    // its unique 200MB; pruning the two unused frees the remaining 800MB.
    // The previous formula summed VirtualSize and would have returned 1.6GB
    // (impossibly larger than LayersSize).
    mockDocker.df.mockResolvedValue({
      LayersSize: 1_000_000_000,
      Images: [
        { Id: 'a', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 },
        { Id: 'b', Containers: 0, VirtualSize: 800_000_000, SharedSize: 400_000_000 },
        { Id: 'c', Containers: 1, VirtualSize: 600_000_000, SharedSize: 400_000_000 },
      ],
      Containers: [],
      Volumes: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableImages).toBe(800_000_000);
    expect(usage.reclaimableImageCount).toBe(2);
  });

  it('skips active images only when no usable size is available', async () => {
    // Truly unaccountable image: both VirtualSize and Size are -1 / missing.
    // Skipping leaks at most one image's worth of bytes into the reclaim
    // total, but modern daemons never return this shape; the prior "always
    // skip on -1" path moved any image with SharedSize=-1 into the leak set.
    mockDocker.df.mockResolvedValue({
      LayersSize: 1000,
      Images: [
        { Id: 'known', Containers: 1, VirtualSize: 400, SharedSize: 100 },
        { Id: 'truly-lost', Containers: 1, VirtualSize: -1, Size: -1 },
      ],
      Containers: [],
      Volumes: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // truly-lost is unaccountable and skipped; used = 400 - 100 = 300
    expect(usage.reclaimableImages).toBe(700);
    expect(usage.reclaimableImageCount).toBe(0);
  });

  it('treats SharedSize=-1 conservatively (full Size counts as used)', async () => {
    // Older daemons may report SharedSize as -1 (unknown) while Size is
    // accurate. Treating SharedSize as 0 in that case under-reports
    // reclaimable, which is the safe direction; the prior skip-on-(-1)
    // path made the image's full Size look reclaimable.
    mockDocker.df.mockResolvedValue({
      LayersSize: 1000,
      Images: [
        { Id: 'modern', Containers: 1, Size: 400, SharedSize: 100 },
        { Id: 'no-shared-info', Containers: 1, Size: 300, SharedSize: -1 },
      ],
      Containers: [],
      Volumes: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // modern: used += 400 - 100 = 300
    // no-shared-info: shared treated as 0, used += 300 - 0 = 300; total = 600
    // (the old buggy formula skipped no-shared-info, leaving used=300 and
    //  reclaimable=700 — i.e. the in-use 300 bytes looked reclaimable)
    expect(usage.reclaimableImages).toBe(400);
  });

  it('handles empty arrays gracefully', async () => {
    mockDocker.df.mockResolvedValue({
      Images: [],
      Containers: [],
      Volumes: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableImages).toBe(0);
    expect(usage.reclaimableContainers).toBe(0);
    expect(usage.reclaimableVolumes).toBe(0);
  });

  it('handles missing fields gracefully', async () => {
    mockDocker.df.mockResolvedValue({});

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableImages).toBe(0);
    expect(usage.reclaimableContainers).toBe(0);
    expect(usage.reclaimableVolumes).toBe(0);
  });

  it('counts only prune-eligible container states (created/exited/dead)', async () => {
    // `docker container prune` removes stopped containers (created/exited/dead).
    // Paused and restarting containers survive it, so counting them would leave
    // a residue the banner can never clear no matter which prune runs.
    mockDocker.df.mockResolvedValue({
      Images: [],
      Volumes: [],
      Containers: [
        { State: 'exited', SizeRw: 200 },      // prunable
        { State: 'created', SizeRw: 50 },      // prunable
        { State: 'dead', SizeRw: 25 },         // prunable
        { State: 'paused', SizeRw: 1000 },     // survives prune
        { State: 'restarting', SizeRw: 1000 }, // survives prune
        { State: 'running', SizeRw: 1000 },    // in use
      ],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableContainers).toBe(275);
    expect(usage.reclaimableContainerCount).toBe(3);
  });

  it('sizes stopped containers by the writable layer (SizeRw), not SizeRootFs', async () => {
    // SizeRootFs includes the read-only image layers, which removing a
    // container never frees. A stopped container that wrote nothing reclaims 0.
    mockDocker.df.mockResolvedValue({
      Images: [],
      Volumes: [],
      Containers: [
        { State: 'exited', SizeRw: 0, SizeRootFs: 500_000_000 }, // wrote nothing
        { State: 'exited', SizeRw: 1_500, SizeRootFs: 900_000 }, // writable layer only
      ],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    expect(usage.reclaimableContainers).toBe(1_500);
    expect(usage.reclaimableContainerCount).toBe(2);
  });

  it('estimateSystemReclaim returns same bytes as getDiskUsage for every target type when no images are held', async () => {
    // Scoped to the no-holds case: estimateSystemReclaim('images') now
    // excludes rollback-held images (getDiskUsage({ excludeHeldImages: true })),
    // so the equality only holds when the held set is empty (the beforeEach
    // default). The deliberate divergence with a hold present is asserted by
    // the companion test below.
    mockDocker.df.mockResolvedValue({
      LayersSize: 1_000_000_000,
      Images: [
        { Id: 'a', Containers: 0, Size: 500_000_000, SharedSize: 0 },
        { Id: 'b', Containers: 1, Size: 300_000_000, SharedSize: 0 },
      ],
      Containers: [
        { State: 'exited', SizeRw: 200 },
      ],
      Volumes: [
        { UsageData: { RefCount: 0, Size: 400 } },
      ],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // Each estimateSystemReclaim(target) must equal the corresponding
    // getDiskUsage() field. knownStackNames is unused in the fast path
    // but retained for call-site symmetry.
    const expectations: Array<[target: 'images' | 'containers' | 'volumes' | 'networks', expected: number]> = [
      ['images', usage.reclaimableImages],
      ['containers', usage.reclaimableContainers],
      ['volumes', usage.reclaimableVolumes],
      ['networks', 0],
    ];
    for (const [target, expected] of expectations) {
      const estimate = await dc.estimateSystemReclaim(target, []);
      expect(estimate.reclaimableBytes).toBe(expected);
    }

    // The fast path uses only docker.df(), never the classified-resources
    // API calls that estimateSystemReclaim previously incurred via
    // getDiskUsageClassified (listImages, listVolumes, etc.).
    expect(mockDocker.df).toHaveBeenCalledTimes(5); // 1 getDiskUsage + 4 estimateSystemReclaim
    expect(mockDocker.listImages).not.toHaveBeenCalled();
    expect(mockDocker.listVolumes).not.toHaveBeenCalled();
    expect(mockDocker.listNetworks).not.toHaveBeenCalled();
    expect(mockDocker.listContainers).not.toHaveBeenCalled();
  });

  it('estimateSystemReclaim images estimate excludes held bytes; other targets are untouched', async () => {
    // The deliberate divergence: the estimate claims "what a prune can free",
    // so the images estimate must drop the held image's unique bytes while the
    // other targets still match the raw daemon figure exactly.
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      LayersSize: 2_000_000,
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
        { Id: 'used-1', Containers: 1, Size: 800, VirtualSize: 800, SharedSize: 0 },
      ],
      Containers: [
        { State: 'exited', SizeRw: 200 },
      ],
      Volumes: [
        { UsageData: { RefCount: 0, Size: 400 } },
      ],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // Base reclaimable images: LayersSize 2_000_000 minus the in-use image's
    // 800 = 1_999_200. Held unique bytes = 1_000. Estimate = 1_998_200.
    expect(usage.reclaimableImages).toBe(1_999_200);
    const estimate = await dc.estimateSystemReclaim('images', []);
    expect(estimate.reclaimableBytes).toBe(usage.reclaimableImages - 1_000);

    const containers = await dc.estimateSystemReclaim('containers', []);
    expect(containers.reclaimableBytes).toBe(usage.reclaimableContainers);
    const volumes = await dc.estimateSystemReclaim('volumes', []);
    expect(volumes.reclaimableBytes).toBe(usage.reclaimableVolumes);
    const networks = await dc.estimateSystemReclaim('networks', []);
    expect(networks.reclaimableBytes).toBe(0);
  });

  // ── held-image exclusion (excludeHeldImages) ──────────────────────────

  it('getDiskUsage default keeps rollback-held images in the reclaimable figure', async () => {
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage();

    // Default call keeps the daemon's own figure: held images are "unused
    // Docker data" for callers whose claim is not "what a prune can free".
    expect(usage.reclaimableImages).toBe(1_500);
    expect(usage.reclaimableImageCount).toBe(2);
  });

  it('getDiskUsage with excludeHeldImages subtracts held bytes and count on the daemon-reclaimable fast path', async () => {
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    expect(usage.reclaimableImages).toBe(500);
    expect(usage.reclaimableImageCount).toBe(1);
  });

  it('getDiskUsage with excludeHeldImages subtracts held unique bytes on the LayersSize fallback path', async () => {
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      LayersSize: 2_000,
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 200 },
        { Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
        { Id: 'used-1', Containers: 1, Size: 800, VirtualSize: 800, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    // Base: LayersSize 2_000 minus the in-use image's 800 = 1_200, count 2.
    // Held unique bytes = 1_000 - 200 = 800. Excluded: 400, count 1.
    expect(usage.reclaimableImages).toBe(400);
    expect(usage.reclaimableImageCount).toBe(1);
  });

  it('getDiskUsage with excludeHeldImages clamps at zero when held bytes exceed the figure', async () => {
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 100 },
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    expect(usage.reclaimableImages).toBe(0);
    expect(usage.reclaimableImageCount).toBe(0);
  });

  it('getDiskUsage with excludeHeldImages does not subtract a held image that a container references', async () => {
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        // Held but in use: never part of the reclaimable set, so exclusion
        // must leave both bytes and count untouched.
        { Id: 'held-1', Containers: 1, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    expect(usage.reclaimableImages).toBe(1_500);
    expect(usage.reclaimableImageCount).toBe(1);
  });

  it('getDiskUsage with excludeHeldImages fails closed to zero when the held lookup fails', async () => {
    // A null held lookup means "held state unknown"; the predicate protects
    // every image, matching the delete guard and the prune plan. The figure
    // claims 0 rather than advertising bytes no prune can free.
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'free-1', Containers: 0, Size: 1_500, VirtualSize: 1_500, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const excluded = await dc.getDiskUsage({ excludeHeldImages: true });
    expect(excluded.reclaimableImages).toBe(0);
    expect(excluded.reclaimableImageCount).toBe(0);

    const plain = await dc.getDiskUsage();
    expect(plain.reclaimableImages).toBe(1_500);
    expect(plain.reclaimableImageCount).toBe(1);
  });

  it('getDiskUsage with excludeHeldImages subtracts service-scoped holds too', async () => {
    // The unified predicate unions stack and service holds; a regression that
    // dropped the service arm from the figure paths would not be caught by
    // the stack-only tests.
    vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['held-svc']));
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'held-svc', Containers: 0, Size: 700, VirtualSize: 700, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 800, VirtualSize: 800, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    expect(usage.reclaimableImages).toBe(800);
    expect(usage.reclaimableImageCount).toBe(1);
  });

  it('getDiskUsage with excludeHeldImages clamps bytes but keeps the remaining count', async () => {
    // Mixed boundary: held bytes exceed the daemon figure (bytes clamp to 0)
    // while a non-held image keeps the count positive. Pins the bytes-side
    // clamp independently of the count-side one.
    holdStackImages(['held-1']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 100 },
      Images: [
        { Id: 'held-1', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'free-1', Containers: 0, Size: 50, VirtualSize: 50, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });

    const dc = DockerController.getInstance(1);
    const usage = await dc.getDiskUsage({ excludeHeldImages: true });

    expect(usage.reclaimableImages).toBe(0);
    expect(usage.reclaimableImageCount).toBe(1);
  });
});

// ── pruneSystem ────────────────────────────────────────────────────────

describe('DockerController - pruneSystem', () => {
  it('prunes containers and returns reclaimed bytes', async () => {
    mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 1024 });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneSystem('containers');

    expect(result).toEqual({ success: true, reclaimedBytes: 1024 });
  });

  it('prunes images with dangling false filter', async () => {
    mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 2048 });

    const dc = DockerController.getInstance(1);
    await dc.pruneSystem('images');

    expect(mockDocker.pruneImages).toHaveBeenCalledWith({
      filters: expect.objectContaining({ dangling: { 'false': true } }),
    });
  });

  it('includes label filter when provided', async () => {
    mockDocker.pruneContainers.mockResolvedValue({ SpaceReclaimed: 0 });

    const dc = DockerController.getInstance(1);
    await dc.pruneSystem('containers', 'com.example=true');

    expect(mockDocker.pruneContainers).toHaveBeenCalledWith({
      filters: { label: ['com.example=true'] },
    });
  });

  it('prunes volumes with all true filter', async () => {
    mockDocker.pruneVolumes.mockResolvedValue({ SpaceReclaimed: 4096 });

    const dc = DockerController.getInstance(1);
    await dc.pruneSystem('volumes');

    expect(mockDocker.pruneVolumes).toHaveBeenCalledWith({
      filters: { all: ['true'] },
    });
  });
});

// ── pruneDanglingImages ────────────────────────────────────────────────

describe('DockerController - pruneDanglingImages', () => {
  it('prunes only dangling images and returns reclaimed bytes', async () => {
    mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 5000 });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneDanglingImages();

    // dangling:true keeps the prune to untagged layers, unlike pruneSystem('images')
    // which uses dangling:false to remove every unused image.
    expect(mockDocker.pruneImages).toHaveBeenCalledWith({
      filters: { dangling: { 'true': true } },
    });
    expect(result).toEqual({ success: true, reclaimedBytes: 5000 });
  });
});

// ── Prune holds (): images held for a pending service-update
// recovery must never be deleted by any image-prune path. ──────────────

describe('DockerController - prune holds (isImageHeld)', () => {
  it('pruneSystem("images") falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-held', RepoTags: ['app:1'], Containers: 0 },
      { Id: 'img-free', RepoTags: ['app:2'], Containers: 0 },
    ]);
    mockDocker.df
      .mockResolvedValueOnce({ LayersSize: 5000 })
      .mockResolvedValueOnce({ LayersSize: 3000 });
    const removeFn = vi.fn().mockResolvedValue(undefined);
    mockDocker.getImage.mockReturnValue({ remove: removeFn });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneSystem('images', undefined, (id: string) => id === 'img-held');

    expect(mockDocker.pruneImages).not.toHaveBeenCalled();
    expect(removeFn).toHaveBeenCalledTimes(1);
    expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
    expect(result).toEqual({ success: true, reclaimedBytes: 2000 });
  });

  it('pruneDanglingImages falls back to enumerate-and-delete when isImageHeld is given, skipping the held image', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-held', RepoTags: [], Containers: 0 },
      { Id: 'img-free', RepoTags: ['<none>:<none>'], Containers: 0 },
      { Id: 'img-tagged', RepoTags: ['app:1'], Containers: 0 }, // not dangling; excluded regardless of hold
    ]);
    mockDocker.df
      .mockResolvedValueOnce({ LayersSize: 5000 })
      .mockResolvedValueOnce({ LayersSize: 4000 });
    const removeFn = vi.fn().mockResolvedValue(undefined);
    mockDocker.getImage.mockReturnValue({ remove: removeFn });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneDanglingImages((id: string) => id === 'img-held');

    expect(mockDocker.pruneImages).not.toHaveBeenCalled();
    expect(removeFn).toHaveBeenCalledTimes(1);
    expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
    expect(result).toEqual({ success: true, reclaimedBytes: 1000 });
  });

  it('pruneManagedOnly("images") re-checks isImageHeld immediately before each delete', async () => {
    mockDocker.df
      .mockResolvedValueOnce({ LayersSize: 5000, Images: [] })
      .mockResolvedValueOnce({ LayersSize: 4000, Images: [] });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-held', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-free', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    const removeFn = vi.fn().mockResolvedValue(undefined);
    mockDocker.getImage.mockReturnValue({ remove: removeFn });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneManagedOnly('images', ['any-stack'], (id: string) => id === 'img-held');

    expect(removeFn).toHaveBeenCalledTimes(1);
    expect(mockDocker.getImage).toHaveBeenCalledWith('img-free');
    expect(result.success).toBe(true);
  });

  it('pruneSystem("images") keeps the bulk Docker prune API when no isImageHeld predicate is given', async () => {
    mockDocker.pruneImages.mockResolvedValue({ SpaceReclaimed: 999 });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneSystem('images');

    expect(mockDocker.pruneImages).toHaveBeenCalled();
    expect(mockDocker.listImages).not.toHaveBeenCalled();
    expect(result).toEqual({ success: true, reclaimedBytes: 999 });
  });
});

// ── getClassifiedResources ─────────────────────────────────────────────

describe('DockerController - getClassifiedResources', () => {
  beforeEach(() => {
    CacheService.getInstance().invalidate('project-name-map');
  });
  afterEach(() => {
    vi.restoreAllMocks();
  });

  it('classifies managed and unmanaged images', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img1', RepoTags: ['nginx:latest'], Size: 100, Containers: 1 },
      { Id: 'img2', RepoTags: ['redis:latest'], Size: 200, Containers: 1 },
      { Id: 'img3', RepoTags: ['old:v1'], Size: 50, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([
      { ImageID: 'img1', Labels: { 'com.docker.compose.project': 'my-stack' } },
      { ImageID: 'img2', Labels: { 'com.docker.compose.project': 'unknown-stack' } },
    ]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    const managed = result.images.find(i => i.Id === 'img1');
    expect(managed!.managedStatus).toBe('managed');
    expect(managed!.managedBy).toBe('my-stack');
    expect(managed!.usedByStacks).toEqual(['my-stack']);

    const unmanaged = result.images.find(i => i.Id === 'img2');
    expect(unmanaged!.managedStatus).toBe('unmanaged');
    expect(unmanaged!.usedByStacks).toEqual([]);

    const unused = result.images.find(i => i.Id === 'img3');
    expect(unused!.managedStatus).toBe('unused');
    expect(unused!.usedByStacks).toEqual([]);
  });

  it('aggregates multi-stack usedByStacks with stable sort and managedBy first entry', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-shared', RepoTags: ['postgres:16'], Size: 100, Containers: 2 },
    ]);
    mockDocker.listContainers.mockResolvedValue([
      { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'zeta' } },
      { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } },
      { ImageID: 'img-shared', Labels: { 'com.docker.compose.project': 'alpha' } },
    ]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['alpha', 'zeta']);
    const img = result.images.find(i => i.Id === 'img-shared');

    expect(img!.usedByStacks).toEqual(['alpha', 'zeta']);
    expect(img!.managedBy).toBe('alpha');
    expect(img!.managedStatus).toBe('managed');
  });

  it('classifies system networks', async () => {
    mockDocker.listImages.mockResolvedValue([]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([
      { Id: 'n1', Name: 'bridge', Driver: 'bridge', Scope: 'local' },
      { Id: 'n2', Name: 'host', Driver: 'host', Scope: 'local' },
      { Id: 'n3', Name: 'none', Driver: 'null', Scope: 'local' },
      { Id: 'n4', Name: 'my-stack_default', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'my-stack' } },
    ]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    expect(result.networks.filter(n => n.managedStatus === 'system')).toHaveLength(3);
    expect(result.networks.find(n => n.Name === 'my-stack_default')!.managedStatus).toBe('managed');
  });

  it('classifies managed and unmanaged volumes', async () => {
    mockDocker.listImages.mockResolvedValue([]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listNetworks.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({
      Volumes: [
        { Name: 'my-stack_data', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/my-stack_data', Labels: { 'com.docker.compose.project': 'my-stack' } },
        { Name: 'random_vol', Driver: 'local', Mountpoint: '/var/lib/docker/volumes/random', Labels: {} },
      ],
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    expect(result.volumes.find(v => v.Name === 'my-stack_data')!.managedStatus).toBe('managed');
    expect(result.volumes.find(v => v.Name === 'random_vol')!.managedStatus).toBe('unmanaged');
  });

  it('excludes an image whose only tag is a synthetic sencho-rb rollback hold', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-hold-only', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 50, Containers: 0 },
      { Id: 'img-normal', RepoTags: ['nginx:latest'], Size: 100, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    expect(result.images.find(i => i.Id === 'img-hold-only')).toBeUndefined();
    expect(result.images.find(i => i.Id === 'img-normal')).toBeDefined();
  });

  it('keeps an image visible when it carries both a normal tag and a sencho-rb hold tag', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-multi-tag', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    const img = result.images.find(i => i.Id === 'img-multi-tag');
    expect(img).toBeDefined();
    expect(img!.RepoTags).toEqual(['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold']);
  });

  it('marks an image rollbackProtected with kind "stack" when StackUpdateRecoveryService holds it', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-stack-held', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-stack-held']));
    vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    const img = result.images.find(i => i.Id === 'img-stack-held');
    expect(img?.rollbackProtected).toBe(true);
    expect(img?.rollbackProtectionKind).toBe('stack');
  });

  it('marks an image rollbackProtected with kind "service" when only ServiceUpdateRecoveryService holds it', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-service-held', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());
    vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-service-held']));

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    const img = result.images.find(i => i.Id === 'img-service-held');
    expect(img?.rollbackProtected).toBe(true);
    expect(img?.rollbackProtectionKind).toBe('service');
  });

  it('fails closed (marks every image rollbackProtected) when a held-image lookup fails', async () => {
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-unrelated', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);
    // getHeldImageIds returns null when its own DB lookup fails (already logs
    // internally); the badge must fail the same direction as the delete guard
    // (recoveryHeldImages.ts), not the opposite.
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
    vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set());

    const dc = DockerController.getInstance(1);
    const result = await dc.getClassifiedResources(['my-stack']);

    const img = result.images.find(i => i.Id === 'img-unrelated');
    expect(img?.rollbackProtected).toBe(true);
  });

  it('getDiskUsageClassified excludes held bytes from the reclaimable figure but keeps inventory totals', async () => {
    // The actual producer of the hero and treemap figures (GET /system/docker-df):
    // the held image's bytes and count leave reclaimableImages, while the
    // managed/unmanaged inventory totals are untouched.
    holdStackImages(['img-held']);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'img-held', Containers: 0, Size: 1_000, VirtualSize: 1_000, SharedSize: 0 },
        { Id: 'img-free', Containers: 0, Size: 500, VirtualSize: 500, SharedSize: 0 },
        { Id: 'img-used', Containers: 1, Size: 2_000, VirtualSize: 2_000, SharedSize: 0 },
        { Id: 'img-unmanaged', Containers: 1, Size: 300, VirtualSize: 300, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-held', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 1_000, Containers: 0 },
      { Id: 'img-free', RepoTags: ['myregistry/app:1.4'], Size: 500, Containers: 0 },
      { Id: 'img-used', RepoTags: ['myregistry/app:2.0'], Size: 2_000, Containers: 1 },
      { Id: 'img-unmanaged', RepoTags: ['other/container:1'], Size: 300, Containers: 1 },
    ]);
    mockDocker.listContainers.mockResolvedValue([
      { Id: 'c1', ImageID: 'img-used', Labels: { 'com.docker.compose.project': 'my-stack' } },
      { Id: 'c2', ImageID: 'img-unmanaged', Labels: { 'com.docker.compose.project': 'other' } },
    ]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getDiskUsageClassified(['my-stack']);

    expect(result.reclaimableImages).toBe(500);
    expect(result.reclaimableImageCount).toBe(1);
    expect(result.managedImageBytes).toBe(2_000);
    expect(result.unmanagedImageBytes).toBe(300);
  });

  it('getDiskUsageClassified stays internally consistent when the held lookup fails', async () => {
    // Split-read state: the reclaimable figure and the rollback badges run
    // independent held lookups. A null stack lookup fails both sides closed,
    // so the response claims 0 reclaimable images while every classified
    // image is badged protected, never the reverse.
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
    mockDocker.df.mockResolvedValue({
      ImageUsage: { Reclaimable: 1_500 },
      Images: [
        { Id: 'img-free', Containers: 0, Size: 1_500, VirtualSize: 1_500, SharedSize: 0 },
      ],
      Volumes: [],
      Containers: [],
    });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-free', RepoTags: ['myregistry/app:1.4'], Size: 1_500, Containers: 0 },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
    mockDocker.listNetworks.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getDiskUsageClassified(['my-stack']);

    expect(result.reclaimableImages).toBe(0);
    expect(result.reclaimableImageCount).toBe(0);
    // The badge side (a separate /system/resources response) fails closed the
    // same way under the same null lookup, so the two surfaces never disagree
    // in direction.
    const classified = await dc.getClassifiedResources(['my-stack']);
    const img = classified.images.find(i => i.Id === 'img-free');
    expect(img?.rollbackProtected).toBe(true);
  });
});

// ── pruneManagedOnly / estimateManagedReclaim (images) ─────────────────

describe('DockerController - managed image prune accounting', () => {
  // Two unused images each report a 1000-byte rolled-up Size, but 400 of those
  // bytes live in a layer shared with a third (in-use) image, so removing
  // the unused pair only frees the 600 unique bytes from each. The buggy
  // sum-of-Size accounting would have reported 2000; the fix should report
  // 1200 (600 × 2).
  const sharedLayerDfMock = {
    Images: [
      { Id: 'img-a', SharedSize: 400 },
      { Id: 'img-b', SharedSize: 400 },
      { Id: 'img-c', SharedSize: 400 },
    ],
  };
  const unusedManagedListMock = [
    { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    { Id: 'img-c', Containers: 1, Size: 800 },  // in-use; filtered by Containers===0
  ];

  it('estimateManagedReclaim subtracts SharedSize per prunable image', async () => {
    mockDocker.df.mockResolvedValue(sharedLayerDfMock);
    mockDocker.listImages.mockResolvedValue(unusedManagedListMock);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    expect(result.reclaimableBytes).toBe(1200);
  });

  it('estimateManagedReclaim skips a held image even when its compose labels match', async () => {
    // docker tag preserves labels, so a held image can carry the stack's
    // com.docker.compose.project label and would otherwise pass the
    // repo/label classifier. The held check runs before classification so
    // the estimate matches the destructive path (pruneManagedOnly).
    holdStackImages(['img-held']);
    mockDocker.df.mockResolvedValue({
      Images: [
        { Id: 'img-held', SharedSize: 0 },
        { Id: 'img-free', SharedSize: 0 },
      ],
    });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-held', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-free', Containers: 0, Size: 600, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    expect(result.reclaimableBytes).toBe(600);
  });

  it('estimateManagedReclaim fails closed to zero when the held lookup fails', async () => {
    vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null);
    mockDocker.df.mockResolvedValue(sharedLayerDfMock);
    mockDocker.listImages.mockResolvedValue(unusedManagedListMock);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    expect(result.reclaimableBytes).toBe(0);
  });

  it('pruneManagedOnly reports the LayersSize delta as the reclaimed total', async () => {
    // df-before reports 5000 bytes on disk; df-after reports 3400. The
    // honest reclaim is 1600, independent of per-image Size/SharedSize.
    // Two prunable images that share a layer exclusively would be
    // undercounted by the per-image formula (1200) but Docker actually
    // frees the shared layer too.
    mockDocker.df
      .mockResolvedValueOnce({
        LayersSize: 5000,
        Images: [
          { Id: 'img-a', SharedSize: 400 },
          { Id: 'img-b', SharedSize: 400 },
        ],
      })
      .mockResolvedValueOnce({ LayersSize: 3400, Images: [] });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    const removeFn = vi.fn().mockResolvedValue(undefined);
    mockDocker.getImage.mockReturnValue({ remove: removeFn });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneManagedOnly('images', ['any-stack']);

    expect(result).toEqual({ success: true, reclaimedBytes: 1600 });
    expect(removeFn).toHaveBeenCalledTimes(2);
  });

  it('pruneManagedOnly falls back to per-image lower bound using before-snapshot when after-df fails', async () => {
    // Before-snapshot succeeded (SharedSize known); after-snapshot failed.
    // Fall back to Σ(Size - SharedSize) over the prunable set: a
    // conservative lower bound, but defensible because each pruned image's
    // SharedSize was known at the start.
    mockDocker.df
      .mockResolvedValueOnce({
        Images: [
          { Id: 'img-a', SharedSize: 400 },
          { Id: 'img-b', SharedSize: 400 },
        ],
      })
      .mockRejectedValueOnce(new Error('df unavailable after prune'));
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneManagedOnly('images', ['any-stack']);

    // (1000 - 400) + (1000 - 400) = 1200
    expect(result).toEqual({ success: true, reclaimedBytes: 1200 });
  });

  it('pruneManagedOnly reports 0 reclaimed when both df snapshots fail', async () => {
    // Without the before-snapshot we cannot build a meaningful per-image
    // bound (after-only has stale image IDs missing from its view), so the
    // destructive path completes the removes and reports 0 rather than a
    // misleading approximation.
    mockDocker.df
      .mockRejectedValueOnce(new Error('df unavailable before'))
      .mockRejectedValueOnce(new Error('df unavailable after'));
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);
    const removeFn = vi.fn().mockResolvedValue(undefined);
    mockDocker.getImage.mockReturnValue({ remove: removeFn });

    const dc = DockerController.getInstance(1);
    const result = await dc.pruneManagedOnly('images', ['any-stack']);

    expect(result).toEqual({ success: true, reclaimedBytes: 0 });
    expect(removeFn).toHaveBeenCalledTimes(1);
  });

  it('estimateManagedReclaim under-reports when layers are shared exclusively between prunable images', async () => {
    // Two prunable images that share a 400-byte layer with no retained
    // image. Docker would free that layer once on prune (1600 bytes total:
    // each image's unique 600 plus the shared 400). The estimate formula
    // subtracts 400 from each image and reports 1200, a conservative
    // lower bound documented in the JSDoc.
    mockDocker.df.mockResolvedValue({
      Images: [
        { Id: 'img-a', SharedSize: 400 },
        { Id: 'img-b', SharedSize: 400 },
      ],
    });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    expect(result.reclaimableBytes).toBe(1200);
  });

  it('invariant: pruneManagedOnly reports >= estimateManagedReclaim on the same inputs', async () => {
    // The estimate is a conservative lower bound; the destructive path
    // reports the truth (df-delta). On any input, destructive >= estimate.
    // This locks the contract so future changes to either formula cannot
    // accidentally flip the direction.
    const dfBefore = {
      LayersSize: 5000,
      Images: [
        { Id: 'img-a', SharedSize: 400 },
        { Id: 'img-b', SharedSize: 400 },
      ],
    };
    const dfAfter = { LayersSize: 3400, Images: [] };
    const listImages = [
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ];

    // Estimate path: df called once.
    mockDocker.df.mockResolvedValueOnce(dfBefore);
    mockDocker.listImages.mockResolvedValue(listImages);
    mockDocker.listContainers.mockResolvedValue([]);
    const dc = DockerController.getInstance(1);
    const estimate = await dc.estimateManagedReclaim('images', ['any-stack']);

    // Reset the df mock for the destructive path: before then after.
    mockDocker.df.mockReset();
    mockDocker.df.mockResolvedValueOnce(dfBefore).mockResolvedValueOnce(dfAfter);
    mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });
    const prune = await dc.pruneManagedOnly('images', ['any-stack']);

    expect(prune.reclaimedBytes).toBeGreaterThanOrEqual(estimate.reclaimableBytes);
  });

  it('treats images missing from the df map as having no shared layers', async () => {
    // df reports only img-a; img-b is omitted (stale / race / older daemon).
    // The accounting should still process img-b, defaulting its SharedSize to 0.
    mockDocker.df.mockResolvedValue({ Images: [{ Id: 'img-a', SharedSize: 400 }] });
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    // img-a: 1000-400=600; img-b: 1000-0=1000; total 1600
    expect(result.reclaimableBytes).toBe(1600);
  });

  it('degrades to no-sharing assumption when df fails', async () => {
    // If df throws, the helper returns an empty map and the accounting falls
    // back to full per-image Size. This preserves prior behavior (over-report)
    // rather than failing the prune.
    mockDocker.df.mockRejectedValue(new Error('daemon df failed'));
    mockDocker.listImages.mockResolvedValue([
      { Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
      { Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
    ]);
    mockDocker.listContainers.mockResolvedValue([]);

    const dc = DockerController.getInstance(1);
    const result = await dc.estimateManagedReclaim('images', ['any-stack']);

    expect(result.reclaimableBytes).toBe(2000);
  });

  it('returns the same free image ID set from plan, estimate, and pruneManagedOnly including working-dir ownership', async () => {
    CacheService.getInstance().invalidate('project-name-map');
    const listImages = [
      {
        Id: 'img-label',
        Containers: 0,
        Size: 100,
        RepoTags: ['svc:1'],
        Labels: { 'com.docker.compose.project': 'any-stack' },
      },
      {
        Id: 'img-workdir',
        Containers: 0,
        Size: 100,
        RepoTags: ['svc2:1'],
        Labels: { 'com.docker.compose.project.working_dir': '/app/compose/any-stack' },
      },
      {
        Id: 'img-repo',
        Containers: 0,
        Size: 100,
        RepoTags: ['running-app:1'],
      },
      {
        Id: 'img-current',
        Containers: 1,
        Size: 100,
        RepoTags: ['running-app:2'],
      },
      {
        Id: 'img-foreign',
        Containers: 0,
        Size: 100,
        RepoTags: ['running-app:0'],
        Labels: { 'com.docker.compose.project': 'other-project' },
      },
    ];
    mockDocker.listImages.mockResolvedValue(listImages);
    mockDocker.listContainers.mockResolvedValue([
      {
        Id: 'c-run',
        Image: 'running-app:2',
        ImageID: 'img-current',
        Labels: { 'com.docker.compose.project': 'any-stack' },
      },
    ]);
    mockDocker.df
      .mockResolvedValue({
        Volumes: [],
        Images: listImages.map((img) => ({ Id: img.Id, SharedSize: 0, Size: img.Size })),
        LayersSize: 500,
      });
    mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });

    const dc = DockerController.getInstance(1);
    const plan = await dc.buildPrunePlan(['images'], 'managed', ['any-stack'], 1);
    const estimate = await dc.estimateManagedReclaim('images', ['any-stack']);
    await dc.pruneManagedOnly('images', ['any-stack']);

    const planIds = plan.items.map((i) => i.id).sort();
    expect(planIds).toEqual(['img-label', 'img-repo', 'img-workdir']);
    expect(estimate.reclaimableBytes).toBe(300);
    const removeCalls = mockDocker.getImage.mock.calls.map((c: unknown[]) => c[0]).sort();
    expect(removeCalls).toEqual(planIds);
  });
});

// ── getOrphanContainers ────────────────────────────────────────────────

describe('DockerController - getOrphanContainers', () => {
  it('returns containers whose project label is not in known stacks', async () => {
    mockDocker.listContainers.mockResolvedValue([
      { Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'orphan-stack' } },
      { Id: 'c2', Names: ['/c2'], State: 'running', Status: 'Up', Image: 'redis', Labels: { 'com.docker.compose.project': 'known-stack' } },
    ]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getOrphanContainers(['known-stack']);

    expect(result['orphan-stack']).toHaveLength(1);
    expect(result['orphan-stack'][0].Id).toBe('c1');
    expect(result['known-stack']).toBeUndefined();
  });

  it('returns empty when all containers belong to known stacks', async () => {
    mockDocker.listContainers.mockResolvedValue([
      { Id: 'c1', Names: ['/c1'], State: 'running', Status: 'Up', Image: 'nginx', Labels: { 'com.docker.compose.project': 'my-stack' } },
    ]);

    const dc = DockerController.getInstance(1);
    const result = await dc.getOrphanContainers(['my-stack']);

    expect(Object.keys(result)).toHaveLength(0);
  });
});

// ── Docker daemon unreachable ──────────────────────────────────────────

describe('DockerController - error paths', () => {
  it('propagates connection errors from Docker daemon', async () => {
    mockDocker.listContainers.mockRejectedValue(
      new Error('connect ECONNREFUSED /var/run/docker.sock')
    );

    const dc = DockerController.getInstance(1);
    await expect(dc.getOrphanContainers(['x'])).rejects.toThrow('ECONNREFUSED');
  });

  it('propagates errors from df() call', async () => {
    mockDocker.df.mockRejectedValue(new Error('daemon not running'));

    const dc = DockerController.getInstance(1);
    await expect(dc.getDiskUsage()).rejects.toThrow('daemon not running');
  });
});

// ── inspectNetwork ────────────────────────────────────────────────────

describe('DockerController - inspectNetwork', () => {
  it('returns full network details from Docker API', async () => {
    const inspectData = {
      Id: 'abc123',
      Name: 'my-network',
      Driver: 'bridge',
      Scope: 'local',
      IPAM: { Config: [{ Subnet: '172.20.0.0/16', Gateway: '172.20.0.1' }] },
      Containers: {
        'ctr1': { Name: 'web', IPv4Address: '172.20.0.2/16', MacAddress: '02:42:ac:14:00:02' },
      },
    };
    mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });

    const dc = DockerController.getInstance(1);
    const result = await dc.inspectNetwork('abc123');

    expect(result).toEqual(inspectData);
    expect(mockDocker.getNetwork).toHaveBeenCalledWith('abc123');
  });

  it('propagates errors when network not found', async () => {
    mockDocker.getNetwork.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(new Error('network not found')),
    });

    const dc = DockerController.getInstance(1);
    await expect(dc.inspectNetwork('nonexistent')).rejects.toThrow('network not found');
  });
});

// ── createNetwork ─────────────────────────────────────────────────────

describe('DockerController - createNetwork', () => {
  it('creates a network with valid options', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-123' });

    const dc = DockerController.getInstance(1);
    const result = await dc.createNetwork({ Name: 'test-network', Driver: 'bridge' });

    expect(result).toEqual({ id: 'new-net-123' });
    expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'test-network', Driver: 'bridge' });
  });

  it('rejects empty network name', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
  });

  it('rejects network name with invalid characters', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
  });

  it('accepts names with hyphens, underscores, and dots', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'ok-123' });

    const dc = DockerController.getInstance(1);
    await dc.createNetwork({ Name: 'my-net_v2.0' });

    expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-net_v2.0' });
  });

  it('propagates Docker daemon errors', async () => {
    mockDocker.createNetwork.mockRejectedValue(new Error('network with name test already exists'));

    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: 'test' })).rejects.toThrow('already exists');
  });

  it('rejects names starting with a dot', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: '.hidden' })).rejects.toThrow('Invalid network name');
  });

  it('rejects names starting with a hyphen', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: '-leading-dash' })).rejects.toThrow('Invalid network name');
  });

  it('rejects names with spaces', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: 'has spaces' })).rejects.toThrow('Invalid network name');
  });

  it('rejects names with slashes (path traversal)', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: 'foo/bar' })).rejects.toThrow('Invalid network name');
  });

  it('passes IPAM config through to Docker', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'ipam-net' });

    const dc = DockerController.getInstance(1);
    const options = {
      Name: 'ipam-test',
      Driver: 'bridge' as const,
      IPAM: { Config: [{ Subnet: '10.0.0.0/24', Gateway: '10.0.0.1' }] },
      Internal: true,
      Attachable: true,
    };
    await dc.createNetwork(options);

    expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
  });

  it('passes labels through to Docker', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'labeled-net' });

    const dc = DockerController.getInstance(1);
    const options = {
      Name: 'labeled',
      Labels: { env: 'test', team: 'infra' },
    };
    await dc.createNetwork(options);

    expect(mockDocker.createNetwork).toHaveBeenCalledWith(options);
  });

  it('accepts single-character name', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'single' });

    const dc = DockerController.getInstance(1);
    await dc.createNetwork({ Name: 'a' });

    expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'a' });
  });
});

// ── inspectNetwork edge cases ─────────────────────────────────────────

describe('DockerController - inspectNetwork edge cases', () => {
  it('returns network with empty containers map', async () => {
    const inspectData = {
      Id: 'empty-net',
      Name: 'isolated',
      Driver: 'bridge',
      Containers: {},
    };
    mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });

    const dc = DockerController.getInstance(1);
    const result = await dc.inspectNetwork('empty-net');

    expect(result.Containers).toEqual({});
  });

  it('returns network with multiple containers', async () => {
    const inspectData = {
      Id: 'multi-net',
      Name: 'busy-network',
      Driver: 'bridge',
      Containers: {
        'c1': { Name: 'web', IPv4Address: '172.20.0.2/16' },
        'c2': { Name: 'db', IPv4Address: '172.20.0.3/16' },
        'c3': { Name: 'cache', IPv4Address: '172.20.0.4/16' },
      },
    };
    mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue(inspectData) });

    const dc = DockerController.getInstance(1);
    const result = await dc.inspectNetwork('multi-net');

    expect(Object.keys(result.Containers ?? {})).toHaveLength(3);
  });

  it('propagates Docker daemon connection errors', async () => {
    mockDocker.getNetwork.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(new Error('Cannot connect to Docker daemon')),
    });

    const dc = DockerController.getInstance(1);
    await expect(dc.inspectNetwork('any-id')).rejects.toThrow('Cannot connect to Docker daemon');
  });
});

// --- inspectImage --------------------------------------------------------------

describe('DockerController - inspectImage', () => {
  it('returns combined inspect + history payload', async () => {
    const inspectData = {
      Id: 'sha256:abc12345',
      RepoTags: ['nginx:1.27'],
      Size: 187_000_000,
      Architecture: 'amd64',
      Os: 'linux',
      Config: { Cmd: ['nginx', '-g', 'daemon off;'] },
    };
    const historyData = [
      { Id: 'layer1', Created: 1700000000, CreatedBy: '/bin/sh -c #(nop) ADD file:abc', Size: 72_000_000, Tags: null, Comment: '' },
      { Id: 'layer2', Created: 1700000010, CreatedBy: 'ENV NGINX_VERSION=1.27', Size: 0, Tags: null, Comment: '' },
    ];
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockResolvedValue(inspectData),
      history: vi.fn().mockResolvedValue(historyData),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.inspectImage('sha256:abc12345');

    expect(result.inspect).toEqual(inspectData);
    expect(result.history).toEqual(historyData);
    expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:abc12345');
  });

  it('propagates 404 from Dockerode when image is missing', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image: missing'), { statusCode: 404 })),
      history: vi.fn().mockResolvedValue([]),
    });

    const dc = DockerController.getInstance(1);
    await expect(dc.inspectImage('missing')).rejects.toThrow('No such image');
  });

  it('returns empty history when an image has none', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ Id: 'sha256:empty', Size: 0 }),
      history: vi.fn().mockResolvedValue([]),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.inspectImage('sha256:empty');

    expect(result.history).toEqual([]);
    expect(result.inspect.Id).toBe('sha256:empty');
  });
});

// --- resolveImageId --------------------------------------------------------------

describe('DockerController - resolveImageId', () => {
  it('returns the canonical full Id from docker.getImage(id).inspect()', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ Id: 'sha256:' + 'a'.repeat(64) }),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.resolveImageId('a'.repeat(12));

    expect(result).toBe('sha256:' + 'a'.repeat(64));
    expect(mockDocker.getImage).toHaveBeenCalledWith('a'.repeat(12));
  });

  it('returns null on a 404 from Docker', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image'), { statusCode: 404 })),
    });

    const dc = DockerController.getInstance(1);
    expect(await dc.resolveImageId('missing')).toBeNull();
  });

  it('rethrows a non-404 Docker error', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(Object.assign(new Error('docker daemon unreachable'), { statusCode: 500 })),
    });

    const dc = DockerController.getInstance(1);
    await expect(dc.resolveImageId('sha256:abc')).rejects.toThrow('docker daemon unreachable');
  });
});

// --- label / image inspection for the label inventory --------------------------

describe('DockerController - inspectImageLabels', () => {
  it('returns the image label map', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
    });
    const dc = DockerController.getInstance(1);
    const result = await dc.inspectImageLabels('sha256:img');
    expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
    expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
  });

  it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
    mockDocker.getImage.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(new Error('No such image')),
    });
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const dc = DockerController.getInstance(1);
    expect(await dc.inspectImageLabels('missing')).toBeNull();
    expect(errSpy).toHaveBeenCalled();
    errSpy.mockRestore();
  });

  it('short-circuits an empty image id without inspecting', async () => {
    const dc = DockerController.getInstance(1);
    expect(await dc.inspectImageLabels('')).toBeNull();
    expect(mockDocker.getImage).not.toHaveBeenCalled();
  });
});

describe('DockerController - inspectContainerLabelsAndImage', () => {
  it('returns labels and the image ref from container inspect', async () => {
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
    });
    const dc = DockerController.getInstance(1);
    const result = await dc.inspectContainerLabelsAndImage('c1');
    expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
  });

  it('returns null and logs when the container inspect fails', async () => {
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(new Error('no such container')),
    });
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const dc = DockerController.getInstance(1);
    expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
    expect(errSpy).toHaveBeenCalled();
    errSpy.mockRestore();
  });
});

// --- createNetwork validation --------------------------------------------------

describe('createNetwork', () => {
  it('rejects empty network name', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: '' })).rejects.toThrow('Invalid network name');
  });

  it('rejects names with invalid characters', async () => {
    const dc = DockerController.getInstance(1);
    await expect(dc.createNetwork({ Name: 'net work' })).rejects.toThrow('Invalid network name');
    await expect(dc.createNetwork({ Name: '../escape' })).rejects.toThrow('Invalid network name');
    await expect(dc.createNetwork({ Name: 'net;rm' })).rejects.toThrow('Invalid network name');
  });

  it('accepts valid network names and passes through to Docker', async () => {
    mockDocker.createNetwork.mockResolvedValue({ id: 'new-net-id' });
    const dc = DockerController.getInstance(1);
    const result = await dc.createNetwork({ Name: 'my-network_v2' });
    expect(result.id).toBe('new-net-id');
    expect(mockDocker.createNetwork).toHaveBeenCalledWith({ Name: 'my-network_v2' });
  });
});

// --- removeContainers mixed results -------------------------------------------

describe('removeContainers', () => {
  it('returns mixed results when some removals fail', async () => {
    mockDocker.getContainer.mockImplementation((id: string) => {
      if (id === 'fail-id') {
        return { remove: vi.fn().mockRejectedValue(new Error('no such container')) };
      }
      return { remove: vi.fn().mockResolvedValue(undefined) };
    });

    const dc = DockerController.getInstance(1);
    const results = await dc.removeContainers(['ok-id-000000', 'fail-id']);
    expect(results).toHaveLength(2);
    expect(results[0]).toEqual({ id: 'ok-id-000000', success: true });
    expect(results[1]).toMatchObject({ id: 'fail-id', success: false });
  });

  it('returns empty array for empty input', async () => {
    const dc = DockerController.getInstance(1);
    const results = await dc.removeContainers([]);
    expect(results).toEqual([]);
  });
});

// ── Network connect / disconnect helpers ───────────────────────────────

describe('DockerController - connectContainerToNetwork', () => {
  it('attaches a container to a network with no static IP', async () => {
    const connect = vi.fn().mockResolvedValue(undefined);
    mockDocker.getNetwork.mockReturnValue({ connect });

    const dc = DockerController.getInstance(1);
    await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234');

    expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
    expect(connect).toHaveBeenCalledWith({ Container: 'sencho-host-1234' });
  });

  it('attaches with a static IPv4 address when provided', async () => {
    const connect = vi.fn().mockResolvedValue(undefined);
    mockDocker.getNetwork.mockReturnValue({ connect });

    const dc = DockerController.getInstance(1);
    await dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234', { ipv4Address: '172.30.0.2' });

    expect(connect).toHaveBeenCalledWith({
      Container: 'sencho-host-1234',
      EndpointConfig: { IPAMConfig: { IPv4Address: '172.30.0.2' } },
    });
  });

  it('treats 403 already-connected as success (idempotent)', async () => {
    const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'endpoint already exists' });
    mockDocker.getNetwork.mockReturnValue({ connect });

    const dc = DockerController.getInstance(1);
    await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
  });

  it('rethrows non-idempotent errors', async () => {
    const connect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
    mockDocker.getNetwork.mockReturnValue({ connect });

    const dc = DockerController.getInstance(1);
    await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
      statusCode: 500,
    });
  });

  it('rethrows a 403 whose message is unrelated to already-attached', async () => {
    const connect = vi.fn().mockRejectedValue({ statusCode: 403, message: 'host-mode container cannot join network' });
    mockDocker.getNetwork.mockReturnValue({ connect });

    const dc = DockerController.getInstance(1);
    await expect(dc.connectContainerToNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
      statusCode: 403,
    });
  });
});

describe('DockerController - disconnectContainerFromNetwork', () => {
  it('detaches a container from a network with force=true', async () => {
    const disconnect = vi.fn().mockResolvedValue(undefined);
    mockDocker.getNetwork.mockReturnValue({ disconnect });

    const dc = DockerController.getInstance(1);
    await dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234');

    expect(mockDocker.getNetwork).toHaveBeenCalledWith('sencho_mesh');
    expect(disconnect).toHaveBeenCalledWith({ Container: 'sencho-host-1234', Force: true });
  });

  it('treats 404 not-connected as success (idempotent)', async () => {
    const disconnect = vi.fn().mockRejectedValue({ statusCode: 404, message: 'no such network endpoint' });
    mockDocker.getNetwork.mockReturnValue({ disconnect });

    const dc = DockerController.getInstance(1);
    await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).resolves.toBeUndefined();
  });

  it('rethrows non-idempotent errors', async () => {
    const disconnect = vi.fn().mockRejectedValue({ statusCode: 500, message: 'server error' });
    mockDocker.getNetwork.mockReturnValue({ disconnect });

    const dc = DockerController.getInstance(1);
    await expect(dc.disconnectContainerFromNetwork('sencho_mesh', 'sencho-host-1234')).rejects.toMatchObject({
      statusCode: 500,
    });
  });
});

// ── getDependencySnapshot ──────────────────────────────────────────────

describe('DockerController - getDependencySnapshot', () => {
  beforeEach(() => {
    // resolveProjectNameMap caches under a constant key; clear it so each test
    // resolves stack ownership from its own mocked compose set.
    CacheService.getInstance().invalidate('project-name-map');
  });

  it('maps service identity, networks, volume mounts, and published ports', async () => {
    mockDocker.listContainers.mockResolvedValue([
      {
        Id: 'abc123def456',
        Names: ['/web-1'],
        Image: 'nginx:alpine',
        State: 'running',
        Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' },
        NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } },
        Mounts: [
          { Type: 'volume', Name: 'web_data', Destination: '/data' },
          { Type: 'bind', Source: '/host/path', Destination: '/app' },
        ],
        Ports: [
          { IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
          { PrivatePort: 9090, Type: 'tcp' },
        ],
      },
    ]);
    mockDocker.listNetworks.mockResolvedValue([
      { Id: 'b', Name: 'bridge' },
      { Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } },
    ]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] });

    const dc = DockerController.getInstance(1);
    const snap = await dc.getDependencySnapshot(['web']);

    const c = snap.containers[0];
    expect(c.service).toBe('api');
    expect(c.stack).toBe('web');
    expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]);
    expect(c.volumes).toEqual(['web_data']); // bind mount dropped
    expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped
    expect(c.exitCode).toBeNull();

    expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true);
    const frontend = snap.networks.find((n) => n.name === 'web_frontend');
    expect(frontend?.isSystem).toBe(false);
    expect(frontend?.stack).toBe('web');

    expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' });
  });

  it('classifies a non-compose container as having no service or stack', async () => {
    mockDocker.listContainers.mockResolvedValue([
      { Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
    ]);
    mockDocker.listNetworks.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });

    const dc = DockerController.getInstance(1);
    const snap = await dc.getDependencySnapshot([]);
    expect(snap.containers[0].service).toBeNull();
    expect(snap.containers[0].stack).toBeNull();
    expect(snap.containers[0].composeProject).toBeNull();
  });

  it('parses exitCode from list Status for exited containers', async () => {
    mockDocker.listContainers.mockResolvedValue([
      { Id: 'a', Names: ['/job-0'], Image: 'busybox', State: 'exited', Status: 'Exited (0) 5 minutes ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
      { Id: 'b', Names: ['/crash-0'], Image: 'busybox', State: 'exited', Status: 'Exited (137) 1 minute ago', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
      { Id: 'c', Names: ['/up-0'], Image: 'busybox', State: 'running', Status: 'Up 3 hours', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
      { Id: 'd', Names: ['/odd-0'], Image: 'busybox', State: 'exited', Status: 'Exited', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
    ]);
    mockDocker.listNetworks.mockResolvedValue([]);
    mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });

    const snap = await DockerController.getInstance(1).getDependencySnapshot([]);
    expect(snap.containers.find(c => c.id === 'a')?.exitCode).toBe(0);
    expect(snap.containers.find(c => c.id === 'b')?.exitCode).toBe(137);
    expect(snap.containers.find(c => c.id === 'c')?.exitCode).toBeNull();
    expect(snap.containers.find(c => c.id === 'd')?.exitCode).toBeNull();
  });
});

// --- getBulkStackStatuses uptime (runningSince from StartedAt) -----------------

describe('DockerController - getBulkStackStatuses uptime', () => {
  beforeEach(() => {
    // resolveProjectNameMap caches under a single key; flush so each test's
    // stack names map cleanly to themselves (compose files do not exist here).
    CacheService.getInstance().flush();
  });

  const runningContainer = (id: string, project: string, created: number) => ({
    Id: id,
    Names: [`/${id}`],
    State: 'running',
    Status: 'Up',
    Image: 'nginx',
    Created: created,
    Labels: { 'com.docker.compose.project': project },
  });

  it('uses StartedAt (not Created) for runningSince', async () => {
    const created = 1000; // ancient creation time
    const startedIso = '2026-06-09T12:00:00.000Z';
    const startedUnix = Math.floor(Date.parse(startedIso) / 1000);

    mockDocker.listContainers.mockResolvedValue([runningContainer('sa-c1', 'sa-stack', created)]);
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.getBulkStackStatuses(['sa-stack']);

    expect(result['sa-stack'].status).toBe('running');
    expect(result['sa-stack'].runningSince).toBe(startedUnix);
    expect(result['sa-stack'].runningSince).not.toBe(created);
  });

  it('picks the oldest StartedAt across running containers in a stack', async () => {
    const olderIso = '2026-06-09T10:00:00.000Z';
    const newerIso = '2026-06-09T11:30:00.000Z';
    const olderUnix = Math.floor(Date.parse(olderIso) / 1000);

    mockDocker.listContainers.mockResolvedValue([
      runningContainer('ow-c1', 'ow-stack', 5000),
      runningContainer('ow-c2', 'ow-stack', 6000),
    ]);
    const byId: Record<string, string> = { 'ow-c1': newerIso, 'ow-c2': olderIso };
    mockDocker.getContainer.mockImplementation((id: string) => ({
      inspect: vi.fn().mockResolvedValue({ State: { StartedAt: byId[id] } }),
    }));

    const dc = DockerController.getInstance(1);
    const result = await dc.getBulkStackStatuses(['ow-stack']);

    expect(result['ow-stack'].runningSince).toBe(olderUnix);
  });

  it('falls back to Created when inspect fails', async () => {
    const created = 1234;
    mockDocker.listContainers.mockResolvedValue([runningContainer('fb-c1', 'fb-stack', created)]);
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockRejectedValue(new Error('inspect boom')),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.getBulkStackStatuses(['fb-stack']);

    expect(result['fb-stack'].status).toBe('running');
    expect(result['fb-stack'].runningSince).toBe(created);
  });

  it('falls back to Created when StartedAt is the Docker zero-time', async () => {
    const created = 4321;
    mockDocker.listContainers.mockResolvedValue([runningContainer('zt-c1', 'zt-stack', created)]);
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '0001-01-01T00:00:00Z' } }),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.getBulkStackStatuses(['zt-stack']);

    expect(result['zt-stack'].runningSince).toBe(created);
  });

  it('derives uptime only from running containers, ignoring exited ones in the stack', async () => {
    const startedIso = '2026-06-09T08:00:00.000Z';
    const startedUnix = Math.floor(Date.parse(startedIso) / 1000);
    mockDocker.listContainers.mockResolvedValue([
      { ...runningContainer('me-run', 'me-stack', 7000) },
      { Id: 'me-exit', Names: ['/me-exit'], State: 'exited', Status: 'Exited (0)', Image: 'nginx', Created: 100, Labels: { 'com.docker.compose.project': 'me-stack' } },
    ]);
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } }),
    });

    const dc = DockerController.getInstance(1);
    const result = await dc.getBulkStackStatuses(['me-stack']);

    expect(result['me-stack'].status).toBe('running');
    // The exited container's ancient Created (100) must not drag uptime down.
    expect(result['me-stack'].runningSince).toBe(startedUnix);
  });

  it('caches StartedAt so repeated calls do not re-inspect within the TTL', async () => {
    const startedIso = '2026-06-09T09:00:00.000Z';
    mockDocker.listContainers.mockResolvedValue([runningContainer('ca-c1', 'ca-stack', 1000)]);
    const inspect = vi.fn().mockResolvedValue({ State: { StartedAt: startedIso } });
    mockDocker.getContainer.mockReturnValue({ inspect });

    // getInstance() hands out fresh instances per request, so a shared (static)
    // cache is what must dedupe the inspect across separate calls.
    const startedUnix = Math.floor(Date.parse(startedIso) / 1000);
    await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']);
    const second = await DockerController.getInstance(1).getBulkStackStatuses(['ca-stack']);

    expect(inspect).toHaveBeenCalledTimes(1);
    // The cache must be read, not merely populated: the second call still
    // resolves the real StartedAt rather than falling back to Created.
    expect(second['ca-stack'].runningSince).toBe(startedUnix);
  });
});

describe('DockerController - getBulkStackStatuses mainPort', () => {
  beforeEach(() => {
    CacheService.getInstance().flush();
  });

  const withPorts = (project: string, ports: { PrivatePort: number; PublicPort: number; Type?: string }[]) => ({
    Id: `${project}-c1`, Names: [`/${project}-c1`], State: 'running', Status: 'Up',
    Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, Ports: ports,
  });

  it('does not set mainPort for a UDP-only published port', async () => {
    mockDocker.listContainers.mockResolvedValue([withPorts('udp-stack', [{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])]);
    mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['udp-stack']);
    expect(result['udp-stack'].status).toBe('running');
    expect(result['udp-stack'].mainPort).toBeUndefined();
  });

  it('selects a TCP web port as mainPort', async () => {
    mockDocker.listContainers.mockResolvedValue([withPorts('tcp-stack', [
      { PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
      { PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
    ])]);
    mockDocker.getContainer.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }) });

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['tcp-stack']);
    expect(result['tcp-stack'].mainPort).toBe(8080);
  });
});

describe('selectMainWebPort', () => {
  it('prefers a known web-UI container port', () => {
    expect(selectMainWebPort([
      { PrivatePort: 5432, PublicPort: 5432, Type: 'tcp' },
      { PrivatePort: 8080, PublicPort: 18080, Type: 'tcp' },
    ])).toBe(18080);
  });

  it('skips UDP ports and picks the TCP one', () => {
    expect(selectMainWebPort([
      { PrivatePort: 53, PublicPort: 5353, Type: 'udp' },
      { PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
    ])).toBe(8080);
  });

  it('returns undefined for a UDP-only published port', () => {
    expect(selectMainWebPort([{ PrivatePort: 53, PublicPort: 5353, Type: 'udp' }])).toBeUndefined();
  });

  it('treats a missing protocol as TCP', () => {
    expect(selectMainWebPort([{ PrivatePort: 80, PublicPort: 8080 }])).toBe(8080);
  });

  it('falls back to the first TCP port when none match the web-UI list', () => {
    expect(selectMainWebPort([{ PrivatePort: 12000, PublicPort: 12000, Type: 'tcp' }])).toBe(12000);
  });

  it('returns undefined when there are no ports', () => {
    expect(selectMainWebPort([])).toBeUndefined();
  });
});

describe('parseExitCode', () => {
  it('extracts the code from an exited Status string', () => {
    expect(parseExitCode('Exited (0) 5 minutes ago')).toBe(0);
    expect(parseExitCode('Exited (137) 2 minutes ago')).toBe(137);
  });

  it('reads the code from a restarting Status string', () => {
    expect(parseExitCode('Restarting (1) 3 seconds ago')).toBe(1);
  });

  it('returns null when no parenthesized code is present', () => {
    expect(parseExitCode('Up 3 hours')).toBeNull();
    expect(parseExitCode('Up 2 hours (healthy)')).toBeNull();
    expect(parseExitCode('Created')).toBeNull();
    expect(parseExitCode(undefined)).toBeNull();
  });
});

describe('isContainerFailed', () => {
  it('treats a dead container as failed', () => {
    expect(isContainerFailed('dead', 'Dead')).toBe(true);
  });

  it('treats a crash-looping restart as failed but a clean restart as not', () => {
    expect(isContainerFailed('restarting', 'Restarting (1) 5 seconds ago')).toBe(true);
    expect(isContainerFailed('restarting', 'Restarting (0) 2 seconds ago')).toBe(false);
  });

  it('treats a non-zero exit as failed and a clean exit as not failed', () => {
    expect(isContainerFailed('exited', 'Exited (137) 2 minutes ago')).toBe(true);
    expect(isContainerFailed('exited', 'Exited (0) 5 minutes ago')).toBe(false);
  });

  it('treats an exited container with an unreadable code as failed', () => {
    expect(isContainerFailed('exited', 'Exited')).toBe(true);
  });

  it('does not treat running, created, or paused containers as failed', () => {
    expect(isContainerFailed('running', 'Up 2 hours')).toBe(false);
    expect(isContainerFailed('created', 'Created')).toBe(false);
    expect(isContainerFailed('paused', 'Up 2 hours (Paused)')).toBe(false);
  });
});

describe('DockerController - getBulkStackStatuses partial status', () => {
  beforeEach(() => {
    CacheService.getInstance().flush();
  });

  const container = (id: string, project: string, state: string, status: string) => ({
    Id: id, Names: [`/${id}`], State: state, Status: status,
    Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project },
  });

  // Any running container triggers an inspect for uptime; stub a valid StartedAt.
  const stubInspect = () => {
    mockDocker.getContainer.mockReturnValue({
      inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }),
    });
  };

  it('keeps a stack UP when a one-shot container exits cleanly', async () => {
    stubInspect();
    mockDocker.listContainers.mockResolvedValue([
      container('clean-run', 'clean-stack', 'running', 'Up 2 hours'),
      container('clean-init', 'clean-stack', 'exited', 'Exited (0) 5 minutes ago'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['clean-stack']);
    expect(result['clean-stack'].status).toBe('running');
    expect(result['clean-stack'].running).toBe(1);
    expect(result['clean-stack'].total).toBe(2);
  });

  it('marks a stack partial when a container crashes alongside a running one', async () => {
    stubInspect();
    mockDocker.listContainers.mockResolvedValue([
      container('crash-run', 'crash-stack', 'running', 'Up 2 hours'),
      container('crash-exit', 'crash-stack', 'exited', 'Exited (137) 1 minute ago'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['crash-stack']);
    expect(result['crash-stack'].status).toBe('partial');
    expect(result['crash-stack'].running).toBe(1);
    expect(result['crash-stack'].total).toBe(2);
  });

  it('marks a stack partial for a dead or restart-looping container', async () => {
    stubInspect();
    mockDocker.listContainers.mockResolvedValue([
      container('d-run', 'dead-stack', 'running', 'Up 2 hours'),
      container('d-dead', 'dead-stack', 'dead', 'Dead'),
      container('r-run', 'restart-stack', 'running', 'Up 2 hours'),
      container('r-loop', 'restart-stack', 'restarting', 'Restarting (1) 5 seconds ago'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['dead-stack', 'restart-stack']);
    expect(result['dead-stack'].status).toBe('partial');
    expect(result['restart-stack'].status).toBe('partial');
  });

  it('keeps a stack running when a sibling is paused rather than crashed', async () => {
    stubInspect();
    mockDocker.listContainers.mockResolvedValue([
      container('p-run', 'paused-stack', 'running', 'Up 2 hours'),
      container('p-paused', 'paused-stack', 'paused', 'Up 2 hours (Paused)'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['paused-stack']);
    expect(result['paused-stack'].status).toBe('running');
    expect(result['paused-stack'].running).toBe(1);
    expect(result['paused-stack'].total).toBe(2);
  });

  it('reports running when every container is up', async () => {
    stubInspect();
    mockDocker.listContainers.mockResolvedValue([
      container('all-1', 'all-stack', 'running', 'Up 2 hours'),
      container('all-2', 'all-stack', 'running', 'Up 1 hour'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['all-stack']);
    expect(result['all-stack'].status).toBe('running');
    expect(result['all-stack'].running).toBe(2);
    expect(result['all-stack'].total).toBe(2);
  });

  it('reports exited when no container is running, even if some crashed', async () => {
    mockDocker.listContainers.mockResolvedValue([
      container('down-1', 'down-stack', 'exited', 'Exited (1) 3 minutes ago'),
      container('down-2', 'down-stack', 'exited', 'Exited (0) 3 minutes ago'),
    ]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['down-stack']);
    expect(result['down-stack'].status).toBe('exited');
    expect(result['down-stack'].running).toBe(0);
    expect(result['down-stack'].total).toBe(2);
  });

  it('reports unknown for a stack with no containers', async () => {
    mockDocker.listContainers.mockResolvedValue([]);

    const result = await DockerController.getInstance(1).getBulkStackStatuses(['empty-stack']);
    expect(result['empty-stack'].status).toBe('unknown');
    expect(result['empty-stack'].running).toBeUndefined();
    expect(result['empty-stack'].total).toBeUndefined();
  });
});

// ── getLegacyOrphanContainersByStack (#1565) ───────────────────────────

type OrphanDcSpies = {
  fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]>;
  smartFallback: (...a: unknown[]) => Promise<unknown[]>;
};

function spyOrphanDc(
  dc: ReturnType<typeof DockerController.getInstance>,
  method: keyof OrphanDcSpies,
) {
  return vi.spyOn(dc as unknown as OrphanDcSpies, method);
}

describe('DockerController - getLegacyOrphanContainersByStack', () => {
  it('returns [] when compose ps already manages containers', async () => {
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }]);

    await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
    expect(fallbackSpy).not.toHaveBeenCalled();
    fetchSpy.mockRestore();
    fallbackSpy.mockRestore();
  });

  it('returns legacy orphan IDs when compose ps is empty', async () => {
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);

    await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]);
    fetchSpy.mockRestore();
    fallbackSpy.mockRestore();
  });

  it('returns [] when compose ps throws, even if fallback would match containers', async () => {
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose ps failed'));
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c2' }]);

    await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
    // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
    expect(fallbackSpy).not.toHaveBeenCalled();
    fetchSpy.mockRestore();
    fallbackSpy.mockRestore();
  });

  it('returns [] and skips fallback when compose ps stdout is unparseable', async () => {
    mockExecFileAsync.mockResolvedValue({
      stdout: 'this is not json at all just random text { broken [',
      stderr: '',
    });
    const dc = DockerController.getInstance(1);
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);

    await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
    expect(fallbackSpy).not.toHaveBeenCalled();
    fallbackSpy.mockRestore();
  });
});

describe('DockerController - classifyLegacyOrphansForUpdate', () => {
  async function classifyDc() {
    const { default: DockerController } = await import('../services/DockerController');
    return DockerController.getInstance(1);
  }

  it('returns none when compose ps already manages containers', async () => {
    const dc = await classifyDc();
    spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]);
    await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'none' });
  });

  it('returns orphans when compose ps is empty and fallback finds legacy containers', async () => {
    const dc = await classifyDc();
    spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
    spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: 'legacy-c2' }]);
    await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({
      status: 'orphans',
      ids: ['legacy-c1', 'legacy-c2'],
    });
  });

  it('returns classification_failed when compose ps throws, even if fallback would match containers', async () => {
    const dc = await classifyDc();
    spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom'));
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c1' }]);
    const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
    expect(result).toEqual({ status: 'classification_failed', error: 'compose boom' });
    // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes.
    expect(fallbackSpy).not.toHaveBeenCalled();
  });

  it('returns classification_failed when compose ps is empty and fallback throws', async () => {
    const dc = await classifyDc();
    spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
    spyOrphanDc(dc, 'smartFallback').mockRejectedValue(new Error('fallback boom'));
    const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
    expect(result).toEqual({ status: 'classification_failed', error: 'fallback boom' });
  });

  it('returns classification_failed when compose ps stdout is unparseable', async () => {
    mockExecFileAsync.mockResolvedValue({
      stdout: 'this is not json at all just random text { broken [',
      stderr: '',
    });
    const dc = await classifyDc();
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]);
    const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
    expect(result.status).toBe('classification_failed');
    expect(String((result as { error?: string }).error ?? '')).toMatch(/unparseable JSON/i);
    expect(fallbackSpy).not.toHaveBeenCalled();
  });
});

describe('DockerController - smartFallback stack-dir evidence', () => {
  const composeYaml = `services:\n  web:\n    image: nginx:alpine\n`;
  let stackDir: string;
  let tmpRoot: string;

  function runningContainer(id: string, name: string, labels: Record<string, string> = {}) {
    return { Id: id, Names: [name], Labels: labels, State: 'running', Status: 'Up', Ports: [] };
  }

  async function orphansWithEmptyPs() {
    const dc = DockerController.getInstance(1);
    spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
    return dc.getLegacyOrphanContainersByStack('my-stack');
  }

  beforeEach(async () => {
    tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-orphan-'));
    composeDirRef.current = tmpRoot;
    stackDir = path.join(tmpRoot, 'my-stack');
    await fs.mkdir(stackDir);
    await fs.writeFile(path.join(stackDir, 'docker-compose.yml'), composeYaml);
  });

  afterEach(async () => {
    composeDirRef.current = '/test/compose';
    await fs.rm(tmpRoot, { recursive: true, force: true });
  });

  it('excludes name-matched containers without stack-dir evidence', async () => {
    mockDocker.listContainers.mockResolvedValue([
      runningContainer('unrelated-web', '/web'),
    ]);
    await expect(orphansWithEmptyPs()).resolves.toEqual([]);
  });

  it('includes name-matched containers whose working_dir equals the stack dir', async () => {
    mockDocker.listContainers.mockResolvedValue([
      runningContainer('legacy-web', '/web', {
        'com.docker.compose.project.working_dir': stackDir,
      }),
    ]);
    await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web' }]);
  });

  it('matches working_dir case-insensitively on win32', async () => {
    if (process.platform !== 'win32') return;
    mockDocker.listContainers.mockResolvedValue([
      runningContainer('legacy-web-case', '/web', {
        'com.docker.compose.project.working_dir': stackDir.toUpperCase(),
      }),
    ]);
    await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web-case' }]);
  });

  it('excludes name-matched containers whose working_dir points elsewhere', async () => {
    mockDocker.listContainers.mockResolvedValue([
      runningContainer('other-stack-web', '/web', {
        'com.docker.compose.project.working_dir': path.join(tmpRoot, 'other-stack'),
      }),
    ]);
    await expect(orphansWithEmptyPs()).resolves.toEqual([]);
  });

  it('includes name-matched containers whose config_files path is under the stack dir', async () => {
    mockDocker.listContainers.mockResolvedValue([
      runningContainer('legacy-cfg', '/my-stack-web-1', {
        'com.docker.compose.project.config_files': path.join(stackDir, 'docker-compose.yml'),
      }),
    ]);
    await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-cfg' }]);
  });

  it('surfaces listContainers failure as classification_failed on update', async () => {
    mockDocker.listContainers.mockRejectedValue(new Error('daemon unavailable'));

    const { default: DockerController } = await import('../services/DockerController');
    const dc = DockerController.getInstance(1);
    spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]);
    const result = await dc.classifyLegacyOrphansForUpdate('my-stack');
    expect(result).toEqual({ status: 'classification_failed', error: 'daemon unavailable' });
  });
});

describe('DockerController - missing stack directory', () => {
  const enoent = () => Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });

  beforeEach(() => {
    vi.spyOn(fs, 'stat').mockRejectedValue(enoent());
  });

  it('getContainersByStack returns [] without spawning compose', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers');

    await expect(dc.getContainersByStack('gone-stack')).resolves.toEqual([]);
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(mockExecFileAsync).not.toHaveBeenCalled();
    expect(errSpy).not.toHaveBeenCalled();
    fetchSpy.mockRestore();
    errSpy.mockRestore();
  });

  it('getLegacyOrphanContainersByStack returns [] without spawning compose', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers');
    const fallbackSpy = spyOrphanDc(dc, 'smartFallback');

    await expect(dc.getLegacyOrphanContainersByStack('gone-stack')).resolves.toEqual([]);
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(fallbackSpy).not.toHaveBeenCalled();
    expect(mockExecFileAsync).not.toHaveBeenCalled();
    expect(errSpy).not.toHaveBeenCalled();
    fetchSpy.mockRestore();
    fallbackSpy.mockRestore();
    errSpy.mockRestore();
  });

  it('classifyLegacyOrphansForUpdate returns classification_failed without spawning compose', async () => {
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const dc = DockerController.getInstance(1);
    const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers');

    await expect(dc.classifyLegacyOrphansForUpdate('gone-stack')).resolves.toEqual({
      status: 'classification_failed',
      error: 'Stack directory is gone',
    });
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(mockExecFileAsync).not.toHaveBeenCalled();
    expect(errSpy).not.toHaveBeenCalled();
    fetchSpy.mockRestore();
    errSpy.mockRestore();
  });

  it('still logs Docker CLI unavailable when the stack dir exists and compose ENOENT fires', async () => {
    vi.spyOn(fs, 'stat').mockResolvedValue({ isDirectory: () => true } as Awaited<ReturnType<typeof fs.stat>>);
    vi.spyOn(os, 'freemem').mockReturnValue(2 * 1024 * 1024 * 1024);
    vi.spyOn(os, 'totalmem').mockReturnValue(8 * 1024 * 1024 * 1024);
    mockExecFileAsync.mockRejectedValue(Object.assign(new Error('spawn docker ENOENT'), { code: 'ENOENT' }));
    const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

    const dc = DockerController.getInstance(1);
    await dc.getContainersByStack('my-stack');

    expect(mockExecFileAsync).toHaveBeenCalled();
    const logged = errSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n');
    expect(logged).toMatch(/Docker Compose Error for/);
    expect(logged).toContain('Docker CLI unavailable on this node');
    errSpy.mockRestore();
  });
});

