import { ConfigService } from '@nestjs/config';
import { ScheduledJobsProcessor } from './scheduled-jobs.processor';
import type { RunBundle } from '../scheduled-jobs-runtime.client';
import type { SandboxResult } from '../sandbox/sandbox-runner';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeBundle(overrides: Partial<RunBundle> = {}): RunBundle {
  return {
    jobId: 'job-1',
    versionId: 'v1',
    code: 'return 42;',
    env: {},
    fetchAllowlist: [],
    credential: null,
    limits: { timeoutMs: 10_000, memoryMb: 64 },
    overlapPolicy: 'ALLOW',
    maxRetries: 0,
    input: null,
    ...overrides,
  };
}

function makeSandboxResult(
  overrides: Partial<SandboxResult> = {},
): SandboxResult {
  return {
    status: 'SUCCESS',
    output: { result: 42 },
    logs: ['done'],
    durationMs: 50,
    ...overrides,
  };
}

const noRedisConfig = {
  get: jest.fn().mockReturnValue(undefined),
} as unknown as ConfigService;

function makeConfigWithPlanfi(baseUrl: string | undefined): ConfigService {
  return {
    get: jest.fn().mockImplementation((key: string) => {
      if (key === 'SCHEDULED_JOBS_PLANFI_BASE_URL') return baseUrl;
      return undefined;
    }),
  } as unknown as ConfigService;
}

