/**
 * Component: Scheduler Service Tests
 * Documentation: documentation/backend/services/scheduler.md
 */

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';

const prismaMock = createPrismaMock();

const jobQueueMock = vi.hoisted(() => ({
  addRepeatableJob: vi.fn(),
  removeRepeatableJob: vi.fn(),
  addPlexScanJob: vi.fn(),
  addPlexRecentlyAddedJob: vi.fn(),
  addAudibleRefreshJob: vi.fn(),
  addRetryMissingTorrentsJob: vi.fn(),
  addRetryFailedImportsJob: vi.fn(),
  addFindMissingEbooksJob: vi.fn(),
  addCleanupSeededTorrentsJob: vi.fn(),
  addMonitorRssFeedsJob: vi.fn(),
  addSyncShelvesJob: vi.fn(),
}));

const configServiceMock = vi.hoisted(() => ({
  getBackendMode: vi.fn(),
  getMany: vi.fn(),
}));

const notificationServiceMock = vi.hoisted(() => ({
  reEncryptUnprotectedBackends: vi.fn().mockResolvedValue(0),
}));

vi.mock('@/lib/services/job-queue.service', () => ({
  getJobQueueService: () => jobQueueMock,
}));

vi.mock('@/lib/services/config.service', () => ({
  getConfigService: () => configServiceMock,
}));

vi.mock('@/lib/services/notification', () => ({
  getNotificationService: () => notificationServiceMock,
}));

vi.mock('@/lib/db', () => ({
  prisma: prismaMock,
}));

