import {
  BadRequestException,
  ConflictException,
  forwardRef,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
  OnModuleInit,
  Optional,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
  Between,
  DataSource,
  FindOptionsWhere,
  ILike,
  In,
  LessThanOrEqual,
  MoreThanOrEqual,
} from 'typeorm';

import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay } from './batch-window.util';
import {
  BATCH_BOARD_STATUSES,
  BatchBoardQueryDto,
} from './dto/batch-board-query.dto';
import {
  Freight,
  PaginatedResponse,
  TrainScheduleStatus as TrainScheduleStatusEnum,
} from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
  buildPaginationMeta,
  normalizePagination,
} from '../../common/utils/pagination.util';


import {
  DEFAULT_BULK_WAGON_CAPACITY_TONS,
  DEFAULT_BULK_WAGON_LENGTH_METERS,
  DEFAULT_BULK_WAGON_TARE_TONS,
  DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
  DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
  DEFAULT_CONTAINER_WAGON_TARE_TONS,
  DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
  LocomotiveLimits,
  WagonTypeDimensions,
  bookingCargoTons,
  bookingGrossWeightTons,
  deriveTrainCapacityFromLocomotive,
  sizePartialOfferWagons,
  trainHardCaps,
  trainSetLocomotiveLimits,
  wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { RemainderPlacementService } from './remainder-placement.service';
import { BookingWindowGateway } from './booking-window.gateway';
import {
  MAX_TEU_SLOTS_PER_WAGON,
  containerWagonsForLines,
} from './wagon-plan.util';
import {
  Capacity,
  CorridorBudget,
  CorridorLeg,
  OverageTolerance,
  stopYardsFor,
} from './corridor-capacity.util';

export type { Capacity } from './corridor-capacity.util';

/**
 * A train's fill limits: the base caps the corridor budget spends from, plus
 * the locomotive overage tolerance spendable only on whole-booking admission.
 */
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };

/**
 * Result of the export whole-booking single-train space check. `scheduleId`
 * is the earliest fillable train that carries the whole booking, or null when
 * none can — then `bestAvailable` reports the largest single-train leftover
 * in the booking's own units and `fullMessage` is the customer-facing copy.
 */
export interface ExportSpaceReport {
  scheduleId: string | null;
  trainsForDay: boolean;
  corridorMatched: boolean;
  need: Capacity;
  bestAvailable: { wagons: number; cargoTons: number } | null;
  fullMessage: string | null;
}

/** A day-level pool key: all trains on this route departing on this EAT day. */
interface RouteDayGroup {
  originYardId: string;
  destinationYardId: string;
  /** EAT calendar day, `yyyy-MM-dd`. */
  day: string;
}

/** One wagon type's footprint: its length on the train, the tare it adds to the
 * locomotive's gross load, and the payload it carries. */
type PerWagonDims = { lengthMeters: number; tareWeightTons: number; capacityTons: number };

/**
 * Wagon dimensions used to size a booking's capacity draw. `byWagonTypeId` holds
 * every wagon type so a booking is measured on the type its cargo/container type
 * actually rides (the same FK resolution allocation uses); `container`/`bulk` are
 * representative fallbacks for bookings whose type has no wagon type configured.
 */
type WagonDims = {
  container: PerWagonDims;
  bulk: PerWagonDims;
  byWagonTypeId: Map<string, PerWagonDims>;
};

export type BatchBoardBookingState =
  | "ALLOCATED"
  | "SELECTED_FOR_BATCH"
  | "READY"
  | "WAITING"
  | "PENDING_CONTRACT"
  | "EXPIRED";

export interface BatchBoardBooking {
  id: string;
  reference: string;
  company: string;
  isGovernment: boolean;
  wagons: number;
  weightTons: number;
  lengthMeters: number;
  paymentDeadline: string | null;
  state: BatchBoardBookingState;
  /** Rule-engine priority score used to rank the batch (higher = boards first). */
  priorityScore: number;
  /** CONTAINER | BULK — for the priority-tracking visuals. */
  freightType: string | null;
}

export type BookingAllocationStatus =
  | "NOT_ATTEMPTED"
  | "ASSIGNED"
  | "DEFERRED"
  | "FAILED";

export interface BatchBoardBookingDetail extends BatchBoardBooking {
  fullyExecutedAt: string | null;
  selectedForBatchAt: string | null;
  allocationStatus: BookingAllocationStatus;
  allocationIssue: string | null;
  /** Set when this booking shares a wagon with a consolidation partner. */
  consolidationPartnerId: string | null;
  consolidationPartnerRef: string | null;
}

export interface BatchBoardCounts {
  allocated: number;
  selectedForBatch: number;
  ready: number;
  waiting: number;
  expired: number;
  pendingContract: number;
}

/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
  counts: BatchBoardCounts;
  bookings: BatchBoardBookingDetail[];
}

export interface BatchBoardScheduleDetail {
  scheduleId: string;
  /** Human-facing schedule reference (S-YYYY-NNNNN). */
  scheduleReference: string | null;
  trainNumber: string | null;
  routeName: string | null;
  origin: string | null;
  destination: string | null;
  scheduleDate: string | null;
  status: string;
  bookingWindowStatus: string;
  direction: string | null;
  windowPhase: string | null;
  windowOpensAt: string | null;
  windowClosesAt: string | null;
  docReviewEndsAt: string | null;
  paymentPhaseEndsAt: string | null;
  bookingCycleNo: number;
  /** Built train (Train Builder) behind this departure, when scheduled by train. */
  train: BatchBoardSchedule["train"];
  locomotive: BatchBoardSchedule["locomotive"];
  capacity: BatchBoardSchedule["capacity"];
  counts: BatchBoardSchedule["counts"];
  /** Bookings inside the schedule's booking window (fully-executed contracts). */
  bookings: BatchBoardBookingDetail[];
  pendingContract: BatchBoardBucket;
  allocationViolations: string[];
}

export interface BatchBoardSchedule {
  scheduleId: string;
  /** Human-facing schedule reference (S-YYYY-NNNNN). */
  scheduleReference: string | null;
  trainNumber: string | null;
  routeName: string | null;
  origin: string | null;
  destination: string | null;
  scheduleDate: string | null;
  createdAt: string | null;
  status: string;
  bookingWindowStatus: string;
  direction: string | null;
  windowPhase: string | null;
  windowOpensAt: string | null;
  windowClosesAt: string | null;
  docReviewEndsAt: string | null;
  paymentPhaseEndsAt: string | null;
  bookingCycleNo: number;
  /** Built train (Train Builder) behind this departure, when scheduled by train. */
  train: {
    id: string;
    code: string;
    trainName: string | null;
  } | null;
  locomotive: {
    code: string;
    name: string | null;
    maxPullWeightTons: number;
    maxTrainLengthMeters: number;
  } | null;
  capacity: {
    /** Wagons on bookings already linked to the train (ALLOCATED only). */
    allocatedWagons: number;
    /** Train length used by allocated bookings (from wagon-type dimensions). */
    allocatedLengthMeters: number;
    maxLengthMeters: number | null;
    /** Weight committed on the train (allocated + selected-for-batch). */
    usedWeightTons: number;
    maxWeightTons: number | null;
    /** Wagon-slot cap for the train (locomotive/wagon-type derived). */
    maxWagons: number | null;
  };
  counts: {
    allocated: number;
    selectedForBatch: number;
    ready: number;
    waiting: number;
    pendingContract: number;
    expired: number;
  };
  bookings: BatchBoardBooking[];
}

/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
 * response wrapper already uses `data`, and the frontend's unwrap() strips one
 * `data` level. */
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;

/**
 * Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
 * by priority, greedily fills the train to capacity (skipping bookings that don't fit),
 * reserves a 1h pay window for commercial customers (government allocated unpaid,
 * preempting lower-priority commercial if needed), then settles each batch 1h later —
 * allocating those who paid and expiring those who didn't, topping up from the waiting list.
 * Capacity is bounded on three axes at once: wagon count (`schedule.maxWagons`), the
 * locomotive's max pull weight, and its max train length (also capped by global rules).
 */
@Injectable()
export class BookingBatchService implements OnModuleInit {
  private readonly logger = new Logger(BookingBatchService.name);

  /**
   * Serialises settle/top-up per schedule. The PAYMENT phase transition and the
   * tick's overdue backstop both call settleDueReservations for the same schedule
   * in the same second; without this they interleave and the top-up runs against a
   * schedule whose phase has already been concluded.
   */
  private readonly scheduleLocks = new Map<string, Promise<void>>();

  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly bookingsRepository: BookingsRepository,
    private readonly trainSchedulesRepository: TrainSchedulesRepository,
    private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
    private readonly notifier: BookingNotifierService,
    private readonly scheduler: SchedulerRegistry,
    // forwardRef: TrainSchedulingService injects this service back (window
    // refresh after adjust-consist), so the classes load in a cycle.
    @Inject(forwardRef(() => TrainSchedulingService))
    private readonly trainSchedulingService: TrainSchedulingService,
    private readonly billing: BillingService,
    private readonly bookingWindowGateway: BookingWindowGateway,
    @Inject(forwardRef(() => BookingPricingService))
    private readonly pricingService: BookingPricingService,

