// ABOUTME: Integration tests for BaseScheduler execution paths
// ABOUTME: Tests concurrent execution prevention, retry path, JobRun lifecycle, and manual triggers

import { BaseScheduler } from '../base-scheduler';
import { ScheduleType, SchedulerConfig, JobContext, JobResult } from '../types';

// ============================================================================
// Mocks
// ============================================================================

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

jest.mock('prom-client', () => ({
  Counter: jest.fn().mockImplementation(() => ({
    inc: jest.fn(),
    labels: jest.fn().mockReturnThis(),
  })),
  Histogram: jest.fn().mockImplementation(() => ({
    observe: jest.fn(),
    labels: jest.fn().mockReturnThis(),
  })),
  Gauge: jest.fn().mockImplementation(() => ({
    set: jest.fn(),
    labels: jest.fn().mockReturnThis(),
  })),
}));

jest.mock('@opentelemetry/api', () => ({
  trace: {
    getTracer: jest.fn(() => ({
      startSpan: jest.fn(() => ({
        setAttributes: jest.fn(),
        setAttribute: jest.fn(),
        setStatus: jest.fn(),
        end: jest.fn(),
      })),
    })),
  },
  SpanStatusCode: {
    OK: 1,
    ERROR: 2,
  },
}));

// Capture node-cron callbacks so tests can trigger executions manually
const cronCallbacks: Array<() => Promise<void>> = [];
jest.mock('node-cron', () => ({
  schedule: jest.fn((_expr: string, callback: () => Promise<void>) => {
    cronCallbacks.push(callback);
    return { stop: jest.fn() };
  }),
}));

// ============================================================================
// Test scheduler - a minimal BaseScheduler subclass for testing
// ============================================================================

class TestScheduler extends BaseScheduler {
  public executeJobMock: jest.Mock<Promise<JobResult>, [JobContext]>;

  constructor(config: SchedulerConfig, prisma: any) {
    super(ScheduleType.MORNING_SCRAPE, config, prisma);
    this.executeJobMock = jest.fn().mockResolvedValue({
      success: true,
      itemsProcessed: 5,
      durationMs: 100,
      errors: [],
    });
  }

  protected async executeJob(context: JobContext): Promise<JobResult> {
    return this.executeJobMock(context);
  }

  /**
   * Expose protected scheduleRetry for testing the retry execution path.
   * The retry path triggers the private execute() method via setTimeout.
   */
  public invokeScheduleRetry(delayMs: number, parentJobRunId: string, retryCount: number): void {
    (this as any).scheduleRetry(delayMs, parentJobRunId, retryCount);
  }

  /**
   * Manual trigger — calls executeJob directly, bypassing the private execute() method.
   * This mirrors how concrete schedulers implement triggerManually().
   */
  async triggerManually(): Promise<JobResult> {
    const context: JobContext = {
      type: this.type,
      triggeredAt: new Date(),
      jobRunId: 'manual-trigger',
      data: { manual: true },
    };
    return this.executeJob(context);
  }
}

// ============================================================================
// Tests
// ============================================================================

