import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq';
import { SCHEDULED_JOBS_QUEUE_NAME } from './scheduled-job-data';
import type { ScheduleSpec } from './scheduled-job-data';
import { BullMqScheduledJobsQueuePort } from './scheduled-jobs-queue.port';

jest.mock('bullmq', () => ({
  Queue: jest.fn(),
}));

describe('BullMqScheduledJobsQueuePort', () => {
  const QueueMock = Queue as jest.MockedClass<typeof Queue>;

  beforeEach(() => {
    jest.clearAllMocks();
  });

  // ── helpers ────────────────────────────────────────────────────────────────

  function configService(values: Record<string, string>): ConfigService {
    return {
      get: jest.fn((key: string) => values[key]),
    } as unknown as ConfigService;
  }

  function makeQueueInstance(overrides: Record<string, jest.Mock> = {}) {
    const instance = {
      add: jest.fn().mockResolvedValue({ id: 'job_1' }),
      upsertJobScheduler: jest.fn().mockResolvedValue(undefined),
      removeJobScheduler: jest.fn().mockResolvedValue(1),
      // By default there is no pre-existing delayed job to remove.
      getJob: jest.fn().mockResolvedValue(undefined),
      close: jest.fn(),
      ...overrides,
    };
    QueueMock.mockImplementationOnce(() => instance as never);
    return instance;
  }

  // ── constructor ────────────────────────────────────────────────────────────

  it('creates Queue with correct name and prefix when REDIS_URL is set', () => {
    makeQueueInstance();

    new BullMqScheduledJobsQueuePort(
      configService({
        REDIS_URL: 'redis://localhost:6379',
        SCHEDULED_JOBS_QUEUE_PREFIX: 'planfi:scheduled-jobs',
      }),
    );

    expect(QueueMock).toHaveBeenCalledWith(
      SCHEDULED_JOBS_QUEUE_NAME,
      expect.objectContaining({ prefix: 'planfi:scheduled-jobs' }),
    );
  });

  it('uses default prefix when SCHEDULED_JOBS_QUEUE_PREFIX is not set', () => {
    makeQueueInstance();

    new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    expect(QueueMock).toHaveBeenCalledWith(
      SCHEDULED_JOBS_QUEUE_NAME,
      expect.objectContaining({ prefix: 'planfi:scheduled-jobs' }),
    );
  });

  // ── INTERVAL ──────────────────────────────────────────────────────────────

  it('upsertSchedule INTERVAL calls upsertJobScheduler with every', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const spec: ScheduleSpec = {
      jobId: 'job-abc',
      type: 'INTERVAL',
      everyMs: 60_000,
      startAt: 1_000_000,
      endAt: 9_000_000,
    };
    const result = await port.upsertSchedule(spec);

    expect(result).toEqual({ schedulerId: 'sjob__job-abc', degraded: false });
    expect(q.upsertJobScheduler).toHaveBeenCalledWith(
      'sjob__job-abc',
      expect.objectContaining({
        every: 60_000,
        startDate: 1_000_000,
        endDate: 9_000_000,
      }),
      expect.objectContaining({
        name: 'run',
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        data: expect.objectContaining({
          jobId: 'job-abc',
          trigger: 'SCHEDULE',
        }),
        // Per-fire retry envelope lives under `opts` in the scheduler template.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        opts: expect.objectContaining({
          attempts: 3,
          backoff: { type: 'exponential', delay: 60_000 },
        }),
      }),
    );
  });

  // ── CRON ──────────────────────────────────────────────────────────────────

  it('upsertSchedule CRON calls upsertJobScheduler with pattern and tz', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const spec: ScheduleSpec = {
      jobId: 'job-cron',
      type: 'CRON',
      pattern: '0 9 * * 1-5',
      tz: 'America/Sao_Paulo',
    };
    const result = await port.upsertSchedule(spec);

    expect(result).toEqual({ schedulerId: 'sjob__job-cron', degraded: false });
    expect(q.upsertJobScheduler).toHaveBeenCalledWith(
      'sjob__job-cron',
      expect.objectContaining({
        pattern: '0 9 * * 1-5',
        tz: 'America/Sao_Paulo',
      }),
      expect.objectContaining({
        name: 'run',
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        data: expect.objectContaining({
          jobId: 'job-cron',
          trigger: 'SCHEDULE',
        }),
        // Per-fire retry envelope lives under `opts` in the scheduler template.
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        opts: expect.objectContaining({
          attempts: 3,
          backoff: { type: 'exponential', delay: 60_000 },
        }),
      }),
    );
  });

  // ── ONE_SHOT ──────────────────────────────────────────────────────────────

  it('upsertSchedule ONE_SHOT adds with deterministic jobId and stable schedulerId', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const now = Date.now();
    const runAtMs = now + 30_000;
    const spec: ScheduleSpec = { jobId: 'job-shot', type: 'ONE_SHOT', runAtMs };
    const result = await port.upsertSchedule(spec);

    // schedulerId must be the stable 'sjob__<id>' (not BullMQ's auto id) so the
    // caller can later cancel/re-register the one-shot deterministically.
    expect(result).toEqual({ schedulerId: 'sjob__job-shot', degraded: false });
    expect(q.add).toHaveBeenCalledWith(
      'run',
      expect.objectContaining({ jobId: 'job-shot', trigger: 'SCHEDULE' }),
      expect.objectContaining({
        jobId: 'sjob__job-shot',
        removeOnComplete: true,
        removeOnFail: 500,
        attempts: 3,
        backoff: { type: 'exponential', delay: 60_000 },
      }),
    );
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    const callOpts = q.add.mock.calls[0][2];
    expect(callOpts.delay).toBeGreaterThanOrEqual(0);
    expect(callOpts.delay).toBeLessThanOrEqual(30_000);
  });

  it('upsertSchedule ONE_SHOT is idempotent: re-adding removes the stale delayed job first', async () => {
    const remove = jest.fn().mockResolvedValue(undefined);
    // Simulate a pre-existing delayed one-shot for this schedule.
    const getJob = jest
      .fn()
      .mockResolvedValue({ id: 'sjob__job-shot', remove });
    const q = makeQueueInstance({ getJob });
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const runAtMs = Date.now() + 30_000;
    const spec: ScheduleSpec = { jobId: 'job-shot', type: 'ONE_SHOT', runAtMs };
    const result = await port.upsertSchedule(spec);

    // Looked up and removed the previous delayed job under the deterministic id…
    expect(getJob).toHaveBeenCalledWith('sjob__job-shot');
    expect(remove).toHaveBeenCalledTimes(1);
    // …then re-added under the same deterministic id (BullMQ dedupes re-adds).
    expect(q.add).toHaveBeenCalledTimes(1);
    expect(q.add).toHaveBeenCalledWith(
      'run',
      expect.objectContaining({ jobId: 'job-shot', trigger: 'SCHEDULE' }),
      expect.objectContaining({ jobId: 'sjob__job-shot' }),
    );
    expect(result).toEqual({ schedulerId: 'sjob__job-shot', degraded: false });
  });

  // ── removeSchedule ────────────────────────────────────────────────────────

  it('removeSchedule calls removeJobScheduler with sjob__ prefixed id', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    await port.removeSchedule('job-abc');

    expect(q.removeJobScheduler).toHaveBeenCalledWith('sjob__job-abc');
  });

  it('removeSchedule also removes the pending delayed one-shot job', async () => {
    const remove = jest.fn().mockResolvedValue(undefined);
    const getJob = jest.fn().mockResolvedValue({ id: 'sjob__job-abc', remove });
    const q = makeQueueInstance({ getJob });
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    await port.removeSchedule('job-abc');

    // Repeatable scheduler removed (INTERVAL/CRON)…
    expect(q.removeJobScheduler).toHaveBeenCalledWith('sjob__job-abc');
    // …and the deterministic delayed one-shot job is cancelled too.
    expect(getJob).toHaveBeenCalledWith('sjob__job-abc');
    expect(remove).toHaveBeenCalledTimes(1);
  });

  it('removeSchedule does not throw when there is no delayed one-shot job', async () => {
    // getJob default mock resolves undefined → nothing to remove.
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    await expect(port.removeSchedule('job-none')).resolves.toBeUndefined();
    expect(q.removeJobScheduler).toHaveBeenCalledWith('sjob__job-none');
    expect(q.getJob).toHaveBeenCalledWith('sjob__job-none');
  });

  // ── enqueueNow ────────────────────────────────────────────────────────────

  it('enqueueNow MANUAL calls queue.add and returns job id', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const result = await port.enqueueNow('job-abc', 'MANUAL');

    expect(result).toEqual({ id: 'job_1', degraded: false });
    expect(q.add).toHaveBeenCalledWith(
      'run',
      { jobId: 'job-abc', trigger: 'MANUAL' },
      expect.objectContaining({
        removeOnComplete: 250,
        removeOnFail: 500,
        attempts: 3,
        backoff: { type: 'exponential', delay: 60_000 },
      }),
    );
  });

  it('enqueueNow CATCHUP calls queue.add with trigger CATCHUP', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    const result = await port.enqueueNow('job-xyz', 'CATCHUP');

    expect(result).toEqual({ id: 'job_1', degraded: false });
    expect(q.add).toHaveBeenCalledWith(
      'run',
      { jobId: 'job-xyz', trigger: 'CATCHUP' },
      expect.anything(),
    );
  });

  // ── degraded path ─────────────────────────────────────────────────────────

  it('returns degraded=true for all methods when REDIS_URL is absent', async () => {
    const port = new BullMqScheduledJobsQueuePort(configService({}));

    expect(QueueMock).not.toHaveBeenCalled();
    expect(port.isAvailable()).toBe(false);

    await expect(
      port.upsertSchedule({ jobId: 'x', type: 'INTERVAL', everyMs: 1000 }),
    ).resolves.toEqual({ schedulerId: null, degraded: true });

    await expect(port.enqueueNow('x', 'MANUAL')).resolves.toEqual({
      id: null,
      degraded: true,
    });

    // removeSchedule should not throw in degraded mode
    await expect(port.removeSchedule('x')).resolves.toBeUndefined();
  });

  // ── onModuleDestroy ───────────────────────────────────────────────────────

  it('onModuleDestroy closes the queue', async () => {
    const q = makeQueueInstance();
    const port = new BullMqScheduledJobsQueuePort(
      configService({ REDIS_URL: 'redis://localhost:6379' }),
    );

    await port.onModuleDestroy();

    expect(q.close).toHaveBeenCalledTimes(1);
  });
});
