import { BookingWindowService } from './booking-window.service';
import type { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';

/**
 * Window state-machine tests: exercise the real advanceImport transitions and the
 * concludeCycle reopen/done decision with mocked collaborators. Drives the exact
 * production phase logic (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → conclude) and
 * asserts the side effects the batch/settle/reopen flow depends on.
 */
describe('BookingWindowService — window state machine', () => {
  const scheduleId = 'sched-1';

  let service: BookingWindowService;
  let batch: {
    setWindow: jest.Mock;
    processRouteDay: jest.Mock;
    expireUnacceptedForRouteDay: jest.Mock;
    settleDueReservations: jest.Mock;
    isScheduleFull: jest.Mock;
    hasLiveReservations: jest.Mock;
    refreshWindowStatus: jest.Mock;
    expireLeftoverDayPool: jest.Mock;
    expireLeftoverExportDay: jest.Mock;
    fillFromWaitingList: jest.Mock;
    countUnacceptedForRouteDay: jest.Mock;
  };
  let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
  let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
  let updateMock: jest.Mock;

  const cfg = {
    importWindowLeadDays: 3,
    exportBookingLeadHours: 24,
    windowOpenHour: 0, // 24h desk → reopen opens immediately
    windowCloseHour: 0,
    windowDurationHours: 1,
    docReviewMinutes: 30,
    paymentWindowMinutes: 60,
  };

  const baseSchedule = (over: Partial<TrainSchedule>): TrainSchedule =>
    ({
      id: scheduleId,
      direction: 'IMPORT',
      originStationId: 'yard-o',
      destinationStationId: 'yard-d',
      scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
      bookingWindowStatus: 'CLOSED',
      windowPhase: 'PRE_WINDOW',
      bookingCycleNo: 0,
      windowOpensAt: null,
      windowClosesAt: null,
      docReviewEndsAt: null,
      docReviewCompletedAt: null,
      paymentPhaseEndsAt: null,
      ...over,
    }) as unknown as TrainSchedule;

  const advanceImport = (s: TrainSchedule, now: Date): Promise<boolean> =>
    (service as unknown as {
      advanceImport: (s: TrainSchedule, c: unknown, n: Date) => Promise<boolean>;
    }).advanceImport(s, cfg, now);
  const concludeCycle = (s: TrainSchedule, now: Date): Promise<void> =>
    (service as unknown as {
      concludeCycle: (s: TrainSchedule, c: unknown, n: Date) => Promise<void>;
    }).concludeCycle(s, cfg, now);

  beforeEach(() => {
    updateMock = jest.fn().mockResolvedValue(undefined);
    batch = {
      setWindow: jest.fn().mockResolvedValue(undefined),
      processRouteDay: jest.fn().mockResolvedValue(undefined),
      expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
      settleDueReservations: jest.fn().mockResolvedValue(undefined),
      isScheduleFull: jest.fn().mockResolvedValue(false),
      // No reservation is mid-pay-window by default, so the cycle concludes.
      hasLiveReservations: jest.fn().mockResolvedValue(false),
      refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
      expireLeftoverDayPool: jest.fn().mockResolvedValue(0),
      expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
      // No waiting booking fits by default, so conclude proceeds to reopen/DONE.
      fillFromWaitingList: jest.fn().mockResolvedValue(0),
      countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0),
    };
    trainSchedulesRepository = {
      findById: jest.fn().mockResolvedValue(null),
      findAll: jest.fn().mockResolvedValue([]),
    };
    trainSchedulingService = {
      finalizeSchedule: jest.fn().mockResolvedValue(undefined),
      getWindowConfig: jest.fn().mockResolvedValue(cfg),
    };

    service = new BookingWindowService(
      { getRepository: () => ({ update: updateMock }) } as never,
      trainSchedulesRepository as never,
      batch as never,
      trainSchedulingService as never,
      { directSend: jest.fn() } as never,
      { notify: jest.fn() } as never,
      { emitPhase: jest.fn() } as never,
    );
  });

  it('PRE_WINDOW → OPEN at windowOpensAt (opens the customer window)', async () => {
    const s = baseSchedule({
      windowPhase: 'PRE_WINDOW',
      windowOpensAt: new Date('2026-07-01T00:00:00.000Z'),
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T00:00:01.000Z'));
    expect(advanced).toBe(true);
    expect(s.windowPhase).toBe('OPEN');
    expect(s.bookingCycleNo).toBe(1);
    expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'OPEN');
  });

  it('OPEN → DOC_REVIEW at windowClosesAt (closes booking, sets doc-review deadline)', async () => {
    const closesAt = new Date('2026-07-01T01:00:00.000Z');
    const s = baseSchedule({
      windowPhase: 'OPEN',
      bookingWindowStatus: 'OPEN',
      windowClosesAt: closesAt,
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T01:00:01.000Z'));
    expect(advanced).toBe(true);
    expect(s.windowPhase).toBe('DOC_REVIEW');
    expect(s.docReviewEndsAt).toEqual(new Date(closesAt.getTime() + 30 * 60_000));
    expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'CLOSED');
  });

  it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => {
    // The batch reserved someone (live reservations exist) → real PAYMENT phase.
    batch.hasLiveReservations.mockResolvedValue(true);
    const s = baseSchedule({
      windowPhase: 'DOC_REVIEW',
      docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
    expect(advanced).toBe(true);
    expect(s.windowPhase).toBe('PAYMENT');
    expect(s.paymentPhaseEndsAt).not.toBeNull();
    // Expiry sweep runs BEFORE the batch (unaccepted must not compete for capacity).
    expect(batch.expireUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
    expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
    const expireOrder = batch.expireUnacceptedForRouteDay.mock.invocationCallOrder[0];
    const batchOrder = batch.processRouteDay.mock.invocationCallOrder[0];
    expect(expireOrder).toBeLessThan(batchOrder);
  });

  it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => {
    batch.hasLiveReservations.mockResolvedValue(true);
    const s = baseSchedule({
      windowPhase: 'DOC_REVIEW',
      docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future
      docReviewCompletedAt: new Date('2026-07-01T01:31:00.000Z'), // staff clicked done
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T01:31:01.000Z'));
    expect(advanced).toBe(true);
    expect(s.windowPhase).toBe('PAYMENT');
  });

  it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => {
    // Default hasLiveReservations=false: the batch reserved nobody. Waiting a
    // full payment window with the desk shut would serve no one — the cycle
    // concludes immediately (24h desk + far departure → straight to PRE_WINDOW).
    const s = baseSchedule({
      windowPhase: 'DOC_REVIEW',
      docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z'));
    expect(advanced).toBe(true);
    expect(batch.processRouteDay).toHaveBeenCalledTimes(1);
    expect(s.windowPhase).toBe('PRE_WINDOW');
    expect(s.windowOpensAt).not.toBeNull();
  });

  it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => {
    const s = baseSchedule({
      windowPhase: 'PAYMENT',
      paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
    expect(advanced).toBe(true);
    // settleDueReservations runs (allocate paid / expire unpaid, then top-up).
    expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
  });

  it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
    // `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
    // booking's own deadline milliseconds later. So the phase deadline always passes
    // first, and concluding here would kill customers who still had time to pay — and
    // leave no cycle for the waiting-list top-up to run in.
    batch.hasLiveReservations.mockResolvedValue(true);
    const s = baseSchedule({
      windowPhase: 'PAYMENT',
      paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
    });

    const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));

    expect(advanced).toBe(true);
    expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
    // Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
    expect(s.windowPhase).toBe('PAYMENT');
    expect(batch.isScheduleFull).not.toHaveBeenCalled();
  });

  it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
    batch.isScheduleFull.mockResolvedValue(true);
    const s = baseSchedule({ windowPhase: 'PAYMENT' });
    await concludeCycle(s, new Date('2026-07-01T02:30:02.000Z'));
    expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL');
    expect(s.windowPhase).toBe('DONE');
    expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId);
    // The day's leftover waiting list is swept once this train is done.
    expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
  });

  it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => {
    batch.isScheduleFull.mockResolvedValue(false);
    const s = baseSchedule({
      windowPhase: 'PAYMENT',
      // departure well in the future so nextCycleOpensAt returns a real time.
      scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
    });
    await concludeCycle(s, new Date('2026-07-01T02:30:03.000Z'));
    expect(s.windowPhase).toBe('PRE_WINDOW');
    expect(s.windowOpensAt).not.toBeNull();
    expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled();
  });

  it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => {
    batch.isScheduleFull.mockResolvedValue(false);
    batch.fillFromWaitingList.mockResolvedValue(2);
    batch.hasLiveReservations.mockResolvedValue(true);
    const s = baseSchedule({
      windowPhase: 'PAYMENT',
      scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'),
    });
    const now = new Date('2026-07-01T02:30:05.000Z');
    await concludeCycle(s, now);
    expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId);
    expect(s.windowPhase).toBe('PAYMENT');
    // Fresh pay window from `now`, not a reopen.
    expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000));
  });

  it('conclude: NOT full but NO cycle fits before departure → DONE', async () => {
    batch.isScheduleFull.mockResolvedValue(false);
    const s = baseSchedule({
      windowPhase: 'PAYMENT',
      // departure already passed → nextCycleOpensAt returns null → finish.
      scheduledDepartureDate: new Date('2026-07-01T00:00:00.000Z'),
    });
    await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z'));
    expect(s.windowPhase).toBe('DONE');
    // No further train can run for this day → leftover waiting list is swept.
    expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId);
  });

  it('no transition fires before its deadline (idempotent tick)', async () => {
    const s = baseSchedule({
      windowPhase: 'OPEN',
      bookingWindowStatus: 'OPEN',
      windowClosesAt: new Date('2026-07-01T10:00:00.000Z'), // future
    });
    const advanced = await advanceImport(s, new Date('2026-07-01T01:00:00.000Z'));
    expect(advanced).toBe(false);
    expect(s.windowPhase).toBe('OPEN');
    expect(batch.setWindow).not.toHaveBeenCalled();
  });

  // ---- header alarm ---------------------------------------------------------

  describe('getDocReviewAlert', () => {
    const reviewing = (over: Partial<TrainSchedule>): TrainSchedule =>
      baseSchedule({
        windowPhase: 'DOC_REVIEW',
        docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
        ...over,
      });

    it('returns null when nothing is under document review', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([
        baseSchedule({ windowPhase: 'OPEN' }),
      ]);
      expect(await service.getDocReviewAlert()).toBeNull();
    });

    it('returns null when every request on the route-day is decided', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
      batch.countUnacceptedForRouteDay.mockResolvedValue(0);
      expect(await service.getDocReviewAlert()).toBeNull();
    });

    it('reports the deadline, its own pending count and the phase length', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
      batch.countUnacceptedForRouteDay.mockResolvedValue(3);

      const alert = await service.getDocReviewAlert();

      expect(alert).toMatchObject({
        scheduleId,
        originYardId: 'yard-o',
        destinationYardId: 'yard-d',
        tradeDirection: 'IMPORT',
        pendingCount: 3,
        docReviewMinutes: 30,
        docReviewEndsAt: '2026-07-01T01:30:00.000Z',
      });
    });

    it('skips the nearest deadline when it has nothing pending', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([
        reviewing({
          id: 'sched-later',
          destinationStationId: 'yard-far',
          docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'),
        }),
        reviewing({ id: 'sched-soon' }),
      ]);
      // Nearest (sched-soon, yard-d) is clear; the later route-day still isn't.
      batch.countUnacceptedForRouteDay.mockImplementation(
        async (g: { destinationYardId: string }) =>
          g.destinationYardId === 'yard-far' ? 2 : 0,
      );

      const alert = await service.getDocReviewAlert();

      expect(alert?.scheduleId).toBe('sched-later');
      expect(alert?.pendingCount).toBe(2);
    });

    it('counts a route-day once when sibling trains share the review phase', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([
        reviewing({ id: 'sched-a' }),
        reviewing({ id: 'sched-b' }),
      ]);
      batch.countUnacceptedForRouteDay.mockResolvedValue(4);

      const alert = await service.getDocReviewAlert();

      expect(alert?.pendingCount).toBe(4);
      expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
    });

    it('ignores a phase staff already completed early', async () => {
      trainSchedulesRepository.findAll.mockResolvedValue([
        reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }),
      ]);
      batch.countUnacceptedForRouteDay.mockResolvedValue(5);
      expect(await service.getDocReviewAlert()).toBeNull();
    });
  });
});
