/**
 * F-6 regression: Fleet itemized plan enumeration and byte estimation both
 * bound the slow `docker system df` call (12s) and surface a recognizable
 * timeout message to the operator.
 *
 * Covers:
 *  - POST /api/fleet/labels/fleet-prune  with dryRun: true
 *  - POST /api/fleet/prune/estimate
 *
 * Uses real timers because supertest dispatches lazily and the in-route
 * `withTimeout` setTimeout cannot be advanced via vi.useFakeTimers from
 * outside the request lifecycle. Five timeout tests add ~49s to the file
 * (three single-target ~12s each, plus two multi-target partial ~12s each).
 */
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';

let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let activeBulkActions: typeof import('../routes/labels').activeBulkActions;

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ app } = await import('../index'));
  ({ default: DockerController } = await import('../services/DockerController'));
  ({ FileSystemService } = await import('../services/FileSystemService'));
  ({ activeBulkActions } = await import('../routes/labels'));
  // 10-minute expiry survives the file even with five ~12s timeout tests.
  const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
  authHeader = `Bearer ${token}`;
});

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

afterEach(() => {
  vi.restoreAllMocks();
  activeBulkActions.clear();
});

function stubLocalEstimate(
  estimateImpl: () => Promise<{ reclaimableBytes: number }>,
  planImpl: () => Promise<unknown> = async () => ({
    nodeId: 1, scope: 'all', targets: ['volumes'], items: [], reclaimableBytes: 0,
    fingerprint: 'empty', createdAt: 1,
  }),
) {
  vi.spyOn(DockerController, 'getInstance').mockReturnValue({
    estimateSystemReclaim: vi.fn().mockImplementation(estimateImpl),
    estimateManagedReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }),
    buildPrunePlan: vi.fn().mockImplementation(planImpl),
  } as unknown as ReturnType<typeof DockerController.getInstance>);
  vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
}

describe('Fleet prune routes bound docker df at 12s on local nodes (F-6)', () => {
  it('POST /api/fleet/labels/fleet-prune dry-run surfaces a busy-daemon error on local timeout', async () => {
    stubLocalEstimate(
      () => Promise.resolve({ reclaimableBytes: 0 }),
      () => new Promise(() => { /* never resolves */ }),
    );

    const t0 = Date.now();
    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({ targets: ['volumes'], scope: 'all', dryRun: true });
    const elapsed = Date.now() - t0;

    expect(res.status).toBe(200);
    const local = res.body.results[0];
    expect(local.reachable).toBe(true);
    expect(local.targets[0].success).toBe(false);
    expect(local.targets[0].error).toMatch(/Docker daemon is busy/);
    expect(elapsed).toBeGreaterThanOrEqual(7_500);
    expect(elapsed).toBeLessThan(15_000);
  }, 20_000);

  it('POST /api/fleet/prune/estimate marks the local node unreachable with a busy-daemon error on timeout', async () => {
    stubLocalEstimate(() => new Promise(() => { /* never resolves */ }));

    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['volumes'], scope: 'all' });

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.perNode)).toBe(true);
    const local = res.body.perNode[0];
    expect(local.reachable).toBe(false);
    expect(local.error).toMatch(/Docker daemon is busy/);
  }, 20_000);

  it('POST /api/fleet/prune/estimate marks the local node unreachable on managed timeout', async () => {
    vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
    // estimateManagedReclaim never settles so the managed path hits
    // FLEET_DF_TIMEOUT_MS and surfaces the busy-daemon error.
    vi.spyOn(DockerController, 'getInstance').mockReturnValue({
      estimateManagedReclaim: vi.fn().mockImplementation(() => new Promise(() => { /* never resolves */ })),
      estimateSystemReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }),
    } as unknown as ReturnType<typeof DockerController.getInstance>);

    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images'], scope: 'managed' });

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body.perNode)).toBe(true);
    const local = res.body.perNode[0];
    expect(local.reachable).toBe(false);
    expect(local.error).toMatch(/Docker daemon is busy/);
  }, 20_000);

  it('POST /api/fleet/prune/estimate keeps successful all-scope bytes when a later target times out', async () => {
    const estimateSystemReclaim = vi.fn().mockImplementation(async (target: string) => {
      if (target === 'images') return { reclaimableBytes: 500 };
      if (target === 'volumes') return new Promise(() => { /* never resolves */ });
      return { reclaimableBytes: 100 };
    });
    vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
    vi.spyOn(DockerController, 'getInstance').mockReturnValue({
      estimateSystemReclaim,
      estimateManagedReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }),
    } as unknown as ReturnType<typeof DockerController.getInstance>);

    const t0 = Date.now();
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images', 'volumes', 'networks'], scope: 'all' });
    const elapsed = Date.now() - t0;

    expect(res.status).toBe(200);
    const local = res.body.perNode[0];
    expect(local.reachable).toBe(true);
    expect(local.partial).toBe(true);
    expect(local.reclaimableBytes).toBe(600);
    expect(local.error).toMatch(/Docker daemon is busy/);
    expect(estimateSystemReclaim).toHaveBeenCalledTimes(3);
    expect(elapsed).toBeGreaterThanOrEqual(11_500);
    expect(elapsed).toBeLessThan(24_000);
  }, 30_000);

  it('POST /api/fleet/prune/estimate keeps successful managed-scope bytes when a later target times out', async () => {
    const estimateManagedReclaim = vi.fn().mockImplementation(async (target: string) => {
      if (target === 'images') return { reclaimableBytes: 500 };
      if (target === 'volumes') return new Promise(() => { /* never resolves */ });
      return { reclaimableBytes: 100 };
    });
    vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
    vi.spyOn(DockerController, 'getInstance').mockReturnValue({
      estimateManagedReclaim,
      estimateSystemReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }),
    } as unknown as ReturnType<typeof DockerController.getInstance>);

    const t0 = Date.now();
    const res = await request(app)
      .post('/api/fleet/prune/estimate')
      .set('Authorization', authHeader)
      .send({ targets: ['images', 'volumes', 'networks'], scope: 'managed' });
    const elapsed = Date.now() - t0;

    expect(res.status).toBe(200);
    const local = res.body.perNode[0];
    expect(local.reachable).toBe(true);
    expect(local.partial).toBe(true);
    expect(local.reclaimableBytes).toBe(600);
    expect(local.error).toMatch(/Docker daemon is busy/);
    expect(estimateManagedReclaim).toHaveBeenCalledTimes(3);
    expect(elapsed).toBeGreaterThanOrEqual(11_500);
    expect(elapsed).toBeLessThan(24_000);
  }, 30_000);

  it('fleet-prune dry-run succeeds normally when estimateSystemReclaim resolves quickly', async () => {
    stubLocalEstimate(
      () => Promise.resolve({ reclaimableBytes: 256 }),
      async () => ({
        nodeId: 1,
        scope: 'all',
        targets: ['volumes'],
        items: [{
          target: 'volumes', id: 'volume-a', name: 'volume-a', sizeBytes: 256,
          managed: false, reason: 'Volume is not referenced by any container',
        }],
        reclaimableBytes: 256,
        fingerprint: 'volume-plan',
        createdAt: 1,
      }),
    );

    const res = await request(app)
      .post('/api/fleet/labels/fleet-prune')
      .set('Authorization', authHeader)
      .send({ targets: ['volumes'], scope: 'all', dryRun: true });

    expect(res.status).toBe(200);
    const local = res.body.results[0];
    expect(local.targets[0]).toMatchObject({ target: 'volumes', success: true, reclaimedBytes: 256, dryRun: true });
  });
});
