import { recoverFromCrash, previewArchive, runArchiveJob } from '../../src/libs/archive/executor';
import { ArchiveMetadata, Subscription, MeterEvent, Invoice, InvoiceItem } from '../../src/store/models';
import { ArchiveLock } from '../../src/store/models/archive-lock';
import { openArchiveSequelize, ensureArchiveTable } from '../../src/libs/archive/store';
import * as configModule from '../../src/libs/archive/config';

const mockAcquireLock = jest.fn();
const mockReleaseLock = jest.fn();

jest.mock('../../src/libs/archive/lock', () => ({
  acquireArchiveLock: (...args: any[]) => mockAcquireLock(...args),
  releaseArchiveLock: (...args: any[]) => mockReleaseLock(...args),
}));

jest.mock('@blocklet/sdk/lib/config', () => ({
  __esModule: true,
  default: {
    env: {
      preferences: {},
      dataDir: '/tmp/test-data',
    },
  },
}));

jest.mock('../../src/store/models', () => ({
  ArchiveMetadata: {
    findAll: jest.fn(),
    create: jest.fn(),
  },
  Customer: { findAll: jest.fn(), count: jest.fn(), min: jest.fn(), max: jest.fn(), rawAttributes: {} },
  Subscription: { findAll: jest.fn().mockResolvedValue([]), rawAttributes: { status: {} } },
  MeterEvent: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  CreditTransaction: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { updated_at: {}, id: {} },
  },
  Event: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { created_at: {}, id: {} },
  },
  WebhookAttempt: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { created_at: {}, id: {} },
  },
  Job: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { created_at: {}, id: {} },
  },
  PaymentIntent: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  Invoice: {
    findAll: jest.fn(),
    findOne: jest.fn().mockResolvedValue(null),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  InvoiceItem: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { updated_at: {}, id: {} },
  },
  Refund: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  Payout: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  CreditGrant: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { status: {}, updated_at: {}, id: {} },
  },
  Product: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { updated_at: {}, id: {} },
  },
  Price: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { updated_at: {}, id: {} },
  },
  Coupon: {
    findAll: jest.fn(),
    destroy: jest.fn(),
    count: jest.fn(),
    min: jest.fn(),
    max: jest.fn(),
    rawAttributes: { updated_at: {}, id: {} },
  },
  RevenueSnapshot: {
    findAll: jest.fn().mockResolvedValue([]),
    findOne: jest.fn().mockResolvedValue(null),
    bulkCreate: jest.fn().mockResolvedValue([]),
  },
  PaymentCurrency: {
    findAll: jest.fn().mockResolvedValue([]),
  },
}));

jest.mock('../../src/store/models/archive-metadata', () => ({
  ArchiveMetadata: {
    findAll: jest.fn(),
    create: jest.fn(),
  },
}));

jest.mock('../../src/store/models/archive-lock', () => ({
  ArchiveLock: {
    update: jest.fn(),
  },
}));

jest.mock('../../src/store/sequelize', () => ({
  sequelize: {
    query: jest.fn(),
  },
}));

jest.mock('../../src/libs/archive/store', () => ({
  getArchiveDir: jest.fn().mockReturnValue('/tmp/test-data/archive'),
  getArchiveFilePath: jest.fn().mockReturnValue('/tmp/archive.db'),
  getArchiveFilePathForYear: jest.fn().mockImplementation((year: number) => `/tmp/archive-${year}.db`),
  getRecordYear: jest.fn().mockReturnValue(2024),
  openArchiveSequelize: jest.fn(),
  ensureArchiveTable: jest.fn(),
  cleanupOldArchiveFiles: jest.fn().mockReturnValue([]),
  getFileSize: jest.fn().mockReturnValue(1024),
}));

jest.mock('fs', () => ({
  ...jest.requireActual('fs'),
  readFileSync: jest.fn().mockReturnValue(Buffer.from('test')),
  statfsSync: jest.fn().mockReturnValue({ bavail: 1000000, bsize: 1024 }),
}));

jest.mock('../../src/libs/logger', () => ({
  __esModule: true,
  default: {
    info: jest.fn(),
    warn: jest.fn(),
    error: jest.fn(),
  },
}));

