// Tests for shims/queue.ts:runAllScheduledJobs aggregated D1 scan.
//
// Verifies the Plan 8 refactor: 16 × 2 per-queue queries collapse into a
// single Job.findAll across all registered queues. Behavior invariants
// preserved: due-delayed + stuck-immediate semantics, 2-minute created_at
// filter for immediate jobs, per-queue dispatch order by created_at.

// Mock the Job model before importing the shim — import order matters
// because shims/queue.ts imports Job at module top.
jest.mock('../../../api/src/store/models/job', () => ({
  Job: {
    findAll: jest.fn(),
  },
}));

// Mock createQueueStore so registering a handler doesn't hit D1.
jest.mock('../../../api/src/libs/queue/store', () => ({
  __esModule: true,
  default: jest.fn(() => ({
    // Only the surface actually touched by dispatchJob / handler registration:
    deleteJob: jest.fn().mockResolvedValue(true),
    getJob: jest.fn(),
    getJobs: jest.fn(),
    getScheduledJobs: jest.fn(),
    addJob: jest.fn(),
    updateJob: jest.fn(),
    isCancelled: jest.fn().mockResolvedValue(false),
    findJobs: jest.fn(),
  })),
}));

import { Op } from 'sequelize';
import { Job } from '../../../api/src/store/models/job';
import {
  runAllScheduledJobs,
  setCFQueue,
  getAllHandlerNames,
  __test__,
} from '../../shims/queue';

const mockedFindAll = Job.findAll as unknown as jest.Mock;

describe('runAllScheduledJobs — aggregated D1 scan', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    __test__?.resetHandlers?.();
    setCFQueue(null);
  });

  function registerQueue(name: string, executeJob = jest.fn().mockResolvedValue(undefined)) {
    __test__!.registerForTest!(name, { executeJob });
    return executeJob;
  }

  it('returns early with no query when no queues are registered', async () => {
    const result = await runAllScheduledJobs();
    expect(mockedFindAll).not.toHaveBeenCalled();
    expect(result).toEqual({ dispatched: 0, failed: 0, queues: [] });
  });

  it('issues exactly ONE findAll across all registered queues (no per-queue loop)', async () => {
    for (let i = 0; i < 16; i += 1) registerQueue(`q${i}`);
    mockedFindAll.mockResolvedValue([]);

    await runAllScheduledJobs();

    expect(mockedFindAll).toHaveBeenCalledTimes(1);
    const arg = mockedFindAll.mock.calls[0][0];
    expect(arg.where.queue).toEqual({ [Op.in]: expect.arrayContaining(['q0', 'q15']) });
    expect(arg.where.cancelled).toBe(false);
    expect(arg.where[Op.or]).toHaveLength(2);
    expect(arg.order).toEqual([['created_at', 'ASC']]);
  });

  it('groups rows by queue and dispatches via the registered handler', async () => {
    const execA = registerQueue('qa');
    const execB = registerQueue('qb');

    mockedFindAll.mockResolvedValue([
      { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
      { id: 'b1', queue: 'qb', job: { v: 2 }, created_at: new Date() },
      { id: 'a2', queue: 'qa', job: { v: 3 }, created_at: new Date() },
    ]);

    const result = await runAllScheduledJobs();

    expect(execA).toHaveBeenCalledTimes(2);
    expect(execB).toHaveBeenCalledTimes(1);
    expect(result.dispatched).toBe(3);
    expect(result.failed).toBe(0);
    expect(result.queues.sort()).toEqual(['qa', 'qb']);
  });

  it('filters out rows missing id or job payload', async () => {
    const exec = registerQueue('qa');

    mockedFindAll.mockResolvedValue([
      { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
      { id: '', queue: 'qa', job: { v: 2 }, created_at: new Date() },
      { id: 'a3', queue: 'qa', job: null, created_at: new Date() },
    ]);

    const result = await runAllScheduledJobs();

    expect(exec).toHaveBeenCalledTimes(1);
    expect(result.dispatched).toBe(1);
  });

  it('continues dispatching siblings when one job throws', async () => {
    const exec = jest
      .fn()
      .mockRejectedValueOnce(new Error('boom'))
      .mockResolvedValue(undefined);
    registerQueue('qa', exec);

    mockedFindAll.mockResolvedValue([
      { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
      { id: 'a2', queue: 'qa', job: { v: 2 }, created_at: new Date() },
    ]);

    const result = await runAllScheduledJobs();

    expect(exec).toHaveBeenCalledTimes(2);
    expect(result.dispatched).toBe(1);
    expect(result.failed).toBe(1);
  });

  it('returns empty result without throwing when D1 scan fails', async () => {
    registerQueue('qa');
    mockedFindAll.mockRejectedValue(new Error('D1 timeout'));

    const result = await runAllScheduledJobs();

    expect(result).toEqual({ dispatched: 0, failed: 0, queues: [] });
  });

  it('ignores rows for queues that are no longer registered', async () => {
    const exec = registerQueue('qa');
    mockedFindAll.mockResolvedValue([
      { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
      { id: 'z1', queue: 'qzombie', job: { v: 2 }, created_at: new Date() },
    ]);

    const result = await runAllScheduledJobs();

    expect(exec).toHaveBeenCalledTimes(1);
    expect(result.dispatched).toBe(1);
    expect(result.queues).toEqual(['qa']);
  });

  it('where clause covers due-delayed OR stuck-immediate jobs with 2-min cutoff', async () => {
    registerQueue('qa');
    mockedFindAll.mockResolvedValue([]);

    const before = Date.now();
    await runAllScheduledJobs();
    const after = Date.now();

    const arg = mockedFindAll.mock.calls[0][0];
    const orClauses = arg.where[Op.or];
    expect(orClauses).toHaveLength(2);

    const delayedClause = orClauses.find((c: any) => c.delay && c.will_run_at);
    expect(delayedClause).toBeDefined();
    expect(delayedClause.delay).toEqual({ [Op.not]: -1 });
    const dueCutoff = delayedClause.will_run_at[Op.lte];
    expect(dueCutoff).toBeGreaterThanOrEqual(before);
    expect(dueCutoff).toBeLessThanOrEqual(after);

    const immediateClause = orClauses.find((c: any) => c.delay === -1);
    expect(immediateClause).toBeDefined();
    // D1 rejects Date objects at the bind layer, so the shim serializes to ISO string.
    const cutoffValue: string = immediateClause.created_at[Op.lt];
    expect(typeof cutoffValue).toBe('string');
    const cutoffMs = new Date(cutoffValue).getTime();
    // 2 minutes back from "now", allowing for small wall-clock drift.
    expect(before - cutoffMs).toBeGreaterThanOrEqual(2 * 60 * 1000 - 50);
    expect(before - cutoffMs).toBeLessThanOrEqual(2 * 60 * 1000 + 50);
  });

  it('registers handlers that show up in getAllHandlerNames', () => {
    registerQueue('q1');
    registerQueue('q2');
    expect(getAllHandlerNames().sort()).toEqual(['q1', 'q2']);
  });
});