/** Build a processor with mocked dependencies and no real Redis/Worker. */
function buildProcessor(opts: {
  bundle?: Partial<RunBundle>;
  sandboxResult?: Partial<SandboxResult>;
  lockAcquired?: boolean;
}) {
  const bundle = makeBundle(opts.bundle);
  const sandboxResult = makeSandboxResult(opts.sandboxResult);

  const getBundle = jest.fn().mockResolvedValue(bundle);
  const createRun = jest.fn().mockResolvedValue({ runId: 'run-abc' });
  const reportRun = jest.fn().mockResolvedValue(undefined);

  const mockRuntime = { getBundle, createRun, reportRun };

  const run = jest.fn().mockResolvedValue(sandboxResult);
  const mockRunner = { run };

  const processor = new ScheduledJobsProcessor(
    mockRuntime as never,
    mockRunner,
    noRedisConfig,
  );

  // No real Redis; override lock methods to avoid any connection attempt.
  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const proc = processor as any;
  const lockAcquired = opts.lockAcquired ?? true;
  jest
    .spyOn(proc, 'acquireLock')
    .mockResolvedValue(lockAcquired ? 'fake-token' : null);
  jest.spyOn(proc, 'releaseLock').mockResolvedValue(undefined);

  return { processor, getBundle, createRun, reportRun, run };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('ScheduledJobsProcessor', () => {
  beforeEach(() => jest.clearAllMocks());

  describe('processJob – SUCCESS path', () => {
    it('creates a run, reports RUNNING, executes sandbox, reports SUCCESS', async () => {
      const { processor, getBundle, createRun, reportRun, run } =
        buildProcessor({});

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      expect(getBundle).toHaveBeenCalledWith('job-1');
      expect(createRun).toHaveBeenCalledWith(
        expect.objectContaining({ jobId: 'job-1', trigger: 'SCHEDULE' }),
      );
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ runId: 'run-abc', status: 'RUNNING' }),
      );
      expect(run).toHaveBeenCalledWith(
        expect.objectContaining({ code: 'return 42;' }),
      );
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ runId: 'run-abc', status: 'SUCCESS' }),
      );
    });

    it('forwards the bundle env and fetchAllowlist to runner.run (P2)', async () => {
      const { processor, run } = buildProcessor({
        bundle: {
          env: { API_BASE: 'https://api.example.com', TOKEN: 't0ken' },
          fetchAllowlist: ['api.example.com', 'cdn.example.com'],
        },
      });

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      expect(run).toHaveBeenCalledWith(
        expect.objectContaining({
          code: 'return 42;',
          env: { API_BASE: 'https://api.example.com', TOKEN: 't0ken' },
          fetchAllowlist: ['api.example.com', 'cdn.example.com'],
        }),
      );
    });

    it('passes planfi.baseUrl when SCHEDULED_JOBS_PLANFI_BASE_URL is set', async () => {
      const bundle = makeBundle();
      const sandboxResult = makeSandboxResult();

      const getBundle = jest.fn().mockResolvedValue(bundle);
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-planfi' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const run = jest.fn().mockResolvedValue(sandboxResult);

      const config = makeConfigWithPlanfi('https://api.planfi.com');

      const processor = new ScheduledJobsProcessor(
        { getBundle, createRun, reportRun } as never,
        { run },
        config,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const anyProc = processor as any;
      jest.spyOn(anyProc, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(anyProc, 'releaseLock').mockResolvedValue(undefined);

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      expect(run).toHaveBeenCalledWith(
        expect.objectContaining({
          planfi: { baseUrl: 'https://api.planfi.com' },
        }),
      );
    });

    it('passes planfi: undefined when SCHEDULED_JOBS_PLANFI_BASE_URL is not set', async () => {
      const { processor, run } = buildProcessor({});

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      const callArgs = run.mock.calls[0][0] as Record<string, unknown>;
      expect(callArgs['planfi']).toBeUndefined();
    });

    it('does NOT include scheduledFor key in createRun when trigger SCHEDULE has none (SJOB-XC-01)', async () => {
      const { processor, createRun } = buildProcessor({});

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      const [createArgs] = createRun.mock.calls[0] as [Record<string, unknown>];
      expect(createArgs).toMatchObject({ jobId: 'job-1', trigger: 'SCHEDULE' });
      // Atlas zod scheduledFor is .optional() and rejects null: omit the key.
      expect(createArgs).not.toHaveProperty('scheduledFor');
    });

    it('includes scheduledFor in createRun when provided', async () => {
      const { processor, createRun } = buildProcessor({});

      await processor.processJob({
        jobId: 'job-1',
        trigger: 'SCHEDULE',
        scheduledFor: '2026-06-24T12:00:00.000Z',
      });

      const [createArgs] = createRun.mock.calls[0] as [Record<string, unknown>];
      expect(createArgs).toMatchObject({
        jobId: 'job-1',
        trigger: 'SCHEDULE',
        scheduledFor: '2026-06-24T12:00:00.000Z',
      });
    });

    it('includes output, logs, and durationMs in the final SUCCESS report', async () => {
      const { processor, reportRun } = buildProcessor({
        sandboxResult: {
          status: 'SUCCESS',
          output: { ok: true },
          logs: ['a', 'b'],
          durationMs: 99,
        },
      });

      await processor.processJob({ jobId: 'job-1' });

      const allCalls = reportRun.mock.calls as Array<[Record<string, unknown>]>;
      const finalCall = allCalls.find(([arg]) => arg['status'] === 'SUCCESS');
      expect(finalCall).toBeDefined();
      const [args] = finalCall!;
      expect(args['output']).toEqual({ ok: true });
      expect(args['logs']).toBe('a\nb');
      expect(args['durationMs']).toBe(99);
    });

    it('does NOT include errorMessage/errorStack keys in the SUCCESS report (SJOB-XC-02)', async () => {
      const { processor, reportRun } = buildProcessor({
        sandboxResult: {
          status: 'SUCCESS',
          output: { ok: true },
          logs: [],
          durationMs: 10,
        },
      });

      await processor.processJob({ jobId: 'job-1' });

      const allCalls = reportRun.mock.calls as Array<[Record<string, unknown>]>;
      const finalCall = allCalls.find(([arg]) => arg['status'] === 'SUCCESS');
      expect(finalCall).toBeDefined();
      const [args] = finalCall!;
      // Atlas zod is .optional() and rejects null: omit, do not send null.
      expect(args).not.toHaveProperty('errorMessage');
      expect(args).not.toHaveProperty('errorStack');
    });
  });

  describe('processJob – FAILED path', () => {
    it('reports FAILED when sandbox returns status FAILED', async () => {
      const { processor, reportRun, run } = buildProcessor({
        sandboxResult: {
          status: 'FAILED',
          output: undefined,
          logs: [],
          error: {
            message: 'ReferenceError: x is not defined',
            stack: 'stack...',
          },
          durationMs: 10,
        },
      });

      await processor.processJob({ jobId: 'job-1' });

      expect(run).toHaveBeenCalledTimes(1);
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({
          status: 'FAILED',
          errorMessage: 'ReferenceError: x is not defined',
          errorStack: 'stack...',
        }),
      );
    });
  });

  describe('processJob – TIMED_OUT path', () => {
    it('reports TIMED_OUT when sandbox returns status TIMED_OUT', async () => {
      const { processor, reportRun } = buildProcessor({
        sandboxResult: {
          status: 'TIMED_OUT',
          logs: [],
          error: { message: 'Script timed out' },
          durationMs: 10_001,
        },
      });

      await processor.processJob({ jobId: 'job-1' });

      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'TIMED_OUT' }),
      );
    });
  });

  describe('processJob – SKIPPED path (overlapPolicy = SKIP, lock NOT acquired)', () => {
    it('reports SKIPPED and does NOT call runner.run', async () => {
      const { processor, createRun, reportRun, run } = buildProcessor({
        bundle: { overlapPolicy: 'SKIP' },
        lockAcquired: false,
      });

      await processor.processJob({ jobId: 'job-1', trigger: 'SCHEDULE' });

      expect(run).not.toHaveBeenCalled();
      expect(createRun).toHaveBeenCalledTimes(1);
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'SKIPPED' }),
      );
      // Should NOT have called reportRun with RUNNING
      const allCalls = reportRun.mock.calls as Array<[Record<string, unknown>]>;
      const runningCall = allCalls.find(([arg]) => arg['status'] === 'RUNNING');
      expect(runningCall).toBeUndefined();
    });
  });

  describe('processJob – unexpected exception mid-run', () => {
    it('reports FAILED and re-throws when sandbox throws unexpectedly', async () => {
      const getBundle = jest.fn().mockResolvedValue(makeBundle());
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-xyz' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const runtimeMock = { getBundle, createRun, reportRun };

      const run = jest.fn().mockRejectedValue(new Error('unexpected crash'));
      const runnerMock = { run };

      const p = new ScheduledJobsProcessor(
        runtimeMock as never,
        runnerMock,
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);

      await expect(p.processJob({ jobId: 'job-1' })).rejects.toThrow(
        'unexpected crash',
      );

      // FAILED must still be reported even though it threw
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({
          runId: 'run-xyz',
          status: 'FAILED',
          errorMessage: 'unexpected crash',
        }),
      );
    });
  });

  describe('processJob – terminal report is reported exactly once (ARFIX-05)', () => {
    it('does NOT re-report FAILED on the same runId when the final SUCCESS report rejects in transport', async () => {
      const getBundle = jest.fn().mockResolvedValue(makeBundle());
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-once' });
      // reportRun resolves for RUNNING but rejects on the final terminal report.
      const reportRun = jest
        .fn()
        .mockImplementation((arg: Record<string, unknown>) => {
          if (arg['status'] === 'SUCCESS') {
            return Promise.reject(new Error('network blip after commit'));
          }
          return Promise.resolve(undefined);
        });
      const runtimeMock = { getBundle, createRun, reportRun };
      const run = jest.fn().mockResolvedValue(makeSandboxResult());
      const runnerMock = { run };

      const p = new ScheduledJobsProcessor(
        runtimeMock as never,
        runnerMock,
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);

      // The final report rejected, so processJob still re-throws.
      await expect(p.processJob({ jobId: 'job-1' })).rejects.toThrow(
        'network blip after commit',
      );

      // Only RUNNING + the attempted SUCCESS were emitted. The catch must NOT
      // overwrite the already-reported terminal status with FAILED.
      const statuses = (
        reportRun.mock.calls as Array<[Record<string, unknown>]>
      ).map(([arg]) => arg['status']);
      expect(statuses).toEqual(['RUNNING', 'SUCCESS']);
      const failedCall = (
        reportRun.mock.calls as Array<[Record<string, unknown>]>
      ).find(([arg]) => arg['status'] === 'FAILED');
      expect(failedCall).toBeUndefined();
    });

    it('does NOT re-report FAILED when a sandbox-FAILED terminal report rejects in transport', async () => {
      const getBundle = jest.fn().mockResolvedValue(makeBundle());
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-f1' });
      const reportRun = jest
        .fn()
        .mockImplementation((arg: Record<string, unknown>) => {
          // The single terminal FAILED report (success path, sandbox returned
          // FAILED) hiccups in transport.
          if (arg['status'] === 'FAILED') {
            return Promise.reject(new Error('blip on FAILED report'));
          }
          return Promise.resolve(undefined);
        });
      const runtimeMock = { getBundle, createRun, reportRun };
      const run = jest.fn().mockResolvedValue(
        makeSandboxResult({
          status: 'FAILED',
          error: { message: 'boom', stack: 's' },
        }),
      );
      const runnerMock = { run };

      const p = new ScheduledJobsProcessor(
        runtimeMock as never,
        runnerMock,
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);

      await expect(p.processJob({ jobId: 'job-1' })).rejects.toThrow(
        'blip on FAILED report',
      );

      // Exactly one terminal report attempt (the success-path FAILED). The
      // catch must NOT emit a second FAILED for the same runId.
      const failedCalls = (
        reportRun.mock.calls as Array<[Record<string, unknown>]>
      ).filter(([arg]) => arg['status'] === 'FAILED');
      expect(failedCalls).toHaveLength(1);
    });
  });

  describe('processJob – maxRetries cap (ARFIX-04 PART B)', () => {
    function buildRetryProcessor(maxRetries: number) {
      const getBundle = jest.fn().mockResolvedValue(makeBundle({ maxRetries }));
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-retry' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const runtimeMock = { getBundle, createRun, reportRun };
      const run = jest.fn().mockRejectedValue(new Error('sandbox kaboom'));
      const runnerMock = { run };

      const p = new ScheduledJobsProcessor(
        runtimeMock as never,
        runnerMock,
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);
      return { processor: p, reportRun };
    }

    it('re-throws the ORIGINAL (retryable) error when attemptsMade < maxRetries', async () => {
      const { processor } = buildRetryProcessor(2);

      const rejected = await processor
        .processJob({ jobId: 'job-1' }, 0)
        .then(() => null)
        .catch((e: unknown) => e);

      expect(rejected).toBeInstanceOf(Error);
      expect((rejected as Error).name).toBe('Error');
      expect((rejected as Error).message).toBe('sandbox kaboom');
    });

    it('throws UnrecoverableError when attemptsMade >= maxRetries (cap reached)', async () => {
      const { processor } = buildRetryProcessor(2);

      const rejected = await processor
        .processJob({ jobId: 'job-1' }, 2)
        .then(() => null)
        .catch((e: unknown) => e);

      expect(rejected).toBeInstanceOf(Error);
      expect((rejected as Error).name).toBe('UnrecoverableError');
      // The original message is preserved.
      expect((rejected as Error).message).toContain('sandbox kaboom');
    });

    it('throws UnrecoverableError on the first failure when maxRetries is 0', async () => {
      const { processor } = buildRetryProcessor(0);

      const rejected = await processor
        .processJob({ jobId: 'job-1' }, 0)
        .then(() => null)
        .catch((e: unknown) => e);

      expect((rejected as Error).name).toBe('UnrecoverableError');
    });

    it('still reports FAILED on the anchored run when the cap is reached', async () => {
      const { processor, reportRun } = buildRetryProcessor(0);

      await processor.processJob({ jobId: 'job-1' }, 0).catch(() => undefined);

      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({
          runId: 'run-retry',
          status: 'FAILED',
          errorMessage: 'sandbox kaboom',
        }),
      );
    });
  });

  describe('processJob – SKIP lock renewal watchdog (ARFIX-06)', () => {
    afterEach(() => {
      jest.useRealTimers();
    });

    it('renews the lock at least once while a long run executes (overlapPolicy SKIP)', async () => {
      jest.useFakeTimers();
      const getBundle = jest.fn().mockResolvedValue(
        makeBundle({
          overlapPolicy: 'SKIP',
          limits: { timeoutMs: 30_000, memoryMb: 64 },
        }),
      );
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-renew' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const runtimeMock = { getBundle, createRun, reportRun };

      // A run that resolves only after we advance timers past the renew period.
      let resolveRun: (v: SandboxResult) => void = () => undefined;
      const run = jest.fn().mockReturnValue(
        new Promise<SandboxResult>((resolve) => {
          resolveRun = resolve;
        }),
      );
      const runnerMock = { run };

      const p = new ScheduledJobsProcessor(
        runtimeMock as never,
        runnerMock,
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);
      const renewLock = jest.spyOn(pAny, 'renewLock').mockResolvedValue(true);

      const pending = p.processJob({ jobId: 'job-1' });
      // Let the synchronous setup + awaits up to runner.run settle.
      await Promise.resolve();
      await Promise.resolve();
      await Promise.resolve();

      // ttl = 30_000 + 90_000 = 120_000; period = ttl/3 = 40_000.
      jest.advanceTimersByTime(45_000);
      expect(renewLock).toHaveBeenCalled();

      resolveRun(makeSandboxResult());
      await pending;
    });

    it('clears the renewal timer in finally so no renewal fires after release', async () => {
      jest.useFakeTimers();
      const getBundle = jest.fn().mockResolvedValue(
        makeBundle({
          overlapPolicy: 'SKIP',
          limits: { timeoutMs: 30_000, memoryMb: 64 },
        }),
      );
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-clear' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const run = jest.fn().mockResolvedValue(makeSandboxResult());
      const processor = new ScheduledJobsProcessor(
        { getBundle, createRun, reportRun } as never,
        { run },
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const anyProc = processor as any;
      jest.spyOn(anyProc, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(anyProc, 'releaseLock').mockResolvedValue(undefined);
      const renewLock = jest
        .spyOn(anyProc, 'renewLock')
        .mockResolvedValue(true);

      await processor.processJob({ jobId: 'job-1' });

      // The run resolved synchronously, so the renewal interval never fired;
      // finally must have cleared it — advancing far past the period stays 0.
      renewLock.mockClear();
      jest.advanceTimersByTime(500_000);
      expect(renewLock).not.toHaveBeenCalled();
    });

    it('does NOT abort the run when renewLock reports the lock was lost (returns false)', async () => {
      jest.useFakeTimers();
      const getBundle = jest.fn().mockResolvedValue(
        makeBundle({
          overlapPolicy: 'SKIP',
          limits: { timeoutMs: 30_000, memoryMb: 64 },
        }),
      );
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-lost' });
      const reportRun = jest.fn().mockResolvedValue(undefined);

      let resolveRun: (v: SandboxResult) => void = () => undefined;
      const run = jest.fn().mockReturnValue(
        new Promise<SandboxResult>((resolve) => {
          resolveRun = resolve;
        }),
      );

      const p = new ScheduledJobsProcessor(
        { getBundle, createRun, reportRun } as never,
        { run },
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);
      jest.spyOn(pAny, 'renewLock').mockResolvedValue(false);
      const warnSpy = jest
        .spyOn(pAny.logger, 'warn')
        .mockImplementation(() => undefined);

      const pending = p.processJob({ jobId: 'job-1' });
      await Promise.resolve();
      await Promise.resolve();
      await Promise.resolve();

      jest.advanceTimersByTime(45_000);
      // Let the renewLock promise (resolved false) settle and the warning log.
      await Promise.resolve();
      await Promise.resolve();

      expect(warnSpy).toHaveBeenCalledWith(
        expect.stringContaining('scheduled-jobs.lock.renew.lost'),
      );

      // The run is still in flight (not aborted) and finishes normally.
      resolveRun(makeSandboxResult());
      await pending;
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'SUCCESS' }),
      );
    });

    it('does NOT start a renewal timer when no lock is acquired (overlapPolicy ALLOW)', async () => {
      jest.useFakeTimers();
      const { processor } = buildProcessor({
        bundle: { overlapPolicy: 'ALLOW' },
      });
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const anyProc = processor as any;
      const renewLock = jest
        .spyOn(anyProc, 'renewLock')
        .mockResolvedValue(true);

      await processor.processJob({ jobId: 'job-1' });

      jest.advanceTimersByTime(500_000);
      expect(renewLock).not.toHaveBeenCalled();
    });
  });

  describe('processJob – SKIP policy with lock acquired', () => {
    it('acquires lock, runs normally, then releases lock', async () => {
      const { processor, reportRun } = buildProcessor({
        bundle: { overlapPolicy: 'SKIP' },
        lockAcquired: true,
      });

      await processor.processJob({ jobId: 'job-1' });

      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const anyProc = processor as any;
      expect(anyProc.acquireLock).toHaveBeenCalledTimes(1);
      expect(anyProc.releaseLock).toHaveBeenCalledTimes(1);
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({ status: 'SUCCESS' }),
      );
    });

    it('uses a lock TTL of timeoutMs + 90s to cover the full HTTP cycle (SJ-PROC-2)', async () => {
      const { processor } = buildProcessor({
        bundle: {
          overlapPolicy: 'SKIP',
          limits: { timeoutMs: 10_000, memoryMb: 64 },
        },
        lockAcquired: true,
      });

      await processor.processJob({ jobId: 'job-1' });

      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const anyProc = processor as any;
      const [lockKey, ttlMs] = anyProc.acquireLock.mock.calls[0] as [
        string,
        number,
      ];
      expect(lockKey).toBe('planfi:scheduled-jobs:lock:job-1');
      // TTL must cover createRun + reportRun RUNNING + runner + reportRun final.
      expect(ttlMs).toBe(10_000 + 90_000);
    });
  });

  describe('processJob – getBundle failure (OBS-P0: falha pré-bundle visível)', () => {
    it('anchors a run BEFORE getBundle and reports FAILED when getBundle throws (e.g. 401)', async () => {
      const getBundle = jest
        .fn()
        .mockRejectedValue(new Error('Request failed with status code 401'));
      const createRun = jest.fn().mockResolvedValue({ runId: 'run-boot' });
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const run = jest.fn();

      const p = new ScheduledJobsProcessor(
        { getBundle, createRun, reportRun } as never,
        { run },
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);

      await expect(p.processJob({ jobId: 'job-1' })).rejects.toThrow('401');

      // Run is anchored BEFORE the bundle fetch, so the failure is visible.
      expect(createRun).toHaveBeenCalledTimes(1);
      expect(run).not.toHaveBeenCalled();
      expect(reportRun).toHaveBeenCalledWith(
        expect.objectContaining({
          runId: 'run-boot',
          status: 'FAILED',
          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
          errorMessage: expect.stringContaining('401'),
        }),
      );
    });

    it('logs bootstrap.unreachable and reports nothing when createRun itself fails', async () => {
      const getBundle = jest.fn();
      const createRun = jest
        .fn()
        .mockRejectedValue(new Error('Request failed with status code 401'));
      const reportRun = jest.fn().mockResolvedValue(undefined);
      const run = jest.fn();

      const p = new ScheduledJobsProcessor(
        { getBundle, createRun, reportRun } as never,
        { run },
        noRedisConfig,
      );
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      const pAny = p as any;
      jest.spyOn(pAny, 'acquireLock').mockResolvedValue('token');
      jest.spyOn(pAny, 'releaseLock').mockResolvedValue(undefined);
      const errSpy = jest
        .spyOn(pAny.logger, 'error')
        .mockImplementation(() => undefined);

      await expect(p.processJob({ jobId: 'job-1' })).rejects.toThrow('401');

      expect(getBundle).not.toHaveBeenCalled();
      expect(reportRun).not.toHaveBeenCalled();
      expect(errSpy).toHaveBeenCalledWith(
        expect.stringContaining('bootstrap.unreachable'),
      );
    });
  });
});