const mockArchiveMetadata = ArchiveMetadata as jest.Mocked<typeof ArchiveMetadata>;
const mockArchiveLock = ArchiveLock as jest.Mocked<typeof ArchiveLock>;
const mockOpenArchiveSequelize = openArchiveSequelize as jest.MockedFunction<typeof openArchiveSequelize>;
const mockEnsureArchiveTable = ensureArchiveTable as jest.MockedFunction<typeof ensureArchiveTable>;
const mockSubscription = Subscription as jest.Mocked<typeof Subscription>;
const mockMeterEvent = MeterEvent as jest.Mocked<typeof MeterEvent>;
const mockInvoice = Invoice as jest.Mocked<typeof Invoice>;
const mockInvoiceItem = InvoiceItem as jest.Mocked<typeof InvoiceItem>;

describe('archive/executor', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    mockAcquireLock.mockReset();
    mockReleaseLock.mockReset();
  });

  describe('recoverFromCrash', () => {
    it('should do nothing when no stale jobs exist', async () => {
      (mockArchiveMetadata.findAll as jest.Mock).mockResolvedValue([]);

      await recoverFromCrash();

      expect(mockArchiveMetadata.findAll).toHaveBeenCalledWith({ where: { status: 'in_progress' } });
      expect(mockArchiveLock.update).not.toHaveBeenCalled();
    });

    it('should recover stale in_progress jobs', async () => {
      const staleJob = {
        id: 'job-1',
        archive_file: 'archive-2024.db',
        created_at: new Date(Date.now() - 3600000),
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.findAll as jest.Mock).mockResolvedValue([staleJob]);
      (mockArchiveLock.update as jest.Mock).mockResolvedValue([1]);

      await recoverFromCrash();

      expect(staleJob.update).toHaveBeenCalledWith(
        expect.objectContaining({
          status: 'failed',
          error: 'Process crashed or restarted during archive job',
        })
      );
      expect(mockArchiveLock.update).toHaveBeenCalled();
    });

    it('should handle errors gracefully', async () => {
      (mockArchiveMetadata.findAll as jest.Mock).mockRejectedValue(new Error('DB error'));

      await expect(recoverFromCrash()).resolves.not.toThrow();
    });
  });

  describe('previewArchive', () => {
    it('should return preview for enabled tables', async () => {
      (mockMeterEvent.count as jest.Mock).mockResolvedValue(100);
      (mockMeterEvent.min as jest.Mock).mockResolvedValue(new Date('2023-01-01'));
      (mockMeterEvent.max as jest.Mock).mockResolvedValue(new Date('2023-12-31'));

      const result = await previewArchive({ tables: ['meter_events'] });

      expect(result).toHaveProperty('meter_events');
      expect(result.meter_events).toHaveProperty('count');
    });

    it('should skip disabled tables', async () => {
      const result = await previewArchive({ tables: ['customers'] });

      expect(result).not.toHaveProperty('customers');
    });

    it('should handle empty tables array', async () => {
      const result = await previewArchive({ tables: [] });

      expect(typeof result).toBe('object');
      expect(Object.keys(result)).toHaveLength(0);
    });
  });

  describe('runArchiveJob', () => {
    it('should return disabled status when retention is not enabled', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: false,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: {},
      });

      const result = await runArchiveJob({ triggeredBy: 'manual' });

      expect(result).toEqual({ status: 'disabled' });
      expect(mockAcquireLock).not.toHaveBeenCalled();
    });

    it('should throw error when lock cannot be acquired', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: { meter_events: { enabled: true, retentionDays: 90 } },
      });
      mockAcquireLock.mockResolvedValue(false);

      await expect(runArchiveJob({ triggeredBy: 'manual' })).rejects.toThrow('Archive job already running');
    });

    it('should release lock on successful completion', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: { meter_events: { enabled: true, retentionDays: 90 } },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockSubscription.findAll as jest.Mock).mockResolvedValue([]);
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue({
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      });

      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({
          bulkInsert: jest.fn().mockResolvedValue(undefined),
        }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);
      (mockMeterEvent.findAll as jest.Mock).mockResolvedValue([]);

      await runArchiveJob({ triggeredBy: 'manual', tables: ['meter_events'] });

      expect(mockReleaseLock).toHaveBeenCalled();
    });

    it('should release lock even on error', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: { meter_events: { enabled: true, retentionDays: 90 } },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockArchiveMetadata.create as jest.Mock).mockRejectedValue(new Error('DB error'));

      await expect(runArchiveJob({ triggeredBy: 'manual' })).rejects.toThrow('DB error');
      expect(mockReleaseLock).toHaveBeenCalled();
    });

    it('should process dry run without deleting records', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: { meter_events: { enabled: true, retentionDays: 90, archivableStatuses: ['completed'] } },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockSubscription.findAll as jest.Mock).mockResolvedValue([]);

      const mockMetadata = {
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);

      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({
          bulkInsert: jest.fn(),
        }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);

      const mockRecord = {
        id: 'evt-1',
        status: 'completed',
        updated_at: new Date('2020-01-01'),
        toJSON: () => ({ id: 'evt-1', status: 'completed', updated_at: new Date('2020-01-01') }),
      };
      (mockMeterEvent.findAll as jest.Mock).mockResolvedValueOnce([mockRecord]).mockResolvedValue([]);

      const result = await runArchiveJob({
        triggeredBy: 'manual',
        tables: ['meter_events'],
        dryRun: true,
      });

      expect(result.status).toBe('dry_run_complete');
      expect(mockMeterEvent.destroy).not.toHaveBeenCalled();
      expect(mockArchiveDb.getQueryInterface().bulkInsert).not.toHaveBeenCalled();
    });

    it('should skip records linked to active subscriptions', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: {
          meter_events: {
            enabled: true,
            retentionDays: 90,
            archivableStatuses: ['completed'],
            excludeConditions: { hasActiveSubscription: true },
          },
        },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);

      (mockSubscription.findAll as jest.Mock).mockResolvedValue([{ id: 'sub_active', customer_id: 'cus_active' }]);

      const mockMetadata = {
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);

      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({
          bulkInsert: jest.fn(),
        }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);

      const linkedRecord = {
        id: 'evt-linked',
        status: 'completed',
        updated_at: new Date('2020-01-01'),
        subscription_id: 'sub_active',
        toJSON: () => ({
          id: 'evt-linked',
          status: 'completed',
          updated_at: new Date('2020-01-01'),
          subscription_id: 'sub_active',
        }),
      };
      const unlinkedRecord = {
        id: 'evt-unlinked',
        status: 'completed',
        updated_at: new Date('2020-01-01'),
        subscription_id: null,
        toJSON: () => ({
          id: 'evt-unlinked',
          status: 'completed',
          updated_at: new Date('2020-01-01'),
          subscription_id: null,
        }),
      };

      (mockMeterEvent.findAll as jest.Mock).mockResolvedValueOnce([linkedRecord, unlinkedRecord]).mockResolvedValue([]);
      (mockMeterEvent.destroy as jest.Mock).mockResolvedValue(1);

      const result = await runArchiveJob({
        triggeredBy: 'manual',
        tables: ['meter_events'],
        dryRun: true,
      });

      expect(result.status).toBe('dry_run_complete');
      expect(result.tables?.meter_events?.archived_count).toBeGreaterThanOrEqual(1);
    });

    it('should archive parent and cascade child tables with consistent counts', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: {
          invoices: {
            enabled: true,
            retentionDays: 365,
            archivableStatuses: ['paid', 'void'],
            cascadeRelations: { invoice_items: 'invoice_id' },
          },
        },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockSubscription.findAll as jest.Mock).mockResolvedValue([]);

      const mockMetadata = {
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);

      const mockBulkInsert = jest.fn().mockResolvedValue(undefined);
      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);

      const mockInvoiceRecord = {
        id: 'inv-1',
        status: 'paid',
        updated_at: new Date('2020-01-01'),
        toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
      };
      (mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);
      (mockInvoice.destroy as jest.Mock).mockResolvedValue(1);

      const mockItemRecord = {
        id: 'ii-1',
        invoice_id: 'inv-1',
        toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
      };
      (mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);
      (mockInvoiceItem.destroy as jest.Mock).mockResolvedValue(1);

      const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });

      expect(result.status).toBe('completed');
      // Parent: archived and deleted counts match
      expect(result.tables?.invoices?.archived_count).toBe(1);
      expect(result.tables?.invoices?.deleted_count).toBe(1);
      // Child: cascade archived and deleted counts match
      expect(result.tables?.invoice_items?.archived_count).toBe(1);
      expect(result.tables?.invoice_items?.deleted_count).toBe(1);
      // bulkInsert called with ignoreDuplicates for both parent and child
      expect(mockBulkInsert).toHaveBeenCalledTimes(2);
      expect(mockBulkInsert).toHaveBeenCalledWith('invoices', expect.any(Array), { ignoreDuplicates: true });
      expect(mockBulkInsert).toHaveBeenCalledWith('invoice_items', expect.any(Array), { ignoreDuplicates: true });
    });

    it('should not inflate parent archived_count when cascade child insert fails', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: {
          invoices: {
            enabled: true,
            retentionDays: 365,
            archivableStatuses: ['paid'],
            cascadeRelations: { invoice_items: 'invoice_id' },
          },
        },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockSubscription.findAll as jest.Mock).mockResolvedValue([]);

      const mockMetadata = {
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);

      const mockBulkInsert = jest
        .fn()
        .mockResolvedValueOnce(undefined) // parent insert succeeds
        .mockRejectedValueOnce(new Error('child insert error')); // child insert fails
      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);

      const mockInvoiceRecord = {
        id: 'inv-1',
        status: 'paid',
        updated_at: new Date('2020-01-01'),
        toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
      };
      (mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);

      const mockItemRecord = {
        id: 'ii-1',
        invoice_id: 'inv-1',
        toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
      };
      (mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);

      // Cascade failure should NOT cause consistency check to throw because
      // archived_count is now accumulated after destroy (which never happens)
      const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });

      expect(result.status).toBe('completed');
      // Parent: archived_count=0 because destroy never executed (cascade failed → break)
      expect(result.tables?.invoices?.archived_count).toBe(0);
      expect(result.tables?.invoices?.deleted_count).toBe(0);
      // Parent was NOT deleted from main DB
      expect(mockInvoice.destroy).not.toHaveBeenCalled();
    });

    it('should not inflate child archived_count when cascade child delete fails', async () => {
      jest.spyOn(configModule, 'getRetentionConfig').mockReturnValue({
        enabled: true,
        defaults: { retentionDays: 365, batchSize: 500 },
        schedule: { enabled: true, hour: 2, cron: '0 0 2 * * *' },
        storage: { minFreeDiskMB: 500, maxArchiveFiles: 10 },
        tables: {
          invoices: {
            enabled: true,
            retentionDays: 365,
            archivableStatuses: ['paid'],
            cascadeRelations: { invoice_items: 'invoice_id' },
          },
        },
      });

      mockAcquireLock.mockResolvedValue(true);
      mockReleaseLock.mockResolvedValue(undefined);
      (mockSubscription.findAll as jest.Mock).mockResolvedValue([]);

      const mockMetadata = {
        id: 'meta-1',
        archive_file: 'archive-2026.db',
        update: jest.fn().mockResolvedValue({}),
      };
      (mockArchiveMetadata.create as jest.Mock).mockResolvedValue(mockMetadata);

      const mockBulkInsert = jest.fn().mockResolvedValue(undefined); // both inserts succeed
      const mockArchiveDb = {
        getQueryInterface: jest.fn().mockReturnValue({ bulkInsert: mockBulkInsert }),
        close: jest.fn().mockResolvedValue(undefined),
      };
      mockOpenArchiveSequelize.mockReturnValue(mockArchiveDb as any);
      mockEnsureArchiveTable.mockResolvedValue(undefined);

      const mockInvoiceRecord = {
        id: 'inv-1',
        status: 'paid',
        updated_at: new Date('2020-01-01'),
        toJSON: () => ({ id: 'inv-1', status: 'paid', updated_at: new Date('2020-01-01') }),
      };
      (mockInvoice.findAll as jest.Mock).mockResolvedValueOnce([mockInvoiceRecord]).mockResolvedValue([]);

      const mockItemRecord = {
        id: 'ii-1',
        invoice_id: 'inv-1',
        toJSON: () => ({ id: 'ii-1', invoice_id: 'inv-1' }),
      };
      (mockInvoiceItem.findAll as jest.Mock).mockResolvedValue([mockItemRecord]);
      // Child delete fails
      (mockInvoiceItem.destroy as jest.Mock).mockRejectedValue(new Error('child delete error'));

      const result = await runArchiveJob({ triggeredBy: 'manual', tables: ['invoices'] });

      expect(result.status).toBe('completed');
      // Both parent and child counts should be 0 (cascade failed → break → no delete)
      expect(result.tables?.invoices?.archived_count).toBe(0);
      expect(result.tables?.invoices?.deleted_count).toBe(0);
      expect(result.tables?.invoice_items?.archived_count).toBe(0);
      expect(result.tables?.invoice_items?.deleted_count).toBe(0);
      // Parent was NOT deleted from main DB
      expect(mockInvoice.destroy).not.toHaveBeenCalled();
    });
  });
});
