import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { BullMqScheduledJobsQueuePort } from './queue/scheduled-jobs-queue.port';
import type { ScheduleSpec } from './queue/scheduled-job-data';
import { SandboxRunner } from './sandbox/sandbox-runner';
import {
  RegisterScheduledJobInput,
  ScheduledJobsService,
} from './scheduled-jobs.service';

type IntervalSpec = Extract<ScheduleSpec, { type: 'INTERVAL' }>;
type CronSpec = Extract<ScheduleSpec, { type: 'CRON' }>;
type OneShotSpec = Extract<ScheduleSpec, { type: 'ONE_SHOT' }>;

const mockQueue = {
  upsertSchedule: jest.fn(),
  removeSchedule: jest.fn(),
  enqueueNow: jest.fn(),
};

const mockRunner = {
  run: jest.fn(),
};

const mockConfig = {
  get: jest.fn().mockReturnValue('https://planfi.example.com'),
};

describe('ScheduledJobsService', () => {
  let service: ScheduledJobsService;

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

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ScheduledJobsService,
        {
          provide: BullMqScheduledJobsQueuePort,
          useValue: mockQueue,
        },
        {
          provide: SandboxRunner,
          useValue: mockRunner,
        },
        {
          provide: ConfigService,
          useValue: mockConfig,
        },
      ],
    }).compile();

    service = module.get<ScheduledJobsService>(ScheduledJobsService);
  });

  // -------------------------------------------------------------------------
  // register — INTERVAL
  // -------------------------------------------------------------------------
  describe('register — INTERVAL', () => {
    it('maps MINUTES to correct everyMs and calls upsertSchedule', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__job1',
        degraded: false,
      });

      const input: RegisterScheduledJobInput = {
        jobId: 'job1',
        scheduleType: 'INTERVAL',
        intervalEvery: 30,
        intervalUnit: 'MINUTES',
      };

      const result = await service.register(input);

      expect(mockQueue.upsertSchedule).toHaveBeenCalledTimes(1);
      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as IntervalSpec;
      expect(spec.type).toBe('INTERVAL');
      expect(spec.everyMs).toBe(30 * 60_000); // 1_800_000
      expect(spec.jobId).toBe('job1');
      expect(result).toEqual({ schedulerId: 'sjob__job1', degraded: false });
    });

    it('maps HOURS to correct everyMs', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__job2',
        degraded: false,
      });

      await service.register({
        jobId: 'job2',
        scheduleType: 'INTERVAL',
        intervalEvery: 2,
        intervalUnit: 'HOURS',
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as IntervalSpec;
      expect(spec.everyMs).toBe(2 * 3_600_000); // 7_200_000
    });

    it('maps DAYS to correct everyMs', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__job3',
        degraded: false,
      });

      await service.register({
        jobId: 'job3',
        scheduleType: 'INTERVAL',
        intervalEvery: 1,
        intervalUnit: 'DAYS',
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as IntervalSpec;
      expect(spec.everyMs).toBe(86_400_000);
    });

    it('includes startAt/endAt as ms when provided', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__job4',
        degraded: false,
      });

      const startAt = '2026-01-01T00:00:00.000Z';
      const endAt = '2026-12-31T23:59:59.000Z';

      await service.register({
        jobId: 'job4',
        scheduleType: 'INTERVAL',
        intervalEvery: 5,
        intervalUnit: 'MINUTES',
        startAt,
        endAt,
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as IntervalSpec;
      expect(spec.startAt).toBe(Date.parse(startAt));
      expect(spec.endAt).toBe(Date.parse(endAt));
    });

    it('throws BadRequestException when intervalEvery is missing', async () => {
      await expect(
        service.register({
          jobId: 'job5',
          scheduleType: 'INTERVAL',
          intervalUnit: 'MINUTES',
        }),
      ).rejects.toThrow(BadRequestException);
    });

    it('throws BadRequestException when everyMs is not a finite positive number', async () => {
      await expect(
        service.register({
          jobId: 'job6',
          scheduleType: 'INTERVAL',
          intervalEvery: Number.NaN,
          intervalUnit: 'MINUTES',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(mockQueue.upsertSchedule).not.toHaveBeenCalled();
    });

    it('throws BadRequestException when startAt is an invalid date', async () => {
      await expect(
        service.register({
          jobId: 'job7',
          scheduleType: 'INTERVAL',
          intervalEvery: 5,
          intervalUnit: 'MINUTES',
          startAt: 'not-a-date',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(mockQueue.upsertSchedule).not.toHaveBeenCalled();
    });

    it('throws BadRequestException when endAt is an invalid date', async () => {
      await expect(
        service.register({
          jobId: 'job8',
          scheduleType: 'INTERVAL',
          intervalEvery: 5,
          intervalUnit: 'MINUTES',
          endAt: 'garbage',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(mockQueue.upsertSchedule).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // register — CRON
  // -------------------------------------------------------------------------
  describe('register — CRON', () => {
    it('maps pattern and tz', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__cron1',
        degraded: false,
      });

      await service.register({
        jobId: 'cron1',
        scheduleType: 'CRON',
        cronExpression: '0 9 * * 1-5',
        timezone: 'America/Sao_Paulo',
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as CronSpec;
      expect(spec.type).toBe('CRON');
      expect(spec.pattern).toBe('0 9 * * 1-5');
      expect(spec.tz).toBe('America/Sao_Paulo');
    });

    it('defaults tz to UTC when timezone is omitted', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__cron2',
        degraded: false,
      });

      await service.register({
        jobId: 'cron2',
        scheduleType: 'CRON',
        cronExpression: '*/15 * * * *',
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as CronSpec;
      expect(spec.tz).toBe('UTC');
    });

    it('throws BadRequestException when cronExpression is missing', async () => {
      await expect(
        service.register({ jobId: 'cron3', scheduleType: 'CRON' }),
      ).rejects.toThrow(BadRequestException);
    });

    it('throws BadRequestException when startAt is an invalid date', async () => {
      await expect(
        service.register({
          jobId: 'cron4',
          scheduleType: 'CRON',
          cronExpression: '0 9 * * *',
          startAt: 'invalid',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(mockQueue.upsertSchedule).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // register — ONE_SHOT
  // -------------------------------------------------------------------------
  describe('register — ONE_SHOT', () => {
    it('maps runAtMs from startAt ISO string', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: '42',
        degraded: false,
      });

      const startAt = '2026-07-01T12:00:00.000Z';

      await service.register({
        jobId: 'shot1',
        scheduleType: 'ONE_SHOT',
        startAt,
      });

      const spec = mockQueue.upsertSchedule.mock.calls[0][0] as OneShotSpec;
      expect(spec.type).toBe('ONE_SHOT');
      expect(spec.runAtMs).toBe(Date.parse(startAt));
    });

    it('throws BadRequestException when startAt is missing', async () => {
      await expect(
        service.register({ jobId: 'shot2', scheduleType: 'ONE_SHOT' }),
      ).rejects.toThrow(BadRequestException);
    });

    it('throws BadRequestException when startAt is an invalid date', async () => {
      await expect(
        service.register({
          jobId: 'shot3',
          scheduleType: 'ONE_SHOT',
          startAt: 'not-a-real-date',
        }),
      ).rejects.toThrow(BadRequestException);
      expect(mockQueue.upsertSchedule).not.toHaveBeenCalled();
    });
  });

  // -------------------------------------------------------------------------
  // unregister
  // -------------------------------------------------------------------------
  describe('unregister', () => {
    it('delegates to queue.removeSchedule', async () => {
      mockQueue.removeSchedule.mockResolvedValue(undefined);

      await service.unregister('job-x');

      expect(mockQueue.removeSchedule).toHaveBeenCalledWith('job-x');
    });
  });

  // -------------------------------------------------------------------------
  // runNow
  // -------------------------------------------------------------------------
  describe('runNow', () => {
    it('delegates to queue.enqueueNow with MANUAL trigger', async () => {
      mockQueue.enqueueNow.mockResolvedValue({ id: '99', degraded: false });

      const result = await service.runNow('job-y');

      expect(mockQueue.enqueueNow).toHaveBeenCalledWith('job-y', 'MANUAL');
      expect(result).toEqual({ id: '99', degraded: false });
    });
  });

  // -------------------------------------------------------------------------
  // runIfDue
  // -------------------------------------------------------------------------
  describe('runIfDue', () => {
    it('INTERVAL — enqueues when no lastRunAt', async () => {
      mockQueue.enqueueNow.mockResolvedValue({ id: '1', degraded: false });

      const result = await service.runIfDue({
        jobId: 'interval-job',
        scheduleType: 'INTERVAL',
        intervalEvery: 10,
        intervalUnit: 'MINUTES',
        lastRunAt: null,
      });

      expect(result).toEqual({ enqueued: true });
      expect(mockQueue.enqueueNow).toHaveBeenCalledWith(
        'interval-job',
        'CATCHUP',
        expect.stringMatching(/^catchup__interval-job__\d+$/),
      );
    });

    it('INTERVAL — enqueues when elapsed >= everyMs', async () => {
      mockQueue.enqueueNow.mockResolvedValue({ id: '2', degraded: false });

      const lastRunAt = new Date(Date.now() - 11 * 60_000).toISOString(); // 11 min ago

      const result = await service.runIfDue({
        jobId: 'interval-job2',
        scheduleType: 'INTERVAL',
        intervalEvery: 10,
        intervalUnit: 'MINUTES',
        lastRunAt,
      });

      expect(result).toEqual({ enqueued: true });
    });

    it('INTERVAL — does NOT enqueue when not yet due', async () => {
      const lastRunAt = new Date(Date.now() - 2 * 60_000).toISOString(); // 2 min ago

      const result = await service.runIfDue({
        jobId: 'interval-job3',
        scheduleType: 'INTERVAL',
        intervalEvery: 10,
        intervalUnit: 'MINUTES',
        lastRunAt,
      });

      expect(result).toEqual({ enqueued: false });
      expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
    });

    it('ONE_SHOT — enqueues when runAtMs <= now and never ran', async () => {
      mockQueue.enqueueNow.mockResolvedValue({ id: '3', degraded: false });

      const startAt = new Date(Date.now() - 5_000).toISOString(); // 5s in the past

      const result = await service.runIfDue({
        jobId: 'shot-job',
        scheduleType: 'ONE_SHOT',
        startAt,
        lastRunAt: null,
      });

      expect(result).toEqual({ enqueued: true });
      expect(mockQueue.enqueueNow).toHaveBeenCalledWith(
        'shot-job',
        'CATCHUP',
        expect.stringMatching(/^catchup__shot-job__\d+$/),
      );
    });

    it('ONE_SHOT — does NOT enqueue when already ran', async () => {
      const startAt = new Date(Date.now() - 5_000).toISOString();
      const lastRunAt = new Date(Date.now() - 1_000).toISOString();

      const result = await service.runIfDue({
        jobId: 'shot-job2',
        scheduleType: 'ONE_SHOT',
        startAt,
        lastRunAt,
      });

      expect(result).toEqual({ enqueued: false });
      expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
    });

    it('ONE_SHOT — does NOT enqueue when in the future', async () => {
      const startAt = new Date(Date.now() + 60_000).toISOString(); // 1 min ahead

      const result = await service.runIfDue({
        jobId: 'shot-job3',
        scheduleType: 'ONE_SHOT',
        startAt,
        lastRunAt: null,
      });

      expect(result).toEqual({ enqueued: false });
      expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
    });

    // -------------------------------------------------------------------------
    // ARFIX-07: CRON due-from-cron (cron-parser, tz-aware)
    // -------------------------------------------------------------------------
    describe('runIfDue — CRON (ARFIX-07)', () => {
      it('CRON — due when a tick fell after lastRunAt', async () => {
        mockQueue.enqueueNow.mockResolvedValue({ id: '10', degraded: false });

        // Pattern: every day at 09:00 UTC
        // lastRunAt = yesterday 09:00 UTC, now = today 09:30 UTC
        // → a 09:00 tick today fell after lastRunAt
        const yesterday0900 = new Date();
        yesterday0900.setUTCDate(yesterday0900.getUTCDate() - 1);
        yesterday0900.setUTCHours(9, 0, 0, 0);

        const now0930 = new Date();
        now0930.setUTCHours(9, 30, 0, 0);

        // We need "now" to be 09:30 so the 09:00 tick is in the past
        // Use a fixed date where today at 09:30 is 'now'
        const fixedNow = new Date('2026-06-25T09:30:00.000Z').getTime();
        const lastRunIso = '2026-06-24T09:00:00.000Z'; // yesterday 09:00 UTC

        jest.spyOn(Date, 'now').mockReturnValue(fixedNow);
        try {
          const result = await service.runIfDue({
            jobId: 'cron-daily',
            scheduleType: 'CRON',
            cronExpression: '0 9 * * *',
            timezone: 'UTC',
            lastRunAt: lastRunIso,
          });

          expect(result).toEqual({ enqueued: true });
          expect(mockQueue.enqueueNow).toHaveBeenCalledWith(
            'cron-daily',
            'CATCHUP',
            expect.stringMatching(/^catchup__cron-daily__\d+$/),
          );
        } finally {
          jest.spyOn(Date, 'now').mockRestore();
        }
      });

      it('CRON — NOT due when no new tick since lastRunAt', async () => {
        // Pattern: every day at 09:00 UTC
        // lastRunAt = today 09:00, now = today 09:30 → no new tick
        const fixedNow = new Date('2026-06-25T09:30:00.000Z').getTime();
        const lastRunIso = '2026-06-25T09:00:00.000Z'; // today 09:00 — the most recent tick

        jest.spyOn(Date, 'now').mockReturnValue(fixedNow);
        try {
          const result = await service.runIfDue({
            jobId: 'cron-daily2',
            scheduleType: 'CRON',
            cronExpression: '0 9 * * *',
            timezone: 'UTC',
            lastRunAt: lastRunIso,
          });

          expect(result).toEqual({ enqueued: false });
          expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
        } finally {
          jest.spyOn(Date, 'now').mockRestore();
        }
      });

      it('CRON — no lastRunAt → always due when a tick exists', async () => {
        mockQueue.enqueueNow.mockResolvedValue({ id: '11', degraded: false });

        const fixedNow = new Date('2026-06-25T09:30:00.000Z').getTime();
        jest.spyOn(Date, 'now').mockReturnValue(fixedNow);
        try {
          const result = await service.runIfDue({
            jobId: 'cron-nodailyrun',
            scheduleType: 'CRON',
            cronExpression: '0 9 * * *',
            timezone: 'UTC',
            lastRunAt: null,
          });

          expect(result).toEqual({ enqueued: true });
        } finally {
          jest.spyOn(Date, 'now').mockRestore();
        }
      });

      it('CRON — timezone honored: Sao_Paulo 09:00 = 12:00 UTC', async () => {
        mockQueue.enqueueNow.mockResolvedValue({ id: '12', degraded: false });

        // now = 12:30 UTC (09:30 SP) → 09:00 SP tick (= 12:00 UTC) is in (lastRun, now]
        const fixedNow = new Date('2026-06-25T12:30:00.000Z').getTime();
        const lastRunIso = '2026-06-24T12:00:00.000Z'; // yesterday 09:00 SP = 12:00 UTC

        jest.spyOn(Date, 'now').mockReturnValue(fixedNow);
        try {
          const result = await service.runIfDue({
            jobId: 'cron-sp',
            scheduleType: 'CRON',
            cronExpression: '0 9 * * *',
            timezone: 'America/Sao_Paulo',
            lastRunAt: lastRunIso,
          });

          expect(result).toEqual({ enqueued: true });
        } finally {
          jest.spyOn(Date, 'now').mockRestore();
        }
      });

      it('CRON — invalid expression → BadRequestException', async () => {
        await expect(
          service.runIfDue({
            jobId: 'cron-bad',
            scheduleType: 'CRON',
            cronExpression: 'not-a-cron',
            lastRunAt: null,
          }),
        ).rejects.toThrow(BadRequestException);
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });
    });

    // -------------------------------------------------------------------------
    // ARFIX-07: startAt/endAt window check in all branches
    // -------------------------------------------------------------------------
    describe('runIfDue — window checks (ARFIX-07)', () => {
      it('INTERVAL — startAt in the future → NOT due', async () => {
        const result = await service.runIfDue({
          jobId: 'interval-win1',
          scheduleType: 'INTERVAL',
          intervalEvery: 1,
          intervalUnit: 'MINUTES',
          startAt: new Date(Date.now() + 60_000).toISOString(),
          lastRunAt: null,
        });

        expect(result).toEqual({ enqueued: false });
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });

      it('INTERVAL — endAt in the past → NOT due', async () => {
        const result = await service.runIfDue({
          jobId: 'interval-win2',
          scheduleType: 'INTERVAL',
          intervalEvery: 1,
          intervalUnit: 'MINUTES',
          endAt: new Date(Date.now() - 60_000).toISOString(),
          lastRunAt: null,
        });

        expect(result).toEqual({ enqueued: false });
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });

      it('CRON — startAt in the future → NOT due', async () => {
        const result = await service.runIfDue({
          jobId: 'cron-win1',
          scheduleType: 'CRON',
          cronExpression: '* * * * *',
          startAt: new Date(Date.now() + 60_000).toISOString(),
          lastRunAt: null,
        });

        expect(result).toEqual({ enqueued: false });
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });

      it('CRON — endAt in the past → NOT due', async () => {
        const result = await service.runIfDue({
          jobId: 'cron-win2',
          scheduleType: 'CRON',
          cronExpression: '* * * * *',
          endAt: new Date(Date.now() - 60_000).toISOString(),
          lastRunAt: null,
        });

        expect(result).toEqual({ enqueued: false });
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });

      it('ONE_SHOT — endAt in the past → NOT due', async () => {
        const result = await service.runIfDue({
          jobId: 'shot-win1',
          scheduleType: 'ONE_SHOT',
          startAt: new Date(Date.now() - 5_000).toISOString(),
          endAt: new Date(Date.now() - 1_000).toISOString(),
          lastRunAt: null,
        });

        expect(result).toEqual({ enqueued: false });
        expect(mockQueue.enqueueNow).not.toHaveBeenCalled();
      });
    });

    // -------------------------------------------------------------------------
    // ARFIX-07: deterministic CATCHUP jobId
    // -------------------------------------------------------------------------
    describe('runIfDue — deterministic CATCHUP jobId (ARFIX-07)', () => {
      it('CATCHUP jobId uses catchup__<jobId>__<tick> format', async () => {
        mockQueue.enqueueNow.mockResolvedValue({ id: '20', degraded: false });

        await service.runIfDue({
          jobId: 'dedup-job',
          scheduleType: 'INTERVAL',
          intervalEvery: 1,
          intervalUnit: 'MINUTES',
          lastRunAt: null,
        });

        const callArgs = mockQueue.enqueueNow.mock.calls[0] as [
          string,
          string,
          string | undefined,
        ];
        expect(callArgs[0]).toBe('dedup-job');
        expect(callArgs[1]).toBe('CATCHUP');
        expect(callArgs[2]).toMatch(/^catchup__dedup-job__\d+$/);
        // jobId must NOT contain ':'
        expect(callArgs[2]).not.toContain(':');
      });

      it('MANUAL run-now does NOT pass a custom jobId', async () => {
        mockQueue.enqueueNow.mockResolvedValue({ id: '21', degraded: false });

        await service.runNow('manual-job');

        const callArgs = mockQueue.enqueueNow.mock.calls[0] as [
          string,
          string,
          string | undefined,
        ];
        expect(callArgs[0]).toBe('manual-job');
        expect(callArgs[1]).toBe('MANUAL');
        // No 3rd argument for MANUAL
        expect(callArgs[2]).toBeUndefined();
      });
    });
  });

  // -------------------------------------------------------------------------
  // reconcile
  // -------------------------------------------------------------------------
  describe('reconcile', () => {
    it('upserts all inputs and returns the count', async () => {
      mockQueue.upsertSchedule.mockResolvedValue({
        schedulerId: 'sjob__x',
        degraded: false,
      });

      const inputs: RegisterScheduledJobInput[] = [
        {
          jobId: 'r1',
          scheduleType: 'INTERVAL',
          intervalEvery: 5,
          intervalUnit: 'MINUTES',
        },
        {
          jobId: 'r2',
          scheduleType: 'CRON',
          cronExpression: '0 8 * * *',
          timezone: 'UTC',
        },
      ];

      const count = await service.reconcile(inputs);

      expect(count).toBe(2);
      expect(mockQueue.upsertSchedule).toHaveBeenCalledTimes(2);
    });
  });

  // -------------------------------------------------------------------------
  // dryRun
  // -------------------------------------------------------------------------
  describe('dryRun', () => {
    it('roda o código no SandboxRunner com limites default e writeMode', async () => {
      mockRunner.run.mockResolvedValue({
        status: 'SUCCESS',
        output: { ok: true },
        logs: [],
        durationMs: 12,
      });

      const res = await service.dryRun({
        code: 'return 1;',
        writeMode: 'safe',
      });

      expect(mockRunner.run).toHaveBeenCalledWith(
        expect.objectContaining({
          code: 'return 1;',
          limits: { timeoutMs: 15000, memoryMb: 128 },
          planfi: { baseUrl: 'https://planfi.example.com', writeMode: 'safe' },
        }),
      );
      expect(res.status).toBe('SUCCESS');
    });

    it('omite planfi quando SCHEDULED_JOBS_PLANFI_BASE_URL não está configurado', async () => {
      mockConfig.get.mockReturnValueOnce(undefined);
      mockRunner.run.mockResolvedValue({
        status: 'SUCCESS',
        logs: [],
        durationMs: 5,
      });

      await service.dryRun({ code: 'return 2;' });

      expect(mockRunner.run).toHaveBeenCalledWith(
        expect.objectContaining({
          planfi: undefined,
        }),
      );
    });
  });
});
