import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import {
  NotificationAudience,
  NotificationType,
  TrainScheduleStatus as TrainScheduleStatusEnum,
} from '@edr/types';

import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import {
  companyNotifyPhoneExpr,
  primaryContactUserJoin,
} from '../notifications/resolve-company-phone.util';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import {
  bookingCloseCutoff,
  clampCloseToOfficeHours,
  eatDay,
  nextCycleOpensAt,
  type OfficeHours,
} from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';

/**
 * The most urgent document-review deadline that still has un-accepted booking
 * requests behind it. Backoffice counts down to it and warns staff, because
 * everything still pending when the phase ends is expired automatically.
 */
export interface DocReviewAlert {
  /** A schedule of the route-day group under review (deep-link target). */
  scheduleId: string;
  originYardId: string;
  destinationYardId: string;
  /** EAT booking day of the group, YYYY-MM-DD. */
  day: string;
  /** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
  tradeDirection: string;
  /** ISO deadline the review phase ends at. */
  docReviewEndsAt: string;
  /** Full length of the review phase — the client warns past its halfway mark. */
  docReviewMinutes: number;
  /** Requests neither accepted nor rejected — they expire at the deadline. */
  pendingCount: number;
}

/**
 * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
 * booking window for EXPORT schedules. All state lives in DB timestamps on the
 * schedule row, so every transition is derived purely from the clock — a restart
 * resumes mid-phase with no loss (onModuleInit runs one tick immediately).
 *
 * Import & domestic phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW
 * (staff accept documents) → PAYMENT (batch reserves in priority order, customers
 * pay) → reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
 * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
 * Only PRE-MIGRATION rows have windowPhase NULL; those are served by the legacy
 * fill (runBatchFill), which this tick invokes every 5th minute. New schedules of
 * every direction get a window phase.
 */