describe('BaseScheduler Integration — execution paths', () => {
  let testScheduler: TestScheduler;
  let mockPrisma: any;

  const defaultConfig: SchedulerConfig = {
    cronExpression: '* * * * *',
    timezone: 'UTC',
    enabled: true,
    description: 'Test scheduler for integration tests',
  };

  beforeEach(() => {
    jest.clearAllMocks();
    // Clear captured cron callbacks from previous tests
    cronCallbacks.length = 0;

    // Shared prisma mock — jobRun create/update for lifecycle tracking
    mockPrisma = {
      jobRun: {
        create: jest.fn().mockResolvedValue({ id: 'test-job-run-id' }),
        update: jest.fn().mockResolvedValue({}),
      },
    };

    testScheduler = new TestScheduler(defaultConfig, mockPrisma);
  });

  // ==========================================================================
  // Concurrent execution prevention
  // ==========================================================================

  describe('concurrent execution prevention', () => {
    it('should skip a second execution when one is already running', async () => {
      // Arrange: create a deferred promise so the first execution stays "in flight"
      let resolveExecution!: (value: JobResult) => void;
      const deferredPromise = new Promise<JobResult>((resolve) => {
        resolveExecution = resolve;
      });
      testScheduler.executeJobMock.mockReturnValue(deferredPromise);

      // Start the scheduler (sets up cron callback)
      testScheduler.start();
      expect(cronCallbacks.length).toBe(1);
      const cronCb = cronCallbacks[0];

      // Act: trigger first execution
      const firstPromise = cronCb();

      // Guard should now be active
      expect(testScheduler.isCurrentlyRunning()).toBe(true);

      // Act: trigger second execution while first is still running
      const secondPromise = cronCb();

      // Let the first execution complete
      resolveExecution({
        success: true,
        itemsProcessed: 10,
        durationMs: 200,
        errors: [],
      });

      await Promise.all([firstPromise, secondPromise]);

      // Assert: executeJob was only called once (second was skipped)
      expect(testScheduler.executeJobMock).toHaveBeenCalledTimes(1);

      // Guard should be released
      expect(testScheduler.isCurrentlyRunning()).toBe(false);
    });

    it('should allow retries to proceed even when already running', async () => {
      jest.useFakeTimers();

      // Arrange: hold the first execution in flight
      let resolveExecution!: (value: JobResult) => void;
      const deferredPromise = new Promise<JobResult>((resolve) => {
        resolveExecution = resolve;
      });
      testScheduler.executeJobMock.mockReturnValue(deferredPromise);

      testScheduler.start();
      const cronCb = cronCallbacks[0];

      // Start first execution (stays in flight on the deferred promise)
      const firstPromise = cronCb();
      expect(testScheduler.isCurrentlyRunning()).toBe(true);

      // Act: schedule a retry (retryCount > 0, bypasses the guard)
      testScheduler.invokeScheduleRetry(0, 'parent-job-id', 1);

      // Advance fake timers to fire the setTimeout callback
      jest.advanceTimersByTime(0);

      // Flush microtasks (the async execution continuation inside setTimeout)
      await Promise.resolve();
      await Promise.resolve();

      // Assert: executeJob was called for both the initial run and the retry
      expect(testScheduler.executeJobMock).toHaveBeenCalledTimes(2);

      // Cleanup: resolve first execution
      resolveExecution({
        success: true,
        itemsProcessed: 5,
        durationMs: 100,
        errors: [],
      });
      await firstPromise;

      jest.useRealTimers();
    });
  });

  // ==========================================================================
  // Retry path
  // ==========================================================================

  describe('retry path', () => {
    beforeEach(() => {
      jest.useFakeTimers();
    });

    afterEach(() => {
      jest.useRealTimers();
    });

    it('should schedule retry with correct parentJobRunId and retryCount', async () => {
      // Act: invoke scheduleRetry with a 100ms delay
      testScheduler.invokeScheduleRetry(100, 'parent-job-run-id', 1);

      // Fast-forward past the setTimeout
      jest.advanceTimersByTime(100);

      // Flush microtasks (the async execution inside setTimeout)
      await Promise.resolve();
      await Promise.resolve();

      // Assert: a new JobRun was created with the retry metadata
      expect(mockPrisma.jobRun.create).toHaveBeenCalledWith({
        data: {
          jobType: ScheduleType.MORNING_SCRAPE,
          status: 'running',
          retryCount: 1,
          parentJobRunId: 'parent-job-run-id',
        },
      });

      // Assert: the executeJob was called with retry context
      expect(testScheduler.executeJobMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: ScheduleType.MORNING_SCRAPE,
          jobRunId: 'test-job-run-id',
          data: expect.objectContaining({
            retryCount: 1,
            parentJobRunId: 'parent-job-run-id',
          }),
        }),
      );
    });

    it('should increment retryCount on subsequent retries', async () => {
      // Act: simulate a second retry (retryCount=2)
      testScheduler.invokeScheduleRetry(200, 'parent-job-run-id', 2);

      jest.advanceTimersByTime(200);
      await Promise.resolve();
      await Promise.resolve();

      // Assert: retryCount=2 in the JobRun create
      expect(mockPrisma.jobRun.create).toHaveBeenCalledWith({
        data: {
          jobType: ScheduleType.MORNING_SCRAPE,
          status: 'running',
          retryCount: 2,
          parentJobRunId: 'parent-job-run-id',
        },
      });
    });
  });

  // ==========================================================================
  // JobRun lifecycle
  // ==========================================================================

  describe('JobRun lifecycle', () => {
    it('should create a JobRun with status "running" on execution start', async () => {
      // Act: trigger execution via cron callback
      testScheduler.start();
      await cronCallbacks[0]();

      // Assert: JobRun was created with running status
      expect(mockPrisma.jobRun.create).toHaveBeenCalledTimes(1);
      expect(mockPrisma.jobRun.create).toHaveBeenCalledWith({
        data: {
          jobType: ScheduleType.MORNING_SCRAPE,
          status: 'running',
          retryCount: 0,
          parentJobRunId: undefined,
        },
      });
    });

    it('should update JobRun to "success" when executeJob succeeds', async () => {
      // Arrange: configure a successful result
      testScheduler.executeJobMock.mockResolvedValue({
        success: true,
        itemsProcessed: 8,
        durationMs: 150,
        errors: [],
      });

      // Act
      testScheduler.start();
      await cronCallbacks[0]();

      // Assert: JobRun was updated with success status and processed counts
      expect(mockPrisma.jobRun.update).toHaveBeenCalledWith({
        where: { id: 'test-job-run-id' },
        data: expect.objectContaining({
          status: 'success',
          itemsProcessed: 8,
          itemsFailed: 0,
          errorMessage: null,
          completedAt: expect.any(Date),
          durationMs: expect.any(Number),
        }),
      });

      // The Counter for success runs should have been incremented
      // (verified through the prom-client mock — we just check it was called)
      expect(testScheduler.isCurrentlyRunning()).toBe(false);
    });

    it('should update JobRun to "failed" when executeJob throws', async () => {
      // Arrange: executeJob throws an error
      testScheduler.executeJobMock.mockRejectedValue(new Error('API connection timeout'));

      // Act
      testScheduler.start();
      await cronCallbacks[0]();

      // Assert: JobRun was updated with failed status and error message
      expect(mockPrisma.jobRun.update).toHaveBeenCalledWith({
        where: { id: 'test-job-run-id' },
        data: expect.objectContaining({
          status: 'failed',
          errorMessage: 'API connection timeout',
          completedAt: expect.any(Date),
          durationMs: expect.any(Number),
        }),
      });

      // Guard should be released even after failure
      expect(testScheduler.isCurrentlyRunning()).toBe(false);
    });

    it('should update JobRun to "partial" when executeJob has errors but processed some items', async () => {
      // Arrange: partial success — some items processed, but with errors
      testScheduler.executeJobMock.mockResolvedValue({
        success: false,
        itemsProcessed: 3,
        durationMs: 200,
        errors: ['Failed to fetch race-2'],
      });

      // Act
      testScheduler.start();
      await cronCallbacks[0]();

      // Assert: status is 'partial' (itemsProcessed > 0 but success=false)
      expect(mockPrisma.jobRun.update).toHaveBeenCalledWith({
        where: { id: 'test-job-run-id' },
        data: expect.objectContaining({
          status: 'partial',
          itemsProcessed: 3,
          itemsFailed: 1,
          errorMessage: 'Failed to fetch race-2',
          completedAt: expect.any(Date),
        }),
      });
    });

    it('should update JobRun to "failed" when executeJob returns no processed items', async () => {
      // Arrange: complete failure — no items processed, errors present
      testScheduler.executeJobMock.mockResolvedValue({
        success: false,
        itemsProcessed: 0,
        durationMs: 50,
        errors: ['All API endpoints unreachable'],
      });

      // Act
      testScheduler.start();
      await cronCallbacks[0]();

      // Assert: status is 'failed' (itemsProcessed === 0 and success=false)
      expect(mockPrisma.jobRun.update).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { id: 'test-job-run-id' },
          data: expect.objectContaining({
            status: 'failed',
            itemsProcessed: 0,
            itemsFailed: 1,
            errorMessage: 'All API endpoints unreachable',
          }),
        }),
      );
    });
  });

  // ==========================================================================
  // Manual trigger
  // ==========================================================================

  describe('manual trigger (triggerManually)', () => {
    it('should call executeJob directly without creating a JobRun', async () => {
      // Act: call triggerManually
      const result = await testScheduler.triggerManually();

      // Assert: executeJob was called
      expect(testScheduler.executeJobMock).toHaveBeenCalledTimes(1);
      expect(testScheduler.executeJobMock).toHaveBeenCalledWith(
        expect.objectContaining({
          type: ScheduleType.MORNING_SCRAPE,
          jobRunId: 'manual-trigger',
          data: { manual: true },
        }),
      );

      // Assert: NO JobRun was created (triggerManually bypasses execute())
      expect(mockPrisma.jobRun.create).not.toHaveBeenCalled();
      expect(mockPrisma.jobRun.update).not.toHaveBeenCalled();

      // Assert: result is returned directly
      expect(result).toEqual({
        success: true,
        itemsProcessed: 5,
        durationMs: 100,
        errors: [],
      });
    });

    it('should bypass the concurrent execution guard', async () => {
      // Arrange: hold a long-running execution
      let resolveExecution!: (value: JobResult) => void;
      const deferredPromise = new Promise<JobResult>((resolve) => {
        resolveExecution = resolve;
      });

      // Start an execution through the cron path (has the guard)
      testScheduler.executeJobMock.mockReturnValue(deferredPromise);
      testScheduler.start();
      const cronExecution = cronCallbacks[0]();

      // Now isRunning = true

      // Act: triggerManually should still execute (it bypasses the guard)
      const manualResultPromise = testScheduler.triggerManually();

      // Allow microtasks to process the manual trigger
      await new Promise(process.nextTick);

      // Assert: executeJob was called twice (once from cron, once from manual)
      expect(testScheduler.executeJobMock).toHaveBeenCalledTimes(2);

      // Cleanup
      resolveExecution({
        success: true,
        itemsProcessed: 5,
        durationMs: 100,
        errors: [],
      });
      await Promise.all([cronExecution, manualResultPromise]);
    });
  });
});
