/**
 * Integration tests for POST /api/stacks/:stackName/backup, the on-demand
 * recovery-generation capture. Covers auth, role, the success path, the
 * missing-stack 404, name validation, and error propagation. The route exists
 * so a scheduled auto_backup can run on a remote node through the proxy path,
 * and so an operator can capture a current generation on demand. Available on
 * every tier (no paid gate).
 */
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';

const { mockBackupStackFiles, mockHasComposeFile, mockCaptureCurrentBackup, mockGetCurrent } = vi.hoisted(() => ({
  mockBackupStackFiles: vi.fn(),
  mockHasComposeFile: vi.fn(),
  mockCaptureCurrentBackup: vi.fn(),
  mockGetCurrent: vi.fn(),
}));

vi.mock('../services/FileSystemService', () => ({
  FileSystemService: {
    getInstance: () => ({
      getBaseDir: () => '/tmp/compose',
      hasComposeFile: mockHasComposeFile,
      backupStackFiles: mockBackupStackFiles,
      getStacks: vi.fn().mockResolvedValue([]),
    }),
  },
}));

vi.mock('../services/StackUpdateRecoveryService', () => ({
  StackUpdateRecoveryService: {
    getInstance: () => ({
      getCurrent: mockGetCurrent,
      captureCurrentBackup: mockCaptureCurrentBackup,
    }),
  },
}));

let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
let tierSpy: ReturnType<typeof vi.spyOn>;

beforeAll(async () => {
  tmpDir = await setupTestDb();
  ({ DatabaseService } = await import('../services/DatabaseService'));

  const { LicenseService } = await import('../services/LicenseService');
  tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');

  ({ app } = await import('../index'));
  adminCookie = await loginAsTestAdmin(app);

  const viewerHash = await bcrypt.hash('viewerpass', 1);
  DatabaseService.getInstance().addUser({ username: 'backup-viewer', password_hash: viewerHash, role: 'viewer' });
  const viewerRes = await request(app).post('/api/auth/login').send({ username: 'backup-viewer', password: 'viewerpass' });
  const cookies = viewerRes.headers['set-cookie'] as string | string[];
  viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});

afterAll(() => {
  vi.restoreAllMocks();
  cleanupTestDb(tmpDir);
});

beforeEach(() => {
  mockBackupStackFiles.mockReset().mockResolvedValue(undefined);
  mockHasComposeFile.mockReset().mockResolvedValue(true);
  mockCaptureCurrentBackup.mockReset().mockResolvedValue({ id: 'gen-1' });
  mockGetCurrent.mockReset().mockReturnValue(null);
  tierSpy.mockReturnValue('paid');
});

describe('POST /api/stacks/:stackName/backup', () => {
  it('backs up the stack files and returns success', async () => {
    const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(mockCaptureCurrentBackup).toHaveBeenCalledWith(expect.objectContaining({
      nodeId: expect.any(Number),
      stackName: 'web',
      createdBy: expect.any(String),
    }));
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('returns 401 without an auth cookie', async () => {
    const res = await request(app).post('/api/stacks/web/backup');
    expect(res.status).toBe(401);
    expect(mockCaptureCurrentBackup).not.toHaveBeenCalled();
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('returns 403 for a viewer (no deploy permission)', async () => {
    const res = await request(app).post('/api/stacks/web/backup').set('Cookie', viewerCookie);
    expect(res.status).toBe(403);
    expect(mockCaptureCurrentBackup).not.toHaveBeenCalled();
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('backs up on the community tier (no paid gate)', async () => {
    tierSpy.mockReturnValue('community');
    const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(mockCaptureCurrentBackup).toHaveBeenCalledWith(expect.objectContaining({
      nodeId: expect.any(Number),
      stackName: 'web',
      createdBy: expect.any(String),
    }));
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('returns 404 when the stack does not exist', async () => {
    mockHasComposeFile.mockResolvedValue(false);
    const res = await request(app).post('/api/stacks/ghost/backup').set('Cookie', adminCookie);
    expect(res.status).toBe(404);
    expect(mockCaptureCurrentBackup).not.toHaveBeenCalled();
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('returns 400 for an invalid stack name', async () => {
    const res = await request(app).post('/api/stacks/..bad../backup').set('Cookie', adminCookie);
    expect(res.status).toBe(400);
    expect(mockCaptureCurrentBackup).not.toHaveBeenCalled();
    expect(mockBackupStackFiles).not.toHaveBeenCalled();
  });

  it('returns 500 when the backup operation fails', async () => {
    mockCaptureCurrentBackup.mockRejectedValue(new Error('disk full'));
    const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
    expect(res.status).toBe(500);
    expect(res.body.error).toContain('disk full');
  });

  it('returns 409 when the stack is busy with another operation', async () => {
    // Handoff mutates the current generation, so backup must not run while a
    // deploy holds the stack-op lock for the same stack.
    const { StackOpLockService } = await import('../services/StackOpLockService');
    const localNodeId = DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
    StackOpLockService.getInstance().tryAcquire(localNodeId, 'web', 'deploy', 'someone');
    try {
      const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
      expect(res.status).toBe(409);
      expect(res.body.code).toBe('stack_op_in_progress');
      expect(mockCaptureCurrentBackup).not.toHaveBeenCalled();
      expect(mockBackupStackFiles).not.toHaveBeenCalled();
    } finally {
      StackOpLockService.getInstance().release(localNodeId, 'web');
    }
  });
});
