/**
 * Tests for {@link LifecycleWarmupScheduler}.
 *
 * Covers:
 *  - Hermetic opt-out via `start({ disable: true })`
 *  - Interval-driven scheduling (deterministic 60-min run = 3 ticks @ 25 min)
 *  - In-flight de-duplication (concurrent `refreshNow` shares a single rebuild)
 *  - Error resilience (a throwing warmup does not break the next tick)
 *  - Graceful shutdown (`dispose()` clears the timer; pending refresh settles)
 *  - Status surface for `get_server_health.lifecycleCache`
 *
 * ISMS Policy: AU-002 (Audit Logging), MO-001 (Monitoring and Alerting)
 */

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

vi.mock('../utils/lifecycleStatistics.js', () => ({
  getLifecycleStatistics: vi.fn(),
}));

import { LifecycleWarmupScheduler } from './LifecycleWarmupScheduler.js';
import { getLifecycleStatistics } from '../utils/lifecycleStatistics.js';
import type { LifecycleStatisticsModel } from '../utils/lifecycleStatistics.js';

const mockedGetLifecycleStatistics = vi.mocked(getLifecycleStatistics);

function buildModel(overrides: Partial<LifecycleStatisticsModel> = {}): LifecycleStatisticsModel {
  return {
    byTypeAndStage: new Map(),
    corpusSize: 500,
    totalObservations: 1234,
    computationTimeMs: 42,
    builtAt: Date.now(),
    ...overrides,
  };
}