    @Optional() private readonly milestoneService?: ClearanceMilestoneService,
    @Optional() private readonly splitService?: BookingSplitService,
    @Optional()
    @Inject(forwardRef(() => RemainderPlacementService))
    private readonly remainderPlacement?: RemainderPlacementService,

  ) {}

  /**
   * Auto-place a paid booking's split remainder onto the next fitting train.
   * Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
   */
  private get autoRemainderEnabled(): boolean {
    return process.env.FREIGHT_AUTO_REMAINDER === "true";
  }

  /**
   * Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
   * on the next train). Separate flag from auto-remainder: export touches the
   * FCFS money path, so partial-offer can be enabled independently.
   */
  private get exportSplitEnabled(): boolean {
    return process.env.FREIGHT_EXPORT_SPLIT === "true";
  }

  /** On boot, reconcile OPEN route-days and re-arm settle timers. */
  async onModuleInit(): Promise<void> {
    const groups = await this.openRouteDayGroups();
    for (const group of groups) {
      try {
        await this.processRouteDay(group);
      } catch (err) {
        this.logger.warn(
          `Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
        );
      }
    }
    const reserved = 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.train_schedule_id IS NOT NULL")
      .getRawMany<{ scheduleId: string }>();
    for (const { scheduleId } of reserved) this.armSettle(scheduleId);
  }

  /**
   * Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
   * (contract sign, payment). Day-level pooling distributes across all of that
   * day's trains, so a single schedule id maps to its whole route-day group.
   */
  enqueueScheduleProcessing(scheduleId: string): void {
    void this.processRouteDayForSchedule(scheduleId).catch((err) =>
      this.logger.error(
        `processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
      ),
    );
  }

  /**
   * Fire-and-forget batch pipeline for a (route, day) directly — used when a
   * booking enters the pool without a target train yet (e.g. after the
   * operations team accepts an operation request). The booking is already
   * FULLY_EXECUTED with its scheduled_date set, so the day-level fill will pick
   * it up; this just runs that fill immediately instead of waiting for the cron.
   */
  enqueueRouteDayProcessing(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): void {
    void this.processRouteDay({ originYardId, destinationYardId, day }).catch(
      (err) =>
        this.logger.error(
          `processRouteDay for ${originYardId}→${destinationYardId} on ${day} failed: ${(err as Error).message}`,
        ),
    );
  }

  /** Resolve a schedule's (route, day) group and run the day-level pipeline. */
  private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule?.scheduledDepartureDate) return;
    await this.processRouteDay({
      originYardId: schedule.originStationId,
      destinationYardId: schedule.destinationStationId,
      day: eatDay(schedule.scheduledDepartureDate),
    });
  }

  /**
   * Day-level pipeline: distribute the (route, day) pool across all its trains,
   * then settle / reconcile / assign wagons per schedule (those steps stay
   * schedule-scoped — only the fill is day-level).
   */
  async processRouteDay(group: RouteDayGroup): Promise<void> {
    this.logger.log(
      `[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`,
    );
    const scheduleIds = await this.fillRouteDay(
      group.originYardId,
      group.destinationYardId,
      group.day,
    );
    for (const scheduleId of scheduleIds) {
      await this.settleDueReservations(scheduleId);
      await this.reconcilePaidUnlinked(scheduleId);
      await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
    }
  }

  /** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
  async processSchedule(scheduleId: string): Promise<void> {
    await this.fillSchedule(scheduleId);
    await this.settleDueReservations(scheduleId);
    await this.reconcilePaidUnlinked(scheduleId);
    await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
  }

  /**
   * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
   * schedules with a `windowPhase` are driven exclusively by the window engine
   * (BookingWindowService), never by the periodic legacy fill.
   */
  private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
    const open = (
      await this.trainSchedulesRepository.findAll({
        where: [
          { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
          { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
        ],
      })
    ).filter((s) => s.windowPhase == null);
    const groups = new Map<string, RouteDayGroup>();
    for (const s of open) {
      if (!s.scheduledDepartureDate) continue;
      const day = eatDay(s.scheduledDepartureDate);
      const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
      if (!groups.has(key)) {
        groups.set(key, {
          originYardId: s.originStationId,
          destinationYardId: s.destinationStationId,
          day,
        });
      }
    }
    return [...groups.values()];
  }

  private groupLabel(group: RouteDayGroup): string {
    return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
  }

  /**
   * Idempotent: link a paid batch booking to its schedule and assign wagons.
   * Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
   */
  async ensurePaidBookingAllocated(bookingId: string): Promise<void> {
    const booking = await this.dataSource.getRepository(Booking).findOne({
      where: { id: bookingId },
      relations: { company: true },
    });
    if (!booking) return;
    if (!booking.trainScheduleId) {
      // A paid booking with no train is money taken and nothing boarding —
      // scream so staff pin it to a schedule manually (batch board / assign).
      if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
        this.logger.error(
          `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
            `its reservation was likely expired before the payment landed. ` +
            `Assign it to a schedule manually from the batch board.`,
        );
      }
      return;
    }

    const isBatchPaid =
      booking.status === "SELECTED_FOR_BATCH" ||
      booking.status === "AWAITING_PAYMENT" ||
      booking.status === "PAID" ||
      booking.paymentStatus === "PAID";
    if (!isBatchPaid) return;

    if (
      booking.status === "SELECTED_FOR_BATCH" ||
      booking.status === "AWAITING_PAYMENT"
    ) {
      await this.dataSource
        .getRepository(Booking)
        .update(bookingId, { paymentStatus: "PAID", status: "PAID" });
    } else if (booking.paymentStatus !== "PAID") {
      await this.dataSource
        .getRepository(Booking)
        .update(bookingId, { paymentStatus: "PAID" });
    }

    // Paying inside the window accepts an open partial offer — reduce the booking
    // to the offered part before it boards (remainder returns to the contract cap).
    if (this.splitService) {
      await this.splitService.applySplit(bookingId);

      // The split only happens on payment (here) — so auto-placing the remainder
      // also only happens once the customer has accepted+paid. Re-read to see if
      // applySplit actually reduced this booking (an open offer existed); if so,
      // auto-create + place the remainder booking on the next fitting train.
      // applySplit committed its own transaction before returning, so this reads
      // the reduced lines. Best-effort: a placement failure never blocks the
      // paid booking from boarding — the remainder falls back to manual rebook.
      if (this.autoRemainderEnabled && this.remainderPlacement) {
        const split = await this.dataSource
          .getRepository(Booking)
          .findOne({ where: { id: bookingId } });
        // Export remainders only auto-place when export split is on — otherwise
        // an export booking never splits in the first place.
        const directionOn =
          split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
        if (split?.isSplit && directionOn) {
          await this.remainderPlacement
            .placeRemainder(split)
            .catch((err) =>
              this.logger.error(
                `Auto-place remainder failed for ${split.reference}: ${
                  err instanceof Error ? err.message : String(err)
                }`,
              ),
            );
        }
      }
    }

    const linked =
      await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
    if (!linked) {
      if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
      await this.allocate(booking.trainScheduleId, booking, "paid");
      this.logger.log(
        `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`,
      );
    } else {
      // Already linked at booking time (export FCFS: the customer books a
      // specific train, so allocate() ran up front). allocate() is where the
      // payment-settled tracking milestones are written, so on this branch we
      // record them here — otherwise a paid, already-linked booking leaves
      // FREIGHT_PAYMENT_SETTLED stuck PENDING and the clearance step never ticks.
      void this.completeTrackingMilestones(bookingId, [
        "WAGON_REQUESTED",
        "FREIGHT_PAYMENT_PENDING",
        "FREIGHT_PAYMENT_SETTLED",
      ]);
      void this.markWagonAllocatedMilestone(bookingId);
    }

    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
      booking.trainScheduleId,
    );
    if (schedule && (await this.isTrainFull(schedule))) {
      await this.setWindow(booking.trainScheduleId, "FULL");
      // This payment may have been the last live pay window on a now-full
      // export day — the settle that normally re-runs the sweep finds nothing
      // left to settle, so trigger it here.
      void this.expireLeftoverExportDay(booking.trainScheduleId);
    }

    const result = await this.trainSchedulingService.tryAutoWagonAllocation(
      booking.trainScheduleId,
    );
    if (result.assignedBookingIds.length) {
      this.logger.log(
        `Wagon allocation for ${booking.reference ?? bookingId}: ${result.assignedBookingIds.length} assigned`,
      );
    }
    if (
      result.issues.some(
        (i) => i.bookingId === bookingId && i.status !== "ASSIGNED",
      )
    ) {
      const issue = result.issues.find((i) => i.bookingId === bookingId);
      this.logger.warn(
        `Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
      );
    }
    this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
  }

  /** Customer paid — delegate to ensurePaidBookingAllocated. */
  async confirmPaidAndAllocate(bookingId: string): Promise<void> {
    await this.ensurePaidBookingAllocated(bookingId);
  }

  /** Open partial-capacity offer summary for booking detail payloads (null when none). */
  async getOpenOfferSummary(bookingId: string): Promise<{
    offeredWagons: number;
    totalWagons: number;
    offeredAmount: number;
    paymentDeadline: Date;
  } | null> {
    if (!this.splitService) return null;
    const offer = await this.splitService.findOpenOffer(bookingId);
    if (!offer) return null;
    return {
      offeredWagons: offer.offeredWagons,
      totalWagons: offer.totalWagons,
      offeredAmount: Number(offer.offeredAmount),
      paymentDeadline: offer.paymentDeadline,
    };
  }

  // ---- export FCFS -----------------------------------------------------------

  /**
   * Whole-booking single-train space report for an EXPORT booking. Export
   * bookings never split — the entire booking must ride ONE train, so the
   * report scans every fillable export train on the booking's corridor/day
   * (earliest first) for one whose remaining budget fits the whole need. When
   * none fits, `bestAvailable` carries the largest single-train leftover
   * converted into the booking's own units (base caps, no overage tolerance)
   * so the customer can be told exactly how much he COULD book on that day.
   */
  async exportSpaceReport(
    booking: Booking,
    need?: Capacity,
  ): Promise<ExportSpaceReport> {
    if (!booking.scheduledDate) {
      throw new BadRequestException('Booking has no scheduled date');
    }
    const day = eatDay(new Date(booking.scheduledDate));
    // Corridor-aware: any train whose route carries the booking's origin
    // strictly before its destination qualifies — a Dire→Djibouti booking may
    // ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
    // stop order, so we fetch the day's open trains without endpoint filters.
    const corridor = await this.trainSchedulesRepository.findAll({
      where: [
        { status: TrainScheduleStatusEnum.Draft },
        { status: TrainScheduleStatusEnum.Scheduled },
      ],
    });
    const candidates = corridor
      .filter(
        (s) =>
          s.scheduledDepartureDate != null &&
          eatDay(s.scheduledDepartureDate) === day &&
          this.isFillable(s),
      )
      .sort(
        (a, b) =>
          a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
      );

    const wagonDims = await this.loadWagonDims();
    const required = need ?? this.needFor(booking, wagonDims);
    const dims = this.dimsFor(booking, wagonDims);
    const report: ExportSpaceReport = {
      scheduleId: null,
      trainsForDay: candidates.length > 0,
      corridorMatched: false,
      need: required,
      bestAvailable: null,
      fullMessage: null,
    };

    for (const candidate of candidates) {
      const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
        candidate.id,
      );
      const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
      if (!schedule || !locomotive) continue;
      const limits = await this.capacityLimits(locomotive);
      const budget = await this.remainingBudget(schedule, limits, wagonDims);
      const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
      if (!leg) continue; // this train's route doesn't carry the booking's leg
      report.corridorMatched = true;
      if (budget.fits(required, leg)) {
        // Earliest fitting train wins — no need to keep sizing leftovers.
        report.scheduleId = schedule.id;
        return report;
      }
      const available = this.bookableWithin(budget.remainingFor(leg), dims);
      if (
        !report.bestAvailable ||
        available.cargoTons > report.bestAvailable.cargoTons ||
        (available.cargoTons === report.bestAvailable.cargoTons &&
          available.wagons > report.bestAvailable.wagons)
      ) {
        report.bestAvailable = available;
      }
    }

    report.fullMessage = this.exportFullMessage(booking, report);
    return report;
  }

  /**
   * Largest booking (in the requester's own wagon-type units) that a single
   * train's leftover base capacity could still admit: bounded by free wagon
   * slots, free train length, and the locomotive's remaining pull weight
   * (gross — each wagon's tare eats into it before any cargo does).
   */
  private bookableWithin(
    remaining: Capacity,
    dims: PerWagonDims,
  ): { wagons: number; cargoTons: number } {
    const byLength =
      dims.lengthMeters > 0
        ? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
        : Math.floor(Math.max(0, remaining.wagons));
    const maxWagons = Math.max(
      0,
      Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
    );
    let bestTons = 0;
    let usableWagons = 0;
    for (let w = 1; w <= maxWagons; w++) {
      if (w * dims.tareWeightTons > remaining.weightTons) break;
      usableWagons = w;
      const tons = Math.min(
        w * dims.capacityTons,
        remaining.weightTons - w * dims.tareWeightTons,
      );
      if (tons > bestTons) bestTons = tons;
    }
    return {
      wagons: usableWagons,
      cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
    };
  }

  /** Customer-facing "train is full" copy carrying the bookable leftover. */
  private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
    if (!report.trainsForDay || !report.corridorMatched) {
      return 'No export train is accepting bookings for this day';
    }
    const best = report.bestAvailable;
    const base =
      'Not enough train space — an export booking must ride a single train whole, ' +
      'and no open train on this day can carry it. ';
    if (!best || best.wagons <= 0) {
      return base + 'No capacity is left on this day — pick another shipment day.';
    }
    if (booking.freightType === 'BULK') {
      return (
        base +
        `The largest remaining space is about ${best.cargoTons} tons ` +
        `(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
      );
    }
    return (
      base +
      `The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
      `(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
      'reduce the booking or pick another day.'
    );
  }

  /**
   * Export is first-come-first-serve: no window cycle, no priority, no batch.
   * Pick the earliest open export train on the booking's corridor/day that still
   * fits the booking. Throws ConflictException when every train is full — the
   * staff accept fails and no more export bookings are taken.
   */
  async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
    const report = await this.exportSpaceReport(booking, need);
    if (report.scheduleId) return report.scheduleId;
    throw new ConflictException(
      report.fullMessage ?? 'Train is full — no export capacity left for this day',
    );
  }

  /**
   * Trains that can carry a booking's leg on a given day, earliest departure
   * first, each with the largest number of wagons it could still admit for the
   * booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
   * IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
   * allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
   * non-primary allowed type still counts. The remainder placer uses this to
   * pick the next fitting train; the `free` wagon count is the best across the
   * allowed types (a train fits under whichever allowed type gives most room).
   */
  async fittingTrainsForDay(
    booking: Booking,
    day: string,
    direction: "IMPORT" | "EXPORT",
  ): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
    const corridor = await this.trainSchedulesRepository.findAll({
      where: [
        { status: TrainScheduleStatusEnum.Draft },
        { status: TrainScheduleStatusEnum.Scheduled },
      ],
    });
    const candidates = corridor
      .filter(
        (s) =>
          s.scheduledDepartureDate != null &&
          eatDay(s.scheduledDepartureDate) === day &&
          s.bookingWindowStatus !== "FULL" &&
          (direction === "EXPORT"
            ? s.direction === "EXPORT"
            : s.direction !== "EXPORT"),
      )
      .sort(
        (a, b) =>
          a.scheduledDepartureDate!.getTime() -
          b.scheduledDepartureDate!.getTime(),
      );

    const wagonDims = await this.loadWagonDims();
    const dimsOptions = this.dimsForAllowed(booking, wagonDims);
    const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];

    for (const candidate of candidates) {
      const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
        candidate.id,
      );
      const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
      if (!schedule || !locomotive) continue;
      const limits = await this.capacityLimits(locomotive);
      const budget = await this.remainingBudget(schedule, limits, wagonDims);
      const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
      if (!leg) continue; // this train's route doesn't carry the booking's leg
      const room = budget.remainingFor(leg);
      // Best usable wagons across the allowed types — a train fits under
      // whichever configured wagon type gives it the most room.
      let freeWagons = 0;
      for (const dims of dimsOptions) {
        const w = this.bookableWithin(room, dims).wagons;
        if (w > freeWagons) freeWagons = w;
      }
      if (freeWagons > 0) {
        out.push({
          scheduleId: schedule.id,
          departure: schedule.scheduledDepartureDate!,
          freeWagons,
        });
      }
    }
    return out;
  }

  /**
   * Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
   * summed across every train on the booking's corridor that day. Unlike the
   * export gate this does NOT block and does NOT first-fit a single train:
   * import is batched and splittable, so the honest number a customer can plan
   * against is the TOTAL room across the day's trains for the booking's wagon
   * type, in that type's own wagon units.
   *
   * It deliberately skips the `isFillable` window-phase gate. A customer picks a
   * shipment day while its window is still OPEN (or pre-window) — the batch fill
   * only makes those trains fillable after the window closes — so gating on the
   * fill phase here would report 0 for exactly the days customers are choosing.
   * We therefore count any non-FULL train that carries the leg, netting out the
   * capacity already consumed by allocated + live-reserved bookings
   * (`remainingBudget`). The count is an upper bound: the batch engine may still
   * split the booking across trains or defer a remainder to a later window.
   */
  async dayImportAvailability(
    booking: Booking,
    day: string,
  ): Promise<{ freeWagons: number; need: number; trainsForDay: boolean }> {
    const corridor = await this.trainSchedulesRepository.findAll({
      where: [
        { status: TrainScheduleStatusEnum.Draft },
        { status: TrainScheduleStatusEnum.Scheduled },
      ],
    });
    const candidates = corridor.filter(
      (s) =>
        s.scheduledDepartureDate != null &&
        eatDay(s.scheduledDepartureDate) === day &&
        s.bookingWindowStatus !== 'FULL' &&
        s.direction !== 'EXPORT',
    );

    const wagonDims = await this.loadWagonDims();
    const dims = this.dimsFor(booking, wagonDims);
    const need = this.wagonsFor(booking, wagonDims);
    let freeWagons = 0;
    let trainsForDay = false;

    for (const candidate of candidates) {
      const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
        candidate.id,
      );
      const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
      if (!schedule || !locomotive) continue;
      const limits = await this.capacityLimits(locomotive);
      const budget = await this.remainingBudget(schedule, limits, wagonDims);
      const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
      if (!leg) continue; // this train's route doesn't carry the booking's leg
      trainsForDay = true;
      freeWagons += this.bookableWithin(budget.remainingFor(leg), dims).wagons;
    }

    return { freeWagons, need, trainsForDay };
  }

  /**
   * Export split: no single train carries the whole booking, so offer the
   * largest fitting part on the export train with the most room for its leg.
   * Returns true when an offer was opened (the caller must NOT then reserve —
   * the offer already opened its own pay window), false when the booking fits
   * whole somewhere (normal FCFS path) or no meaningful partial exists.
   *
   * Only the offer is written here: the booking is reduced to the offered part
   * on payment (applySplit), and the leftover is auto-placed afterwards. So an
   * unpaid export booking stays whole and the customer may still cancel it.
   */
  private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
    if (!this.splitService) return false;
    const report = await this.exportSpaceReport(booking);
    // A train fits it whole — nothing to split, take the normal path.
    if (report.scheduleId) return false;
    if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;

    if (!booking.scheduledDate) return false;
    const day = eatDay(new Date(booking.scheduledDate));
    const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
    if (!fitting.length) return false;
    // Most room first — the largest single part ships now, the smallest leftover
    // is what has to find another train.
    const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];

    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
      target.scheduleId,
    );
    const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
    if (!schedule || !locomotive) return false;
    const wagonDims = await this.loadWagonDims();
    const limits = await this.capacityLimits(locomotive);
    const budget = await this.remainingBudget(schedule, limits, wagonDims);
    const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
    if (!leg) return false;

    const offered = await this.tryPartialOffer(
      booking,
      schedule.id,
      budget.remainingFor(leg),
      report.need,
    );
    if (!offered) return false;
    this.logger.log(
      `[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
        `${schedule.id} — leftover rebooks on the next train once paid.`,
    );
    this.notifyBoardChanged(schedule.id, "batch_fill");
    return true;
  }

  /**
   * Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
   * A consolidated booking reserves as a pair only once BOTH partners are ready
   * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
   * against the combined shared-wagon need; the first partner's accept just waits.
   * Throws ConflictException (before this booking is persisted-ready) when there is
   * no export capacity for the day, so staff accept fails.
   */
  async acceptExportBooking(booking: Booking): Promise<void> {
    const partnerId = booking.consolidationPartnerId ?? null;
    if (!partnerId) {
      // Export split: when no single train carries the whole booking, offer the
      // largest fitting part instead of failing the accept. The customer pays
      // that part; on payment applySplit reduces this booking to it and the
      // leftover is auto-placed as its own booking on the next train. Pairs are
      // excluded (handled below) — a shared wagon is never split.
      if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
        const offered = await this.tryExportPartialOffer(booking);
        if (offered) return;
      }
      const scheduleId = await this.pickExportSchedule(booking);
      await this.reserveOnExport([booking], scheduleId);
      return;
    }

    const partner = await this.dataSource
      .getRepository(Booking)
      .findOne({
        where: { id: partnerId },
        relations: {
          company: true,
          bookingContainers: { containerType: true },
          cargoType: true,
        },
      });
    // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
    // waits; the partner's later accept will reserve the pair.
    if (!partner || partner.status !== 'FULLY_EXECUTED') {
      return;
    }
    const wagonDims = await this.loadWagonDims();
    const need = this.combinedNeed(booking, partner, wagonDims);
    const scheduleId = await this.pickExportSchedule(booking, need);
    await this.reserveOnExport([booking, partner], scheduleId);
  }

  /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
  private async reserveOnExport(
    bookings: Booking[],
    scheduleId: string,
  ): Promise<void> {
    // H8: the capacity check (pickExportSchedule → budget.fits) and the
    // reservation writes below are not atomic on their own — two concurrent
    // export accepts can each see the same train as fitting and both reserve,
    // overshooting the train's capacity. Serialize reservations against this
    // schedule: take a pessimistic_write lock on the TrainSchedule row
    // (SELECT … FOR UPDATE), then RE-VERIFY budget.fits for these bookings'
    // combined need from freshly-committed state INSIDE the lock before the
    // reserve writes run. A loser (another accept took the space first) gets a
    // ConflictException — the staff accept fails and reverts, exactly as an
    // up-front full train does. Covered: the fits-vs-reserve overshoot on the
    // export FCFS path; the lock is held for the duration of the reserve writes.
    await this.dataSource.transaction(async (manager) => {
      const locked = await manager.findOne(TrainSchedule, {
        where: { id: scheduleId },
        lock: { mode: "pessimistic_write" },
      });
      if (!locked) {
        throw new ConflictException(
          "Export train is no longer available for reservation",
        );
      }
      const schedule =
        await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
      const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
      if (!schedule || !locomotive) {
        throw new ConflictException(
          "Export train is no longer available for reservation",
        );
      }
      const wagonDims = await this.loadWagonDims();
      const limits = await this.capacityLimits(locomotive);
      const budget = await this.remainingBudget(schedule, limits, wagonDims);
      const leg = budget.legOf(
        bookings[0].originYardId,
        bookings[0].destinationYardId,
      );
      const need =
        bookings.length >= 2
          ? this.combinedNeed(bookings[0], bookings[1], wagonDims)
          : this.needFor(bookings[0], wagonDims);
      if (!leg || !budget.fits(need, leg)) {
        throw new ConflictException(
          "Train is full — no export capacity left for this day",
        );
      }

      for (const b of bookings) await this.reserve(b, scheduleId);
    });
    this.armSettle(scheduleId);
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (schedule && (await this.isTrainFull(schedule))) {
      await this.setWindow(scheduleId, 'FULL');
    }
    this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
  }

  /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
  async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
    const unlinked =
      await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId);
    for (const booking of unlinked) {
      // Held on purpose (paid, no wagon free) — the cron must not undo it.
      if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
      if (await this.holdIfWagonShort(scheduleId, booking)) continue;
      await this.allocate(scheduleId, booking, "paid");
      this.logger.log(
        `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
      );
    }
    if (unlinked.length > 0) {
      this.notifyBoardChanged(scheduleId, "paid_reconciled");
    }
  }

  // ---- legacy fill entry point ----------------------------------------------

  /**
   * Legacy periodic fill for schedules without a window phase (DOMESTIC and
   * pre-migration trains). Invoked by BookingWindowService's tick — the old
   * standalone cron was replaced by the window engine.
   */
  async runBatchFill(): Promise<void> {
    const groups = await this.openRouteDayGroups();
    this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
    for (const group of groups) {
      try {
        await this.processRouteDay(group);
      } catch (err) {
        this.logger.error(
          `Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
        );
      }
    }
  }

  // ---- monitoring board -----------------------------------------------------

  /**
   * Read model for the batch monitoring page: every import schedule — including
   * dispatched, arrived and cancelled history — with its locomotive, capacity
   * usage and its bookings grouped by lifecycle state (allocated / awaiting
   * payment / paid-waiting / pending contract / expired). Paginated and
   * filterable; per-schedule booking summaries are only computed for the
   * requested page.
   */
  async getBatchBoard(
    query: BatchBoardQueryDto = {},
  ): Promise<BatchBoardListResponse> {
    // Board cards are heavy (per-schedule booking summaries), so the default
    // page is smaller than the toolkit-wide 20.
    const { page, pageSize, skip, take } = normalizePagination(query, {
      defaultPageSize: 12,
    });

    // Status filter: any subset of the lifecycle. Omitted = all statuses, so
    // arrived / cancelled / dispatched schedules stay visible as history.
    const allowedStatuses = new Set<string>(BATCH_BOARD_STATUSES);
    const statuses = (query.statuses ?? "")
      .split(",")
      .map((v) => v.trim().toUpperCase())
      .filter((v) => allowedStatuses.has(v));

    const dateRange = (from?: string, to?: string) => {
      const f = from ? new Date(from) : null;
      const t = to ? new Date(to) : null;
      if (f && t) return Between(f, t);
      if (f) return MoreThanOrEqual(f);
      if (t) return LessThanOrEqual(t);
      return undefined;
    };

    // Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
    // and domestic/legacy schedules run the legacy fill, not the window batch.
    const base: FindOptionsWhere<TrainSchedule> = { direction: "IMPORT" };
    if (statuses.length) base.status = In(statuses) as never;
    if (query.bookingWindowStatus) {
      base.bookingWindowStatus = query.bookingWindowStatus;
    }
    const departure = dateRange(query.departureFrom, query.departureTo);
    if (departure) base.scheduledDepartureDate = departure as never;
    const created = dateRange(query.createdFrom, query.createdTo);
    if (created) base.createdAt = created as never;

    // Search fans out across every human-recognizable label. Each OR variant
    // repeats the base filters so the search never widens them.
    const term = query.search?.trim();
    let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
      base;
    if (term) {
      const like = ILike(`%${term}%`);
      where = [
        { ...base, trainNumber: like as never },
        { ...base, originStation: { label: like } },
        { ...base, destinationStation: { label: like } },
        { ...base, route: { originYard: { label: like } } },
        { ...base, route: { destinationYard: { label: like } } },
        { ...base, trainSet: { locomotive: { code: like } } },
      ] as FindOptionsWhere<TrainSchedule>[];
    }

    const sortBy = query.sortBy ?? "createdAt";
    const sortOrder = query.sortOrder ?? "DESC";

    const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
      where,
      relations: {
        trainSet: { locomotive: true, train: true },
        originStation: true,
        destinationStation: true,
        // Yards supply the route's display name for `routeName` below;
        // milestones (with yards) give it the full corridor path.
        route: { originYard: true, destinationYard: true, milestones: { yard: true } },
      },
      order: { [sortBy]: sortOrder } as never,
      skip,
      take,
    });

    const wagonDims = await this.loadWagonDims();
    const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);

    // One links query + one bookings query for the whole page (was 2 per card).
    const scheduleIds = schedules.map((s) => s.id);
    const [allLinks, allBookings] = await Promise.all([
      scheduleIds.length
        ? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
        : Promise.resolve([]),
      this.bookingsRepository.findAllBySchedules(scheduleIds),
    ]);
    const linkedIdsBySchedule = new Map<string, Set<string>>();
    for (const l of allLinks) {
      let set = linkedIdsBySchedule.get(l.trainScheduleId);
      if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
      set.add(l.bookingId);
    }
    const bookingsBySchedule = new Map<string, Booking[]>();
    for (const b of allBookings) {
      if (!b.trainScheduleId) continue;
      let list = bookingsBySchedule.get(b.trainScheduleId);
      if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
      list.push(b);
    }

    const board: BatchBoardSchedule[] = [];
    for (const s of schedules) {
      const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
      const bookings = bookingsBySchedule.get(s.id) ?? [];

      const items: BatchBoardBooking[] = bookings.map((b) => {
        const need = this.needFor(b, wagonDims);
        return {
          id: b.id,
          reference: b.reference ?? b.id.slice(0, 8),
          company: b.isGovernment
            ? (b.governmentInstitution ?? "Government")
            : (b.company?.name ?? "—"),
          isGovernment: Boolean(b.isGovernment),
          wagons: need.wagons,
          weightTons: need.weightTons,
          lengthMeters: need.lengthMeters,
          paymentDeadline: b.paymentDeadline
            ? b.paymentDeadline.toISOString()
            : null,
          state: this.boardState(b, linkedIds.has(b.id)),
          priorityScore: Number(b.priorityScore ?? 0),
          freightType: b.freightType ?? null,
        };
      });

      board.push(this.buildScheduleSummary(s, items));
    }

    return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
  }

  /** Schedule-level batch board: the schedule's own booking window plus its
   * bookings split into in-window (contract executed) vs pending-contract. */
  async getBatchBoardDetail(
    scheduleId: string,
  ): Promise<BatchBoardScheduleDetail> {
    const s =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!s)
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    // Arrived / cancelled schedules stay viewable — the board is also the
    // historical record of what each train carried.
    // Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
    if (s.direction !== "IMPORT") {
      throw new BadRequestException(
        "The batch board only covers import schedules",
      );
    }

    const wagonDims = await this.loadWagonDims();
    // The full graph already carries the schedule↔booking links — no separate
    // link query needed.
    const linkedIds = new Set(
      (s.scheduleBookings ?? []).map((l) => l.bookingId),
    );
    const bookings = await this.bookingsRepository.findAllBySchedule(s.id);

    let allocationPreview: Awaited<
      ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
    >;
    try {
      // Reuse the graph loaded above — the preview otherwise re-loads the same
      // heavy schedule graph a second time per request.
      allocationPreview =
        await this.trainSchedulingService.previewAllocationForSchedule(
          s.id,
          s,
        );
    } catch {
      allocationPreview = {
        assignedBookingIds: [],
        deferred: [],
        issues: [],
        violations: [],
      };
    }
    const allocationByBooking = new Map(
      allocationPreview.issues.map((i) => [i.bookingId, i]),
    );

    // Resolve consolidation-partner references for the shared-wagon badge. Most
    // partners are on this same schedule; look up any that aren't in one query.
    const refById = new Map(
      bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
    );
    const missingPartnerIds = [
      ...new Set(
        bookings
          .map((b) => b.consolidationPartnerId)
          .filter((id): id is string => Boolean(id) && !refById.has(id!)),
      ),
    ];
    if (missingPartnerIds.length) {
      const partners = await this.dataSource
        .getRepository(Booking)
        .find({ where: { id: In(missingPartnerIds) } });
      for (const p of partners) {
        refById.set(p.id, p.reference ?? p.id.slice(0, 8));
      }
    }

    const items: BatchBoardBookingDetail[] = bookings.map((b) => {
      const need = this.needFor(b, wagonDims);
      const alloc = allocationByBooking.get(b.id);
      return {
        id: b.id,
        reference: b.reference ?? b.id.slice(0, 8),
        company: b.isGovernment
          ? (b.governmentInstitution ?? "Government")
          : (b.company?.name ?? "—"),
        isGovernment: Boolean(b.isGovernment),
        wagons: need.wagons,
        weightTons: need.weightTons,
        lengthMeters: need.lengthMeters,
        paymentDeadline: b.paymentDeadline
          ? b.paymentDeadline.toISOString()
          : null,
        state: this.boardState(b, linkedIds.has(b.id)),
        priorityScore: Number(b.priorityScore ?? 0),
        freightType: b.freightType ?? null,
        fullyExecutedAt: b.fullyExecutedAt
          ? b.fullyExecutedAt.toISOString()
          : null,
        selectedForBatchAt: b.selectedForBatchAt
          ? b.selectedForBatchAt.toISOString()
          : null,
        allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
        allocationIssue: alloc?.issue ?? null,
        consolidationPartnerId: b.consolidationPartnerId ?? null,
        consolidationPartnerRef: b.consolidationPartnerId
          ? (refById.get(b.consolidationPartnerId) ?? null)
          : null,
      };
    });

    const loco = trainSetLocomotiveLimits(s.trainSet);

    // The board renders ONE booking window — the schedule's own frozen window
    // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
    // split into two buckets: contract executed (in the window) vs pending
    // contract. The old per-cycle window projection was dropped — the UI never
    // showed it, and reconstructing every cycle cost a config load + grouping
    // pass per request.
    const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
      const counts: BatchBoardCounts = {
        allocated: 0,
        selectedForBatch: 0,
        ready: 0,
        waiting: 0,
        expired: 0,
        pendingContract: 0,
      };
      for (const b of bucket) {
        if (b.state === "ALLOCATED") counts.allocated += 1;
        else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
        else if (b.state === "READY") counts.ready += 1;
        else if (b.state === "WAITING") counts.waiting += 1;
        else if (b.state === "EXPIRED") counts.expired += 1;
        else counts.pendingContract += 1;
      }
      return counts;
    };

    const windowBookings = items.filter((i) => i.fullyExecutedAt);
    const pendingBookings = items.filter((i) => !i.fullyExecutedAt);

    return {
      scheduleId: s.id,
      scheduleReference: s.reference ?? null,
      trainNumber: s.trainNumber ?? null,
      routeName: s.route ? formatRouteLabel(s.route) : null,
      origin: s.originStation?.label ?? s.originStation?.code ?? null,
      destination:
        s.destinationStation?.label ?? s.destinationStation?.code ?? null,
      scheduleDate: s.scheduledDepartureDate
        ? s.scheduledDepartureDate.toISOString()
        : null,
      status: s.status,
      bookingWindowStatus: s.bookingWindowStatus,
      direction: s.direction ?? null,
      windowPhase: s.windowPhase ?? null,
      windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
      windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
      docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
      paymentPhaseEndsAt: s.paymentPhaseEndsAt
        ? s.paymentPhaseEndsAt.toISOString()
        : null,
      bookingCycleNo: s.bookingCycleNo ?? 0,
      train: s.trainSet?.train
        ? {
          id: s.trainSet.train.id,
          code: s.trainSet.train.code,
          trainName: s.trainSet.train.trainName ?? null,
        }
        : null,
      // Identity from the primary (legacy) locomotive; limit figures from the
      // whole set's effective minimum — what the fill engine actually spends.
      locomotive: loco
        ? {
          code: s.trainSet?.locomotive?.code ?? '',
          name: s.trainSet?.locomotive?.name ?? null,
          maxPullWeightTons: Number(loco.maxPullWeightTons),
          maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
        }
        : null,
      capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
      counts: {
        allocated: items.filter((i) => i.state === "ALLOCATED").length,
        selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
          .length,
        ready: items.filter((i) => i.state === "READY").length,
        waiting: items.filter((i) => i.state === "WAITING").length,
        pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
          .length,
        expired: items.filter((i) => i.state === "EXPIRED").length,
      },
      bookings: windowBookings,
      pendingContract: {
        counts: countFor(pendingBookings),
        bookings: pendingBookings,
      },
      allocationViolations: allocationPreview.violations,
    };
  }

  /** Run wagon-level allocation for all eligible linked bookings on a schedule. */
  async runWagonAllocation(scheduleId: string) {
    const result =
      await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
    if (result.assignedBookingIds.length > 0) {
      this.notifyBoardChanged(scheduleId, "wagon_allocation_run");
    }
    return result;
  }

  /**
   * Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
   * includes the tare of the wagons it occupies), so the ceiling it is measured
   * against must be the same one the fill loop spends from: the locomotive's own
   * limits widened by its overage tolerance (global rule caps do not apply, same
   * as {@link capacityLimits}). Reading the raw `loco.maxPullWeightTons` here
   * showed staff a ceiling the batch engine did not use.
   */
  private computeBoardCapacity(
    items: Array<{
      state: BatchBoardBookingState;
      wagons: number;
      weightTons: number;
      lengthMeters: number;
    }>,
    loco: LocomotiveLimits | null,
    maxWagons: number | null,
  ): BatchBoardSchedule["capacity"] {
    const allocated = items.filter((i) => i.state === "ALLOCATED");
    // Every booking still targeting this train holds gross weight — including
    // PAID ones waiting for wagon allocation (WAITING) and post-dispatch
    // catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
    // board's weight the moment customers paid. Only EXPIRED released its hold.
    const committed = items.filter((i) => i.state !== "EXPIRED");
    const caps = loco
      ? trainHardCaps({
          maxPullWeightTons: Number(loco.maxPullWeightTons),
          maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
          overageToleranceTons: Number(loco.overageToleranceTons) || 0,
          overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
        })
      : null;
    const round2 = (value: number) => Math.round(value * 100) / 100;

    return {
      allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
      allocatedLengthMeters: round2(
        allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
      ),
      maxLengthMeters: caps ? caps.maxLengthMeters : null,
      usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
      maxWeightTons: caps ? caps.maxWeightTons : null,
      maxWagons: maxWagons ?? null,
    };
  }

  private buildScheduleSummary(
    s: TrainSchedule,
    items: BatchBoardBooking[],
  ): BatchBoardSchedule {
    const loco = trainSetLocomotiveLimits(s.trainSet);

    return {
      scheduleId: s.id,
      scheduleReference: s.reference ?? null,
      trainNumber: s.trainNumber ?? null,
      routeName: s.route ? formatRouteLabel(s.route) : null,
      origin: s.originStation?.label ?? s.originStation?.code ?? null,
      destination:
        s.destinationStation?.label ?? s.destinationStation?.code ?? null,
      scheduleDate: s.scheduledDepartureDate
        ? s.scheduledDepartureDate.toISOString()
        : null,
      createdAt: s.createdAt ? s.createdAt.toISOString() : null,
      status: s.status,
      bookingWindowStatus: s.bookingWindowStatus,
      direction: s.direction ?? null,
      windowPhase: s.windowPhase ?? null,
      windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
      windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
      docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
      paymentPhaseEndsAt: s.paymentPhaseEndsAt
        ? s.paymentPhaseEndsAt.toISOString()
        : null,
      bookingCycleNo: s.bookingCycleNo ?? 0,
      train: s.trainSet?.train
        ? {
          id: s.trainSet.train.id,
          code: s.trainSet.train.code,
          trainName: s.trainSet.train.trainName ?? null,
        }
        : null,
      // Identity from the primary (legacy) locomotive; limit figures from the
      // whole set's effective minimum — what the fill engine actually spends.
      locomotive: loco
        ? {
          code: s.trainSet?.locomotive?.code ?? '',
          name: s.trainSet?.locomotive?.name ?? null,
          maxPullWeightTons: Number(loco.maxPullWeightTons),
          maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
        }
        : null,
      capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
      counts: {
        allocated: items.filter((i) => i.state === "ALLOCATED").length,
        selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
          .length,
        ready: items.filter((i) => i.state === "READY").length,
        waiting: items.filter((i) => i.state === "WAITING").length,
        pendingContract: items.filter((i) => i.state === "PENDING_CONTRACT")
          .length,
        expired: items.filter((i) => i.state === "EXPIRED").length,
      },
      bookings: items.slice(0, 3),
    };
  }

  private boardState(
    booking: Booking,
    linked: boolean,
  ): BatchBoardBookingState {
    if (linked) return "ALLOCATED";
    if (
      booking.status === "SELECTED_FOR_BATCH" ||
      booking.status === "AWAITING_PAYMENT"
    ) {
      return "SELECTED_FOR_BATCH";
    }
    if (booking.status === "EXPIRED") return "EXPIRED";
    if (booking.status === "FULLY_EXECUTED" && booking.fullyExecutedAt)
      return "READY";
    if (booking.status === "PAID") return "WAITING";
    return "PENDING_CONTRACT";
  }

  // ---- core fill ------------------------------------------------------------

  /**
   * Whether the batch engine may reserve/allocate onto this schedule right now.
   * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
   * Import window cycle: the engine fills while the customer window is CLOSED —
   * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
   * Export: FCFS while the booking window is open.
   */
  isFillable(schedule: TrainSchedule): boolean {
    if (schedule.bookingWindowStatus === "FULL") return false;
    if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
    if (schedule.direction === "EXPORT") {
      return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
    }
    return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
  }

  /**
   * Fill one schedule from its priority-ordered pool until full. Returns the
   * number of commercial units it RESERVED this pass (0 for government-only or
   * no-fit passes) so a top-up caller can extend the payment phase only when a
   * fresh pay window actually opened.
   */
  async fillSchedule(scheduleId: string): Promise<number> {
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule || !this.isFillable(schedule)) return 0;
    const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
    if (!schedule.trainSetId || !locomotive) {
      this.logger.warn(
        `Schedule ${scheduleId} has no locomotive/train set — skipped.`,
      );
      return 0;
    }

    const wagonDims = await this.loadWagonDims();
    const limits = await this.capacityLimits(locomotive);
    await this.syncScheduleMaxWagons(schedule, locomotive);
    const budget = await this.remainingBudget(schedule, limits, wagonDims);
    const minPerWagon = this.minPerWagonNeed(wagonDims);
    if (budget.isExhausted(minPerWagon)) {
      await this.setWindow(scheduleId, "FULL");
      return 0;
    }

    const pool = await this.bookingsRepository.findBatchPool(scheduleId);
    // Same bulk re-score as fillRouteDayInternal — the legacy per-schedule fill
    // must rank bulk bookings by their wagon-derived priority too.
    await this.recomputeBulkPriorities(pool, wagonDims);
    this.resortPoolByPriority(pool);
    const units = this.groupConsolidatedPool(pool);
    let armed = false;
    let preempted = false;
    let reservedThisPass = 0;
    let commercialReserved = 0;

    // Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
    // reservations trickle instead of landing in one pass (a reserve() throwing
    // mid-loop, e.g. schema drift, or a mis-synced capacity cap).
    this.logger.debug(
      `[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
        `maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
        `poolSize=${pool.length} units=${units.length}`,
    );

    for (const unit of units) {
      const { primary: booking, partner } = unit;
      const isPair = partner != null;
      const need = isPair
        ? this.combinedNeed(booking, partner, wagonDims)
        : this.needFor(booking, wagonDims);
      const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
      // Consolidated partners always share one corridor, so the primary's leg
      // stands for the pair.
      const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);

      // Per-unit fit trace: which axis (wagons/weight/length) admits or rejects.
      this.logger.debug(
        `[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
          `roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
      );

      if (!budget.fits(need, leg)) {
        if (isGov) {
          const freed = await this.preemptForGovernment(
            scheduleId,
            need,
            leg,
            budget,
            wagonDims,
          );
          preempted = true;
          if (!freed) continue; // still doesn't fit even after preempt
        } else {
          // Doesn't fit whole. A split-eligible import booking is offered the part
          // that fits in the remaining room (top-up path splits the boundary
          // booking, mirroring fillRouteDay); otherwise skip and try the next.
          const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
            id: scheduleId,
            budget,
            armed,
          };
          if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
            armed = cand.armed;
            continue;
          }
          continue; // skip a unit that exceeds weight/length/wagons, try the next
        }
      }

      // Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup)
      // can't abort the whole top-up pass and leave the rest to trickle in one
      // per tick. Log + skip the failing unit, keep going.
      try {
        if (isGov) {
          await this.allocate(scheduleId, booking, "gov");
          if (partner) await this.allocate(scheduleId, partner, "gov");
        } else {
          await this.reserve(booking, scheduleId);
          if (partner) await this.reserve(partner, scheduleId);
          armed = true;
          commercialReserved += 1;
        }
        budget.subtract(need, leg);
        reservedThisPass += 1;
      } catch (err) {
        this.logger.error(
          `[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` +
            `— skipping this unit, continuing: ${(err as Error).message}`,
        );
        continue;
      }
      if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
    }

    this.logger.log(
      `[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
    );
    if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
    if (armed) this.armSettle(scheduleId);
    // One push per fill pass (never per booking) — only when rows changed.
    if (reservedThisPass > 0 || armed || preempted) {
      this.notifyBoardChanged(scheduleId, "batch_fill");
    }
    void this.triggerWagonAllocation(scheduleId);
    return commercialReserved;
  }

  /**
   * Distribute one (route, day) pool across ALL of that day's OPEN trains, by
   * priority, filling each train (earliest departure first) until it's full and
   * spilling overflow to the next. Government bookings that fit no train preempt
   * lower-priority commercial; bookings that fit no train at all stay pending and
   * trigger a staff `unplaced` warning. Returns the schedule ids that were touched
   * (or that had remaining pool work) so the caller can settle them per-schedule.
   */
  async fillRouteDay(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): Promise<string[]> {
    const { scheduleIds } = await this.fillRouteDayInternal(
      originYardId,
      destinationYardId,
      day,
    );
    return scheduleIds;
  }

  /**
   * Route-day top-up for a single schedule: re-run the DAY pool over the whole
   * corridor the schedule belongs to, and report how many commercial units got a
   * fresh pay window.
   *
   * `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
   * `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
   * booking that has not been reserved yet has a NULL `train_schedule_id` — it is
   * only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
   * and the waiting list never boarded after an expiry freed capacity; bookings
   * trickled in one per window cycle instead.
   */
  private async topUpFill(scheduleId: string): Promise<number> {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule?.scheduledDepartureDate) return 0;
    const { commercialReserved } = await this.fillRouteDayInternal(
      schedule.originStationId,
      schedule.destinationStationId,
      eatDay(schedule.scheduledDepartureDate),
    );
    return commercialReserved;
  }

  private async fillRouteDayInternal(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
    // The day's fillable schedules on this exact corridor, earliest first. Fillable
    // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
    // the batch must run while the customer window is closed.
    const corridor = await this.trainSchedulesRepository.findAll({
      where: [
        {
          originStationId: originYardId,
          destinationStationId: destinationYardId,
          status: TrainScheduleStatusEnum.Draft,
        },
        {
          originStationId: originYardId,
          destinationStationId: destinationYardId,
          status: TrainScheduleStatusEnum.Scheduled,
        },
      ],
    });
    const onDay = corridor
      .filter(
        (s) =>
          s.scheduledDepartureDate != null &&
          eatDay(s.scheduledDepartureDate) === day,
      )
      .sort(
        (a, b) =>
          a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
      );

    // A schedule flagged FULL is rejected by isFillable() before its budget is
    // ever consulted. Re-derive that flag from live capacity first, so a train
    // whose bookings all expired is not skipped forever with an empty consist.
    for (const s of onDay) {
      if (s.bookingWindowStatus === "FULL") {
        await this.refreshWindowStatus(s.id);
        const fresh = await this.trainSchedulesRepository.findById(s.id);
        if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
      }
    }

    const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);

    if (scheduleIds.length === 0) {
      return { scheduleIds: [], commercialReserved: 0 };
    }

    const wagonDims = await this.loadWagonDims();

    // Live per-schedule corridor budget + arm/changed flags, in departure order.
    const trains: Array<{
      id: string;
      budget: CorridorBudget;
      armed: boolean;
      changed: boolean;
    }> = [];
    for (const id of scheduleIds) {
      const schedule =
        await this.trainSchedulesRepository.findByIdWithFullGraph(id);
      const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
      if (!schedule || !schedule.trainSetId || !locomotive) {
        this.logger.warn(
          `Schedule ${id} has no locomotive/train set — skipped.`,
        );
        continue;
      }
      const limits = await this.capacityLimits(locomotive);
      await this.syncScheduleMaxWagons(schedule, locomotive);
      const budget = await this.remainingBudget(schedule, limits, wagonDims);
      trains.push({ id, budget, armed: false, changed: false });
    }
    if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };

    // The day pool covers every booking whose leg lies somewhere on one of the
    // day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
    // Addis→Djibouti train). Which train actually takes a booking is decided
    // by the per-train legOf check below.
    const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
    const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
      corridorYards,
      day,
    );
    // BULK bookings only get their real (wagon-derived) priority score now, at
    // batch time — stamp it and re-rank before the fill consumes the pool.
    await this.recomputeBulkPriorities(pool, wagonDims);
    this.resortPoolByPriority(pool);
    // Consolidated partners collapse into one atomic unit (both-or-neither); a
    // consolidated booking whose partner isn't ready this cycle is skipped.
    const units = this.groupConsolidatedPool(pool);

    // Batch fill trace: each train's caps + the day pool size at entry.
    this.logger.debug(
      `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
        `trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
        `poolSize=${pool.length} units=${units.length}`,
    );
    let reservedThisPass = 0;
    let commercialReserved = 0;

    for (const unit of units) {
      const { primary: booking, partner } = unit;
      const isPair = partner != null;
      const need = isPair
        ? this.combinedNeed(booking, partner, wagonDims)
        : this.needFor(booking, wagonDims);
      const isGov = booking.isGovernment || (partner?.isGovernment ?? false);

      const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
        t.budget.legOf(booking.originYardId, booking.destinationYardId);

      // First train (earliest departure) whose corridor carries this booking's
      // leg and still fits it as-is.
      let target = trains.find((t) => {
        const leg = legOn(t);
        return leg != null && t.budget.fits(need, leg);
      });

      // Per-unit trace: chosen train + each train's remaining room on this leg.
      this.logger.debug(
        `[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
          `targetTrain=${target?.id ?? "none"} ` +
          `rooms=${trains
            .map((t) => {
              const leg = legOn(t);
              return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
            })
            .join(",")}`,
      );

      if (!target && isGov) {
        // Government fits nowhere on its own — try to preempt commercial
        // on each corridor-matching train (earliest first) until one frees room.
        for (const t of trains) {
          const leg = legOn(t);
          if (!leg) continue;
          const freed = await this.preemptForGovernment(
            t.id,
            need,
            leg,
            t.budget,
            wagonDims,
          );
          // Preempt may have displaced (expired) victims even when the need
          // still doesn't fit — the board must refresh either way.
          t.changed = true;
          if (freed) {
            target = t;
            break;
          }
        }
      }

      if (!target) {
        // Fits no train whole. A split-eligible booking is offered the largest
        // part that fits on the train with the most free wagons on its leg (this
        // covers both "fits nowhere" and the boundary case where earlier bookings
        // already consumed most of the room). Consolidated pairs / government /
        // non-import never split — isSplitEligible guards that. Passing the live
        // `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
        const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
        if (offered) {
          // A partial offer opens a real commercial pay window, same as reserve().
          commercialReserved += 1;
          reservedThisPass += 1;
          continue;
        }
        // Stays in the pool, retried next batch/window cycle.
        this.notifier.unplaced(booking, day);
        if (partner) this.notifier.unplaced(partner, day);
        continue;
      }

      // A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort
      // the whole pass — otherwise only the bookings before the failure get a pay
      // window and the rest trickle in one-per-tick on later retries (the
      // "selected one at a time / staggered" symptom). Isolate each unit: log +
      // skip a failing one, keep reserving the others. The skipped unit stays in
      // the pool and is retried next cycle.
      try {
        if (isGov) {
          await this.allocate(target.id, booking, "gov");
          if (partner) await this.allocate(target.id, partner, "gov");
        } else {
          await this.reserve(booking, target.id);
          if (partner) await this.reserve(partner, target.id);
          target.armed = true;
          commercialReserved += 1;
        }
        target.budget.subtract(need, legOn(target)!);
        target.changed = true;
        reservedThisPass += 1;
      } catch (err) {
        this.logger.error(
          `[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` +
            `— skipping this unit, continuing the batch: ${(err as Error).message}`,
        );
      }
    }

    this.logger.log(
      `[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
    );

    const minPerWagon = this.minPerWagonNeed(wagonDims);
    for (const t of trains) {
      if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
      if (t.armed) this.armSettle(t.id);
      // One push per touched train per pass (never per booking). `armed` covers
      // commercial reserves + partial offers; `changed` covers gov allocations
      // and preemption.
      if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill");
      void this.triggerWagonAllocation(t.id);
    }

    return { scheduleIds: trains.map((t) => t.id), commercialReserved };
  }

  /**
   * A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
   * partial (split-on-payment). Consolidated pairs never split (both-or-neither
   * shared wagon) and government bookings never split (they preempt).
   *
   * IMPORT is always eligible. EXPORT is eligible only when export split is
   * enabled: export historically rides one train whole, so splitting it changes
   * the FCFS money path — each split part still rides ONE train whole, and the
   * leftover becomes its own booking on the next train.
   */
  private isSplitEligible(booking: Booking, isPair: boolean): boolean {
    const directionOk =
      booking.tradeDirection === "IMPORT" ||
      (booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
    return (
      !isPair &&
      !booking.isGovernment &&
      directionOk &&
      (booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
      this.splitService != null
    );
  }

  /**
   * Offer the largest fitting part of a booking that does not fit any candidate
   * train whole, on the train with the most free wagons on the booking's leg.
   * Mutates the chosen candidate's budget + armed flag in place. Returns true when
   * an offer was opened (caller should `continue` past this unit), false otherwise.
   * Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
   * is computed per candidate from the booking's yards, so callers pass their live
   * train entries and only leg-carrying trains are considered.
   */
  private async maybeOfferPartial(
    booking: Booking,
    isPair: boolean,
    candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
    need: Capacity,
  ): Promise<boolean> {
    if (!this.isSplitEligible(booking, isPair)) return false;
    const target = candidates
      .map((c) => {
        const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
        return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
      })
      .filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
      .sort((a, b) => b.room.wagons - a.room.wagons)[0];
    if (!target) return false;
    const offered = await this.tryPartialOffer(
      booking,
      target.c.id,
      target.room,
      need,
    );
    if (!offered) return false;
    target.c.budget.subtract(offered, target.leg);
    target.c.armed = true;
    return true;
  }

  /**
   * Offer the largest fitting part of an over-capacity booking as a partial
   * (split-on-payment). Returns the capacity the offer consumes, or null when no
   * meaningful partial fits / an offer is already open.
   */
  private async tryPartialOffer(
    booking: Booking,
    scheduleId: string,
    budget: Capacity,
    need: Capacity,
  ): Promise<Capacity | null> {
    if (!this.splitService) return null;
    // A consolidated booking is already half of a shared wagon — never split it.
    if (booking.consolidationPartnerId) return null;
    if (await this.splitService.findOpenOffer(booking.id)) return null;

    const wagonDims = await this.loadWagonDims();

    // The wagon-slot axis alone under-constrains the offer. On a weight- or
    // length-limited train (slots to spare, but e.g. only 798T of pull weight
    // left) sizing by slots either produced an offer the fits() check below
    // rejected, or — when the free slots exceeded the booking's own wagon
    // count — sizeOffer refused outright, so a bulk booking on a weight-bound
    // train was never offered a split at all. Size across all three axes,
    // measured on the booking's REAL wagon type — the same one allocation
    // validates against. Bulk splits ride FULL wagons only: the offer never
    // part-loads its last wagon.
    const perWagon = this.dimsFor(booking, wagonDims);
    const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, {
      fullWagonsOnly: booking.freightType === "BULK",
    });
    if (!partial) return null;

    const sized = await this.splitService.sizeOffer(
      booking,
      partial.wagons,
      need.wagons,
      perWagon.capacityTons,
      partial.maxCargoTons,
    );
    if (!sized) return null;

    const offeredNeed: Capacity = {
      wagons: sized.offeredWagons,
      weightTons: bookingGrossWeightTons(
        sized.offeredWeightTons,
        sized.offeredWagons,
        perWagon.tareWeightTons,
      ),
      lengthMeters: sized.offeredWagons * perWagon.lengthMeters,
    };
    if (!this.fits(offeredNeed, budget)) return null;

    const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
    await this.splitService.createOffer(booking, scheduleId, sized, deadline);
    // Reserve like a normal batch selection, but the partial invoice + partial
    // pay-now notification were already produced by createOffer.
    await this.bookingsRepository.update(booking.id, {
      trainScheduleId: scheduleId,
      status: "SELECTED_FOR_BATCH",
      selectedForBatchAt: new Date(),
      paymentDeadline: deadline,
    } as never);
    booking.trainScheduleId = scheduleId;
    return offeredNeed;
  }

  /**
   * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
   * how to treat a reservation with no deadline (durable path: leave it; timeout
   * path: expire it). Consolidated pairs settle atomically: both allocate only
   * when both paid; if either partner expires, both expire (a half-paid shared
   * wagon must not ship). Returns whether anything changed.
   */
  private async settleReserved(
    scheduleId: string,
    expireUnpaidUnknownDeadline: boolean,
  ): Promise<boolean> {
    const reserved =
      await this.bookingsRepository.findReservedForSchedule(scheduleId);
    const now = Date.now();
    const byId = new Map(reserved.map((b) => [b.id, b]));
    const done = new Set<string>();
    let anySettled = false;
    this.logger.debug(
      `[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`,
    );

    const isPaid = (b: Booking) =>
      b.paymentStatus === "PAID" || b.status === "PAID";
    const isExpired = (b: Booking) =>
      b.paymentDeadline
        ? b.paymentDeadline.getTime() <= now
        : expireUnpaidUnknownDeadline;

    for (const booking of reserved) {
      if (done.has(booking.id)) continue;
      const partner = booking.consolidationPartnerId
        ? (byId.get(booking.consolidationPartnerId) ?? null)
        : null;

      if (partner) {
        done.add(booking.id);
        done.add(partner.id);
        // Both-or-neither: allocate the shared wagon only when both partners paid;
        // if either lapsed, expire both so no half-paid wagon rides.
        if (isPaid(booking) && isPaid(partner)) {
          await this.allocate(scheduleId, booking, "paid");
          await this.allocate(scheduleId, partner, "paid");
          anySettled = true;
        } else if (isExpired(booking) || isExpired(partner)) {
          await this.expire(booking);
          await this.expire(partner);
          anySettled = true;
        }
        continue;
      }

      done.add(booking.id);
      if (isPaid(booking)) {
        if (!(await this.holdIfWagonShort(scheduleId, booking))) {
          await this.allocate(scheduleId, booking, "paid");
        }
        anySettled = true;
      } else if (isExpired(booking)) {
        await this.expire(booking);
        anySettled = true;
      }
    }
    return anySettled;
  }

  /**
   * Durable settle: allocate paid / expire overdue reservations, then top up the
   * freed capacity from the waiting list.
   *
   * Serialised per schedule. Two callers race here every time a payment phase
   * ends: `advanceImport`'s PAYMENT branch and the tick's `settleOverdueReservations`
   * backstop. Both read the same reserved rows in the same second, so without the
   * lock the second caller re-settles rows the first is mid-way through expiring,
   * and `concludeCycle` observes capacity that is neither pre- nor post-expiry.
   */
  async settleDueReservations(scheduleId: string): Promise<void> {
    await this.withScheduleLock(scheduleId, () =>
      this.settleAndTopUp(scheduleId, false),
    );
  }

  /**
   * Conclude-time retry: promote whatever still fits from the route-day waiting
   * list, opening fresh pay windows. Returns how many commercial units got
   * reserved — corridor-wide, since the fill is day-level and may reserve onto a
   * sibling train; the caller must check `hasLiveReservations` for its OWN
   * schedule before deciding to stay in PAYMENT.
   */
  async fillFromWaitingList(scheduleId: string): Promise<number> {
    return this.withScheduleLock(scheduleId, async () => {
      let promoted = 0;
      for (let round = 0; round < 10; round += 1) {
        const reservedThisRound = await this.topUpFill(scheduleId);
        if (reservedThisRound <= 0) break;
        promoted += reservedThisRound;
        await this.extendPaymentPhaseForTopUp(scheduleId);
      }
      if (promoted > 0) {
        this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill");
      }
      return promoted;
    });
  }

  /**
   * Settle, then keep promoting the waiting list until the train can take no more.
   * Returns whether anything settled.
   *
   * One top-up pass is not enough: expiring an N-wagon booking can free room for
   * several smaller ones, and reserving those can in turn leave room for the next
   * size down. Loop until a pass reserves nothing, so the batch ends with the train
   * as full as the pool allows — rather than leaving a booking stranded until the
   * next window cycle.
   *
   * Each round that opens a fresh pay window pushes `paymentPhaseEndsAt` out, so
   * `concludeCycle` cannot fire before the promoted customers' deadlines.
   */
  private async settleAndTopUp(
    scheduleId: string,
    expireUnpaidUnknownDeadline: boolean,
  ): Promise<boolean> {
    const anySettled = await this.settleReserved(
      scheduleId,
      expireUnpaidUnknownDeadline,
    );
    if (!anySettled) return false;

    this.logger.log(
      `[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
    );

    // Bounded: every round either reserves at least one unit (shrinking the pool)
    // or breaks. The cap is a backstop against a pathological reserve/expire cycle.
    let promoted = 0;
    for (let round = 0; round < 10; round += 1) {
      const reservedThisRound = await this.topUpFill(scheduleId);
      if (reservedThisRound <= 0) break;
      promoted += reservedThisRound;
      await this.extendPaymentPhaseForTopUp(scheduleId);
    }

    if (promoted > 0) {
      this.logger.log(
        `[BATCH] top-up promoted ${promoted} waiting booking(s) onto ${scheduleId} ` +
          `— payment phase extended for them`,
      );
    }
    // The settle may have resolved the last pay window on a full export day
    // (paid → allocated, and the top-up found nothing else that fits) — sweep
    // the date's leftover bookings. Self-guarded: no-op for import/domestic
    // and while any train on the day can still take bookings.
    await this.expireLeftoverExportDay(scheduleId);
    // Emitted here (not in settleDueReservations/settleBatch, which both wrap
    // this) so one settle produces one push, after every allocation/expiry/
    // top-up extension for this schedule has been persisted.
    this.notifyBoardChanged(scheduleId, "reservations_settled");
    return true;
  }

  /**
   * Run `fn` with exclusive access to `scheduleId`. Concurrent callers await the
   * in-flight run rather than interleaving with it. Single-process only — a second
   * API replica would need a row lock on the schedule instead.
   */
  private async withScheduleLock<T>(
    scheduleId: string,
    fn: () => Promise<T>,
  ): Promise<T> {
    const inFlight = this.scheduleLocks.get(scheduleId) ?? Promise.resolve();
    // Chain onto the previous holder; swallow its rejection so one failure does
    // not poison every later caller's lock.
    const run = inFlight.catch(() => undefined).then(fn);
    const gate = run.then(
      () => undefined,
      () => undefined,
    );
    this.scheduleLocks.set(scheduleId, gate);
    try {
      return await run;
    } finally {
      // Last one out clears the slot so the map does not grow without bound.
      if (this.scheduleLocks.get(scheduleId) === gate) {
        this.scheduleLocks.delete(scheduleId);
      }
    }
  }

  // ---- settle (1h after a batch) -------------------------------------------

  /** Allocate paid reservations, expire the rest, then top up. */
  async settleBatch(scheduleId: string): Promise<void> {
    this.removeTimeout(scheduleId);
    await this.withScheduleLock(scheduleId, () =>
      this.settleAndTopUp(scheduleId, true),
    );
    void this.triggerWagonAllocation(scheduleId);
  }

  private triggerWagonAllocation(scheduleId: string): void {
    void this.trainSchedulingService
      .tryAutoWagonAllocation(scheduleId)
      .catch((err) =>
        this.logger.warn(
          `Auto wagon allocation failed for ${scheduleId}: ${(err as Error).message}`,
        ),
      );
  }

  /**
   * Announce that a schedule's batch-board data changed so open boards refetch.
   * Called AFTER the state change is persisted; a push failure only logs — it
   * must never break the business transaction that triggered it.
   */
  private notifyBoardChanged(scheduleId: string, reason: string): void {
    try {
      this.bookingWindowGateway.emitBatchChanged(scheduleId, reason);
    } catch (err) {
      this.logger.warn(
        `Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`,
      );
    }
  }

  // ---- staff override actions ----------------------------------------------

  /** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
  async markPaid(bookingId: string): Promise<void> {
    const booking = await this.dataSource
      .getRepository(Booking)
      .findOne({ where: { id: bookingId } });
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
    if (!booking.trainScheduleId) {
      throw new BadRequestException(
        "Booking has no target schedule to allocate to",
      );
    }
    await this.dataSource
      .getRepository(Booking)
      .update(bookingId, { paymentStatus: "PAID" });
    if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) {
      await this.allocate(booking.trainScheduleId, booking, "paid");
    }

    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
      booking.trainScheduleId,
    );
    if (schedule && (await this.isTrainFull(schedule))) {
      await this.setWindow(booking.trainScheduleId, "FULL");
      // Same as the webhook path: a staff mark-paid can settle the last live
      // pay window on a now-full export day — sweep the date's leftovers.
      void this.expireLeftoverExportDay(booking.trainScheduleId);
    }
    void this.triggerWagonAllocation(booking.trainScheduleId!);
    this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
  }

  /**
   * Re-point a booking to another OPEN same-route schedule (keeps approval/contract + priority).
   * Used for EXPIRED or full-schedule bookings — no re-approval.
   */
  async moveToSchedule(
    bookingId: string,
    newScheduleId: string,
  ): Promise<void> {
    const booking = await this.dataSource
      .getRepository(Booking)
      .findOne({ where: { id: bookingId } });
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);

    const schedule = await this.dataSource
      .getRepository(TrainSchedule)
      .findOne({ where: { id: newScheduleId } });
    if (!schedule)
      throw new NotFoundException(`Train schedule ${newScheduleId} not found`);
    if (schedule.bookingWindowStatus !== "OPEN") {
      throw new BadRequestException(
        "Target schedule is not accepting bookings",
      );
    }
    const stops = await this.stopsForSchedule(schedule);
    const fromIdx = stops.indexOf(booking.originYardId);
    const toIdx = stops.indexOf(booking.destinationYardId);
    if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
      throw new BadRequestException(
        "Target schedule is not on the booking route",
      );
    }

    const sourceScheduleId = booking.trainScheduleId ?? null;
    await this.dataSource.transaction(async (manager) => {
      if (booking.trainScheduleId) {
        await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
          booking.trainScheduleId,
          bookingId,
          manager,
        );
      }
      const restoredStatus =
        booking.status === "EXPIRED"
          ? booking.isGovernment
            ? "APPROVED"
            : "FULLY_EXECUTED"
          : booking.status;
      await manager.getRepository(Booking).update(bookingId, {
        trainScheduleId: newScheduleId,
        status: restoredStatus,
        // A paid booking still hunting for a wagon keeps its flag through the
        // move — it only clears when wagons are actually assigned.
        schedulingStatus:
          booking.schedulingStatus === "WAITING_FOR_WAGON"
            ? "WAITING_FOR_WAGON"
            : "ELIGIBLE",
        paymentDeadline: null,
        selectedForBatchAt: null,
      } as never);
    });
    // Both boards changed: the booking left the source train and joined the target.
    if (sourceScheduleId && sourceScheduleId !== newScheduleId) {
      this.notifyBoardChanged(sourceScheduleId, "booking_moved");
    }
    this.notifyBoardChanged(newScheduleId, "booking_moved");
  }

  /** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
  async expireReservation(bookingId: string): Promise<void> {
    const booking = await this.dataSource
      .getRepository(Booking)
      .findOne({ where: { id: bookingId } });
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
    // Capture the train before expire() detaches the booking from it — the
    // top-up has to run against the schedule whose wagons were just freed.
    const freedScheduleId = booking.trainScheduleId;
    await this.expire(booking);
    if (freedScheduleId) {
      const topUpReserved = await this.topUpFill(freedScheduleId);
      if (topUpReserved > 0) {
        await this.extendPaymentPhaseForTopUp(freedScheduleId);
      }
      // After the top-up + phase extension so one push carries the final state.
      this.notifyBoardChanged(freedScheduleId, "reservation_expired");
    }
  }

  // ---- intercity ride-along API ---------------------------------------------

  /**
   * Remaining corridor capacity budget (per-edge wagons / weight / length) for
   * a schedule, and the per-booking need calculator — exposed for the intercity
   * accept flow, which reserves ride-along bookings onto import/export trains
   * outside the batch engine. Segment-based: an intercity booking fits whenever
   * ITS leg has room, even if the train is full on other legs.
   */
  async intercityCapacity(scheduleId: string): Promise<{
    budget: CorridorBudget;
    needFor: (booking: Booking) => Capacity;
  } | null> {
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    const locomotive = trainSetLocomotiveLimits(schedule?.trainSet);
    if (!schedule || !locomotive) return null;
    const wagonDims = await this.loadWagonDims();
    const limits = await this.capacityLimits(locomotive);
    // Built trains use the leg-aware corridor budget too: the wagon planner
    // consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon
    // that runs empty Gelan→Adama genuinely can carry an intercity booking
    // there before its export cargo boards at Adama. A train full on one leg
    // still accepts ride-alongs on its empty legs — that is the whole point
    // of the ride-along flow.
    const budget = await this.remainingBudget(schedule, limits, wagonDims);
    return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
  }

  /**
   * Accept an intercity booking onto the given train. Commercial bookings get
   * the same pay-window lifecycle as a batch reservation (deadline, invoice
   * due-date sync, pay-now notify, settle on the window tick), so payment →
   * allocation needs no special path. Government bookings allocate directly.
   */
  async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
    if (booking.isGovernment) {
      await this.dataSource
        .getRepository(Booking)
        .update(booking.id, { trainScheduleId: scheduleId });
      booking.trainScheduleId = scheduleId;
      await this.allocate(scheduleId, booking, 'gov');
      this.notifyBoardChanged(scheduleId, 'intercity_accepted');
      return;
    }
    await this.reserve(booking, scheduleId);
    this.armSettle(scheduleId);
    this.notifyBoardChanged(scheduleId, 'intercity_accepted');
  }

  // ---- mutations ------------------------------------------------------------

  /**
   * Reserve capacity for a commercial booking on a specific train and open its
   * pay window. `scheduleId` is persisted so the settle/allocate lifecycle
   * (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
   * which is all keyed off `booking.trainScheduleId`, can find the train — with
   * day-level pooling the booking arrives here with `trainScheduleId` still null,
   * so the engine sets it as it picks the train.
   */
  private async reserve(booking: Booking, scheduleId: string): Promise<void> {
    // Idempotency guard: a booking already reserved (pay window open) or already
    // paid on THIS schedule must never be re-reserved — that would fire a second
    // `payNow` and reset its deadline, the "asked to pay again after paying"
    // symptom. Read fresh state (the in-memory `booking` may be stale from the
    // pooled query). Only bookings not yet committed to this train pass through.
    const fresh = await this.dataSource
      .getRepository(Booking)
      .findOne({ where: { id: booking.id } });
    if (
      fresh &&
      fresh.trainScheduleId === scheduleId &&
      (fresh.status === "SELECTED_FOR_BATCH" ||
        fresh.status === "AWAITING_PAYMENT" ||
        fresh.status === "PAID" ||
        fresh.paymentStatus === "PAID")
    ) {
      this.logger.debug(
        `[BATCH] reserve skipped for ${booking.reference} — already ` +
          `${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
      );
      return;
    }
    const now = new Date();
    let deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
    // EXPORT parity: pay windows on an export train never outlive its booking
    // window — export bookings expire at close, so anything reserved onto the
    // same train (FCFS export or an intercity ride-along) must too. Import
    // keeps the plain payment window; its cycles re-fill after settle.
    const targetSchedule = await this.dataSource
      .getRepository(TrainSchedule)
      .findOne({ where: { id: scheduleId } });
    if (targetSchedule?.direction === "EXPORT") {
      const cutoff =
        targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate;
      if (cutoff && cutoff.getTime() <= now.getTime()) {
        throw new BadRequestException(
          "Export booking window has closed — cannot open a pay window on this train",
        );
      }
      if (cutoff && cutoff.getTime() < deadline.getTime()) {
        deadline = new Date(cutoff);
      }
    }
    await this.bookingsRepository.update(booking.id, {
      trainScheduleId: scheduleId,
      status: "SELECTED_FOR_BATCH",
      selectedForBatchAt: now,
      paymentDeadline: deadline,
    } as never);
    booking.trainScheduleId = scheduleId;
    // The invoice was generated DRAFT at booking creation / operation-accept,
    // before this pay window existed. Reserving is the moment the booking becomes
    // payable (SELECTED_FOR_BATCH + a real deadline), so issue the draft here and
    // print the deadline as its due date — never earlier, or the customer could
    // settle an invoice for a slot they have not been offered yet. Idempotent: a
    // re-reserve only refreshes `dueAt`.
    await this.billing.issuePayable(
      Freight.InvoiceSource.Booking,
      booking.id,
      deadline,
      "PREPAID",
    );
    await this.notifier.payNow(booking, deadline);
    const reservedWagons = this.wagonsFor(booking, await this.loadWagonDims());
    this.logger.log(
      `[BATCH] RESERVED ${booking.reference} (${reservedWagons}w, ` +
        `priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId} — ` +
        `pay by ${deadline.toISOString()}`,
    );
    // Customer tracking: a wagon slot is reserved and the freight pay window is
    // open. Doc-trigger path — silent no-op for bookings without milestone rows.
    void this.completeTrackingMilestones(booking.id, [
      "WAGON_REQUESTED",
      "FREIGHT_PAYMENT_PENDING",
    ]);
  }

  /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */
  /**
   * Fleet preflight shared by every single-booking paid-allocation path: when
   * no wagon of the booking's required type is free, hold it OUT of the train
   * instead of linking — it stays PAID + unlinked in the (route, day) pool,
   * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from
   * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true
   * when the booking was held. Consolidated pairs are exempt (the shared wagon
   * is both-or-neither and settles atomically in settleReserved).
   */
  private async holdIfWagonShort(
    scheduleId: string,
    booking: Booking,
  ): Promise<boolean> {
    if (booking.consolidationPartnerId) return false;
    const shortage =
      await this.trainSchedulingService.previewPaidBookingWagonShortage(
        scheduleId,
        booking.id,
      );
    if (!shortage) return false;

    await this.dataSource.getRepository(Booking).update(booking.id, {
      status: "PAID",
      paymentStatus: "PAID",
      schedulingStatus: "WAITING_FOR_WAGON",
      paymentDeadline: null,
      selectedForBatchAt: null,
    } as never);
    // Payment landed — record it even though nothing boards yet. The wagon
    // milestone stays pending until staff assign one.
    void this.completeTrackingMilestones(booking.id, [
      "FREIGHT_PAYMENT_PENDING",
      "FREIGHT_PAYMENT_SETTLED",
    ]);
    this.logger.warn(
      `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` +
        `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
        `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` +
        `Held in the day pool for manual placement.`,
    );
    this.notifyBoardChanged(scheduleId, "booking_waiting_wagon");
    return true;
  }

  private async allocate(
    scheduleId: string,
    booking: Booking,
    reason: "paid" | "gov",
  ): Promise<void> {
    await this.dataSource.transaction(async (manager) => {
      const exists =
        await this.trainScheduleBookingsRepository.existsForBooking(
          booking.id,
          manager,
        );
      if (!exists) {
        await this.trainScheduleBookingsRepository.createMany(
          [{ trainScheduleId: scheduleId, bookingId: booking.id }],
          manager,
        );
      }
      await manager.getRepository(Booking).update(booking.id, {
        status: reason === "paid" ? "PAID" : booking.status,
        schedulingStatus: "SCHEDULED",
        scheduledAt: new Date(),
        paymentDeadline: null,
        selectedForBatchAt: null,
      } as never);
    });
    this.logger.log(
      `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
    );
    this.notifier.secured(booking, reason, scheduleId);
    void this.triggerWagonAllocation(scheduleId);
    void this.markWagonAllocatedMilestone(booking.id);
    // Customer tracking: freight payment settled (commercial pay-window path).
    // Government allocations don't pay upfront — theirs stay pending.
    if (reason === 'paid') {
      void this.completeTrackingMilestones(booking.id, [
        'WAGON_REQUESTED',
        'FREIGHT_PAYMENT_PENDING',
        'FREIGHT_PAYMENT_SETTLED',
      ]);
    }
  }

  private async markWagonAllocatedMilestone(bookingId: string): Promise<void> {
    if (!this.milestoneService) return;
    try {
      await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
    } catch {
      // Booking may have no milestone rows (non-contract path).
    }
  }

  /**
   * Complete customer-tracking milestones on lifecycle events via the
   * doc-trigger path — a silent no-op for bookings without milestone rows
   * (non-customs bookings). Never blocks the batch action.
   */
  private async completeTrackingMilestones(
    bookingId: string,
    codes: string[],
  ): Promise<void> {
    if (!this.milestoneService) return;
    for (const code of codes) {
      try {
        await this.milestoneService.completeByDocTrigger({ bookingId }, code);
      } catch (err) {
        this.logger.warn(
          `Milestone ${code} completion failed for booking ${bookingId}: ${(err as Error).message}`,
        );
      }
    }
  }

  /**
   * Expire an unpaid reservation and free its capacity. With day-level pooling we
   * also clear `trainScheduleId` so the booking is no longer pinned to the train
   * it failed to pay for — it's back in the day pool for staff to act on.
   * `reason` picks the customer message: 'payment' (pay window lapsed) or
   * 'no-capacity' (no train on the chosen day could take the booking).
   *
   * PAID GUARD: a booking whose payment has landed is never expired — money was
   * taken, so it boards, even when the webhook arrived after the deadline or the
   * settle read a stale row. It allocates onto the train it was selected for; if
   * the wagon planner then finds no physical wagon, the booking stays linked and
   * staff assign wagons manually. Consolidated bookings are exempt from the
   * rescue: the shared wagon is both-or-neither, and settleReserved owns that
   * pair decision.
   */
  private async expire(
    booking: Booking,
    reason: "payment" | "no-capacity" = "payment",
  ): Promise<void> {
    if (!booking.consolidationPartnerId) {
      const fresh = await this.dataSource
        .getRepository(Booking)
        .findOne({ where: { id: booking.id }, relations: { company: true } });
      const paid =
        fresh != null &&
        (fresh.paymentStatus === "PAID" || fresh.status === "PAID");
      const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId;
      if (paid && paidScheduleId) {
        this.logger.log(
          `[BATCH] expire skipped for ${booking.reference} — payment already ` +
            `landed; allocating on schedule ${paidScheduleId} instead`,
        );
        if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) {
          await this.allocate(paidScheduleId, fresh, "paid");
        }
        return;
      }
    }
    const freedScheduleId = booking.trainScheduleId;
    await this.bookingsRepository.update(booking.id, {
      trainScheduleId: null,
      status: "EXPIRED",
      schedulingStatus: "ELIGIBLE",
      paymentDeadline: null,
      selectedForBatchAt: null,
    } as never);
    booking.trainScheduleId = null;
    // The wagons this reservation held are back — a schedule parked at FULL
    // because of it must reopen, or it can never be filled again.
    if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
    // An unpaid partial offer dies with the reservation — the booking stays whole.
    if (this.splitService) {
      await this.splitService.expireOpenOffer(booking.id);
    }
    // Pay window closed before settlement → expire the booking's open invoice too
    // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
    // source-agnostic.
    await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
    if (reason === "no-capacity") {
      this.notifier.expiredNoCapacity(booking);
    } else {
      this.notifier.expired(booking);
    }
    this.logger.log(
      `[BATCH] EXPIRED ${booking.reference} — ` +
        (reason === "no-capacity"
          ? "no train on its day had capacity left"
          : "payment window passed; freed its wagons back to the pool for top-up"),
    );
  }

  /**
   * End-of-day sweep: once a schedule's window cycle concludes and NO other
   * train on the same route-day can still run a cycle, the waiting pool for
   * that day is dead — a FULLY_EXECUTED booking left in it would wait forever.
   * Expire every leftover commercial booking and tell the customers to rebook
   * another day. Government bookings are never auto-expired (they preempt).
   * Returns how many bookings were expired.
   */
  async expireLeftoverDayPool(scheduleId: string): Promise<number> {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule?.scheduledDepartureDate) return 0;
    const day = eatDay(schedule.scheduledDepartureDate);
    const group: RouteDayGroup = {
      originYardId: schedule.originStationId,
      destinationYardId: schedule.destinationStationId,
      day,
    };

    // Another train on this route-day that can still take bookings keeps the
    // pool alive — when IT concludes, its own sweep runs this check again.
    const siblings = await this.trainSchedulesRepository.findAll({
      where: [
        {
          originStationId: group.originYardId,
          destinationStationId: group.destinationYardId,
          status: TrainScheduleStatusEnum.Draft,
        },
        {
          originStationId: group.originYardId,
          destinationStationId: group.destinationYardId,
          status: TrainScheduleStatusEnum.Scheduled,
        },
      ],
    });
    const anotherTrainStillOpen = siblings.some(
      (s) =>
        s.id !== schedule.id &&
        s.scheduledDepartureDate != null &&
        eatDay(s.scheduledDepartureDate) === day &&
        s.windowPhase !== "DONE" &&
        s.bookingWindowStatus !== "FULL",
    );
    if (anotherTrainStillOpen) return 0;

    const corridorYards = await this.corridorYardsForRouteDay(group);
    const pool = corridorYards.length
      ? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day)
      : await this.bookingsRepository.findBatchPoolByRouteDay(
          group.originYardId,
          group.destinationYardId,
          day,
        );
    const leftovers = pool.filter((b) => !b.isGovernment);
    // Capture pinned schedules BEFORE expire() clears trainScheduleId, so each
    // touched board gets exactly one push at the end of the sweep.
    const touchedScheduleIds = new Set<string>();
    if (leftovers.length) touchedScheduleIds.add(scheduleId);
    for (const booking of leftovers) {
      if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
      await this.expire(booking, "no-capacity");
    }
    if (leftovers.length) {
      this.logger.log(
        `[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` +
          `expired ${leftovers.length} waiting booking(s)`,
      );
    }
    for (const id of touchedScheduleIds) {
      this.notifyBoardChanged(id, "day_pool_expired");
    }
    return leftovers.length;
  }

  /**
   * EXPORT counterpart of the conclude-time sweep. Export has no batch cycle,
   * so nothing ever concluded its day: bookings still waiting when the trains
   * filled up or the window closed stayed pending forever. Once every export
   * train on this route-day is shut — window DONE, or FULL with no pay window
   * still live that could lapse and free space — the date is dead: expire the
   * un-accepted bookings staff can no longer accept AND the ready
   * (FULLY_EXECUTED) bookings that never got a reservation (consolidation
   * waiters). Runs at export window close and whenever an export train's
   * fullness settles.
   */
  async expireLeftoverExportDay(scheduleId: string): Promise<void> {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) {
      return;
    }
    const day = eatDay(schedule.scheduledDepartureDate);
    const trains = (
      await this.trainSchedulesRepository.findAll({
        where: [
          {
            originStationId: schedule.originStationId,
            destinationStationId: schedule.destinationStationId,
            status: TrainScheduleStatusEnum.Draft,
          },
          {
            originStationId: schedule.originStationId,
            destinationStationId: schedule.destinationStationId,
            status: TrainScheduleStatusEnum.Scheduled,
          },
        ],
      })
    ).filter(
      (s) =>
        s.scheduledDepartureDate != null &&
        eatDay(s.scheduledDepartureDate) === day,
    );
    for (const s of trains) {
      // Any train still taking bookings keeps the date alive.
      if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return;
      // A FULL train whose reservations are still inside their pay windows can
      // reopen when one lapses unpaid — defer; the settle re-runs this sweep.
      if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) {
        return;
      }
    }
    await this.expireUnacceptedForRouteDay({
      originYardId: schedule.originStationId,
      destinationYardId: schedule.destinationStationId,
      day,
    });
    await this.expireLeftoverDayPool(scheduleId);
  }

  /**
   * Union of stop yards across the day's fillable schedules on this corridor —
   * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
   * are covered. Empty when no fillable schedule exists for the group.
   */
  private async corridorYardsForRouteDay(
    group: RouteDayGroup,
  ): Promise<string[]> {
    const corridor = await this.trainSchedulesRepository.findAll({
      where: [
        {
          originStationId: group.originYardId,
          destinationStationId: group.destinationYardId,
          status: TrainScheduleStatusEnum.Draft,
        },
        {
          originStationId: group.originYardId,
          destinationStationId: group.destinationYardId,
          status: TrainScheduleStatusEnum.Scheduled,
        },
      ],
    });
    const yards = new Set<string>();
    for (const schedule of corridor) {
      if (
        schedule.scheduledDepartureDate == null ||
        eatDay(schedule.scheduledDepartureDate) !== group.day
      ) {
        continue;
      }
      for (const yardId of await this.stopsForSchedule(schedule)) {
        yards.add(yardId);
      }
    }
    return [...yards];
  }

  /**
   * Sweep bookings on a route-day whose operation request staff did NOT accept by
   * the time the window's document-review phase ends. They never reached
   * FULLY_EXECUTED, so they never enter the batch — expire them (customer must
   * rebook a new window). No reservation and no invoice exists yet at this stage,
   * so this is a lighter expiry than `expire()`: just flip status + notify, and
   * best-effort close any payable if one was issued early. Government/export are
   * excluded by the query.
   */
  async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
    const corridorYards = await this.corridorYardsForRouteDay(group);
    if (corridorYards.length === 0) return;
    const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
      corridorYards,
      group.day,
    );
    if (unaccepted.length > 0) {
      this.logger.log(
        `[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` +
          `on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
      );
    }
    // Only bookings pinned to a train show on a board — collect their schedules
    // and push once per schedule after the sweep (most unaccepted rows are
    // unpinned under day-level pooling, so this usually emits nothing).
    const touchedScheduleIds = new Set<string>();
    for (const booking of unaccepted) {
      if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
      await this.bookingsRepository.update(booking.id, {
        status: "EXPIRED",
        schedulingStatus: "ELIGIBLE",
        // Free the shipment day so the customer can rebook a fresh window.
        scheduledDate: null,
      } as never);
      // Close any payable issued before doc-review end (normally none — the invoice
      // is created at ops-accept, which by definition has not happened here).
      await this.billing
        .expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
        .catch(() => undefined);
      this.notifier.expired(booking);
      this.logger.log(
        `[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
      );
    }
    for (const id of touchedScheduleIds) {
      this.notifyBoardChanged(id, "unaccepted_expired");
    }
  }

  /**
   * How many bookings on this route-day would be expired if document review
   * ended right now — i.e. requests staff have neither accepted nor rejected.
   * Same query the doc-review-end sweep runs, so the number staff see is
   * exactly what is at risk.
   */
  async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
    const corridorYards = await this.corridorYardsForRouteDay(group);
    if (corridorYards.length === 0) return 0;
    const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
      corridorYards,
      group.day,
    );
    return unaccepted.length;
  }

  /**
   * Free capacity for a government booking by displacing the lowest-priority commercial
   * bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
   * Only victims whose legs overlap the government booking's leg actually free useful
   * room, so others are skipped. Mutates `budget`; returns whether the need now fits.
   */
  private async preemptForGovernment(
    scheduleId: string,
    need: Capacity,
    leg: CorridorLeg,
    budget: CorridorBudget,
    wagonDims: WagonDims,
  ): Promise<boolean> {
    if (budget.fits(need, leg)) return true;
    const reservedCommercial = (
      await this.bookingsRepository.findReservedForSchedule(scheduleId)
    ).filter((b) => !b.isGovernment);
    const allocatedCommercial =
      await this.bookingsRepository.findAllocatedCommercialForSchedule(
        scheduleId,
      );

    // lowest priority first; reserved are cheaper to free than allocated
    const candidates = [...reservedCommercial, ...allocatedCommercial].sort(
      (a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
    );

    for (const victim of candidates) {
      if (budget.fits(need, leg)) break;
      const victimLeg = budget.legForYards(
        victim.originYardId,
        victim.destinationYardId,
      );
      // Displacing a booking on a disjoint leg frees nothing the government
      // booking can use — don't kill it for nothing.
      const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
      if (!overlaps) continue;
      await this.dataSource.transaction(async (manager) => {
        await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
          scheduleId,
          victim.id,
          manager,
        );
        await manager.getRepository(Booking).update(victim.id, {
          status: "EXPIRED",
          schedulingStatus: "ELIGIBLE",
          paymentDeadline: null,
          selectedForBatchAt: null,
        } as never);
        // Displaced → EXPIRED: close its open invoice too, so a dead booking
        // can't still be paid (mirrors `expire()`; enlisted in this txn).
        await this.billing.expirePayable(
          Freight.InvoiceSource.Booking,
          victim.id,
          "PREPAID",
          manager,
        );
      });
      this.notifier.displaced(victim);
      budget.add(this.needFor(victim, wagonDims), victimLeg);
      // Displacing frees wagons the same way an expiry does — don't leave the
      // schedule stuck at FULL.
      await this.refreshWindowStatus(scheduleId);
    }
    return budget.fits(need, leg);
  }

  // ---- capacity helpers -----------------------------------------------------

  /**
   * Collapse consolidated partners into single pool entries so the fill treats a
   * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
   *  - no `consolidationPartnerId` → passes through as a lone booking.
   *  - consolidated + partner also in this pool → emitted ONCE (at the position of
   *    whichever partner ranks first) as a pair; the partner is not emitted again.
   *  - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
   *    it waits for the partner to become ready in a later cycle).
   * The pool is already priority-ordered, so emitting the pair at the first-seen
   * partner's slot ranks it by the stronger (max-priority) partner automatically.
   */
  private groupConsolidatedPool(
    pool: Booking[],
  ): Array<{ primary: Booking; partner: Booking | null }> {
    const byId = new Map(pool.map((b) => [b.id, b]));
    const emitted = new Set<string>();
    const units: Array<{ primary: Booking; partner: Booking | null }> = [];
    for (const booking of pool) {
      if (emitted.has(booking.id)) continue;
      const partnerId = booking.consolidationPartnerId ?? null;
      if (!partnerId) {
        emitted.add(booking.id);
        units.push({ primary: booking, partner: null });
        continue;
      }
      const partner = byId.get(partnerId) ?? null;
      if (!partner) {
        // Both-or-neither: partner not ready in this pool → skip the pair entirely.
        emitted.add(booking.id);
        continue;
      }
      emitted.add(booking.id);
      emitted.add(partner.id);
      units.push({ primary: booking, partner });
    }
    return units;
  }

  /**
   * Combined capacity need of a consolidated pair sharing wagons. The whole point of
   * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
   * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
   * independently-rounded-up needs (that is the capacity consolidation saves).
   */
  private combinedNeed(
    primary: Booking,
    partner: Booking,
    wagonDims: WagonDims,
  ): Capacity {
    const containers = (b: Booking): number =>
      (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
    const totalContainers = containers(primary) + containers(partner);
    const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner);

    // Consolidation shares TEU slots, never rated payload: the pair still needs
    // enough wagons to carry its combined cargo, so the weight axis bounds the
    // shared count exactly as it bounds an individual booking's. A pair shares
    // wagons, so the primary's wagon type stands for both partners.
    const dims = this.dimsFor(primary, wagonDims);
    const capacityTons = dims.capacityTons;
    const byWeight =
      cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;
    const byLength =
      totalContainers > 0
        ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
        : this.wagonsFor(primary, wagonDims) + this.wagonsFor(partner, wagonDims);
    const sharedWagons = Math.max(byLength, byWeight);

    return {
      wagons: sharedWagons,
      // Consolidation saves tare as well as slots: the pair rides `sharedWagons`
      // wagons, so it is charged `sharedWagons` tares, not one per booking.
      weightTons: bookingGrossWeightTons(
        cargoTons,
        sharedWagons,
        dims.tareWeightTons,
      ),
      lengthMeters: sharedWagons * dims.lengthMeters,
    };
  }

  /**
   * Stamp real priority scores on the pool's BULK bookings before the batch
   * ranks it. Submit-time scoring runs with totalWagons = 0 for bulk (a bulk
   * booking has no container lines to carry a wagon count), so every
   * wagon-range priority config missed and bulk import bookings entered the
   * batch at score 0 — they were never prioritized. Their wagon footprint is
   * derivable from tonnage vs. live wagon capacity (wagonsFor), so the score
   * is computed here — when doc review closes and the batch runs — and
   * persisted so the priority board shows the same ranking. The pool arrives
   * SQL-ordered by the old scores; the caller must re-sort after this.
   */
  private async recomputeBulkPriorities(
    pool: Booking[],
    wagonDims: WagonDims,
  ): Promise<void> {
    for (const booking of pool) {
      if (booking.freightType !== 'BULK') continue;
      try {
        const wagons = this.wagonsFor(booking, wagonDims);
        const score = await this.pricingService.computeSubmitPriorityScore(
          booking,
          wagons,
        );
        if (Number(booking.priorityScore ?? 0) === score) continue;
        await this.dataSource
          .getRepository(Booking)
          .update(booking.id, { priorityScore: score });
        booking.priorityScore = score;
      } catch (err) {
        // A failed recompute keeps the stored score — never blocks the batch.
        this.logger.warn(
          `Bulk priority recompute failed for ${booking.reference ?? booking.id}: ` +
            `${(err as Error).message}`,
        );
      }
    }
  }

  /** Restore the batch pool ordering (mirrors findBatchPool's ORDER BY) after scores changed. */
  private resortPoolByPriority(pool: Booking[]): void {
    pool.sort(
      (a, b) =>
        Number(b.isGovernment) - Number(a.isGovernment) ||
        Number(b.priorityScore ?? 0) - Number(a.priorityScore ?? 0) ||
        (a.fullyExecutedAt?.getTime() ?? Infinity) -
          (b.fullyExecutedAt?.getTime() ?? Infinity) ||
        a.createdAt.getTime() - b.createdAt.getTime(),
    );
  }

  /**
   * Wagons a booking occupies. Two axes bind independently and the booking needs
   * enough wagons to satisfy BOTH, so the count is the larger of:
   *
   *   weight — ceil(cargoTons / wagonType.capacityTons), the rated payload
   *   length — TEU geometry, two 20ft to a wagon (container bookings only)
   *
   * The weight axis was missing entirely. A BULK booking carries no container
   * lines, so `containerWagonsForLines` returned 0 and every bulk booking
   * collapsed to a single wagon no matter its tonnage — a 2590T fertilizer
   * booking counted as 1 wagon, and `needFor` then charged 1 tare instead of 37.
   * That under-reported the board and let the fill loop overbook the train.
   */
  private wagonsFor(booking: Booking, wagonDims: WagonDims): number {
    // Stored wagonsRequired is a candidate, never an early return: rows written
    // while sumWagonsRequired hardcoded BULK to 1 wagon are still in the DB, and
    // trusting them charged one tare for a whole bulk consist (a 700T booking on
    // 70T wagons read 700 + 1 tare instead of 700 + 10 tares).
    const stored =
      booking.wagonsRequired && booking.wagonsRequired > 0
        ? Math.ceil(booking.wagonsRequired)
        : 0;

    // TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback
    // summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
    const byLength = containerWagonsForLines(booking.bookingContainers ?? []);

    const capacityTons = this.dimsFor(booking, wagonDims).capacityTons;
    const cargoTons = bookingCargoTons(booking);
    const byWeight =
      cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0;

    return Math.max(DEFAULT_WAGONS_PER_BOOKING, stored, byLength, byWeight);
  }

  /**
   * What one booking consumes along all three capacity axes.
   *
   * The weight axis is GROSS — cargo plus the tare of every wagon the booking
   * occupies — because it is spent against the locomotive's pull limit, which
   * governs the whole train and not just its payload. Charging cargo alone let a
   * 37-wagon box-wagon train read 2590T when it really weighed 3522T.
   */
  private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
    const wagons = this.wagonsFor(booking, wagonDims);
    const dims = this.dimsFor(booking, wagonDims);
    return {
      wagons,
      weightTons: bookingGrossWeightTons(
        bookingCargoTons(booking),
        wagons,
        dims.tareWeightTons,
      ),
      lengthMeters: wagons * dims.lengthMeters,
    };
  }

  private fits(need: Capacity, budget: Capacity): boolean {
    return (
      need.wagons <= budget.wagons &&
      need.weightTons <= budget.weightTons &&
      need.lengthMeters <= budget.lengthMeters
    );
  }

  /**
   * Caps for a schedule's train: gross pull weight, train length, and the
   * length-derived wagon slot count (never a fixed 53). Bookings spend against
   * `base` via {@link needFor}, whose weight axis is gross. The locomotive's
   * overage tolerance is returned separately — the corridor budget spends it
   * only to admit a booking whole, never to size a split.
   *
   * Limits come from the LOCOMOTIVE ALONE — the global-rules weight/length
   * caps deliberately do not apply here (a mis-set global row once capped
   * every train at 14m and no export booking could board).
   */
  private async capacityLimits(locomotive: LocomotiveLimits): Promise<TrainLimits> {
    const wagonTypes = await this.loadWagonTypeDimensions();
    const derived = deriveTrainCapacityFromLocomotive(
      {
        maxPullWeightTons: Number(locomotive.maxPullWeightTons),
        maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
        overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
        overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
      },
      wagonTypes,
    );
    return {
      base: {
        wagons: derived.maxWagonSlots,
        weightTons: derived.baseWeightTons,
        lengthMeters: derived.baseLengthMeters,
      },
      tolerance: {
        weightTons: derived.toleranceTons,
        lengthMeters: derived.toleranceMeters,
      },
    };
  }

  /**
   * Keep schedule.max_wagons aligned with the train's boarding limit. A built
   * train's limit is its physical consist — the wagon count staff marshalled
   * (and may change via adjust-consist). Only schedules WITHOUT a built train
   * fall back to the locomotive's length-derived slot count, where bookings
   * are admitted on length/weight alone and yard staff attach the wagons
   * manually before departure.
   */
  private async syncScheduleMaxWagons(
    schedule: TrainSchedule,
    locomotive: LocomotiveLimits,
  ): Promise<void> {
    const physicalWagons = await this.builtTrainWagonCount(schedule);
    const maxWagons =
      physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
    if ((schedule.maxWagons ?? 0) !== maxWagons) {
      await this.dataSource
        .getRepository(TrainSchedule)
        .update(schedule.id, { maxWagons });
      schedule.maxWagons = maxWagons;
    }
  }

  /**
   * Every active wagon type, so the slot count is derived from the shortest wagon
   * the fleet can actually marshal rather than from an arbitrary two-code sample.
   */
  private async loadWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
    const types = await this.dataSource
      .getRepository(WagonType)
      .find({ where: { isActive: true } });
    if (types.length) return types.map(wagonTypeDimensionsFromEntity);
    return [
      {
        lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
        capacityTons: 70,
        tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
      },
      {
        lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
        capacityTons: 60,
        tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
      },
    ];
  }

  /**
   * Every wagon type keyed by id (drives per-booking dims via the cargo/container
   * type's wagon_type_id FK), plus representative fallbacks per freight type
   * (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
   * no wagon type configured yet.
   */
  /** Wagon types are near-static reference data — a short TTL cache spares one
   * table scan per board/detail request without letting edits go stale long. */
  private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;

  private async loadWagonDims(): Promise<WagonDims> {
    if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
      return this.wagonDimsCache.value;
    }
    const types = await this.dataSource.getRepository(WagonType).find();
    const byCode = new Map(
      types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
    );
    const byWagonTypeId = new Map(
      types.map((t) => [t.id, wagonTypeDimensionsFromEntity(t)]),
    );
    const nw5 = byCode.get("NW5");
    const cw3 = byCode.get("CW3");
    // capacityTons divides a bulk booking's cargo, so a 0 or missing rated payload
    // must fall back rather than yield an infinite wagon count.
    const payload = (value: number | undefined, fallback: number): number =>
      value && value > 0 ? value : fallback;
    const value: WagonDims = {
      container: {
        lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
        tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
        capacityTons: payload(nw5?.capacityTons, DEFAULT_CONTAINER_WAGON_CAPACITY_TONS),
      },
      bulk: {
        lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
        tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
        capacityTons: payload(cw3?.capacityTons, DEFAULT_BULK_WAGON_CAPACITY_TONS),
      },
      byWagonTypeId,
    };
    this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
    return value;
  }

  /**
   * Dimensions of the wagon type THIS booking rides: bulk resolves through its
   * cargo type's allowed wagon-type list, container through the first container
   * line's — the same list resolution the scheduling planner applies when the
   * paid booking is allocated. Board/fill math measured on a representative
   * wagon while allocation validated the real one let a selected batch flunk
   * the post-payment gross-weight check; sharing the resolution closes that
   * gap. Uses the first configured type (the fill engine has no train context);
   * falls back to the representative dims when the list or relation is absent.
   */
  private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims {
    const fallback =
      booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
    const wagonTypeId =
      booking.freightType === "BULK"
        ? booking.cargoType?.wagonTypes?.[0]?.id
        : (booking.bookingContainers ?? [])
            .flatMap((line) => line.containerType?.wagonTypes ?? [])
            .map((wagonType) => wagonType.id)
            .find((id): id is string => Boolean(id));
    const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined;
    if (!dims) return fallback;
    return {
      ...dims,
      capacityTons: dims.capacityTons > 0 ? dims.capacityTons : fallback.capacityTons,
    };
  }

  /**
   * EVERY wagon-type dimension a booking may ride — its cargo/container type's
   * full allowed (many-to-many) wagon-type list, not just the first like
   * {@link dimsFor}. The remainder placer needs the whole set so a train that
   * stocks a non-primary allowed type still counts as fitting: a container type
   * mapped to both NW5 and (say) NW7 must be measured against whichever a given
   * train actually has free. Deduped by wagon-type id; falls back to the single
   * representative dims when no allowed type is configured.
   */
  private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
    const fallback =
      booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
    const ids =
      booking.freightType === "BULK"
        ? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
        : (booking.bookingContainers ?? [])
            .flatMap((line) => line.containerType?.wagonTypes ?? [])
            .map((wt) => wt.id);
    const seen = new Set<string>();
    const dims: PerWagonDims[] = [];
    for (const id of ids) {
      if (!id || seen.has(id)) continue;
      seen.add(id);
      const d = wagonDims.byWagonTypeId.get(id);
      if (d) {
        dims.push({
          ...d,
          capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
        });
      }
    }
    return dims.length ? dims : [fallback];
  }

  /**
   * Ordered stop yards of the schedule's route (origin → milestones →
   * destination); the legacy two-stop pseudo-route when milestones are absent.
   */
  private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
    let milestoneYards: string[] | null = null;
    if (schedule.routeId) {
      const milestones = await this.dataSource
        .getRepository(RouteMilestone)
        .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
      if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
    }
    return stopYardsFor(
      milestoneYards,
      schedule.originStationId,
      schedule.destinationStationId,
    );
  }

  /**
   * Remaining capacity per corridor edge = hard caps minus what allocated +
   * reserved bookings already use ON THEIR OWN LEGS. A booking riding only
   * Dire→Djibouti leaves the Addis→Dire edges untouched.
   *
   * Two capacity regimes, decided by the schedule's train:
   * - Built train (Train Builder consist with physical wagons): the consist IS
   *   the capacity. Wagon slots = physical wagon count; weight and length are
   *   NOT re-checked here — the builder and adjust-consist already enforced the
   *   locomotive's pull/length limits when the consist was assembled.
   * - No built train (legacy schedules): the locomotive's length-derived slot
   *   count plus its weight/length budgets, as before — yard staff attach the
   *   missing wagons manually before wagon assignment.
   */
  private async remainingBudget(
    schedule: TrainSchedule,
    limits: TrainLimits,
    wagonDims: WagonDims,
  ): Promise<CorridorBudget> {
    const physicalWagons = await this.builtTrainWagonCount(schedule);
    if (physicalWagons != null) {
      limits = {
        base: {
          wagons: physicalWagons,
          weightTons: Number.POSITIVE_INFINITY,
          lengthMeters: Number.POSITIVE_INFINITY,
        },
        tolerance: { weightTons: 0, lengthMeters: 0 },
      };
    }
    // Built trains keep the leg-aware multi-edge corridor too: the wagon
    // planner consumes stock per edge (planWagonsWithStock legs), so a consist
    // wagon serves disjoint legs — capacity freed past an alight yard is real.
    const stops = await this.stopsForSchedule(schedule);
    const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
    const allocated = (schedule.scheduleBookings ?? [])
      .map((sb) => sb.booking)
      .filter((b): b is Booking => Boolean(b));
    const reserved = await this.bookingsRepository.findReservedForSchedule(
      schedule.id,
    );
    for (const b of [...allocated, ...reserved]) {
      budget.subtract(
        this.needFor(b, wagonDims),
        budget.legForYards(b.originYardId, b.destinationYardId),
      );
    }
    return budget;
  }

  /**
   * Physical wagons marshalled in the schedule's built train, or null when the
   * schedule has NO built train and the legacy locomotive-derived capacity must
   * apply. This count is what caps a built train's bookings: 50 wagons coupled
   * → 50 wagon slots, no more.
   *
   * A built train with an EMPTY consist returns 0, NOT null: zero coupled
   * wagons means zero capacity. Folding that case into null used to hand an
   * un-consisted train the abstract locomotive budget, so an empty train
   * advertised its full maxWagons as free space and accepted bookings the
   * allocator could never place.
   */
  private async builtTrainWagonCount(
    schedule: TrainSchedule,
  ): Promise<number | null> {
    const trainId = schedule.trainSet?.train?.id;
    if (!trainId) return null;
    return this.dataSource.getRepository(Wagon).count({ where: { trainId } });
  }

  /**
   * Wagon slots still boardable somewhere on the corridor (most-open edge).
   * ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
   * FULL signal is {@link isTrainFull}, which also closes weight/length-bound
   * trains that still show free slots.
   */
  private async remainingWagons(schedule: TrainSchedule): Promise<number> {
    const wagonDims = await this.loadWagonDims();
    const budget = await this.remainingBudget(
      schedule,
      {
        base: {
          wagons: schedule.maxWagons ?? 0,
          weightTons: Number.POSITIVE_INFINITY,
          lengthMeters: Number.POSITIVE_INFINITY,
        },
        tolerance: { weightTons: 0, lengthMeters: 0 },
      },
      wagonDims,
    );
    return budget.maxRemaining().wagons;
  }

  async setWindow(
    scheduleId: string,
    status: "OPEN" | "FULL" | "CLOSED",
  ): Promise<void> {
    await this.dataSource
      .getRepository(TrainSchedule)
      .update(scheduleId, { bookingWindowStatus: status });
    // Push the change (open / train full / closed) so portal home and GL cards
    // flip in real time — FULL in particular happens outside the window tick
    // (batch fill, staff mark-paid) and had no live signal before.
    try {
      const fresh = await this.trainSchedulesRepository.findById(scheduleId);
      if (fresh) this.bookingWindowGateway.emitPhase(fresh);
    } catch (err) {
      this.logger.warn(
        `Booking-window push failed for ${scheduleId}: ${(err as Error).message}`,
      );
    }
  }

  /**
   * A reservation on this schedule still has time left to pay.
   *
   * The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
   * is stamped when the phase starts, then `reserve()` gives each booking
   * `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So
   * the first settle after the phase deadline finds every reservation still in date,
   * expires nothing, reports `anySettled = false`, runs no top-up — and the caller
   * concludes the cycle out from under customers who still had time to pay. The next
   * tick then expires them with no cycle left to promote the waiting list into.
   *
   * Callers must not conclude the cycle while this returns true.
   */
  async hasLiveReservations(scheduleId: string): Promise<boolean> {
    const reserved =
      await this.bookingsRepository.findReservedForSchedule(scheduleId);
    const now = Date.now();
    return reserved.some(
      (b) =>
        b.paymentStatus !== "PAID" &&
        b.status !== "PAID" &&
        b.paymentDeadline != null &&
        b.paymentDeadline.getTime() > now,
    );
  }

  /**
   * FULL is DIRECTIONAL: the schedule's trade direction is full when the
   * border-crossing edge (which every export/import must ride) can't take one
   * more minimal wagon on any axis — slots for built trains (the consist is
   * the capacity, weight/length settled at build), all three axes otherwise
   * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44).
   * Home-side legs may still run empty; intercity ride-alongs keep filling
   * them via the per-leg budget and never consult this flag. Domestic routes
   * (no border) are full only when every edge is closed.
   */
  async isScheduleFull(scheduleId: string): Promise<boolean> {
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) return false;
    return this.isTrainFull(schedule);
  }

  /**
   * Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the
   * schedule's slot capacity, how many slots allocated + reserved bookings
   * already hold on the busiest edge, how many are still free on the most-open
   * edge, and by how many slots the consist has been trimmed BELOW what is
   * already committed (0 when nothing is over-allocated).
   */
  async scheduleWagonUsage(scheduleId: string): Promise<{
    maxWagons: number;
    allocatedWagons: number;
    remainingSlots: number;
    overAllocatedBy: number;
  } | null> {
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) return null;
    const capacity =
      (await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0;
    const wagonDims = await this.loadWagonDims();
    const budget = await this.remainingBudget(
      schedule,
      {
        base: {
          wagons: capacity,
          weightTons: Number.POSITIVE_INFINITY,
          lengthMeters: Number.POSITIVE_INFINITY,
        },
        tolerance: { weightTons: 0, lengthMeters: 0 },
      },
      wagonDims,
    );
    const tightest = budget.remainingFor(budget.fullLeg()).wagons;
    return {
      maxWagons: capacity,
      allocatedWagons: capacity - tightest,
      remainingSlots: Math.max(0, budget.maxRemaining().wagons),
      overAllocatedBy: Math.max(0, -tightest),
    };
  }

  /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
  private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
    // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and
    // every import must cross the ET↔DJ border edge, so once that edge can't
    // take one more minimal wagon the booking window may close — even while
    // home-side legs still run empty. Intercity ride-alongs never consult this
    // flag; they keep booking the free legs through the per-leg budget.
    // A single-country (domestic) corridor has no mandatory edge, so it is
    // full only when EVERY edge is closed on some axis.
    const wagonDims = await this.loadWagonDims();
    const physicalWagons = await this.builtTrainWagonCount(schedule);
    let limits: TrainLimits;
    if (physicalWagons != null) {
      // The consist is the capacity; weight/length were settled at build time.
      // remainingBudget swaps in the physical wagon count per edge itself.
      limits = {
        base: {
          wagons: physicalWagons,
          weightTons: Number.POSITIVE_INFINITY,
          lengthMeters: Number.POSITIVE_INFINITY,
        },
        tolerance: { weightTons: 0, lengthMeters: 0 },
      };
    } else {
      const locomotive = trainSetLocomotiveLimits(schedule.trainSet);
      // No loco, no built train: only the slot axis exists to bind against.
      if (!locomotive) return (await this.remainingWagons(schedule)) <= 0;
      limits = await this.capacityLimits(locomotive);
    }
    const budget = await this.remainingBudget(schedule, limits, wagonDims);
    const minNeed = this.minPerWagonNeed(wagonDims);
    const border = await this.borderLeg(budget.stops);
    if (border) {
      return !budget.fits(
        {
          wagons: 1,
          weightTons: minNeed.grossWeightTons,
          lengthMeters: minNeed.lengthMeters,
        },
        border,
      );
    }
    return budget.isExhausted(minNeed);
  }

  /**
   * The corridor's single border-crossing edge (last home-country stop → first
   * far-country stop), or null when every stop is in one country. This is the
   * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it
   * books — which makes it the train's directional fullness gauge.
   */
  private async borderLeg(stops: string[]): Promise<CorridorLeg | null> {
    if (stops.length < 2) return null;
    const yards = await this.dataSource
      .getRepository(Yard)
      .find({ where: { id: In(stops) } });
    const countryOf = new Map(yards.map((y) => [y.id, y.country]));
    const first = countryOf.get(stops[0]);
    if (!first) return null;
    const crossIdx = stops.findIndex((id) => {
      const country = countryOf.get(id);
      return country != null && country !== first;
    });
    if (crossIdx <= 0) return null;
    return { fromEdge: crossIdx - 1, toEdge: crossIdx };
  }

  /**
   * Smallest gross weight / shortest length one more wagon could add: the
   * lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
   * so FULL is only declared when not even this wagon fits anywhere.
   */
  private minPerWagonNeed(wagonDims: WagonDims): {
    grossWeightTons: number;
    lengthMeters: number;
  } {
    const all = [
      wagonDims.container,
      wagonDims.bulk,
      ...wagonDims.byWagonTypeId.values(),
    ];
    return {
      grossWeightTons: Math.min(
        ...all.map((d) => d.tareWeightTons + d.capacityTons),
      ),
      lengthMeters: Math.min(...all.map((d) => d.lengthMeters)),
    };
  }

  /**
   * Re-derive `bookingWindowStatus` from live capacity after wagons were freed
   * (a reservation expired, a booking was displaced, a link was removed).
   *
   * FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
   * it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
   * a train that filled once and then lost every booking to expiry stayed FULL
   * with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
   * forever while `concludeCycle` (which reads real capacity, not the flag) kept
   * reopening it. Clearing FULL here is what lets the next batch actually run.
   *
   * Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
   * DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
   */
  async refreshWindowStatus(scheduleId: string): Promise<void> {
    const schedule =
      await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
    // Symmetric with isScheduleFull: a weight/length-bound FULL is not stale
    // just because slots remain — clearing it here would reopen a train
    // nothing can board.
    if (await this.isTrainFull(schedule)) return;

    const customerWindowOpen =
      schedule.windowPhase == null || schedule.windowPhase === "OPEN";
    await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
    this.logger.log(
      `[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
        `${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
    );
  }

  // ---- timer plumbing -------------------------------------------------------

  /** Configured customer pay window in ms (global rules, with defaults). */
  private async paymentWindowMs(): Promise<number> {
    const cfg = await this.trainSchedulingService.getWindowConfig();
    return cfg.paymentWindowMinutes * 60_000;
  }

  private timeoutName(scheduleId: string): string {
    return `settle:${scheduleId}`;
  }

  /**
   * In-process accelerator only — the durable settle enforcement is the window
   * engine's minute tick calling settleDueReservations off `paymentDeadline`.
   */
  private armSettle(scheduleId: string): void {
    void this.paymentWindowMs()
      .then((delayMs) => {
        this.removeTimeout(scheduleId);
        const handle = setTimeout(() => {
          void this.settleBatch(scheduleId).catch((err) =>
            this.logger.error(
              `settleBatch ${scheduleId} failed: ${(err as Error).message}`,
            ),
          );
        }, delayMs);
        this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
      })
      .catch((err) =>
        this.logger.warn(
          `armSettle ${scheduleId} skipped: ${(err as Error).message}`,
        ),
      );
  }

  /**
   * A top-up reservation (settle freed capacity mid-cycle, so the next waiting
   * booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
   * `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
   * concludeCycle — was frozen when the phase started. Without this, concludeCycle
   * fires before the top-up customer's deadline and expires a booking that still
   * had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
   * window from now, but never past departure. Only while the schedule is still
   * in the PAYMENT phase (a reopened cycle manages its own phase).
   */
  async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
    const schedule = await this.dataSource
      .getRepository(TrainSchedule)
      .findOne({ where: { id: scheduleId } });
    if (!schedule || schedule.windowPhase !== "PAYMENT") return;
    const windowMs = await this.paymentWindowMs();
    let target = new Date(Date.now() + windowMs);
    if (
      schedule.scheduledDepartureDate &&
      target > schedule.scheduledDepartureDate
    ) {
      target = schedule.scheduledDepartureDate;
    }
    // Only ever push the deadline OUT, never pull it in.
    if (
      schedule.paymentPhaseEndsAt &&
      schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
    ) {
      return;
    }
    await this.dataSource
      .getRepository(TrainSchedule)
      .update(scheduleId, { paymentPhaseEndsAt: target });
    this.logger.log(
      `[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
        `(top-up reservation opened a fresh pay window)`,
    );
  }

  private removeTimeout(scheduleId: string): void {
    const name = this.timeoutName(scheduleId);
    try {
      if (this.scheduler.doesExist("timeout", name)) {
        this.scheduler.deleteTimeout(name);
      }
    } catch {
      // ignore — not armed
    }
  }
}