@Injectable()
export class BookingWindowService implements OnModuleInit {
  private readonly logger = new Logger(BookingWindowService.name);
  private ticking = false;
  private tickCount = 0;

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly trainSchedulesRepository: TrainSchedulesRepository,
    private readonly bookingBatchService: BookingBatchService,
    private readonly trainSchedulingService: TrainSchedulingService,
    private readonly notifications: NotificationsService,
    private readonly inbox: NotificationInboxService,
    private readonly gateway: BookingWindowGateway,
  ) {}

  async onModuleInit(): Promise<void> {
    await this.tick().catch((err) =>
      this.logger.warn(`Boot window tick failed: ${(err as Error).message}`),
    );
  }

  // 10-second cadence: every transition is derived from persisted timestamps
  // and applied idempotently, so a finer tick only shrinks the lag between a
  // deadline passing and the phase actually moving (was a full minute).
  @Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
  async tick(): Promise<void> {
    if (this.ticking) return;
    this.ticking = true;
    try {
      const now = new Date();
      const liveCfg = await this.trainSchedulingService.getWindowConfig();

      const active = (
        await this.trainSchedulesRepository.findAll({
          where: [
            { status: TrainScheduleStatusEnum.Draft },
            { status: TrainScheduleStatusEnum.Scheduled },
          ],
        })
      ).filter(
        // CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW):
        // still pick those rows up so advanceImport can revive them next morning.
        (s) => s.windowPhase != null && s.windowPhase !== 'DONE',
      );

      for (const schedule of active) {
        try {
          // Each train runs under its OWN frozen rule snapshot, not the live global
          // config — a later global-rules edit must not retro-change the window an
          // existing train already advertised, and the reopen cycles must match the
          // board (which is drawn from the same snapshot).
          await this.advanceSchedule(
            schedule,
            effectiveWindowConfig(schedule, liveCfg),
            now,
          );
        } catch (err) {
          // This is THE line to watch when a window freezes mid-phase: the tick
          // catches a throw here per-schedule and moves on, so a schedule whose
          // transition keeps throwing stays stuck in its phase forever. Log the
          // phase + stack so the failing step is obvious.
          this.logger.error(
            `[WINDOW] transition FAILED for schedule ${schedule.id} ` +
              `(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
              `${(err as Error).message}`,
          );
          this.logger.error(
            `[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
          );
        }
      }

      await this.settleOverdueReservations();

      // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
      // (30 ticks at the 10-second cadence).
      this.tickCount += 1;
      if (this.tickCount % 30 === 0) {
        await this.bookingBatchService.runBatchFill();
      }
    } finally {
      this.ticking = false;
    }
  }

  /**
   * The route-day currently in document review whose deadline is nearest and
   * which still has un-accepted requests. Null when nothing is under review or
   * every request has been decided — the backoffice header shows nothing then.
   *
   * One card, one deadline, one count: route-days are checked in deadline order
   * and the first with pending work wins, so the number always belongs to the
   * clock beside it.
   */
  async getDocReviewAlert(): Promise<DocReviewAlert | null> {
    const reviewing = (
      await this.trainSchedulesRepository.findAll({
        where: [
          { status: TrainScheduleStatusEnum.Draft },
          { status: TrainScheduleStatusEnum.Scheduled },
        ],
      })
    )
      .filter(
        (s) =>
          s.windowPhase === 'DOC_REVIEW' &&
          s.docReviewCompletedAt == null &&
          s.docReviewEndsAt != null &&
          s.scheduledDepartureDate != null,
      )
      .sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
    if (reviewing.length === 0) return null;

    const liveCfg = await this.trainSchedulingService.getWindowConfig();
    const seen = new Set<string>();
    for (const schedule of reviewing) {
      const group = {
        originYardId: schedule.originStationId,
        destinationYardId: schedule.destinationStationId,
        day: eatDay(schedule.scheduledDepartureDate),
      };
      // Sibling trains share one review phase for the route-day pool — count it once.
      const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
      if (seen.has(key)) continue;
      seen.add(key);

      const pendingCount =
        await this.bookingBatchService.countUnacceptedForRouteDay(group);
      if (pendingCount === 0) continue;

      return {
        scheduleId: schedule.id,
        ...group,
        // Carried so the backoffice list opens on the same direction the
        // at-risk requests belong to (import corridor, or a domestic day).
        tradeDirection: schedule.direction ?? 'IMPORT',
        docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
        docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
        pendingCount,
      };
    }
    return null;
  }

  /** Staff finished document review early — start the batch/payment phase now. */
  async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.windowPhase !== 'DOC_REVIEW') {
      // Idempotent for the whole route-day group: only DOC_REVIEW schedules move.
      return schedule;
    }
    const now = new Date();
    const liveCfg = await this.trainSchedulingService.getWindowConfig();
    // Stamp the whole route-day group so one staff action releases every train
    // sharing this booking day's pool.
    const group = (
      await this.trainSchedulesRepository.findAll({
        where: {
          originStationId: schedule.originStationId,
          destinationStationId: schedule.destinationStationId,
        },
      })
    ).filter(
      (s) =>
        s.windowPhase === 'DOC_REVIEW' &&
        s.scheduledDepartureDate != null &&
        eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate),
    );
    for (const s of group) {
      await this.dataSource
        .getRepository(TrainSchedule)
        .update(s.id, { docReviewCompletedAt: now });
      s.docReviewCompletedAt = now;
      await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
      // The batch fill emits only for trains it actually reserved onto — this
      // covers the empty-pool case so every open board still refetches. Push
      // failures only log; the doc-review completion itself already persisted.
      try {
        this.gateway.emitBatchChanged(s.id, 'doc_review_completed');
      } catch (err) {
        this.logger.warn(
          `Batch-board push failed for ${s.id}: ${(err as Error).message}`,
        );
      }
    }
    const fresh = await this.trainSchedulesRepository.findById(scheduleId);
    return fresh ?? schedule;
  }

  // ---- transitions ------------------------------------------------------------

  private async advanceSchedule(
    schedule: TrainSchedule,
    cfg: BookingWindowConfig,
    now: Date,
  ): Promise<void> {
    // Apply every transition that is due, in order (fast-forwards after downtime).
    for (let guard = 0; guard < 6; guard += 1) {
      const advanced =
        schedule.direction === 'EXPORT'
          ? await this.advanceExport(schedule, now)
          : await this.advanceImport(schedule, cfg, now);
      if (!advanced) return;
      // Push the new window state to portal home / backoffice GL sections so
      // they refresh instantly instead of waiting out their poll interval.
      this.gateway.emitPhase(schedule);
    }
  }

  /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */
  private async advanceExport(schedule: TrainSchedule, now: Date): Promise<boolean> {
    if (
      schedule.windowPhase === 'PRE_WINDOW' &&
      schedule.windowOpensAt &&
      now >= schedule.windowOpensAt
    ) {
      await this.setPhase(schedule, {
        windowPhase: 'OPEN',
        bookingCycleNo: schedule.bookingCycleNo + 1,
      });
      if (schedule.bookingWindowStatus !== 'FULL') {
        await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
        schedule.bookingWindowStatus = 'OPEN';
      }
      // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
      // (the `ticking` guard would otherwise delay every schedule's transition).
      void this.notifyWindowOpened(schedule);
      this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
      return true;
    }
    if (
      schedule.windowPhase === 'OPEN' &&
      schedule.windowClosesAt &&
      now >= schedule.windowClosesAt
    ) {
      await this.setPhase(schedule, { windowPhase: 'DONE' });
      if (schedule.bookingWindowStatus === 'OPEN') {
        await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
        schedule.bookingWindowStatus = 'CLOSED';
      }
      // Export has no conclude step: this close is the last moment the day's
      // bookings could have boarded. Once every train on the route-day is
      // shut, expire what is still waiting for this date (the sweep defers
      // while a sibling train stays open).
      await this.bookingBatchService.expireLeftoverExportDay(schedule.id);
      return true;
    }
    return false;
  }

  private async advanceImport(
    schedule: TrainSchedule,
    cfg: BookingWindowConfig,
    now: Date,
  ): Promise<boolean> {
    const { windowPhase, windowOpensAt, windowClosesAt } = schedule;

    // Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive
    // them through the same not-full conclude path so they resume next morning
    // (or finalize as DONE if no cycle fits before departure).
    if (windowPhase === 'CLOSED_FOR_DAY') {
      await this.concludeCycle(schedule, cfg, now);
      return true;
    }

    if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
      await this.setPhase(schedule, {
        windowPhase: 'OPEN',
        bookingCycleNo: schedule.bookingCycleNo + 1,
        docReviewCompletedAt: null,
        docReviewEndsAt: null,
        paymentPhaseEndsAt: null,
      });
      if (schedule.bookingWindowStatus !== 'FULL') {
        await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
        schedule.bookingWindowStatus = 'OPEN';
      }
      // Only announce the first opening of the day; reopen cycles don't re-notify.
      // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
      if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
      this.logger.log(
        `[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
          `(cycle ${schedule.bookingCycleNo})`,
      );
      return true;
    }

    if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) {
      const docReviewEndsAt = new Date(
        windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000,
      );
      await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt });
      if (schedule.bookingWindowStatus === 'OPEN') {
        await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
        schedule.bookingWindowStatus = 'CLOSED';
      }
      this.logger.log(
        `[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
          `review until ${docReviewEndsAt.toISOString()}`,
      );
      return true;
    }

    if (
      windowPhase === 'DOC_REVIEW' &&
      (schedule.docReviewCompletedAt != null ||
        (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt))
    ) {
      const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
      await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
      const routeDay = {
        originYardId: schedule.originStationId,
        destinationYardId: schedule.destinationStationId,
        day: eatDay(schedule.scheduledDepartureDate),
      };
      // Doc review is over: bookings staff never accepted (still pending) can no
      // longer make this train — expire them BEFORE the batch so they never
      // compete for capacity and never reach the pool.
      await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay);
      // Run the batch: priority fill over the route-day pool, reserving pay windows
      // (or allocating government) — skipped automatically for everyone who fits
      // is handled inside the fill (all fit → all reserved → all notified).
      await this.bookingBatchService.processRouteDay(routeDay);
      // Batch reserved nobody (empty pool, or it allocated without pay windows):
      // a PAYMENT phase with nobody to pay is a dead hour with the window shut.
      // Conclude straight away — full → DONE, otherwise reopen per office hours.
      if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) {
        this.logger.log(
          `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` +
            `skipping the empty payment phase and concluding the cycle`,
        );
        await this.concludeCycle(schedule, cfg, now);
        return true;
      }
      this.logger.log(
        `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
          `until ${paymentPhaseEndsAt.toISOString()}`,
      );
      return true;
    }

    if (
      windowPhase === 'PAYMENT' &&
      schedule.paymentPhaseEndsAt != null &&
      now >= schedule.paymentPhaseEndsAt
    ) {
      this.logger.log(
        `[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
          `(allocate paid / expire unpaid) then concluding the cycle`,
      );
      await this.bookingBatchService.settleDueReservations(schedule.id);

      // The settle expires unpaid reservations and promotes the waiting list into
      // the wagons they free. Those promoted customers get a fresh pay window, and
      // `extendPaymentPhaseForTopUp` pushes `paymentPhaseEndsAt` past `now` to
      // cover it. Concluding here on the STALE in-memory timestamp would end the
      // cycle the top-up just extended and expire them before they could pay — so
      // re-read, and stay in PAYMENT if the deadline moved.
      const settled = await this.trainSchedulesRepository.findById(schedule.id);
      if (settled?.paymentPhaseEndsAt && now < settled.paymentPhaseEndsAt) {
        schedule.paymentPhaseEndsAt = settled.paymentPhaseEndsAt;
        this.logger.log(
          `[WINDOW] ${schedule.id} PAYMENT extended to ` +
            `${settled.paymentPhaseEndsAt.toISOString()} — waiting-list bookings were ` +
            `promoted into the freed wagons; not concluding this cycle yet`,
        );
        return true;
      }

      // `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
      // deadline is set milliseconds later, per booking, so the phase always expires
      // a fraction before the reservations it opened. Concluding here would end the
      // cycle while customers still had time to pay, and the settle that finally
      // expires them (next tick) would have no cycle left to promote the waiting
      // list into. Hold in PAYMENT until every reservation has actually resolved.
      if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
        this.logger.log(
          `[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
            `are still within their pay windows — holding the cycle open`,
        );
        return true;
      }

      await this.concludeCycle(schedule, cfg, now);
      return true;
    }

    return false;
  }

  /**
   * After settle: full → finalize + DONE; waiting bookings still fit → fresh pay
   * window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE.
   */
  private async concludeCycle(
    schedule: TrainSchedule,
    cfg: BookingWindowConfig,
    now: Date,
  ): Promise<void> {
    const full = await this.bookingBatchService.isScheduleFull(schedule.id);
    if (full) {
      await this.bookingBatchService.setWindow(schedule.id, 'FULL');
      await this.setPhase(schedule, { windowPhase: 'DONE' });
      await this.tryAutoFinalize(schedule.id);
      this.logger.log(
        `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
      );
      // This train is done. If no other train on the route-day can still take
      // the waiting list, those bookings have nowhere to go — expire + notify
      // them now instead of leaving them FULLY_EXECUTED forever.
      await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
      return;
    }

    // Not full, so any FULL flag left over from a batch whose bookings later
    // expired is stale. Clear it here too: the PRE_WINDOW→OPEN transition below
    // refuses to reopen a FULL schedule, which is how a train with an empty
    // consist used to cycle forever without ever being fillable again. Re-read
    // the flag onto the in-memory row — advanceSchedule keeps looping on this
    // same object, and PRE_WINDOW→OPEN reads it.
    if (schedule.bookingWindowStatus === 'FULL') {
      await this.bookingBatchService.refreshWindowStatus(schedule.id);
      const fresh = await this.trainSchedulesRepository.findById(schedule.id);
      if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus;
    }

    // The window reopens only once the waiting list is exhausted: a booking can
    // still reach the pool mid-payment (late doc accept, consolidation partner),
    // so retry the batch before reopening. Anything that fits gets a fresh pay
    // window and the cycle stays in PAYMENT; check live reservations on THIS
    // schedule because the day-level fill may have reserved onto a sibling.
    // Waiting bookings that fit no train stay pooled and the window reopens.
    // Booking shuts at the configured cutoff (departure − closeOffset), not
    // departure — every phase-end below is bounded by it, mirroring the initial
    // window computation.
    const cutoff = bookingCloseCutoff(
      schedule.scheduledDepartureDate,
      schedule.direction,
      cfg,
    );

    const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id);
    if (
      promoted > 0 &&
      (await this.bookingBatchService.hasLiveReservations(schedule.id))
    ) {
      let paymentPhaseEndsAt = new Date(
        now.getTime() + cfg.paymentWindowMinutes * 60_000,
      );
      if (paymentPhaseEndsAt > cutoff) {
        paymentPhaseEndsAt = cutoff;
      }
      await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
      this.logger.log(
        `[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` +
          `fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`,
      );
      return;
    }

    // Doc review + payment have already run, so the desk is ready to reopen NOW —
    // office hours decide whether that is this afternoon or tomorrow morning. Past
    // the last cycle before departure, nextCycleOpensAt returns null and we finish.
    const officeHours: OfficeHours = {
      windowOpenHour: cfg.windowOpenHour,
      windowCloseHour: cfg.windowCloseHour,
    };
    const nextOpensAt = nextCycleOpensAt(now, officeHours, cutoff);
    if (nextOpensAt == null) {
      await this.setPhase(schedule, { windowPhase: 'DONE' });
      this.logger.log(
        `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
          `departure — window DONE`,
      );
      // No further cycle on this train. Same sweep as the FULL branch: if no
      // sibling train can still take the day's waiting list, expire + notify.
      await this.bookingBatchService.expireLeftoverDayPool(schedule.id);
      return;
    }

    let nextClosesAt = new Date(
      nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000,
    );
    // Office hours end a running window early: never let the duration outlive
    // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00).
    nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours);
    if (nextClosesAt > cutoff) {
      nextClosesAt = cutoff;
    }
    // Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
    // whether that is later today or next morning after the office-hours break.
    await this.setPhase(schedule, {
      windowPhase: 'PRE_WINDOW',
      windowOpensAt: nextOpensAt,
      windowClosesAt: nextClosesAt,
      docReviewCompletedAt: null,
      docReviewEndsAt: null,
      paymentPhaseEndsAt: null,
    });
    const sameDay = eatDay(nextOpensAt) === eatDay(now);
    this.logger.log(
      `[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
        `REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
    );
  }

  private async tryAutoFinalize(scheduleId: string): Promise<void> {
    try {
      await this.trainSchedulingService.finalizeSchedule(scheduleId);
      this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`);
    } catch (err) {
      // Not DRAFT / no linked bookings yet — staff finalize manually.
      this.logger.warn(
        `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`,
      );
    }
  }

  /** Durable settle backstop: expire/allocate reservations whose deadline passed. */
  private async settleOverdueReservations(): Promise<void> {
    const overdue = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('b')
      .select('DISTINCT b.train_schedule_id', 'scheduleId')
      .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
      .andWhere('b.payment_deadline <= now()')
      .andWhere('b.train_schedule_id IS NOT NULL')
      .getRawMany<{ scheduleId: string }>();
    for (const { scheduleId } of overdue) {
      try {
        await this.bookingBatchService.settleDueReservations(scheduleId);
      } catch (err) {
        this.logger.warn(
          `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`,
        );
      }
    }
  }

  /**
   * SMS + email every active-contract customer on this schedule's route when its
   * booking window opens, so they can book from the portal home before it closes.
   * Fire-and-forget; a failed notification never blocks the window transition.
   */
  private async notifyWindowOpened(schedule: TrainSchedule): Promise<void> {
    try {
      const rows: Array<{
        company_id: string;
        phone: string | null;
        email: string | null;
      }> = await this.dataSource.query(
        `SELECT DISTINCT
                c.company_id,
                ${companyNotifyPhoneExpr('co')} AS phone,
                COALESCE(co.email, co.general_manager_email) AS email
           FROM freight.contract_routes cr
           JOIN freight.contracts c
             ON c.id = cr.contract_id
            AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
            AND c.deleted_at IS NULL
           JOIN freight.companies co ON co.id = c.company_id
           ${primaryContactUserJoin('co')}
          WHERE cr.origin_yard_id = $1
            AND cr.destination_yard_id = $2
            AND cr.deleted_at IS NULL`,
        [schedule.originStationId, schedule.destinationStationId],
      );
      if (!rows.length) return;

      const closes = schedule.windowClosesAt
        ? schedule.windowClosesAt.toLocaleString('en-GB', { timeZone: BATCH_TIMEZONE })
        : 'later today';
      const depart = schedule.scheduledDepartureDate.toLocaleDateString('en-GB', {
        timeZone: BATCH_TIMEZONE,
      });
      const msg =
        `Booking is now open for the train departing ${depart}. ` +
        `Book your shipment from the portal home page before ${closes} EAT.`;

      const seenPhone = new Set<string>();
      const seenEmail = new Set<string>();
      const seenCompany = new Set<string>();
      for (const r of rows) {
        if (r.phone && !seenPhone.has(r.phone)) {
          seenPhone.add(r.phone);
          await this.notifications
            .directSend('sms', r.phone, msg)
            .catch((e) => this.logger.warn(`Window-open SMS failed: ${(e as Error).message}`));
        }
        if (r.email && !seenEmail.has(r.email)) {
          seenEmail.add(r.email);
          await this.notifications
            .directSend('email', r.email, msg)
            .catch((e) => this.logger.warn(`Window-open email failed: ${(e as Error).message}`));
        }
        // In-app inbox item for every portal user of each eligible company,
        // deep-linking to the new-booking page.
        if (r.company_id && !seenCompany.has(r.company_id)) {
          seenCompany.add(r.company_id);
          void this.inbox.notify({
            recipients: { companyId: r.company_id },
            audience: NotificationAudience.PORTAL,
            type: NotificationType.SCHEDULE_UPDATE,
            title: 'Booking window open',
            body: msg,
            link: '/bookings/new',
            data: { trainScheduleId: schedule.id },
          });
        }
      }
      this.logger.log(
        `Notified ${seenPhone.size} phone / ${seenEmail.size} email / ${seenCompany.size} companies (in-app) of open window for schedule ${schedule.id}`,
      );
    } catch (err) {
      this.logger.warn(
        `notifyWindowOpened failed for ${schedule.id}: ${(err as Error).message}`,
      );
    }
  }

  private async setPhase(
    schedule: TrainSchedule,
    patch: Partial<
      Pick<
        TrainSchedule,
        | 'windowPhase'
        | 'windowOpensAt'
        | 'windowClosesAt'
        | 'docReviewEndsAt'
        | 'docReviewCompletedAt'
        | 'paymentPhaseEndsAt'
        | 'bookingCycleNo'
      >
    >,
  ): Promise<void> {
    await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
    Object.assign(schedule, patch);
  }
}