describe('SchedulerService', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    prismaMock.scheduledJob.findFirst.mockReset();
    prismaMock.scheduledJob.create.mockReset();
    prismaMock.scheduledJob.findMany.mockReset();
    prismaMock.scheduledJob.findUnique.mockReset();
    prismaMock.scheduledJob.update.mockReset();
    prismaMock.scheduledJob.delete.mockReset();
    configServiceMock.getBackendMode.mockReset();
    configServiceMock.getMany.mockReset();
  });

  it('initializes defaults and schedules enabled jobs', async () => {
    prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
    prismaMock.scheduledJob.create.mockResolvedValue({});
    prismaMock.scheduledJob.findMany
      .mockResolvedValueOnce([]) // cleanupDeprecatedJobs
      .mockResolvedValueOnce([
        // scheduleAllJobs
        {
          id: 'job-1',
          name: 'Audible Data Refresh',
          type: 'audible_refresh',
          schedule: '0 0 * * *',
          enabled: true,
        },
      ])
      .mockResolvedValue([]); // triggerOverdueJobs

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.start();

    expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(10);
    expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      { scheduledJobId: 'job-1' },
      '0 0 * * *',
      'scheduled-job-1'
    );
  });

  it('rejects invalid cron expressions', async () => {
    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    await expect(
      service.createScheduledJob({
        name: 'Bad job',
        type: 'audible_refresh',
        schedule: 'bad',
      })
    ).rejects.toThrow('Invalid cron expression format');
  });

  it('creates and schedules enabled jobs', async () => {
    prismaMock.scheduledJob.create.mockResolvedValue({
      id: 'job-2',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: true,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.createScheduledJob({
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: true,
    });

    expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      { scheduledJobId: 'job-2' },
      '0 0 * * *',
      'scheduled-job-2'
    );
  });

  it('returns scheduled jobs and single jobs', async () => {
    prismaMock.scheduledJob.findMany.mockResolvedValue([{ id: 'job-2' }]);
    prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-2' });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const jobs = await service.getScheduledJobs();
    const job = await service.getScheduledJob('job-2');

    expect(prismaMock.scheduledJob.findMany).toHaveBeenCalledWith({ orderBy: { name: 'asc' } });
    expect(prismaMock.scheduledJob.findUnique).toHaveBeenCalledWith({ where: { id: 'job-2' } });
    expect(jobs).toEqual([{ id: 'job-2' }]);
    expect(job).toEqual({ id: 'job-2' });
  });

  it('updates jobs and reschedules when enabled', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-3',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: true,
      payload: {},
    });
    prismaMock.scheduledJob.update.mockResolvedValue({
      id: 'job-3',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '*/15 * * * *',
      enabled: true,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.updateScheduledJob('job-3', { schedule: '*/15 * * * *' });

    expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      '0 0 * * *',
      'scheduled-job-3'
    );
    expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      { scheduledJobId: 'job-3' },
      '*/15 * * * *',
      'scheduled-job-3'
    );
  });

  it('unschedules jobs when disabling updates', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-3b',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: true,
      payload: {},
    });
    prismaMock.scheduledJob.update.mockResolvedValue({
      id: 'job-3b',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: false,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.updateScheduledJob('job-3b', { enabled: false });

    expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      '0 0 * * *',
      'scheduled-job-3b'
    );
    expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
  });

  it('triggers Plex scan jobs with validated config', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-4',
      name: 'Library Scan',
      type: 'plex_library_scan',
      schedule: '0 */6 * * *',
      enabled: true,
      payload: {},
    });
    configServiceMock.getBackendMode.mockResolvedValue('plex');
    configServiceMock.getMany.mockResolvedValue({
      plex_url: 'http://plex',
      plex_token: 'token',
      plex_audiobook_library_id: 'lib-1',
    });
    jobQueueMock.addPlexScanJob.mockResolvedValue('bull-1');
    prismaMock.scheduledJob.update.mockResolvedValue({});

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const jobId = await service.triggerJobNow('job-4');

    expect(jobId).toBe('bull-1');
    expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('lib-1', undefined, undefined);
    expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({
      where: { id: 'job-4' },
      data: {
        lastRun: expect.any(Date),
        lastRunJobId: 'bull-1',
      },
    });
  });

  it('triggers Audiobookshelf scans when configured', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-4b',
      name: 'Library Scan',
      type: 'plex_library_scan',
      schedule: '0 */6 * * *',
      enabled: true,
      payload: { libraryId: 'abs-lib' },
    });
    configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
    configServiceMock.getMany.mockResolvedValue({
      'audiobookshelf.server_url': 'http://abs',
      'audiobookshelf.api_token': 'token',
      'audiobookshelf.library_id': 'abs-lib-2',
    });
    jobQueueMock.addPlexScanJob.mockResolvedValue('bull-abs');
    prismaMock.scheduledJob.update.mockResolvedValue({});

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const jobId = await service.triggerJobNow('job-4b');

    expect(jobId).toBe('bull-abs');
    expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('abs-lib', undefined, undefined);
  });

  it('throws on unknown scheduled job types', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-5',
      name: 'Mystery',
      type: 'unknown',
      schedule: '* * * * *',
      enabled: true,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    await expect(service.triggerJobNow('job-5')).rejects.toThrow('Unknown job type');
  });

  it.each([
    ['plex_recently_added_check', 'addPlexRecentlyAddedJob'],
    ['audible_refresh', 'addAudibleRefreshJob'],
    ['retry_missing_torrents', 'addRetryMissingTorrentsJob'],
    ['retry_failed_imports', 'addRetryFailedImportsJob'],
    ['find_missing_ebooks', 'addFindMissingEbooksJob'],
    ['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
    ['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
    ['sync_reading_shelves', 'addSyncShelvesJob'],
  ])('triggers %s jobs with job queue', async (type, queueMethod) => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-type',
      name: 'Job',
      type,
      schedule: '* * * * *',
      enabled: true,
      payload: {},
    });
    (jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type');
    prismaMock.scheduledJob.update.mockResolvedValue({});

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const jobId = await service.triggerJobNow('job-type');

    expect(jobId).toBe('bull-type');
    expect((jobQueueMock as any)[queueMethod]).toHaveBeenCalledWith('job-type');
  });

  it('parses cron intervals for common patterns', async () => {
    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    expect((service as any).getIntervalFromCron('*/15 * * * *')).toBe(15 * 60 * 1000);
    expect((service as any).getIntervalFromCron('0 */6 * * *')).toBe(6 * 60 * 60 * 1000);
    expect((service as any).getIntervalFromCron('0 4 * * *')).toBe(24 * 60 * 60 * 1000);
    expect((service as any).getIntervalFromCron('0 4 * * 1')).toBe(7 * 24 * 60 * 60 * 1000);
    expect((service as any).getIntervalFromCron('invalid cron')).toBeNull();
  });

  it('does not schedule disabled jobs on creation', async () => {
    prismaMock.scheduledJob.create.mockResolvedValue({
      id: 'job-6',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: false,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.createScheduledJob({
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: false,
    });

    expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
  });

  it('does not reschedule when updated job stays disabled', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-7',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: false,
      payload: {},
    });
    prismaMock.scheduledJob.update.mockResolvedValue({
      id: 'job-7',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 1 * * *',
      enabled: false,
      payload: {},
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.updateScheduledJob('job-7', { schedule: '0 1 * * *', enabled: false });

    expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
    expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
  });

  it('unschedules jobs when deleted', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-8',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: true,
      payload: {},
    });
    prismaMock.scheduledJob.delete.mockResolvedValue({});

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.deleteScheduledJob('job-8');

    expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
      'audible_refresh',
      '0 0 * * *',
      'scheduled-job-8'
    );
    expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
  });

  it('deletes disabled jobs without unscheduling', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-8b',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '0 0 * * *',
      enabled: false,
      payload: {},
    });
    prismaMock.scheduledJob.delete.mockResolvedValue({});

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.deleteScheduledJob('job-8b');

    expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
    expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
  });

  it('triggers overdue jobs based on lastRun and schedule', async () => {
    const overdueJob = {
      id: 'job-9',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '*/5 * * * *',
      enabled: true,
      payload: {},
      lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
    };

    prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockResolvedValue('bull-9');

    await (service as any).triggerOverdueJobs();

    expect(triggerSpy).toHaveBeenCalledWith('job-9');
  });

  it('logs and continues when overdue jobs fail to trigger', async () => {
    const overdueJob = {
      id: 'job-9b',
      name: 'Audible Data Refresh',
      type: 'audible_refresh',
      schedule: '*/5 * * * *',
      enabled: true,
      payload: {},
      lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
    };

    prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockRejectedValue(new Error('fail'));

    await expect((service as any).triggerOverdueJobs()).resolves.toBeUndefined();
    expect(triggerSpy).toHaveBeenCalledWith('job-9b');
  });
  it('identifies overdue jobs when lastRun is missing', async () => {
    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    const overdue = (service as any).isJobOverdue({
      name: 'No last run',
      schedule: '0 * * * *',
      lastRun: null,
    });

    expect(overdue).toBe(true);
  });

  it('returns false for unparseable cron intervals', async () => {
    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    const overdue = (service as any).isJobOverdue({
      name: 'Bad cron',
      schedule: 'bad',
      lastRun: new Date().toISOString(),
    });

    expect(overdue).toBe(false);
  });

  it('throws when Audiobookshelf scan configuration is missing', async () => {
    prismaMock.scheduledJob.findUnique.mockResolvedValue({
      id: 'job-10',
      name: 'Library Scan',
      type: 'plex_library_scan',
      schedule: '0 */6 * * *',
      enabled: true,
      payload: {},
    });
    configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
    configServiceMock.getMany.mockResolvedValue({
      'audiobookshelf.server_url': null,
      'audiobookshelf.api_token': null,
      'audiobookshelf.library_id': null,
    });

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    await expect(service.triggerJobNow('job-10')).rejects.toThrow('Audiobookshelf is not configured');
  });

  it('rewrites stale literal job names with type-gated updateMany on startup', async () => {
    prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' });
    prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
    prismaMock.scheduledJob.findMany
      .mockResolvedValueOnce([]) // cleanupDeprecatedJobs
      .mockResolvedValueOnce([]) // scheduleAllJobs
      .mockResolvedValue([]); // triggerOverdueJobs

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();
    await service.start();

    const updateManyCalls = prismaMock.scheduledJob.updateMany.mock.calls;
    expect(updateManyCalls).toHaveLength(2);

    // Type-gating safety: each WHERE must match BOTH name AND type exact-equals.
    expect(updateManyCalls[0][0]).toEqual({
      where: { name: 'Plex Library Scan', type: 'plex_library_scan' },
      data: { name: 'Library Scan' },
    });
    expect(updateManyCalls[1][0]).toEqual({
      where: { name: 'Plex Recently Added Check', type: 'plex_recently_added_check' },
      data: { name: 'Recently Added Check' },
    });
  });

  it('swallows rename errors and continues startup (idempotent)', async () => {
    prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' });
    prismaMock.scheduledJob.updateMany.mockRejectedValue(new Error('db blip'));
    prismaMock.scheduledJob.findMany
      .mockResolvedValueOnce([]) // cleanupDeprecatedJobs
      .mockResolvedValueOnce([]) // scheduleAllJobs
      .mockResolvedValue([]); // triggerOverdueJobs

    const { SchedulerService } = await import('@/lib/services/scheduler.service');
    const service = new SchedulerService();

    // Must not throw — rename failure is non-fatal.
    await expect(service.start()).resolves.toBeUndefined();
  });
});