describe('LifecycleWarmupScheduler', () => {
  beforeEach(() => {
    mockedGetLifecycleStatistics.mockReset();
    vi.spyOn(console, 'error').mockImplementation(() => {
      /* silence audit logs during tests */
    });
  });

  afterEach(() => {
    vi.restoreAllMocks();
    vi.useRealTimers();
  });

  describe('start({ disable: true }) — hermetic mode', () => {
    it('does not schedule a timer when disabled', () => {
      const setIntervalSpy = vi.spyOn(global, 'setInterval');
      const scheduler = new LifecycleWarmupScheduler(60_000);
      scheduler.start({ disable: true });
      expect(setIntervalSpy).not.toHaveBeenCalled();
      expect(scheduler.getStatus().running).toBe(false);
      scheduler.dispose();
    });

    it('is idempotent — repeated start() calls register only one timer', () => {
      vi.useFakeTimers();
      const setIntervalSpy = vi.spyOn(global, 'setInterval');
      const scheduler = new LifecycleWarmupScheduler(60_000);
      scheduler.start();
      scheduler.start();
      scheduler.start();
      expect(setIntervalSpy).toHaveBeenCalledTimes(1);
      scheduler.dispose();
    });
  });

  describe('refreshNow — successful warmup', () => {
    it('returns a success outcome and updates status counters', async () => {
      const model = buildModel({ corpusSize: 250, totalObservations: 500 });
      mockedGetLifecycleStatistics.mockResolvedValue(model);

      const scheduler = new LifecycleWarmupScheduler(60_000);
      const outcome = await scheduler.refreshNow();

      expect(outcome.kind).toBe('success');
      if (outcome.kind === 'success') {
        expect(outcome.corpusSize).toBe(250);
        expect(outcome.totalObservations).toBe(500);
      }
      const status = scheduler.getStatus();
      expect(status.totalAttempts).toBe(1);
      expect(status.successfulAttempts).toBe(1);
      expect(status.failedAttempts).toBe(0);
      expect(status.lastSuccessAt).not.toBeNull();
      expect(status.lastRefreshErrorAt).toBeNull();
    });

    it('forces a refresh (passes forceRefresh: true)', async () => {
      mockedGetLifecycleStatistics.mockResolvedValue(buildModel());
      const scheduler = new LifecycleWarmupScheduler(60_000);
      await scheduler.refreshNow();
      expect(mockedGetLifecycleStatistics).toHaveBeenCalledWith({ forceRefresh: true });
    });
  });

  describe('refreshNow — in-flight de-duplication', () => {
    it('concurrent callers share a single rebuild', async () => {
      let resolve: ((m: LifecycleStatisticsModel) => void) | null = null;
      const pending = new Promise<LifecycleStatisticsModel>((res) => {
        resolve = res;
      });
      mockedGetLifecycleStatistics.mockReturnValue(pending);

      const scheduler = new LifecycleWarmupScheduler(60_000);
      const first = scheduler.refreshNow();
      const second = scheduler.refreshNow();
      const third = scheduler.refreshNow();

      expect(mockedGetLifecycleStatistics).toHaveBeenCalledTimes(1);
      resolve?.(buildModel({ corpusSize: 333 }));

      const [a, b, c] = await Promise.all([first, second, third]);
      expect(a.kind).toBe('success');
      expect(b.kind).toBe('in-flight');
      expect(c.kind).toBe('in-flight');
      expect(scheduler.getStatus().totalAttempts).toBe(1);
    });
  });

  describe('refreshNow — error tolerance', () => {
    it('returns an error outcome and does not throw', async () => {
      mockedGetLifecycleStatistics.mockRejectedValue(
        new Error('EP API unreachable'),
      );
      const scheduler = new LifecycleWarmupScheduler(60_000);

      const outcome = await scheduler.refreshNow();
      expect(outcome.kind).toBe('error');
      if (outcome.kind === 'error') {
        expect(outcome.errorMessage).toContain('EP API unreachable');
      }
      const status = scheduler.getStatus();
      expect(status.failedAttempts).toBe(1);
      expect(status.lastRefreshErrorAt).not.toBeNull();
      expect(status.lastRefreshErrorMessage).toContain('EP API unreachable');
    });

    it('sanitises long error messages (caps at 200 chars, single-line)', async () => {
      const longMessage = 'x'.repeat(500) + '\n\nstack trace line';
      mockedGetLifecycleStatistics.mockRejectedValue(
        new Error(longMessage),
      );
      const scheduler = new LifecycleWarmupScheduler(60_000);
      const outcome = await scheduler.refreshNow();
      expect(outcome.kind).toBe('error');
      if (outcome.kind === 'error') {
        expect(outcome.errorMessage.length).toBeLessThanOrEqual(200);
        expect(outcome.errorMessage).not.toContain('\n');
      }
    });
  });

  describe('interval scheduling — deterministic clock', () => {
    it('fires exactly three times across a simulated 75-minute run @ 25-min interval', async () => {
      vi.useFakeTimers();
      mockedGetLifecycleStatistics.mockResolvedValue(buildModel());
      const scheduler = new LifecycleWarmupScheduler(25 * 60 * 1000);
      scheduler.start();

      // Advance 75 minutes. setInterval should fire at t=25, 50, 75.
      await vi.advanceTimersByTimeAsync(75 * 60 * 1000);
      expect(mockedGetLifecycleStatistics).toHaveBeenCalledTimes(3);

      scheduler.dispose();
    });

    it('continues firing after a failed warmup (resilience)', async () => {
      vi.useFakeTimers();
      mockedGetLifecycleStatistics
        .mockRejectedValueOnce(new Error('transient'))
        .mockResolvedValueOnce(buildModel())
        .mockResolvedValueOnce(buildModel());
      const scheduler = new LifecycleWarmupScheduler(60_000);
      scheduler.start();

      await vi.advanceTimersByTimeAsync(60_000);
      await vi.advanceTimersByTimeAsync(60_000);
      await vi.advanceTimersByTimeAsync(60_000);

      expect(mockedGetLifecycleStatistics).toHaveBeenCalledTimes(3);
      const status = scheduler.getStatus();
      expect(status.failedAttempts).toBe(1);
      expect(status.successfulAttempts).toBe(2);
      scheduler.dispose();
    });
  });

  describe('dispose', () => {
    it('clears the timer and leaves status.running false', () => {
      vi.useFakeTimers();
      const scheduler = new LifecycleWarmupScheduler(60_000);
      scheduler.start();
      expect(scheduler.getStatus().running).toBe(true);
      scheduler.dispose();
      expect(scheduler.getStatus().running).toBe(false);
    });

    it('is safe to call multiple times', () => {
      const scheduler = new LifecycleWarmupScheduler(60_000);
      scheduler.dispose();
      scheduler.dispose();
      expect(scheduler.getStatus().running).toBe(false);
    });

    it('does not cancel an in-flight refresh (it settles on its own)', async () => {
      let resolve: ((m: LifecycleStatisticsModel) => void) | null = null;
      mockedGetLifecycleStatistics.mockReturnValue(
        new Promise<LifecycleStatisticsModel>((res) => {
          resolve = res;
        }),
      );
      const scheduler = new LifecycleWarmupScheduler(60_000);
      const refreshPromise = scheduler.refreshNow();
      scheduler.dispose();
      resolve?.(buildModel());
      const outcome = await refreshPromise;
      expect(outcome.kind).toBe('success');
    });
  });

  describe('constructor — interval resolution', () => {
    it('uses the explicit override when provided', () => {
      const scheduler = new LifecycleWarmupScheduler(123_456);
      expect(scheduler.getStatus().intervalMs).toBe(123_456);
    });

    it('clamps environment-sourced values (validated upstream by resolveLifecycleWarmupIntervalMs)', async () => {
      // The env-driven path is tested in config.test.ts; here we simply
      // verify that an out-of-range explicit override is accepted as-is so
      // tests can use a small interval like 60_000.
      const scheduler = new LifecycleWarmupScheduler(60_000);
      expect(scheduler.getStatus().intervalMs).toBe(60_000);
    });
  });
});
