import {
  AllocationLoadType,
  Freight,
  LoadingStatus,
  SchedulingStatus,
  TrainCheckpointKind,
  TrainScheduleStatus as TrainScheduleStatusEnum,
  WagonAllocationSnapshot,
  WagonMovementKind,
  WagonStatus,
} from '@edr/types';
import {
  BadRequestException,
  ConflictException,
  forwardRef,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
  Optional,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
import {
  DataSource,
  EntityManager,
  FindOptionsWhere,
  ILike,
  In,
  IsNull,
  Not,
  QueryFailedError,
  Raw,
} from 'typeorm';

import {
  buildPaginationMeta,
  normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import {
  ListTrainSchedulesQueryDto,
  TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import {
  ImportDjiboutiOperation,
  type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity';
import {
  ImportDjiboutiActionDto,
  UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
import {
  computeFleetAvailability,
  summarizeFleetWarnings,
  totalAssignedWeight,
  wagonsRequiredForBooking,
  type BookingWagonShortage,
  type DeferredBookingRow,
  type FleetAvailabilityRow,
} from './fleet-plan.util';
import {
  applyWagonOrderReversal,
  planWagonsWithStock,
  type AllowedWagonTypeMap,
  type WagonStock,
} from './wagon-plan-flex.util';
import {
  containerWagonsForLines,
  expandBookingContainerUnits,
  getContainerSlotSequenceNos,
  roundTons,
  sumWagonsRequired,
  type TrainLimitConfig,
  maxEdgeConsistUsage,
  validateContainerPlacements,
  validateMixedTrainLimitsPerEdge,
  type ContainerPlacementInput,
  type WagonPlanSlot,
} from './wagon-plan.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
  bookingCargoTons,
  deriveTrainCapacityFromLocomotive,
  minLocomotiveLimits,
  trainSetLocomotiveLimits,
  wagonTypeDimensionsFromEntity,
  LocomotiveLimits,
  WagonTypeDimensions,
} from './train-capacity.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,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
  computeExportWindowTimes,
  computeImportWindowTimes,
  earliestSchedulableDeparture,
  eatDay,
  eatDayToUtc,
  shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
import {
  autoFillPlacements,
  findMissingContainerNumberIssues,
  isPlaceholderContainerNumber,
  placementsForBookings,
  type ContainerUnitForPlacement,
} from './container-placement.util';

const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;

/**
 * The booking-window rule fields frozen onto a train schedule at creation (and
 * refreshed by restampPendingWindows for not-yet-open schedules). The board draws
 * its display cycles from this snapshot, so a later global-rules edit never redraws
 * an already-open schedule's windows. The reopen gap is derived here — doc review +
 * payment — because that is the real delay between a cycle closing and reopening.
 */
function windowRuleSnapshot(cfg: BookingWindowConfig) {
  return {
    ruleWindowOpenHour: cfg.windowOpenHour,
    ruleWindowCloseHour: cfg.windowCloseHour,
    ruleWindowDurationHours: cfg.windowDurationHours,
    ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
    ruleImportWindowLeadDays: cfg.importWindowLeadDays,
    ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
    ruleImportCloseOffsetMinutes: cfg.importCloseOffsetMinutes ?? null,
    ruleExportCloseOffsetMinutes: cfg.exportCloseOffsetMinutes ?? null,
  };
}

/**
 * The booking-window config a specific schedule runs under: its frozen rule
 * snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
 * config, with the live config filling any snapshot field a legacy row lacks.
 *
 * The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
 * the runtime cycle engine matches exactly what the board drew and the customer
 * saw — a later global-rule edit must not retro-change an existing train. The
 * doc-review / payment split is an internal process timing (not part of the
 * window the customer sees) and is not stored split in the snapshot, so it always
 * takes the live values; their sum is only used as a fallback reopen gap when the
 * row predates `ruleReopenDelayMinutes`.
 */
export function effectiveWindowConfig(
  schedule: {
    ruleWindowOpenHour?: number | null;
    ruleWindowCloseHour?: number | null;
    ruleWindowDurationHours?: number | null;
    ruleReopenDelayMinutes?: number | null;
    ruleImportWindowLeadDays?: number | null;
    ruleExportBookingLeadHours?: number | null;
    ruleImportCloseOffsetMinutes?: number | null;
    ruleExportCloseOffsetMinutes?: number | null;
  },
  liveCfg: BookingWindowConfig,
): BookingWindowConfig {
  return {
    importWindowLeadDays:
      schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
    exportBookingLeadHours:
      schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
    windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
    windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
    windowDurationHours:
      schedule.ruleWindowDurationHours != null
        ? Number(schedule.ruleWindowDurationHours)
        : liveCfg.windowDurationHours,
    docReviewMinutes: liveCfg.docReviewMinutes,
    paymentWindowMinutes: liveCfg.paymentWindowMinutes,
    // The close offset is frozen per-schedule: a snapshot value of null means
    // "created with no offset" and must NOT inherit a later live offset (that
    // would retro-shrink an open train's window). Only a truly legacy row that
    // predates the snapshot column (value undefined) falls back to live config.
    importCloseOffsetMinutes:
      schedule.ruleImportCloseOffsetMinutes !== undefined
        ? schedule.ruleImportCloseOffsetMinutes
        : liveCfg.importCloseOffsetMinutes,
    exportCloseOffsetMinutes:
      schedule.ruleExportCloseOffsetMinutes !== undefined
        ? schedule.ruleExportCloseOffsetMinutes
        : liveCfg.exportCloseOffsetMinutes,
  };
}

export type BookingWagonAllocationStatus =
  | 'NOT_ATTEMPTED'
  | 'ASSIGNED'
  | 'DEFERRED'
  | 'FAILED';

export interface BookingWagonAllocationIssue {
  bookingId: string;
  status: BookingWagonAllocationStatus;
  issue: string | null;
}

export interface WagonAllocationAttemptResult {
  assignedBookingIds: string[];
  deferred: DeferredBookingRow[];
  issues: BookingWagonAllocationIssue[];
  violations: string[];
}

export interface CompositionUnassignedBookingRow {
  id: string;
  reference: string | null;
  freightType: string | null;
  priorityScore: number;
  cargoTotalWeightVgm: number;
  /** GROSS: cargo VGM + tare of every wagon the booking occupies. */
  grossWeightTons: number;
  status: string | null;
  schedulingStatus: string | null;
  wagonsRequired: number;
  requiredWagonTypeCode: string;
  yardWagonsAvailable: number;
  canAssign: boolean;
  blockReason: string | null;
  /** Structured fleet shortage when the block is missing wagons (null otherwise). */
  shortage: BookingWagonShortage | null;
}

export interface UnassignedBookingsResponse {
  fleetAtOrigin: FleetAvailabilityRow[];
  bookings: CompositionUnassignedBookingRow[];
}

const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
  maxWeightTons: 3500,
  maxLengthMeters: 760,
  maxWagonsPerTrain: Math.floor(760 / 14),
  max20ftContainerWeightTons: 30,
  max20ftPairWeightDiffTons: 10,
};

/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
  schedule_id: string;
  reference: string | null;
  contract_id: string | null;
  contract_kind: string | null;
  direction: string | null;
  window_phase: string | null;
  window_opens_at: Date | null;
  window_closes_at: Date | null;
  doc_review_ends_at: Date | null;
  payment_phase_ends_at: Date | null;
  booking_window_status: string;
  booking_cycle_no: number;
  scheduled_departure_date: Date;
  origin_label: string | null;
  origin_code: string | null;
  destination_label: string | null;
  destination_code: string | null;
  /** Full ordered corridor (origin → milestones → destination) from the schedule's route. */
  route_stations: string[] | null;
}

@Injectable()
export class TrainSchedulingService {
  private readonly logger = new Logger(TrainSchedulingService.name);

  constructor(
    @InjectDataSource()
    private readonly dataSource: DataSource,
    private readonly bookingsRepository: BookingsRepository,
    private readonly locomotivesRepository: LocomotivesRepository,
    // Kept in the DI signature for constructor-arity stability (specs mock it);
    // wagon-type resolution now flows through the config lists on the bookings.
    _wagonTypesRepository: WagonTypesRepository,
    private readonly trainSchedulesRepository: TrainSchedulesRepository,
    private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
    private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository,
    private readonly wagonAllocationContainerItemsRepository: WagonAllocationContainerItemsRepository,
    private readonly wagonAllocationBulkLoadsRepository: WagonAllocationBulkLoadsRepository,
    private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
    private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
    private readonly warehouseInventoryService: WarehouseInventoryService,
    private readonly pdfDocuments: WarehouseReleaseDocumentService,
    private readonly bookingWindowGateway: BookingWindowGateway,
    private readonly bookingJourneyService: BookingJourneyService,
    private readonly bookingNotifier: BookingNotifierService,
    @Optional() private readonly milestoneService?: ClearanceMilestoneService,
    private readonly configService?: ConfigService,
    // forwardRef: BookingBatchService injects this service back; @Optional so
    // existing specs that construct the service without it keep working.
    @Optional()
    @Inject(forwardRef(() => BookingBatchService))
    private readonly bookingBatchService?: BookingBatchService,
  ) {}

  /**
   * Notify each booking's customer that their shipment was dispatched / arrived,
   * with a deep-link to the booking. Fire-and-forget — never blocks the action.
   */
  private async notifyScheduleBookings(
    schedule: TrainSchedule,
    event: 'dispatched' | 'arrived',
  ): Promise<void> {
    try {
      const ids = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId).filter(Boolean);
      if (!ids.length) return;
      const origin = schedule.originStation?.label ?? schedule.originStation?.code ?? null;
      const destination =
        schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null;
      const bookings = await this.dataSource.getRepository(Booking).find({
        where: { id: In(ids) },
        relations: { company: true },
      });
      for (const b of bookings) {
        if (event === 'dispatched') this.bookingNotifier.dispatched(b, origin, destination);
        else this.bookingNotifier.arrived(b, origin, destination);
      }
    } catch (err) {
      this.logger.warn(`Failed to notify schedule bookings (${event}): ${(err as Error).message}`);
    }
  }

  /**
   * Complete customer-tracking clearance milestones for every booking on a
   * schedule when a physical lifecycle event fires (dispatch, arrive, load,
   * unload, gatepass). Uses the doc-trigger path, which is a silent no-op for
   * bookings without milestone rows (non-customs bookings), so this is safe to
   * call for every direction and flow. Never blocks the operational action.
   */
  private async completeMilestonesForScheduleBookings(
    scheduleId: string,
    codes: string[],
    filter?: { originYardId?: string; destinationYardId?: string },
  ): Promise<void> {
    if (!this.milestoneService || codes.length === 0) return;
    try {
      const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
      const params: unknown[] = [scheduleId];
      if (filter?.originYardId) {
        params.push(filter.originYardId);
        conditions.push(`b.origin_yard_id = $${params.length}`);
      }
      if (filter?.destinationYardId) {
        params.push(filter.destinationYardId);
        conditions.push(`b.destination_yard_id = $${params.length}`);
      }
      const rows: Array<{ booking_id: string }> = await this.dataSource.query(
        `SELECT tsb.booking_id
           FROM freight.train_schedule_bookings tsb
           JOIN freight.bookings b ON b.id = tsb.booking_id
          WHERE ${conditions.join(' AND ')}`,
        params,
      );
      for (const { booking_id } of rows) {
        for (const code of codes) {
          await this.milestoneService.completeByDocTrigger(
            { bookingId: booking_id },
            code,
          );
        }
      }
    } catch (err) {
      this.logger.warn(
        `Milestone completion (${codes.join(', ')}) failed for schedule ${scheduleId}: ${(err as Error).message}`,
      );
    }
  }

  /**
   * Push a schedule's current booking-window state over the socket so the
   * portal home card and backoffice GL/batch views update in real time —
   * used for lifecycle changes outside the window tick (create, cancel,
   * finalize, restamp). A push failure must never break the mutation.
   */
  private async emitWindowState(scheduleId: string): Promise<void> {
    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 schedule GROUP is every schedule sharing an origin, destination, and EAT
   * departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
   * group together, since the route entity keys only origin + destination). All
   * schedules in a group must run ONE shared booking-window timeline so a
   * customer booking on a later-created train is never expired by a sibling
   * train's payment window closing on a different clock.
   *
   * Grouping keys off the columns the schedule already carries — no new schema.
   * Callers pass a live `manager` so both the create (inside its transaction) and
   * the update paths see uncommitted siblings.
   */
  private async findGroupSiblings(
    manager: EntityManager,
    originStationId: string,
    destinationStationId: string,
    departure: Date,
    excludeScheduleId?: string,
  ): Promise<TrainSchedule[]> {
    const day = eatDay(departure);
    const dayStart = eatDayToUtc(day, 0);
    const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
    const qb = manager
      .getRepository(TrainSchedule)
      .createQueryBuilder('s')
      .where('s.originStationId = :originStationId', { originStationId })
      .andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
      .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
      .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
      // A cancelled train is not a sibling: cancel retires its window as DONE,
      // and a newborn anchoring to it would inherit that dead window verbatim.
      .andWhere('s.status != :cancelledStatus', {
        cancelledStatus: TrainScheduleStatusEnum.Cancelled,
      });
    if (excludeScheduleId) {
      qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
    }
    return qb.getMany();
  }

  /**
   * A built train makes at most ONE departure per route per EAT day. Returns
   * the non-cancelled schedule already holding this train on this route for
   * `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
   * legal — siblings must be different trains.
   */
  private async findTrainRouteDayConflict(
    trainId: string,
    routeId: string,
    departure: Date,
    excludeScheduleId?: string,
  ): Promise<TrainSchedule | null> {
    const day = eatDay(departure);
    const dayStart = eatDayToUtc(day, 0);
    const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
    const qb = this.dataSource
      .getRepository(TrainSchedule)
      .createQueryBuilder('s')
      .innerJoin('s.trainSet', 'ts')
      .where('ts.trainId = :trainId', { trainId })
      .andWhere('s.routeId = :routeId', { routeId })
      .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
      .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
      .andWhere('s.status != :cancelledStatus', {
        cancelledStatus: TrainScheduleStatusEnum.Cancelled,
      });
    if (excludeScheduleId) {
      qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
    }
    return qb.getOne();
  }

  /**
   * The window timeline a brand-new schedule must adopt to join its route+day
   * group. Returns the canonical open/close times + rule snapshot copied from an
   * existing sibling, or null when this is the first schedule in the group (the
   * caller then computes its own times as before — nothing changes for the
   * single-schedule case).
   *
   * The anchor is the sibling that best represents where the GROUP currently is
   * on its shared clock, so a train created mid-cycle joins the group AT its
   * current phase (with the group's exact doc-review / payment deadlines) instead
   * of restarting the whole cycle on its own `now`. When the group has advanced
   * past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
   * the phase the joiner must adopt to see the same "N minutes left" the group
   * already shows. When every sibling is still PRE_WINDOW we pick the
   * earliest-opening one (the group's frozen open clock).
   */
  private async findGroupWindowAnchor(
    manager: EntityManager,
    originStationId: string,
    destinationStationId: string,
    departure: Date,
  ): Promise<TrainSchedule | null> {
    const siblings = await this.findGroupSiblings(
      manager,
      originStationId,
      destinationStationId,
      departure,
    );
    if (siblings.length === 0) return null;
    // A DONE window is retired (the day's last cycle already ran) — anchoring
    // to it would hand the newborn a dead window no tick ever advances. With no
    // live or pending sibling left, fall back to fresh times (return null).
    const withWindow = siblings.filter(
      (s) => s.windowOpensAt != null && s.windowPhase !== 'DONE',
    );
    if (withWindow.length === 0) return null;

    // A group whose window is live (some sibling has moved past PRE_WINDOW but is
    // not yet DONE) — the joiner must land in that same phase on the same clock.
    const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
    const live = withWindow.filter(
      (s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
        s.windowPhase !== 'PRE_WINDOW',
    );
    if (live.length > 0) {
      // Most-advanced phase leads; ties broken by earliest open for determinism.
      return live.reduce((best, s) => {
        const a = PHASE_ORDER.indexOf(s.windowPhase!);
        const b = PHASE_ORDER.indexOf(best.windowPhase!);
        if (a !== b) return a > b ? s : best;
        return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
      });
    }

    // Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
    // defines the group clock (the one a customer would have seen first).
    const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
    const pool = pending.length > 0 ? pending : withWindow;
    return pool.reduce((earliest, s) =>
      s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
    );
  }

  /**
   * The full window state an anchor sibling hands down to a schedule JOINING its
   * group. Copies not just the open/close times + frozen rule snapshot but the
   * anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
   * paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
   * train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
   * the group: it enters directly at the group's current phase with the group's
   * shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
   * `now` and stamping its own later `paymentPhaseEndsAt`.
   *
   * `targetDeparture` is the JOINING schedule's own departure: every time is
   * clamped to it so a group whose trains depart at different times of the same
   * day never hands an earlier-departing train a deadline that outlives its
   * departure (computeImport/ExportWindowTimes clamp to departure at source; this
   * preserves that invariant when the anchor departed later).
   */
  private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
    const cap = targetDeparture.getTime();
    const clamp = (d: Date | null | undefined): Date | null =>
      d == null ? null : d.getTime() > cap ? targetDeparture : d;
    return {
      // Live phase + its deadlines — a mid-cycle joiner lands here directly.
      windowPhase: anchor.windowPhase,
      bookingWindowStatus:
        anchor.bookingWindowStatus === 'FULL'
          ? 'OPEN'
          : anchor.bookingWindowStatus,
      bookingCycleNo: anchor.bookingCycleNo,
      windowOpensAt: clamp(anchor.windowOpensAt),
      windowClosesAt: clamp(anchor.windowClosesAt),
      docReviewEndsAt: clamp(anchor.docReviewEndsAt),
      docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
      paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
      // Frozen rule snapshot.
      ruleWindowOpenHour: anchor.ruleWindowOpenHour,
      ruleWindowCloseHour: anchor.ruleWindowCloseHour,
      ruleWindowDurationHours: anchor.ruleWindowDurationHours,
      ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
      ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
      ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
    };
  }

  async getEligibleBookings(query: GetEligibleBookingsDto) {
    // Day-level pooling: when the wizard targets a schedule, surface the whole
    // (route, EAT day) pool — not just bookings pre-pinned to that train — by
    // resolving the schedule's route + day and filtering on the day instead.
    let day: string | undefined;
    let originStationId = query.originStationId;
    let destinationStationId = query.destinationStationId;
    if (query.trainScheduleId) {
      const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
      if (schedule?.scheduledDepartureDate) {
        day = eatDay(schedule.scheduledDepartureDate);
        originStationId = originStationId ?? schedule.originStationId;
        destinationStationId = destinationStationId ?? schedule.destinationStationId;
      }
    }

    const bookings = await this.bookingsRepository.findEligibleForScheduling({
      freightType: query.freightType,
      originStationId,
      destinationStationId,
      schedulingStatus: query.schedulingStatus,
      trainScheduleId: query.trainScheduleId,
      day,
    });
    const tareDims = await this.loadWagonTareDims();
    return {
      count: bookings.length,
      items: bookings.map((b) => this.mapEligibleBooking(b, tareDims)),
    };
  }

  async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
    return this.getEligibleBookings({ ...query, freightType: 'CONTAINER' });
  }

  async getEligibleBulkBookings(query: GetEligibleBulkBookingsDto) {
    return this.getEligibleBookings({ ...query, freightType: 'BULK' });
  }

  async getTrainSchedulingGlobalRules() {
    return this.toPublicGlobalRules(await this.loadGlobalRulesRow());
  }

  /**
   * Train length/weight and 20ft weight caps are engine-internal (wagon
   * planning still reads them off the row); they are no longer exposed or
   * editable through the global-rules endpoints.
   */
  private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
    if (!row) return row;
    const {
      maxTrainLengthMeters: _len,
      maxTrainWeightTons: _wt,
      max20ftContainerWeightTons: _cw,
      max20ftPairWeightDiffTons: _pd,
      ...pub
    } = row;
    return pub;
  }

  async updateTrainSchedulingGlobalRules(dto: UpdateTrainSchedulingGlobalRulesDto) {
    const row = await this.loadGlobalRulesRow();
    if (!row) {
      throw new NotFoundException('Train scheduling global rules not configured');
    }
    if (dto.maxWagonsPerTrain != null) row.maxWagonsPerTrain = dto.maxWagonsPerTrain;
    if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
    if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
    if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
    if (dto.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
    if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
    if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
    if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
    // Store 0 as null so "no offset" is a single canonical value.
    if (dto.importCloseOffsetMinutes !== undefined)
      row.importCloseOffsetMinutes = dto.importCloseOffsetMinutes || null;
    if (dto.exportCloseOffsetMinutes !== undefined)
      row.exportCloseOffsetMinutes = dto.exportCloseOffsetMinutes || null;

    // The booking desk supports three shapes: a same-day range
    // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
    // overnight range that wraps past midnight (openHour > closeHour, e.g.
    // 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.

    // Fields that change the STAMPED open/close times of a schedule. docReview/
    // payment/reopen are read live by the cron each tick, so they need no
    // re-stamp; only the four below feed computeImport/ExportWindowTimes.
    const windowTimingChanged =
      dto.importWindowLeadDays != null ||
      dto.windowOpenHour != null ||
      dto.windowCloseHour != null ||
      dto.windowDurationHours != null ||
      dto.docReviewMinutes != null ||
      dto.paymentWindowMinutes != null ||
      dto.exportBookingLeadHours != null ||
      dto.importCloseOffsetMinutes !== undefined ||
      dto.exportCloseOffsetMinutes !== undefined;

    const saved = await this.dataSource
      .getRepository(TrainSchedulingGlobalRules)
      .save(row);

    // The cron reads config fresh every tick, so derived timings (doc review,
    // payment, reopen) take effect on the next tick with no restart. But each
    // schedule's initial open/close times were FROZEN at creation — re-stamp the
    // ones whose window has not opened yet so a config edit applies to them too.
    if (windowTimingChanged) {
      await this.restampPendingWindows();
    }

    return this.toPublicGlobalRules(saved);
  }

  /**
   * Override the booking-window rule for ONE schedule (staff action on the ops
   * board). Only the fields provided are changed; the rest keep the schedule's
   * existing snapshot (falling back to the live global config for legacy rows).
   * The window must not have opened yet — an OPEN/past schedule stays frozen so
   * customers keep the times they were shown. windowOpensAt/ClosesAt are
   * re-derived from the merged rule, and the snapshot is updated so the board
   * draws the new cycles.
   */
  async updateScheduleWindowRule(
    id: string,
    dto: UpdateScheduleWindowRuleDto,
  ): Promise<TrainSchedule> {
    const schedule = await this.trainSchedulesRepository.findById(id);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${id} not found`);
    }
    if (schedule.windowPhase !== 'PRE_WINDOW') {
      throw new BadRequestException(
        'Booking window settings can only be changed before the window opens ' +
          `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
      );
    }
    const now = new Date();
    if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
      throw new BadRequestException(
        'This schedule has already departed or has no departure date.',
      );
    }

    // Merge the override onto the schedule's current effective rule (its snapshot,
    // or the live config where a legacy row has no snapshot).
    const liveCfg = await this.getWindowConfig();
    const merged: BookingWindowConfig = {
      importWindowLeadDays:
        dto.importWindowLeadDays ??
        schedule.ruleImportWindowLeadDays ??
        liveCfg.importWindowLeadDays,
      exportBookingLeadHours:
        dto.exportBookingLeadHours ??
        schedule.ruleExportBookingLeadHours ??
        liveCfg.exportBookingLeadHours,
      windowOpenHour:
        dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
      windowCloseHour:
        dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
      windowDurationHours:
        dto.windowDurationHours ??
        (schedule.ruleWindowDurationHours != null
          ? Number(schedule.ruleWindowDurationHours)
          : liveCfg.windowDurationHours),
      // The reopen gap is doc review + payment; keep the config values unless the
      // override changes them, so the derived snapshot delay stays consistent.
      docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
      paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
      // A per-schedule override isn't a close-offset control, so inherit the
      // offset already frozen on the schedule (null = none), or the live one for
      // legacy rows — the override must not silently drop the global offset.
      importCloseOffsetMinutes:
        schedule.ruleImportCloseOffsetMinutes !== undefined
          ? schedule.ruleImportCloseOffsetMinutes
          : liveCfg.importCloseOffsetMinutes,
      exportCloseOffsetMinutes:
        schedule.ruleExportCloseOffsetMinutes !== undefined
          ? schedule.ruleExportCloseOffsetMinutes
          : liveCfg.exportCloseOffsetMinutes,
    };

    // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
    // — officeHoursOpen resolves each, so no close-vs-open ordering guard here.

    const times =
      schedule.direction === 'EXPORT'
        ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
        : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
    if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
      throw new BadRequestException(
        'These settings leave no booking window before departure — with the ' +
          'desk hours applied, the window would only open once the train has left.',
      );
    }

    // Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
    // WHOLE group — every schedule sharing this origin + destination + EAT
    // departure day. They all adopt the SAME rule snapshot and share the SAME
    // window open/close timeline (the whole point of grouping). The shared times
    // are clamped to each train's OWN departure so a group whose trains depart at
    // different times of the same day never hands an earlier-departing sibling a
    // window that outlives its departure. Only still-PRE_WINDOW siblings are
    // touched — a sibling that has already opened, finalized, or dispatched stays
    // frozen on the times its customers were shown and simply drops out of the
    // group; the remaining pending trains stay in sync. EXPORT is excluded
    // (departure-anchored FCFS window, no cross-expiry), so an export override
    // only touches its own schedule.
    const ruleFields = windowRuleSnapshot(merged);
    const repo = this.dataSource.getRepository(TrainSchedule);
    const cap = (d: Date, departure: Date): Date =>
      d.getTime() > departure.getTime() ? departure : d;

    const targets: Array<{ id: string; departure: Date }> = [
      { id, departure: schedule.scheduledDepartureDate },
    ];
    if (schedule.direction !== 'EXPORT') {
      const siblings = await this.findGroupSiblings(
        this.dataSource.manager,
        schedule.originStationId,
        schedule.destinationStationId,
        schedule.scheduledDepartureDate,
        id,
      );
      for (const sib of siblings) {
        if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
          targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
        }
      }
    }

    for (const t of targets) {
      await repo.update(t.id, {
        windowOpensAt: cap(times.windowOpensAt, t.departure),
        windowClosesAt: cap(times.windowClosesAt, t.departure),
        ...ruleFields,
      });
    }
    this.logger.log(
      `Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
        `route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
    );
    for (const t of targets) void this.emitWindowState(t.id);

    const fresh = await this.trainSchedulesRepository.findById(id);
    return fresh ?? schedule;
  }

  /**
   * Reschedule ONE train's departure date (staff action on the ops board). Only
   * allowed while the booking window has not opened yet — an OPEN/past schedule
   * stays frozen so customers keep the times they were shown. The new date must
   * still leave room for the booking lead window before departure (same floor as
   * schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
   * open/close times are re-derived from the schedule's existing rule snapshot.
   */
  async updateScheduleDate(
    id: string,
    dto: UpdateScheduleDateDto,
  ): Promise<TrainSchedule> {
    const schedule = await this.trainSchedulesRepository.findById(id);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${id} not found`);
    }
    if (schedule.windowPhase !== 'PRE_WINDOW') {
      throw new BadRequestException(
        'The departure date can only be changed before the booking window opens ' +
          `(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
      );
    }

    const now = new Date();
    const departure = new Date(dto.scheduleDate);
    if (Number.isNaN(departure.getTime())) {
      throw new BadRequestException('Invalid departure date.');
    }

    // Staff cannot schedule inside the lead window — there must be room for a
    // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days;
    // EXPORT lead is in hours. Mirrors the create-schedule check.
    const windowCfg = await this.getWindowConfig();
    const earliest = earliestSchedulableDeparture(
      schedule.direction,
      windowCfg,
      now,
    );
    if (departure.getTime() < earliest.getTime()) {
      const detail =
        schedule.direction === 'EXPORT'
          ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
          : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
      throw new BadRequestException(
        `Departure ${departure.toISOString()} is inside the booking lead window; ` +
          `${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
          `(earliest ${earliest.toISOString()}).`,
      );
    }

    // Moving onto a day where this same built train already runs this route
    // would double-book the physical train — blocked for planning moves.
    if (schedule.trainSetId && schedule.routeId) {
      const trainSet = await this.dataSource
        .getRepository(TrainSet)
        .findOne({ where: { id: schedule.trainSetId } });
      if (trainSet?.trainId) {
        const conflict = await this.findTrainRouteDayConflict(
          trainSet.trainId,
          schedule.routeId,
          departure,
          id,
        );
        if (conflict) {
          throw new ConflictException(
            `This train is already scheduled on this route for that day ` +
              `(${conflict.reference ?? conflict.id}) — one departure per route per day`,
          );
        }
      }
    }

    // Re-derive the window from the schedule's own rule snapshot (falling back to
    // the live config where a legacy row has no snapshot) against the new date.
    const merged = effectiveWindowConfig(schedule, windowCfg);

    const times =
      schedule.direction === 'EXPORT'
        ? computeExportWindowTimes(departure, merged)
        : computeImportWindowTimes(departure, merged, now);

    // Moving the departure moves this train between route+day GROUPS. If the
    // destination day already has a group (a sibling on the same origin +
    // destination + new EAT day), adopt that group's shared timeline instead of
    // the times just derived, so the rescheduled train lines up with the group
    // it lands in rather than drifting onto its own clock. Otherwise it keeps its
    // own re-derived times and becomes the anchor for that day. EXPORT is
    // excluded — its window is anchored to its own departure, not shared.
    const anchor =
      schedule.direction === 'EXPORT'
        ? null
        : await this.findGroupWindowAnchor(
            this.dataSource.manager,
            schedule.originStationId,
            schedule.destinationStationId,
            departure,
          );
    const windowFields = anchor
      ? this.groupWindowFieldsFrom(anchor, departure)
      : { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };

    await this.dataSource.getRepository(TrainSchedule).update(id, {
      scheduledDepartureDate: departure,
      ...windowFields,
    });

    // Only customers whose bookings already HOLD wagons on this train are told
    // about the move (SMS + email + portal inbox). Linked-but-unallocated
    // bookings are skipped — nothing of theirs is riding this departure yet.
    let notifiedCount = 0;
    if (schedule.trainSetId) {
      const allocations = await this.dataSource
        .getRepository(WagonBookingAllocation)
        .createQueryBuilder('a')
        .innerJoin('a.trainSetWagon', 'slot')
        .where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
        .getMany();
      const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
      if (allocatedBookingIds.length) {
        const allocatedBookings = await this.dataSource.getRepository(Booking).find({
          where: { id: In(allocatedBookingIds) },
          relations: { company: true },
        });
        for (const booking of allocatedBookings) {
          if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
          this.bookingNotifier.rescheduled(booking, departure);
          notifiedCount += 1;
        }
      }
    }

    this.logger.log(
      `Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
        `(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
        `${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
        `${notifiedCount} allocated customer booking(s) notified`,
    );
    void this.emitWindowState(id);

    const fresh = await this.trainSchedulesRepository.findById(id);
    return fresh ?? schedule;
  }

  /**
   * Maintenance reschedule: the admin moves a train (with everything aboard) to
   * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
   * phase and inside the booking lead window — a maintenance move is an
   * operational fact, not a planning choice. What moves and what stays:
   *
   *  - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
   *    aboard/targeted booking's scheduledDate (the day-pool queries key on it,
   *    so a booking left on the old day would fall out of its own train's pool).
   *  - STAYS: train set, wagon assignments, schedule↔booking links, route,
   *    maxWagons, and the window RULE snapshot. Stamped window times are only
   *    re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
   *    schedule mid- or post-window keeps its timeline untouched.
   *
   * Customers of every moved booking are notified (maintenanceMoved).
   */
  async maintenanceReschedule(
    id: string,
    dto: MaintenanceRescheduleDto,
  ): Promise<TrainSchedule> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${id} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException(
        `Cannot reschedule a ${schedule.status.toLowerCase()} train`,
      );
    }

    const departure = new Date(dto.newDepartureDate);
    if (Number.isNaN(departure.getTime())) {
      throw new BadRequestException('Invalid departure date.');
    }
    if (departure.getTime() <= Date.now()) {
      throw new BadRequestException('New departure must be in the future.');
    }

    const deltaMs =
      departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
    const scheduledArrivalDate = schedule.scheduledArrivalDate
      ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
      : undefined;

    // PRE_WINDOW only: the stamped open/close were derived from the old
    // departure and the window hasn't opened yet, so re-derive them from the
    // schedule's own rule snapshot against the new date (joining the target
    // day's route group timeline when one exists, exactly like
    // updateScheduleDate). Mid/post-window schedules keep their timeline.
    const windowFields =
      schedule.windowPhase === 'PRE_WINDOW'
        ? await (async () => {
            const merged = effectiveWindowConfig(
              schedule,
              await this.getWindowConfig(),
            );
            const times =
              schedule.direction === 'EXPORT'
                ? computeExportWindowTimes(departure, merged)
                : computeImportWindowTimes(departure, merged, new Date());
            const anchor =
              schedule.direction === 'EXPORT'
                ? null
                : await this.findGroupWindowAnchor(
                    this.dataSource.manager,
                    schedule.originStationId,
                    schedule.destinationStationId,
                    departure,
                  );
            return anchor
              ? this.groupWindowFieldsFrom(anchor, departure)
              : {
                  windowOpensAt: times.windowOpensAt,
                  windowClosesAt: times.windowClosesAt,
                };
          })()
        : {};

    await this.dataSource.getRepository(TrainSchedule).update(id, {
      scheduledDepartureDate: departure,
      ...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
      ...windowFields,
    });

    // Everything aboard or targeted rides along: bookings linked on the train
    // (schedule_bookings) plus reservations still pointing at it via
    // train_schedule_id (paid-but-unlinked, awaiting payment, …).
    const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
    const targeted = await this.dataSource.getRepository(Booking).find({
      where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
      relations: { company: true },
    });
    const aboard = targeted.filter(
      (b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
    );
    if (aboard.length) {
      await this.dataSource
        .getRepository(Booking)
        .update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
      for (const booking of aboard) {
        this.bookingNotifier.maintenanceMoved(booking, departure);
      }
    }

    this.logger.log(
      `[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
        `(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
        `${aboard.length} booking(s) moved with the train.`,
    );
    void this.emitWindowState(id);

    const fresh = await this.trainSchedulesRepository.findById(id);
    return fresh ?? schedule;
  }

  /**
   * Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
   * not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
   * in the future) using the CURRENT global-rules config. Schedules already OPEN or
   * past their window are left untouched — customers may have booked against the
   * times they were shown, so those stay frozen. Returns the count re-stamped.
   */
  async restampPendingWindows(): Promise<number> {
    const cfg = await this.getWindowConfig();
    const now = new Date();
    const schedules = await this.trainSchedulesRepository.findAll({
      where: [
        { status: TrainScheduleStatusEnum.Draft, windowPhase: 'PRE_WINDOW' },
        { status: TrainScheduleStatusEnum.Scheduled, windowPhase: 'PRE_WINDOW' },
      ],
    });

    const repo = this.dataSource.getRepository(TrainSchedule);
    let restamped = 0;
    for (const s of schedules) {
      if (!s.scheduledDepartureDate || s.scheduledDepartureDate <= now) continue;
      const times =
        s.direction === 'EXPORT'
          ? computeExportWindowTimes(s.scheduledDepartureDate, cfg)
          : computeImportWindowTimes(s.scheduledDepartureDate, cfg, now);
      // A not-yet-open schedule legitimately adopts the new rule, so refresh its
      // snapshot alongside the re-stamped times — the board then draws the new
      // window from this same rule.
      await repo.update(s.id, {
        windowOpensAt: times.windowOpensAt,
        windowClosesAt: times.windowClosesAt,
        ...windowRuleSnapshot(cfg),
      });
      restamped += 1;
      // New times take effect immediately on every card (the tick then opens
      // the window within seconds if the re-derived open is already due).
      void this.emitWindowState(s.id);
    }
    if (restamped > 0) {
      this.logger.log(
        `Re-stamped booking windows for ${restamped} pending schedule(s) after a global-rules change`,
      );
    }
    return restamped;
  }

  /**
   * Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
   * Numeric columns come back from pg as strings — normalize every field.
   */
  async getWindowConfig(): Promise<BookingWindowConfig> {
    const row = await this.loadGlobalRulesRow();
    const num = (v: unknown, fallback: number) => {
      const n = v == null ? NaN : Number(v);
      return Number.isFinite(n) ? n : fallback;
    };
    // Offsets are optional: a missing/unset value means "no offset", not a
    // numeric default — keep it null so bookingCloseCutoff falls back to
    // departure. Zero and negatives are treated as "no offset" too.
    const offset = (v: unknown): number | null => {
      const n = v == null ? NaN : Number(v);
      return Number.isFinite(n) && n > 0 ? n : null;
    };
    return {
      importWindowLeadDays: num(row?.importWindowLeadDays, 3),
      exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
      windowOpenHour: num(row?.windowOpenHour, 8),
      windowCloseHour: num(row?.windowCloseHour, 17),
      windowDurationHours: num(row?.windowDurationHours, 3),
      docReviewMinutes: num(row?.docReviewMinutes, 30),
      paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
      importCloseOffsetMinutes: offset(row?.importCloseOffsetMinutes),
      exportCloseOffsetMinutes: offset(row?.exportCloseOffsetMinutes),
    };
  }

  async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
    const limits = await this.resolveTrainLimitConfig(dto);
    return this.buildPreviewResponse(
      await this.validateBookingsForScheduling(
        dto,
        null,
        false,
        [],
        false,
        limits,
        dto.targetScheduleId,
      ),
    );
  }

  async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
    const limits = await this.resolveTrainLimitConfig(dto);
    return this.buildPreviewResponse(
      await this.validateBookingsForScheduling(
        dto,
        'CONTAINER',
        false,
        [],
        false,
        limits,
        dto.targetScheduleId,
      ),
    );
  }

  async previewBulkTrainSchedule(dto: PreviewBulkTrainScheduleDto) {
    const limits = await this.resolveTrainLimitConfig(dto);
    return this.buildPreviewResponse(
      await this.validateBookingsForScheduling(
        dto,
        'BULK',
        false,
        [],
        false,
        limits,
        dto.targetScheduleId,
      ),
    );
  }

  private buildPreviewResponse(validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>) {
    const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
    return {
      valid: validation.valid,
      violations: validation.violations,
      warnings: validation.warnings,
      summary: validation.summary,
      fleetAvailability: validation.fleetAvailability,
      deferredBookings: validation.deferredBookings,
      bookingIds: validation.bookings.map((b) => b.id),
      wagonPlan: validation.wagonPlan,
      containerUnits: containerBookings.length
        ? expandBookingContainerUnits(containerBookings)
        : [],
      containerSlotSequenceNos: getContainerSlotSequenceNos(validation.wagonPlan),
    };
  }

  async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
    const route = await this.getSchedulableRoute(dto.routeId);

    const scheduleWarnings: string[] = [];

    // The pulling set comes either from a built train (Train Builder) or from
    // hand-picked locomotive ids (legacy path). A built train also links the
    // schedule's train set back to it (`train_sets.train_id`) so its lifecycle
    // and yard follow the schedule.
    let builtTrain: Train | null = null;
    let locomotiveIds: string[];
    if (dto.trainId) {
      builtTrain = await this.dataSource.getRepository(Train).findOne({
        where: { id: dto.trainId },
        relations: { locomotives: true },
        order: { locomotives: { sequenceNo: 'ASC' } },
      });
      if (!builtTrain) {
        throw new NotFoundException(`Train ${dto.trainId} not found`);
      }
      if (
        builtTrain.status === Freight.TrainStatus.OutOfService ||
        builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
        builtTrain.status === Freight.TrainStatus.Deactivated
      ) {
        throw new ConflictException(
          `Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
        );
      }
      locomotiveIds = (builtTrain.locomotives ?? [])
        .slice()
        .sort((a, b) => a.sequenceNo - b.sequenceNo)
        .map((link) => link.locomotiveId);
      if (locomotiveIds.length < 2) {
        throw new BadRequestException(
          `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`,
        );
      }
      if (builtTrain.currentYardId !== route.originYardId) {
        scheduleWarnings.push(
          `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
        );
      }
      const conflict = await this.findTrainRouteDayConflict(
        builtTrain.id,
        route.id,
        new Date(dto.scheduleDate),
      );
      if (conflict) {
        throw new ConflictException(
          `Train ${builtTrain.code} is already scheduled on this route for that day ` +
            `(${conflict.reference ?? conflict.id}) — one departure per route per day`,
        );
      }
    } else {
      locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
      if (locomotiveIds.length < 2) {
        throw new BadRequestException('A train must be pulled by at least two locomotives');
      }
    }

    const createdScheduleId = await this.dataSource.transaction(async (manager) => {
      // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
      // multiple future schedules and does not need to be at the origin yard yet — staff
      // plan around its arrival. Only decommissioned locomotives are hard-blocked;
      // everything else surfaces as a warning.
      const lockedLocomotives: Locomotive[] = [];
      for (const locomotiveId of locomotiveIds) {
        const locked = await manager.getRepository(Locomotive).findOne({
          where: { id: locomotiveId },
          lock: { mode: 'pessimistic_write' },
        });
        if (!locked) {
          throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
        }
        if (locked.status === 'OUT_OF_SERVICE') {
          throw new ConflictException(`Locomotive ${locked.code} is out of service`);
        }
        if (locked.status !== 'AVAILABLE') {
          scheduleWarnings.push(
            `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
          );
        }
        if (locked.currentYardId !== route.originYardId) {
          scheduleWarnings.push(
            `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
          );
        }
        lockedLocomotives.push(locked);
      }

      // Frozen on the route at create/update from the yard-country enum;
      // getSchedulableRoute already rejected DOMESTIC (intercity).
      const direction = this.resolveRouteDirection(route);

      // Direction-matched fixed number from the built train's typed pair.
      // Legacy locomotive-picked schedules keep dispatch-time pool assignment
      // (assignTrainNumber is idempotent, so both paths compose).
      const pairTrainNumber = builtTrain
        ? (direction === 'IMPORT'
            ? builtTrain.importTrainNumber
            : builtTrain.exportTrainNumber) ?? null
        : null;
      if (builtTrain && !pairTrainNumber) {
        scheduleWarnings.push(
          `Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`,
        );
      }

      const trainSet = await this.buildEmptyTrainSet(
        manager,
        lockedLocomotives,
        builtTrain?.id ?? null,
      );
      // Effective capacity is capped by the weakest locomotive in the set.
      const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
      const departure = new Date(dto.scheduleDate);
      // Every schedule starts with a CLOSED customer window; the window engine opens
      // it on schedule. DOMESTIC runs the same one-booking-day cycle as IMPORT
      // (opens at 08:00 EAT `importWindowLeadDays` before departure); EXPORT opens
      // 24h before departure (FCFS). No schedule is ever always-open now.
      const windowCfg = await this.getWindowConfig();

      // Staff cannot schedule inside the lead window — there must be room for a
      // booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
      // days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
      // lead is in hours (24h = 1 day ahead).
      const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
      if (departure.getTime() < earliest.getTime()) {
        const detail =
          direction === 'EXPORT'
            ? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
            : `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
        throw new BadRequestException(
          `Departure ${departure.toISOString()} is inside the booking lead window; ` +
            `${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
            `(earliest ${earliest.toISOString()})`,
        );
      }
      // Freeze the rule this schedule is born with. A later global-rules edit
      // only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
      // already-open schedule keeps this snapshot, and the batch board draws its
      // windows from it rather than the live config.
      const ruleSnapshot = windowRuleSnapshot(windowCfg);
      // Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
      // on this origin + destination + EAT departure day, this new train JOINS
      // its group and adopts the group's shared window timeline (open/close +
      // frozen rule) verbatim — it does NOT compute its own `now`-based times.
      // That keeps every train on the day advancing through the same open/
      // doc-review/payment/close instants, so a booking on one train is never
      // expired by a sibling train's payment window closing on a different clock.
      // First train in the group falls through to the normal computation.
      //
      // EXPORT is excluded: an export window is a single FCFS window anchored to
      // each train's OWN departure (windowClosesAt = departure) with no
      // doc-review/payment phase — so there is no cross-expiry to fix, and two
      // export trains departing the same day at different times must keep their
      // own departure-anchored windows.
      const groupAnchor =
        direction === 'EXPORT'
          ? null
          : await this.findGroupWindowAnchor(
              manager,
              route.originYardId,
              route.destinationYardId,
              departure,
            );
      const computedTimes =
        direction === 'EXPORT'
          ? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
          : {
              // IMPORT and DOMESTIC share the import booking-day window cycle.
              ...ruleSnapshot,
              ...computeImportWindowTimes(departure, windowCfg, new Date()),
            };
      const windowFields = {
        bookingWindowStatus: 'CLOSED',
        windowPhase: 'PRE_WINDOW',
        ...(groupAnchor
          ? this.groupWindowFieldsFrom(groupAnchor, departure)
          : computedTimes),
      };
      // A built train's own consist is the schedule's capacity: full when all
      // its wagons are allocated. Trains built without wagons yet fall back to
      // the configured limit.
      const builtTrainWagonCount = builtTrain
        ? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } })
        : 0;
      const maxWagons =
        builtTrainWagonCount > 0
          ? builtTrainWagonCount
          : (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain;
      // Retry past a concurrent insert that grabbed the same S-<year> sequence
      // (the unique index rejects the loser; it re-reads the max and tries again).
      const saved = await this.insertScheduleWithReference(manager, (reference) =>
        manager.getRepository(TrainSchedule).create({
          reference,
          trainSetId: trainSet.id,
          routeId: route.id,
          originStationId: route.originYardId,
          destinationStationId: route.destinationYardId,
          scheduledDepartureDate: departure,
          status: TrainScheduleStatusEnum.Draft,
          direction,
          trainNumber: pairTrainNumber ?? undefined,
          maxWagons,
          reverseWagonOrder: dto.reverseWagonOrder ?? false,
          ...windowFields,
        }),
      );
      // Locomotives stay in their current status until dispatch — advance scheduling
      // must not block the locomotive from serving earlier trains.
      if (builtTrain) {
        await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id);
      }
      return saved.id;
    });

    const created = await this.getTrainScheduleById(createdScheduleId);
    // New window announced — portal home / GL cards pick it up immediately.
    void this.emitWindowState(createdScheduleId);
    return { ...created, warnings: scheduleWarnings };
  }

  async assignBookingsToSchedule(
    scheduleId: string,
    dto: AssignBookingsDto,
    freightType?: 'CONTAINER' | 'BULK',
  ) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException(
        `Cannot assign bookings to schedule in status ${schedule.status}`,
      );
    }
    if (!schedule.trainSet) {
      throw new BadRequestException('Schedule has no train set');
    }

    // Batch parity: a schedule may only allocate bookings from its route-day POOL.
    // Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
    // a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
    // pinned to whichever train in the day's group first held it. Every train
    // sharing this origin + destination + EAT departure day draws from ONE shared
    // pool (one shared booking window), so a booking is allocatable here when it is
    // either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
    // pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
    // day/capacity fit is enforced downstream by validateBookingsForScheduling.
    // EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
    if (dto.bookingIds.length) {
      const groupScheduleIds = new Set<string>([scheduleId]);
      if (schedule.direction !== 'EXPORT') {
        const siblings = await this.findGroupSiblings(
          this.dataSource.manager,
          schedule.originStationId,
          schedule.destinationStationId,
          schedule.scheduledDepartureDate,
          scheduleId,
        );
        for (const sib of siblings) groupScheduleIds.add(sib.id);
      }
      const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
      const stray = targeted.filter(
        (b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
      );
      if (stray.length) {
        throw new BadRequestException(
          `These bookings are pinned to a train on a different route or day: ${stray
            .map((b) => b.reference ?? b.id)
            .join(', ')}`,
        );
      }
    }

    // The rebuild below deletes EVERY schedule↔booking link row and recreates
    // only what makes the new plan. Ride-along (intercity) bookings are linked
    // OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the
    // workspace's picked ids, so planning from dto.bookingIds alone silently
    // orphans them: PAID + SCHEDULED with no link and no wagon, invisible in
    // every list. Every (re)assignment therefore re-plans the WHOLE train:
    // the requested ids plus everything currently linked.
    const linkedRows =
      await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId);
    const allBookingIds = [
      ...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]),
    ];

    const previewDto = {
      bookingIds: allBookingIds,
      scheduleDate: schedule.scheduledDepartureDate.toISOString(),
      originStationId: schedule.originStationId,
      destinationStationId: schedule.destinationStationId,
      maxTrainWeightTons: dto.maxTrainWeightTons,
      maxTrainLengthMeters: dto.maxTrainLengthMeters,
      maxWagonsPerTrain: dto.maxWagonsPerTrain,
      // The reverse-order choice is a property of the SCHEDULE, frozen when it was
      // created — every (re)assignment rebuilds the plan under the same flag so the
      // stored train order stays consistent no matter how bookings are added.
      reverseWagonOrder: schedule.reverseWagonOrder ?? false,
    };

    const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
    const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
    const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);

    // Callers that add bookings without hand-picking container slots (the
    // workspace "Add from pool" button, re-adding a removed booking) send no
    // containerPlacements. Auto-fill them the same way the batch engine does:
    // preview the wagon plan first, then lay containers into the plan's slots.
    // Without this the placement validator rejects container bookings outright
    // ("Container placements are required for container bookings").
    // Callers hand-pick placements only for the bookings they know about; the
    // union above may have folded in linked ride-alongs those placements never
    // covered. Auto-fill whatever units are missing (all of them when no
    // placements were sent at all) so the placement validator doesn't reject
    // container bookings the caller couldn't have placed.
    let containerPlacements = dto.containerPlacements;
    {
      const preview = await this.validateBookingsForScheduling(
        previewDto,
        freightType ?? null,
        dto.forceAssign,
        [],
        false,
        limits,
        scheduleId,
      );
      const containerBookings = preview.bookings.filter(
        (b) => b.freightType === 'CONTAINER',
      );
      if (containerBookings.length) {
        const units = expandBookingContainerUnits(containerBookings);
        const providedKeys = new Set(
          (containerPlacements ?? []).map(
            (p) => `${p.bookingContainerId}:${p.unitIndex}`,
          ),
        );
        const unplacedUnits = units.filter(
          (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`),
        );
        if (unplacedUnits.length) {
          const slots = getContainerSlotSequenceNos(preview.wagonPlan);
          const generated = autoFillPlacements(unplacedUnits, slots);
          const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
          if (missing.length) {
            throw new BadRequestException({
              message: `Booking validation failed: ${missing
                .map((m) => m.issue)
                .join('; ')}`,
              violations: missing.map((m) => m.issue),
            });
          }
          containerPlacements = [...(containerPlacements ?? []), ...generated];
        }
      }
    }

    const validation = await this.validateBookingsForScheduling(
      previewDto,
      freightType ?? null,
      dto.forceAssign,
      containerPlacements,
      true,
      limits,
      scheduleId,
    );

    if (!validation.valid) {
      // Put the violation detail in the message itself — global exception
      // filters flatten the body, and "Booking validation failed" alone tells
      // staff nothing (e.g. which wagon type is missing at the yard).
      throw new BadRequestException({
        message: `Booking validation failed: ${validation.violations.join('; ')}`,
        violations: validation.violations,
        warnings: validation.warnings,
      });
    }

    if (!validation.bookings.length) {
      const shortfall = validation.deferredBookings
        .map((d) => `${d.reference}: ${d.reason}`)
        .join('; ');
      throw new BadRequestException({
        message: `No wagons available for the selected bookings${shortfall ? ` — ${shortfall}` : ''}`,
        violations: ['Insufficient fleet wagons for the selected bookings'],
        warnings: validation.warnings,
        deferredBookings: validation.deferredBookings,
      });
    }

    // Every REQUESTED booking must have made the plan. Silently dropping a
    // deferred one let the workspace "Add from pool" report success while the
    // booking never boarded (e.g. it needs a PW2 wagon and the train only has
    // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op.
    // A stock shortage is a physical impossibility, so forceAssign cannot
    // override it either.
    // Linked ride-alongs count as requested too: silently dropping one here is
    // exactly the delete-and-recreate orphan this method must never produce.
    const plannedIds = new Set(validation.bookings.map((b) => b.id));
    const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
    if (droppedRequested.length) {
      const reasonById = new Map(
        validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
      );
      const details = droppedRequested.map(
        (id) =>
          reasonById.get(id) ??
          `${id}: does not fit the train's wagon stock or capacity`,
      );
      throw new BadRequestException({
        message: `Cannot allocate — ${details.join('; ')}`,
        violations: details,
        warnings: validation.warnings,
        deferredBookings: validation.deferredBookings,
      });
    }

    const { bookings, wagonPlan, warnings, deferredBookings } = validation;
    const totalWeightTons = validation.summary.totalWeightTons;
    const totalLengthMeters = validation.summary.totalLengthMeters;

    if (!limitLoco) {
      throw new BadRequestException('Schedule train set has no locomotives');
    }
    // forceAssign lets staff overload the locomotive set knowingly — the
    // validator has already surfaced it as a warning in that case. Each
    // locomotive's overageToleranceTons/Meters extends the hard cap before that
    // override is even needed (e.g. the fertilizer example's +90T deviation).
    const weightCapWithOverage =
      limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
    const lengthCapWithOverage =
      limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
    // The locomotives pull GROSS weight: the customers' cargo plus wagon tare.
    // A built train hauls EVERY coupled wagon's tare — empty ones included —
    // so train-bound schedules count the full consist, not just planned slots.
    const consistWagons = schedule.trainSet?.trainId
      ? await this.dataSource.getRepository(Wagon).find({
          where: { trainId: schedule.trainSet.trainId },
          relations: { wagonType: true },
        })
      : null;
    const planTareTons = roundTons(
      wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
    );
    const consistTareTons = consistWagons
      ? roundTons(
          consistWagons.reduce(
            (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0),
            0,
          ),
        )
      : planTareTons;
    // The pull limit binds on the HEAVIEST LEG, not the whole-route sum —
    // disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never
    // hauled at the same time. Coupled-but-unplanned wagons ride every edge,
    // so their tare rides on top of the binding edge.
    const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons);
    const edgeUsage = maxEdgeConsistUsage(
      wagonPlan,
      await this.stopYardsForSchedule(schedule),
    );
    const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons);
    if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) {
      throw new BadRequestException(
        `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`,
      );
    }
    const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);
    if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) {
      throw new BadRequestException(
        `Train set locomotives cannot support ${maxEdgeLengthMeters}m`,
      );
    }

    await this.dataSource.transaction(async (manager) => {
      const trainSetId = schedule.trainSetId;

      const deletedAllocationIds =
        await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager);

      if (deletedAllocationIds.length) {
        await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
          deletedAllocationIds,
          manager,
        );
        await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(
          deletedAllocationIds,
          manager,
        );
      }

      await manager.getRepository(TrainSetWagon).delete({ trainSetId });
      await manager.getRepository(TrainScheduleBooking).delete({ trainScheduleId: scheduleId });

      await manager.getRepository(TrainSet).update(trainSetId, {
        totalWeightTons,
        totalLengthMeters,
        wagonCount: wagonPlan.length,
        status: 'ASSIGNED',
      });

      const savedWagons = await this.persistTrainSetWagons(
        manager,
        trainSetId,
        wagonPlan,
      );

      const scheduleBookingRecords = bookings.map((booking) => ({
        trainScheduleId: scheduleId,
        bookingId: booking.id,
      }));
      await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);

      await this.persistAllocationsAndLoads(
        manager,
        savedWagons,
        wagonPlan,
        bookings,
        containerPlacements ?? [],
      );

      // The link above puts these bookings on the train: they are SCHEDULED, not
      // ELIGIBLE. Leaving them ELIGIBLE re-offers an allocated booking to the next
      // batch fill, which unlinks it and frees its wagons on the next window cycle.
      const scheduledAt = new Date();
      for (const booking of bookings) {
        await this.bookingsRepository.updateSchedulingFields(
          booking.id,
          {
            schedulingStatus: SchedulingStatus.Scheduled,
            scheduledAt,
            wagonsRequired: sumWagonsRequired(booking, wagonPlan),
          },
          manager,
        );
      }

      if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
        await this.trainSchedulesRepository.updateStatus(
          scheduleId,
          TrainScheduleStatusEnum.Draft,
          {},
          manager,
        );
      }

      await this.autoPinWagonsForSchedule(
        manager,
        scheduleId,
        schedule.originStationId,
        savedWagons,
        schedule.reverseWagonOrder ?? false,
      );
    });

    const detail = await this.getTrainScheduleById(scheduleId);
    return { ...detail, warnings, deferredBookings };
  }

  async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException('Cannot unassign from a finalized or dispatched schedule');
    }

    const link = schedule.scheduleBookings?.find((sb) => sb.bookingId === bookingId);
    if (!link) {
      throw new NotFoundException(`Booking ${bookingId} is not assigned to this schedule`);
    }

    const booking = await this.bookingsRepository.findById(bookingId);
    const bookingReference = booking?.reference ?? null;

    await this.dataSource.transaction(async (manager) => {
      const allocationIds = (schedule.trainSet?.wagons ?? [])
        .flatMap((w) => w.allocations ?? [])
        .filter((a) => a.bookingId === bookingId)
        .map((a) => a.id);

      if (allocationIds.length) {
        await this.wagonAllocationContainerItemsRepository.deleteByAllocationIds(
          allocationIds,
          manager,
        );
        await this.wagonAllocationBulkLoadsRepository.deleteByAllocationIds(allocationIds, manager);
        await manager.getRepository(WagonBookingAllocation).delete(allocationIds);
      }

      await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
        scheduleId,
        bookingId,
        manager,
      );

      const booking = await this.bookingsRepository.findById(bookingId);
      const schedulingStatus = this.resolvePostUnassignStatus(booking);
      // Clear the schedule pointer too: unassign fully detaches the booking from
      // this train. Leaving trainScheduleId set glued the booking to a schedule
      // that may then be dispatched/cancelled/deleted, orphaning it — the
      // assign-bookings parity guard would reject it from every OTHER schedule.
      await this.bookingsRepository.updateSchedulingFields(
        bookingId,
        { schedulingStatus, wagonsRequired: null, trainScheduleId: null },
        manager,
      );

      // Recompute the train-set composition from whatever survives this removal.
      // The removed booking's allocations were already deleted above, so any slot
      // left with zero allocations was ridden only by this booking — release it
      // (frees its reserved wagon slot). Shared slots keep their surviving
      // allocations and are re-weighed. This fixes stale tonnage/length/wagonCount
      // and orphaned RESERVED slots on a PARTIAL unassign (previously only the
      // fully-empty train was reset).
      const survivingSlots = await manager.getRepository(TrainSetWagon).find({
        where: { trainSetId: schedule.trainSetId },
        relations: { allocations: true },
      });
      let recomputedWeightTons = 0;
      let recomputedLengthMeters = 0;
      let recomputedWagonCount = 0;
      for (const slot of survivingSlots) {
        const slotAllocations = slot.allocations ?? [];
        if (slotAllocations.length === 0) {
          await manager.getRepository(TrainSetWagon).delete(slot.id);
          continue;
        }
        const slotWeight = slotAllocations.reduce(
          (sum, a) => sum + Number(a.allocatedWeightTons ?? 0),
          0,
        );
        if (Number(slot.assignedWeightTons) !== slotWeight) {
          await manager
            .getRepository(TrainSetWagon)
            .update(slot.id, { assignedWeightTons: roundTons(slotWeight) });
        }
        recomputedWeightTons += slotWeight;
        recomputedLengthMeters += Number(slot.lengthMeters ?? 0);
        recomputedWagonCount += 1;
      }
      await manager.getRepository(TrainSet).update(schedule.trainSetId, {
        totalWeightTons: roundTons(recomputedWeightTons),
        totalLengthMeters: roundTons(recomputedLengthMeters),
        wagonCount: recomputedWagonCount,
        // Only downgrade to DRAFT once the train is fully empty; otherwise keep
        // the current status (an object literal lets TypeORM's contextual typing
        // accept the partial without pulling in relation fields).
        ...(recomputedWagonCount === 0 ? { status: 'DRAFT' } : {}),
      });
    });

    await this.trainCompositionRemovalLogRepository.create({
      scheduleId,
      bookingId,
      bookingReference,
      removedByUserId: userId ?? null,
      removedAt: new Date(),
    });

    console.log(
      `[NOTIFY] Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer should be notified to reschedule or cancel.`,
    );

    return this.getTrainScheduleById(scheduleId);
  }

  private async runWarehouseArrivalAutomation(scheduleId: string) {
    // Runs after the arrival transaction has committed — must never throw, or a
    // successfully arrived train reports a 500 and looks stuck to the operator.
    let direction: string | undefined;
    try {
      const [schedule]: Array<{
        originCountry: string | null;
        destinationCountry: string | null;
        destinationCode: string | null;
        destinationName: string | null;
      }> = await this.dataSource.query(
        `SELECT oy.country AS "originCountry",
                dy.country AS "destinationCountry",
                dy.code AS "destinationCode",
                dy.label AS "destinationName"
           FROM freight.train_schedules ts
           LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
           LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
          WHERE ts.id = $1 AND ts.deleted_at IS NULL
          LIMIT 1`,
        [scheduleId],
      );

      if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };

      direction = deriveTradeDirection(
        { country: schedule.originCountry },
        { country: schedule.destinationCountry },
      );
      if (direction === 'IMPORT') {
        const result = await this.warehouseInventoryService.autoUnloadArrivedBookings(
          scheduleId,
          'SYSTEM_TRAIN_ARRIVAL',
        );
        // Customer tracking: cargo is off the train at the destination yard.
        void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
        return {
          direction,
          action: 'IMPORT_AUTO_UNLOAD',
          status: 'COMPLETED',
          result,
        };
      }

      if (direction === 'EXPORT' && this.isDjiboutiPortDestination(`${schedule.destinationCode ?? ''} ${schedule.destinationName ?? ''}`)) {
        const result = await this.warehouseInventoryService.autoUnloadExportAtDjibouti(
          scheduleId,
          'SYSTEM_TRAIN_ARRIVAL',
        );
        // Customer tracking: cargo is off the train at the Djibouti port.
        void this.completeMilestonesForScheduleBookings(scheduleId, ['OFFLOADED']);
        return {
          direction,
          action: 'EXPORT_DJIBOUTI_AUTO_UNLOAD',
          status: 'COMPLETED',
          result,
        };
      }

      return { direction, status: 'SKIPPED', reason: 'No warehouse arrival automation for this route' };
    } catch (error) {
      return {
        direction,
        status: 'FAILED',
        reason: error instanceof Error ? error.message : String(error),
      };
    }
  }

  private isDjiboutiPortDestination(value: string | null | undefined): boolean {
    const normalized = (value ?? '').toUpperCase();
    return ['DJIBOUTI', 'DORALEH', 'DMP', 'DCT', 'NAGAD'].some((token) =>
      normalized.includes(token),
    );
  }

  async getImportLoadingBookings(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }

    const [scheduleBookings, allocations] = await Promise.all([
      this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
      this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
    ]);
    if (!scheduleBookings.length) {
      return { count: 0, items: [] };
    }

    const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
    const statusByBookingId = new Map(
      scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]),
    );
    const candidateIds = scheduleBookings
      .map((sb) => sb.bookingId)
      .filter((id) => allocatedBookingIds.has(id));
    if (!candidateIds.length) {
      return { count: 0, items: [] };
    }

    const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds);
    const tareDims = await this.loadWagonTareDims();
    const items = bookings
      .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID')
      .map((b) => ({
        id: b.id,
        reference: b.reference ?? null,
        customer: b.company?.name ?? null,
        // GROSS: cargo + tare of the wagons the booking occupies.
        weightTons: this.grossBookingWeightTons(b, tareDims),
        loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded,
      }));
    return { count: items.length, items };
  }

  async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }

    const [scheduleBookings, allocations, bookings] = await Promise.all([
      this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
      this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
      this.bookingsRepository.findByIdsForScheduling(dto.bookingIds),
    ]);

    const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
    const allocatedIds = new Set(allocations.map((a) => a.bookingId));
    const bookingById = new Map(bookings.map((b) => [b.id, b]));

    const invalid: string[] = [];
    for (const id of dto.bookingIds) {
      const booking = bookingById.get(id);
      if (
        !scheduledIds.has(id) ||
        !allocatedIds.has(id) ||
        !booking ||
        booking.tradeDirection !== 'IMPORT' ||
        booking.paymentStatus !== 'PAID'
      ) {
        invalid.push(id);
      }
    }
    if (invalid.length) {
      throw new BadRequestException(
        `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`,
      );
    }

    await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
      scheduleId,
      dto.bookingIds,
      dto.loadingStatus,
    );
    return this.getImportLoadingBookings(scheduleId);
  }

  /**
   * Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
   * direction (import/export/domestic). Distinct from unassign: the booking stays
   * on its wagon; this only records whether cargo is physically loaded. Allowed
   * only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
   * warehouse automation owns unload, so staff can no longer hand-edit the flag.
   */
  async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
    const schedule = await this.trainSchedulesRepository.findById(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException(
        `Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
      );
    }

    const [scheduleBookings, allocations] = await Promise.all([
      this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
      this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
    ]);
    const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
    const allocatedIds = new Set(allocations.map((a) => a.bookingId));

    // Only bookings that are on this train AND pinned to a wagon can be loaded —
    // no direction/payment filter, staff load whatever is physically on the set.
    const invalid = dto.bookingIds.filter(
      (id) => !scheduledIds.has(id) || !allocatedIds.has(id),
    );
    if (invalid.length) {
      throw new BadRequestException(
        `Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
      );
    }

    await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
      scheduleId,
      dto.bookingIds,
      dto.loadingStatus,
    );
    return this.getTrainScheduleById(scheduleId);
  }

  async pinWagons(scheduleId: string, dto: PinWagonsDto) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule');
    }

    const slots = schedule.trainSet?.wagons ?? [];
    const slotIds = new Set(slots.map((w) => w.id));
    const slotById = new Map(slots.map((w) => [w.id, w]));
    const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId);
    // Occupancy is judged against THIS schedule's own slots only — a wagon
    // pinned on another schedule (e.g. the same train's July 17 run) stays
    // pinnable here.
    const slotIdByPhysicalId = new Map(
      slots
        .filter((w) => w.physicalWagonId)
        .map((w) => [w.physicalWagonId as string, w.id]),
    );

    await this.dataSource.transaction(async (manager) => {
      for (const assignment of dto.assignments) {
        if (!slotIds.has(assignment.trainSetWagonId)) {
          throw new BadRequestException(
            `Train set wagon ${assignment.trainSetWagonId} does not belong to this schedule`,
          );
        }

        const physicalWagon = await manager.getRepository(Wagon).findOne({
          where: { id: assignment.physicalWagonId },
        });
        if (!physicalWagon) {
          throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`);
        }
        const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId);
        if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) {
          const occupyingSlot = slotById.get(occupyingSlotId);
          throw new ConflictException(
            `Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`,
          );
        }
        if (builtTrainId) {
          // Train-bound schedule: only the built train's own consist may be
          // pinned — wherever the wagons currently sit, they travel with the
          // train, so no yard/status gate applies.
          if (physicalWagon.trainId !== builtTrainId) {
            throw new ConflictException(
              `Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`,
            );
          }
        } else {
          if (physicalWagon.trainId) {
            throw new ConflictException(
              `Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`,
            );
          }
          if (!this.isWagonPhysicallyUsable(physicalWagon)) {
            throw new ConflictException(
              `Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`,
            );
          }
          if (
            physicalWagon.currentTrainScheduleId &&
            physicalWagon.currentTrainScheduleId !== scheduleId
          ) {
            throw new ConflictException(
              `Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`,
            );
          }
          if (physicalWagon.currentYardId !== schedule.originStationId) {
            throw new ConflictException(
              `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`,
            );
          }
        }

        // The pin lives ONLY on the schedule's slot — the Wagon entity keeps
        // its status untouched so other schedules can still use the wagon.
        await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, {
          physicalWagonId: assignment.physicalWagonId,
          status: 'RESERVED',
        });
        for (const [physicalId, slotId] of slotIdByPhysicalId) {
          if (slotId === assignment.trainSetWagonId) {
            slotIdByPhysicalId.delete(physicalId);
            break;
          }
        }
        slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId);
      }
    });

    return this.getTrainScheduleById(scheduleId);
  }

  async finalizeSchedule(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status !== TrainScheduleStatusEnum.Draft) {
      throw new BadRequestException('Only DRAFT schedules can be finalized');
    }
    if (!schedule.scheduleBookings?.length) {
      throw new BadRequestException('Cannot finalize a schedule with no bookings');
    }

    const now = new Date();
    await this.dataSource.transaction(async (manager) => {
      await this.trainSchedulesRepository.updateStatus(
        scheduleId,
        TrainScheduleStatusEnum.Scheduled,
        {},
        manager,
      );
      for (const sb of schedule.scheduleBookings ?? []) {
        await this.bookingsRepository.updateSchedulingFields(
          sb.bookingId,
          { schedulingStatus: SchedulingStatus.Scheduled, scheduledAt: now },
          manager,
        );
      }
    });

    // Finalized — push so portal/GL cards reflect the new state instantly.
    void this.emitWindowState(scheduleId);
    return this.getTrainScheduleById(scheduleId);
  }

  /**
   * EXPORT ONLY. An export train must not leave carrying nothing while its cargo
   * sits in the shed: the goods are received into the origin warehouse, GRN'd and
   * loaded onto the wagons allocated to the booking, so anything still in the
   * warehouse at dispatch is being left behind. Blocks dispatch when an allocated
   * booking has warehouse inventory that never made it onto a wagon (received /
   * stored / ready but not LOADED) — either load it from the Load-to-Train queue,
   * or drop the booking's wagon allocation so it rides a later train.
   *
   * Import/domestic are untouched: their cargo isn't loaded out of an origin
   * warehouse, so warehouse inventory says nothing about what's aboard.
   *
   * Bookings with no warehouse inventory at all are NOT blocked — allocating a
   * wagon before the goods arrive is normal planning; they simply aren't aboard.
   */
  private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
    const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
      await this.dataSource.query(
        `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
           FROM freight.train_schedules ts
           LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
           LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
          WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
        [scheduleId],
      );
    if (!route) return;
    const direction = deriveTradeDirection(
      { country: route.originCountry },
      { country: route.destinationCountry },
    );
    if (direction !== 'EXPORT') return;

    // Only bookings boarding at the schedule's ORIGIN station gate dispatch —
    // a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
    // train reaches its yard, so its warehouse state says nothing at departure.
    const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
      `WITH ${SCHEDULE_BOOKINGS_CTE}
       SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
         FROM sched_bookings sb
         JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
         JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
         JOIN freight.warehouse_inventory inv
           ON inv.booking_id = b.id AND inv.deleted_at IS NULL
        WHERE sb.schedule_id = $1
          AND b.origin_yard_id = ts.origin_station_id
          AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
      [scheduleId],
    );
    if (rows.length) {
      const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
      throw new BadRequestException(
        `Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
          `Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
      );
    }
  }

  async dispatchSchedule(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
      throw new BadRequestException('Only SCHEDULED trains can be dispatched');
    }
    await this.assertImportDjiboutiMayDepart(schedule);
    // Export only: don't leave received cargo behind in the warehouse.
    await this.assertAllocatedCargoLoaded(scheduleId);
    // A locomotive may sit on many future schedules, but it can only pull one train
    // at a time — block dispatch while any set locomotive is out on a dispatched train.
    const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
    await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
    // Same rule for wagons: many schedules may pin the same wagon, but it can
    // only be OUT on one dispatched train at a time.
    const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? [])
      .map((slot) => slot.physicalWagonId)
      .filter((id): id is string => Boolean(id));
    if (pinnedPhysicalIds.length) {
      const rolling = await this.dataSource.getRepository(Wagon).find({
        where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) },
      });
      const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId);
      if (busy.length) {
        throw new ConflictException(
          `Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`,
        );
      }
    }

    const now = new Date();
    await this.dataSource.transaction(async (manager) => {
      const trainNumber = await this.assignTrainNumber(manager, schedule);
      if (setLocomotiveIds.length) {
        await manager
          .getRepository(Locomotive)
          .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
      }

      await this.trainSchedulesRepository.updateStatus(
        scheduleId,
        TrainScheduleStatusEnum.Dispatched,
        {
          actualDepartureAt: now,
          trainNumber,
          // Freeze the wagon plan the moment the train leaves the editable phase.
          wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
            schedule,
            TrainScheduleStatusEnum.Dispatched,
            now,
          ),
        },
        manager,
      );
      if (schedule.trainSetId) {
        await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
      }
      // A built train follows its schedule out: IN_SERVICE until arrival.
      if (schedule.trainSet?.trainId) {
        await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
      }
      // The train is out — every pinned wagon is ASSIGNED to this schedule and
      // stays pinned so no other schedule can pick it while it's rolling.
      const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
        .map((slot) => slot.physicalWagonId)
        .filter((id): id is string => Boolean(id));
      if (dispatchedPhysicalIds.length) {
        await manager
          .getRepository(Wagon)
          .update(
            { id: In(dispatchedPhysicalIds) },
            { status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
          );
      }
      for (const sb of schedule.scheduleBookings ?? []) {
        await this.bookingsRepository.updateSchedulingFields(
          sb.bookingId,
          { schedulingStatus: SchedulingStatus.Dispatched },
          manager,
        );
      }
      // Per-booking journey fallback: bookings boarding at the TRAIN's origin
      // that the operator didn't load individually are auto-loaded now — the
      // train is leaving with them. Mid-corridor boarders stay PAID until the
      // operator loads them at their own yard.
      await manager.query(
        `UPDATE freight.bookings b
            SET status = 'IN_TRANSIT',
                loaded_at = COALESCE(b.loaded_at, $3)
           FROM freight.train_schedule_bookings tsb
          WHERE tsb.booking_id = b.id
            AND tsb.train_schedule_id = $1
            AND tsb.deleted_at IS NULL
            AND b.deleted_at IS NULL
            AND b.origin_yard_id = $2
            AND b.loaded_at IS NULL
            AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
        [scheduleId, schedule.originStationId, now],
      );
      // Close the booking window; any still-pending (unallocated) reservations don't ride this train.
      await manager
        .getRepository(TrainSchedule)
        .update(scheduleId, { bookingWindowStatus: 'CLOSED' });
      await manager
        .getRepository(Booking)
        .createQueryBuilder()
        .update()
        .set({
          status: 'EXPIRED',
          schedulingStatus: SchedulingStatus.Eligible,
          paymentDeadline: null,
        })
        .where('train_schedule_id = :scheduleId', { scheduleId })
        .andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
        .execute();
    });

    if (this.isImportDjiboutiSchedule(schedule)) {
      const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
      await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
        departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
      });
      console.log(
        `[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
      );
    }

    // Dispatch closed the window — drop it from portal/GL cards right away.
    void this.emitWindowState(scheduleId);
    // Customer tracking: cargo is on the departing train — loading milestones
    // plus the direction's "departed" handoff milestone. Restricted to bookings
    // that BOARD at the train's origin; mid-corridor boarders get their loading
    // milestones from their own operator load at their own yard.
    if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
      void this.completeMilestonesForScheduleBookings(
        scheduleId,
        [
          // CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
          // doc-trigger path no-ops it for import bookings.
          'CARGO_ARRIVED',
          'READY_FOR_LOADING',
          'LOADED',
          schedule.direction === 'IMPORT'
            ? 'DEPARTED_FROM_DJIBOUTI'
            : 'DEPARTED_TO_DJIBOUTI',
        ],
        { originYardId: schedule.originStationId },
      );
    }
    void this.notifyScheduleBookings(schedule, 'dispatched');

    const detail = await this.getTrainScheduleById(scheduleId);
    // Surface a compact dispatch confirmation so the caller can toast the "train
    // is out" info (train number, departure, wagons committed) without re-deriving it.
    const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
      (slot) => slot.physicalWagonId,
    ).length;
    return Object.assign(detail, {
      dispatchInfo: {
        // The real train number was assigned inside the txn — read it back off
        // the persisted detail (schedule.trainNumber is the pre-dispatch value).
        trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
        departedAt: now.toISOString(),
        wagonsDispatched: dispatchedWagonCount,
        originStationId: schedule.originStationId,
        destinationStationId: schedule.destinationStationId,
      },
    });
  }

  async getImportDjiboutiOperation(scheduleId: string) {
    const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    return this.mapImportDjiboutiOperation(schedule, operation);
  }

  async uploadImportDjiboutiDocument(
    scheduleId: string,
    dto: UploadImportDjiboutiDocumentDto,
  ) {
    const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    const documents = {
      ...(operation.documents ?? {}),
      [dto.documentType]: {
        fileId: dto.fileId ?? null,
        fileUrl: dto.fileUrl ?? null,
        reference: dto.reference ?? null,
        uploadedAt: new Date().toISOString(),
        uploadedBy: dto.performedBy ?? null,
        notes: dto.notes ?? null,
      },
    };

    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      documents,
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });

    return this.getImportDjiboutiOperation(schedule.id);
  }

  async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
    const documents = { ...(operation.documents ?? {}) };
    if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
      documents.GATE_PASS = {
        fileId: dto.fileId ?? null,
        fileUrl: dto.fileUrl ?? null,
        reference: dto.reference ?? null,
        uploadedAt: new Date().toISOString(),
        uploadedBy: dto.performedBy ?? null,
        notes: dto.notes ?? null,
      };
    }

    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      documents,
      gatepassGrantedAt: securedAt,
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });

    await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);

    console.log(
      `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
    );
    return this.getImportDjiboutiOperation(schedule.id);
  }

  /**
   * Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
   * milestone for every customs booking on this schedule, so contract/booking
   * clearance views still reading that milestone (older deployed builds) see
   * the gate pass as done. Drop once every clearance-api deployment reads
   * ImportDjiboutiOperation.gatepassGrantedAt directly.
   *
   * A booking only earns its gate pass once the customer has settled the freight
   * charges (FREIGHT_PAYMENT_SETTLED). The gate pass itself is secured per train
   * schedule, so an unpaid booking must not ride a paid neighbour's grant: it
   * keeps GATEPASS_GRANTED pending — and therefore cannot upload T1 — while the
   * train and its paid bookings proceed. Re-securing the gate pass after payment
   * settles picks the booking up; so does any later call to this bridge.
   */
  private async completeGatepassMilestoneForSchedule(
    scheduleId: string,
    securedAt: Date,
  ): Promise<void> {
    const bookings = await this.dataSource.getRepository(Booking).find({
      where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
    });
    if (bookings.length === 0) return;

    const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
    const bookingIds = bookings.map((b) => b.id);
    const rows = await milestoneRepo.find({
      where: {
        bookingId: In(bookingIds),
        milestoneCode: In(['GATEPASS_GRANTED', 'FREIGHT_PAYMENT_SETTLED']),
      },
    });

    const paidBookingIds = new Set(
      rows
        .filter(
          (r) => r.milestoneCode === 'FREIGHT_PAYMENT_SETTLED' && r.status === 'COMPLETED',
        )
        .map((r) => r.bookingId),
    );
    // A booking whose payment settled through a path that never wrote the
    // milestone still counts as paid — the clearance views self-heal the row on
    // read, and the gate pass must not lag behind that.
    for (const booking of bookings) {
      if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
        paidBookingIds.add(booking.id);
      }
    }

    const skipped: string[] = [];
    for (const row of rows) {
      if (row.milestoneCode !== 'GATEPASS_GRANTED') continue;
      if (row.status === 'COMPLETED') continue;
      if (!row.bookingId || !paidBookingIds.has(row.bookingId)) {
        skipped.push(row.bookingId ?? '(unknown)');
        continue;
      }
      row.status = 'COMPLETED';
      row.triggeredAt = securedAt;
      row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
      await milestoneRepo.save(row);
    }

    if (skipped.length > 0) {
      this.logger.warn(
        `Gate pass secured for schedule ${scheduleId}, but ${skipped.length} booking(s) have not settled freight payment and stay pending: ${skipped.join(', ')}`,
      );
    }
  }

  async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.getImportDjiboutiSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    this.assertImportDjiboutiGatepassGranted(operation);

    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });

    return this.getImportDjiboutiOperation(schedule.id);
  }

  async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.getImportDjiboutiSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    this.assertImportDjiboutiGatepassGranted(operation);

    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
      loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });

    return this.getImportDjiboutiOperation(schedule.id);
  }

  /**
   * Confirm cargo is loaded on the train from the workspace, for any direction.
   * For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
   * loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
   * For every other schedule there is no departure loading gate, so this is a
   * success no-op and simply returns the current detail.
   */
  async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    // Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
    if (this.isImportDjiboutiSchedule(schedule)) {
      await this.confirmImportLoadedOnTrain(scheduleId, dto);
    }
    // Confirming loading also marks every wagon-assigned booking LOADED, so the
    // per-booking loading flag and the dispatch gate agree (otherwise the
    // dispatch pre-check keeps reporting these bookings as unloaded).
    const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
    if (wagonAssignedIds.size) {
      // Export cargo must be received at the warehouse with a GRN before it can
      // be confirmed loaded — an allocation is not proof the goods are in hand.
      if (this.isExportSchedule(schedule)) {
        await this.assertExportBookingsReceived([...wagonAssignedIds]);
      }
      await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
        scheduleId,
        [...wagonAssignedIds],
        LoadingStatus.Loaded,
      );
    }
    // Customer tracking: staff confirmed cargo is on the wagons (CARGO_ARRIVED
    // is the export-side "cargo reached origin yard" step that precedes it).
    void this.completeMilestonesForScheduleBookings(scheduleId, [
      'CARGO_ARRIVED',
      'READY_FOR_LOADING',
      'LOADED',
    ]);
    return this.getTrainScheduleById(scheduleId);
  }

  async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.getImportDjiboutiSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    this.assertImportDjiboutiGatepassGranted(operation);
    // Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).

    if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
      await this.dispatchSchedule(schedule.id);
    } else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
      throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
    }
    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });
    return this.getImportDjiboutiOperation(schedule.id);
  }

  async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
    const schedule = await this.getImportDjiboutiSchedule(scheduleId);
    const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
    const generatedAt = operation.loadListGeneratedAt ?? new Date();

    await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
      loadListGeneratedAt: generatedAt,
      performedBy: dto.performedBy ?? operation.performedBy ?? null,
      notes: dto.notes ?? operation.notes ?? null,
    });

    return {
      generatedAt: generatedAt.toISOString(),
      trainScheduleId: schedule.id,
      trainNumber: schedule.trainNumber ?? null,
      route: schedule.route ? formatRouteLabel(schedule.route) : null,
      origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
      destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
      totalBookings: schedule.scheduleBookings?.length ?? 0,
      // Every wagon on the train set, loaded or not, in consist order. An empty
      // wagon has an empty `allocations` array — it is still part of the train
      // and still belongs on the marshalling document.
      wagons: [...(schedule.trainSet?.wagons ?? [])]
        .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
        .map((wagon) => ({
          sequenceNo: wagon.sequenceNo,
          wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
          allocations: (wagon.allocations ?? []).map((allocation) => ({
            bookingId: allocation.bookingId,
            bookingReference: allocation.booking?.reference ?? null,
            loadType: allocation.loadType ?? null,
            allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
            containerNumbers: (allocation.containerItems ?? [])
              .map((item) => item.containerNumber)
              .filter(Boolean),
          })),
        })),
      operation: await this.getImportDjiboutiOperation(schedule.id),
    };
  }

  async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
    const loadList = await this.generateImportLoadList(scheduleId, {
      performedBy: 'DOCUMENT_GENERATION',
    });
    const html = this.buildImportLoadListHtml(loadList);
    // Styled table-aware fallback (marshalling grid) when Chromium is unavailable —
    // NOT the release-order fallback (would mislabel this as a gate-clearance order).
    const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Import marshalling / load list');
    const reference = loadList.trainNumber ?? loadList.trainScheduleId;
    return {
      filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
      buffer,
    };
  }

  async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!this.isExportSchedule(schedule)) {
      throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
    }

    const html = this.buildExportLoadListHtml(schedule);
    // Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
    const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Export marshalling / load list');
    const reference = schedule.trainNumber ?? schedule.id;
    return {
      filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
      buffer,
    };
  }

  private buildExportLoadListHtml(schedule: TrainSchedule): string {
    const esc = (value: unknown) =>
      String(value ?? '-')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
    const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
    const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
    // The document is checked against the physical train, so it has to run in
    // consist order — the relation comes back unordered.
    const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
      (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
    );
    const rows = wagons
      .flatMap((wagon) => {
        // Wagon identity is the same on every row the wagon produces, loaded or not.
        const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
            <td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
            <td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
            <td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
            <td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
            <td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
        const allocations = wagon.allocations ?? [];
        // An empty wagon still runs in the consist, so it still gets a line. Staff
        // check this document against the physical train — a wagon with no row
        // reads as a wagon that is not there, and the count stops matching.
        if (allocations.length === 0) {
          return [
            `<tr class="empty">
            ${wagonCells}
            <td colspan="4">EMPTY — no cargo allocated</td>
          </tr>`,
          ];
        }
        return allocations.map((allocation) => {
          const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
          const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
          const containerItems = allocation.containerItems ?? [];
          const firstContainer = containerItems[0];
          const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
          const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
          const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
          return `<tr>
            ${wagonCells}
            <td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
            <td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
            <td>${esc(chassisNumbers)}</td>
            <td>${esc(sealNumbers)}</td>
          </tr>`;
        });
      })
      .join('');
    const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
    const totalWeight = wagons.reduce(
      (sum, wagon) =>
        sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
      0,
    );

    return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Export Marshalling Document</title>
  <style>
    @page { size: A4 landscape; margin: 10mm; }
    * { box-sizing: border-box; }
    body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
    .top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
    .brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
    h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
    .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
    .meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
    .summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
    .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
    .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
    .tile strong { font-size: 11px; }
    table { width: 100%; border-collapse: collapse; }
    th { background: #f8fafc; color: #475569; text-align: left; }
    th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
    .num { text-align: right; }
    tr.empty td { background: #f8fafc; color: #64748b; }
    tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
    .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
    .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
    .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
  </style>
</head>
<body>
  <div class="top">
    <div>
      <div class="brand">Ethio-Djibouti Railway S.C.</div>
      <h1>Export Marshalling Document / Load List</h1>
    </div>
    <div class="meta">
      Train / Schedule
      <strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
      Generated: ${esc(new Date().toLocaleString('en-GB'))}
    </div>
  </div>

  <div class="summary">
    <div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
    <div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
    <div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
    <div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
    <div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
    <div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
    <div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
    <div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
    <div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
    <div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
    <div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
    <div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
  </div>

  <table>
    <thead>
      <tr>
        <th>Seq</th>
        <th>Wagon No</th>
        <th>Wagon Type</th>
        <th class="num">Equated Length</th>
        <th class="num">Tare Weight</th>
        <th class="num">Load Capacity</th>
        <th>Cargo Type</th>
        <th>Container No</th>
        <th>Chassis No</th>
        <th>Seal No</th>
      </tr>
    </thead>
    <tbody>
      ${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
    </tbody>
  </table>

  <div class="notice">
    Loading and dispatch staff must verify wagon identity, seal number, container number,
    cargo type, and customer booking against the physical consist before departure.
  </div>

  <div class="signatures">
    <div class="line">Prepared person / date</div>
    <div class="line">Check person / date</div>
    <div class="line">Operations authorization / date</div>
  </div>
</body>
</html>`;
  }

  private isExportSchedule(schedule: TrainSchedule): boolean {
    const direction =
      (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
      (schedule.originStation && schedule.destinationStation
        ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
        : null);
    return direction === 'EXPORT';
  }

  /**
   * Every export booking being confirmed loaded must already be received at the
   * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
   * is the check that the cargo is physically in the yard before we call it loaded.
   */
  private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
    if (!bookingIds.length) return;
    const rows: Array<{ reference: string | null }> = await this.dataSource.query(
      `SELECT b.reference
         FROM freight.bookings b
        WHERE b.id = ANY($1)
          AND b.deleted_at IS NULL
          AND NOT EXISTS (
            SELECT 1 FROM freight.warehouse_inventory inv
             WHERE inv.booking_id = b.id
               AND inv.deleted_at IS NULL
               AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
               AND COALESCE(
                     NULLIF(TRIM(inv.grn_number), ''),
                     substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
                   ) IS NOT NULL
          )`,
      [bookingIds],
    );
    if (rows.length) {
      const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
      throw new BadRequestException(
        `These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
      );
    }
  }

  private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
    const esc = (value: unknown) =>
      String(value ?? '-')
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
    const status = loadList.operation.status;
    const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
    const totalWeight = loadList.wagons.reduce(
      (sum, wagon) =>
        sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
      0,
    );
    const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
    const allocationRows = loadList.wagons
      .flatMap((wagon) => {
        const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
            <td>${esc(wagon.wagonNumber)}</td>`;
        // An empty wagon still runs in the consist, so it still gets a line — see
        // buildExportLoadListHtml.
        if (wagon.allocations.length === 0) {
          return [
            `<tr class="empty">
            ${wagonCells}
            <td colspan="4">EMPTY — no cargo allocated</td>
          </tr>`,
          ];
        }
        return wagon.allocations.map(
          (allocation) => `<tr>
            ${wagonCells}
            <td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
            <td>${esc(allocation.loadType)}</td>
            <td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
            <td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
          </tr>`,
        );
      })
      .join('');

    return `<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Import Load List / Marshalling Document</title>
  <style>
    @page { size: A4; margin: 14mm; }
    * { box-sizing: border-box; }
    body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
    .doc { min-height: 100vh; position: relative; }
    .top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
    .brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
    h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
    .subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
    .meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
    .meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
    .summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
    .tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
    .tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
    .tile strong { font-size: 13px; }
    .status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
    .step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
    .done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
    .pending { background: #f8fafc; color: #64748b; }
    h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
    table { width: 100%; border-collapse: collapse; }
    th { background: #f8fafc; color: #475569; text-align: left; }
    th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
    .num { text-align: right; }
    tr.empty td { background: #f8fafc; color: #64748b; }
    tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
    .notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
    .signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
    .line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
    .footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
  </style>
</head>
<body>
  <div class="doc">
    <div class="top">
      <div>
        <div class="brand">Ethio-Djibouti Railway S.C.</div>
        <h1>Import Load List /<br />Marshalling Document</h1>
        <div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
      </div>
      <div class="meta">
        Train / Schedule
        <strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
        Generated: ${esc(date(loadList.generatedAt))}
      </div>
    </div>

    <div class="summary">
      <div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
      <div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
      <div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
      <div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
      <div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
      <div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
      <div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
      <div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
    </div>

    <div class="status">
      <div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
      <div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
      <div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
      <div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
      <div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
      <div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
    </div>

    <h2>Wagon Marshalling Allocation</h2>
    <table>
      <thead>
        <tr>
          <th>Seq</th>
          <th>Wagon</th>
          <th>Booking</th>
          <th>Load</th>
          <th>Container numbers</th>
          <th class="num">Weight T</th>
        </tr>
      </thead>
      <tbody>
        ${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
      </tbody>
    </table>

    <div class="notice">
      Gate and loading staff must verify this document against the granted gatepass,
      railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
    </div>

    <div class="signatures">
      <div class="line">Prepared by Djibouti operations</div>
      <div class="line">Train loading supervisor</div>
      <div class="line">EDR operations authorization</div>
    </div>

    <div class="footer">
      System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
    </div>
  </div>
</body>
</html>`;
  }

  private safeDocumentName(value: string): string {
    return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
  }

  private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
    if (!this.isImportDjiboutiSchedule(schedule)) return;
    const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
      where: { trainScheduleId: schedule.id },
    });
    this.assertImportDjiboutiGatepassGranted(operation);
    // Loading confirmation does NOT gate dispatch. Per-booking loading is
    // tracking only and the loaded-on-train step is optional — a scheduled train
    // dispatches without waiting on loading.
  }

  private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
    const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
    if (!this.isImportDjiboutiSchedule(schedule)) {
      throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
    }
    return schedule;
  }

  private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!this.isDjiboutiGatepassSchedule(schedule)) {
      throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
    }
    return schedule;
  }

  private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
    return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
  }

  private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
    const direction =
      (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
      (schedule.originStation && schedule.destinationStation
        ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
        : null);
    return (
      direction === 'IMPORT' &&
      this.isDjiboutiPortDestination(
        `${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
      )
    );
  }

  private isExportDjiboutiSchedule(schedule: TrainSchedule): boolean {
    const direction =
      (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
      (schedule.originStation && schedule.destinationStation
        ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
        : null);
    return (
      direction === 'EXPORT' &&
      this.isDjiboutiPortDestination(
        `${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
      )
    );
  }

  private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
    const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
    const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
    if (existing) return existing;
    return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
  }

  private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
    void operation;
    return [];
  }

  private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
    if (!operation?.gatepassGrantedAt) {
      throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
    }
  }

  private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
    const missingDocuments = this.missingImportDjiboutiDocuments(operation);
    const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
    return {
      trainScheduleId: schedule.id,
      trainNumber: schedule.trainNumber ?? null,
      direction: schedule.direction ?? null,
      status: {
        documentsComplete: missingDocuments.length === 0,
        missingDocuments,
        gatepassStatus,
        gatepassGranted: Boolean(operation.gatepassGrantedAt),
        readyForLoading: Boolean(operation.readyForLoadingAt),
        loadedOnTrain: Boolean(operation.loadedOnTrainAt),
        departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
        loadListGenerated: Boolean(operation.loadListGeneratedAt),
      },
      documents: operation.documents ?? {},
      gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
      gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
      gatepassStatus,
      readyForLoadingAt: operation.readyForLoadingAt ?? null,
      loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
      departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
      loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
      performedBy: operation.performedBy ?? null,
      notes: operation.notes ?? null,
    };
  }

  /**
   * Assign a fixed train number on dispatch. The number is drawn from the pool
   * for the train's dominant cargo type (container vs bulk) and trade direction
   * (export = odd, import = even). Numbers recycle once a train ARRIVES, so the
   * "used" set is every still-DISPATCHED schedule's number. Locked FOR UPDATE so
   * concurrent dispatches can't grab the same number. Throws when the pool is
   * exhausted. Idempotent: returns the existing number if already assigned.
   */
  private async assignTrainNumber(
    manager: EntityManager,
    schedule: TrainSchedule,
  ): Promise<string> {
    if (schedule.trainNumber) {
      // Creation-assigned pair number: two live runs may never share a number,
      // so block dispatch while another DISPATCHED schedule still carries it.
      const clash = await manager
        .getRepository(TrainSchedule)
        .createQueryBuilder('s')
        .where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched })
        .andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber })
        .andWhere('s.id != :id', { id: schedule.id })
        .getOne();
      if (clash) {
        throw new ConflictException(
          `Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`,
        );
      }
      return schedule.trainNumber;
    }

    // Count container vs bulk wagons from the planned allocations.
    let containerWagons = 0;
    let bulkWagons = 0;
    for (const wagon of schedule.trainSet?.wagons ?? []) {
      const isBulk = (wagon.allocations ?? []).some((a) => a.loadType === 'BULK');
      if (isBulk) bulkWagons += 1;
      else containerWagons += 1;
    }

    const direction =
      (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
      (schedule.originStation && schedule.destinationStation
        ? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
        : null);

    const pool = pickTrainNumberPool(containerWagons, bulkWagons, direction);

    // Lock the set of currently-active numbered schedules so two concurrent
    // dispatches serialize and can't both claim the same lowest-free number.
    // DRAFT/SCHEDULED are included because pair numbers are now assigned at
    // creation and must be invisible to pool picks.
    const activeNumbered = await manager
      .getRepository(TrainSchedule)
      .createQueryBuilder('schedule')
      .setLock('pessimistic_write')
      .where('schedule.status IN (:...statuses)', {
        statuses: [
          TrainScheduleStatusEnum.Draft,
          TrainScheduleStatusEnum.Scheduled,
          TrainScheduleStatusEnum.Dispatched,
        ],
      })
      .andWhere('schedule.train_number IS NOT NULL')
      .getMany();

    // Every typed train pair is reserved for its train — the pool may never
    // hand one out, even when that train has no active schedule right now.
    const pairRows: { n: string }[] = await manager.query(
      `SELECT import_train_number AS n FROM freight.trains
        WHERE deleted_at IS NULL AND import_train_number IS NOT NULL
       UNION
       SELECT export_train_number FROM freight.trains
        WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`,
    );

    const usedNumbers = [
      ...activeNumbered
        .map((s) => s.trainNumber)
        .filter((n): n is string => Boolean(n)),
      ...pairRows.map((row) => row.n),
    ];

    const number = pickLowestFreeNumber(pool.numbers, usedNumbers);
    if (!number) {
      throw new ConflictException(
        `No free ${pool.cargo.toLowerCase()} ${pool.direction.toLowerCase()} train number available; a train must arrive to free one`,
      );
    }
    return number;
  }

  /** Open or close a schedule's booking window (staff override). */
  async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
    if (status === 'OPEN') {
      const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
      if (schedule?.bookingWindowStatus === 'FULL') {
        throw new ConflictException('Train is full — the booking window cannot be reopened');
      }
    }
    await this.dataSource
      .getRepository(TrainSchedule)
      .update(scheduleId, { bookingWindowStatus: status });
    void this.emitWindowState(scheduleId);
  }

  /** Build the ordered station list for a schedule's corridor (origin → milestones → destination). */
  private async buildScheduleStations(schedule: TrainSchedule) {
    type Station = { sequenceNo: number; yardId: string; label: string; code: string };
    const stations: Station[] = [];

    const route = schedule.routeId
      ? await this.dataSource.getRepository(Route).findOne({
          where: { id: schedule.routeId },
          relations: { originYard: true, destinationYard: true, milestones: { yard: true } },
        })
      : null;

    if (route) {
      // `route.milestones` is the complete ordered corridor and already includes
      // the origin (first) and destination (last) yards — `route.originYardId`
      // and `route.destinationYardId` are derived from them. Use the milestones
      // directly so the endpoints aren't double-counted (Addis…Addis, Dire…Dire).
      const milestones = [...(route.milestones ?? [])].sort(
        (a: RouteMilestone, b: RouteMilestone) => a.sequenceNo - b.sequenceNo,
      );

      if (milestones.length > 0) {
        milestones.forEach((m, i) =>
          stations.push({
            sequenceNo: i,
            yardId: m.yardId,
            label: m.yard?.label ?? m.yard?.code ?? `Stop ${i + 1}`,
            code: m.yard?.code ?? '',
          }),
        );
        return stations;
      }

      // Route with no milestones recorded — fall back to its origin/destination.
      const origin = route.originYard;
      const destination = route.destinationYard;
      stations.push({
        sequenceNo: 0,
        yardId: route.originYardId,
        label: origin?.label ?? origin?.code ?? 'Origin',
        code: origin?.code ?? '',
      });
      stations.push({
        sequenceNo: 1,
        yardId: route.destinationYardId,
        label: destination?.label ?? destination?.code ?? 'Destination',
        code: destination?.code ?? '',
      });
      return stations;
    }

    // Fallback: no route milestones — just origin → destination from the schedule stations.
    stations.push({
      sequenceNo: 0,
      yardId: schedule.originStationId,
      label: schedule.originStation?.label ?? schedule.originStation?.code ?? 'Origin',
      code: schedule.originStation?.code ?? '',
    });
    stations.push({
      sequenceNo: 1,
      yardId: schedule.destinationStationId,
      label:
        schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? 'Destination',
      code: schedule.destinationStation?.code ?? '',
    });
    return stations;
  }

  /** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
  async getScheduleCheckpoints(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }

    const stations = await this.buildScheduleStations(schedule);
    const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);

    // Resolve each checkpoint's position by its yard against the canonical
    // corridor rather than the stored sequenceNo, so legacy checkpoints logged
    // under an older station numbering still line up with the current stations.
    const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
    const resolvedSeq = (e: TrainCheckpointEvent) =>
      seqByYard.get(e.yardId) ?? e.sequenceNo;

    const currentSequenceNo = events.length
      ? Math.max(...events.map(resolvedSeq))
      : -1;

    return {
      scheduleId,
      status: schedule.status,
      direction: schedule.direction ?? null,
      trainNumber: schedule.trainNumber ?? null,
      actualDepartureAt: schedule.actualDepartureAt
        ? schedule.actualDepartureAt.toISOString()
        : null,
      actualArrivalAt: schedule.actualArrivalAt
        ? schedule.actualArrivalAt.toISOString()
        : null,
      scheduledDepartureAt: schedule.scheduledDepartureDate
        ? schedule.scheduledDepartureDate.toISOString()
        : null,
      scheduledArrivalAt: schedule.scheduledArrivalDate
        ? schedule.scheduledArrivalDate.toISOString()
        : null,
      origin: stations[0]?.label ?? null,
      destination: stations[stations.length - 1]?.label ?? null,
      stations,
      currentSequenceNo,
      checkpoints: events.map((e) => ({
        id: e.id,
        sequenceNo: resolvedSeq(e),
        yardId: e.yardId,
        label: e.yard?.label ?? e.yard?.code ?? null,
        kind: e.kind,
        occurredAt: e.occurredAt.toISOString(),
        note: e.note ?? null,
      })),
    };
  }

  /** Log the train passing a station. Logging the destination station triggers arrival. */
  async recordCheckpoint(scheduleId: string, dto: RecordCheckpointDto) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
      throw new BadRequestException('Only DISPATCHED trains can be tracked');
    }

    const stations = await this.buildScheduleStations(schedule);
    const finalSeq = stations[stations.length - 1].sequenceNo;
    const station = stations.find((s) => s.sequenceNo === dto.sequenceNo);
    if (!station) {
      throw new BadRequestException(`Station ${dto.sequenceNo} is not on this route`);
    }

    const kind =
      dto.kind ??
      (dto.sequenceNo === 0
        ? TrainCheckpointKind.Departed
        : dto.sequenceNo === finalSeq
          ? TrainCheckpointKind.Arrived
          : TrainCheckpointKind.Passed);
    const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();

    // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
    const [existing] = await this.trainCheckpointEventsRepository.findAll({
      where: { trainScheduleId: scheduleId, sequenceNo: dto.sequenceNo },
    });
    if (existing) {
      await this.trainCheckpointEventsRepository.update(existing.id, {
        kind,
        occurredAt,
        note: dto.note ?? null,
        yardId: station.yardId,
      });
    } else {
      await this.trainCheckpointEventsRepository.create({
        trainScheduleId: scheduleId,
        yardId: station.yardId,
        sequenceNo: dto.sequenceNo,
        kind,
        occurredAt,
        note: dto.note ?? null,
      });
    }

    if (dto.sequenceNo === finalSeq) {
      await this.arriveSchedule(scheduleId);
    } else {
      // Mid-corridor auto-unload: bookings destined for this yard alight the
      // moment the train is recorded here — the yard operator no longer has to
      // unload each one by hand. The final station is covered by
      // arriveSchedule's bulk fallback above.
      await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
    }

    return this.getScheduleCheckpoints(scheduleId);
  }

  /**
   * Mark a dispatched train arrived: close out the schedule, move the locomotive
   * and wagons to the destination yard, and free the assets for re-use.
   */
  async arriveSchedule(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
      throw new BadRequestException('Only DISPATCHED trains can arrive');
    }

    const now = new Date();

    await this.dataSource.transaction(async (manager) => {
      await this.trainSchedulesRepository.updateStatus(
        scheduleId,
        TrainScheduleStatusEnum.Arrived,
        {
          actualArrivalAt: now,
          // Freeze the plan before the wagons below are released to their yards.
          wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
            schedule,
            TrainScheduleStatusEnum.Arrived,
            now,
          ),
        },
        manager,
      );

      if (schedule.trainSetId) {
        await manager.getRepository(TrainSet).update(schedule.trainSetId, {
          status: 'COMPLETED',
        });
      }
      // A built train arrives with its schedule: settle it at the destination
      // yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait).
      if (schedule.trainSet?.trainId) {
        await this.syncBuiltTrainAfterScheduleChange(
          manager,
          schedule.trainSet.trainId,
          schedule.destinationStationId,
        );
      }

      // Per-booking journey: bookings destined for the FINAL yard that the
      // operator didn't unload individually get their arrival stamped now as a
      // bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
      // their own unload (possibly already done while the train kept rolling).
      await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);

      // Release every locomotive of the set (not just the legacy primary) and move it
      // to the destination yard where it physically arrived.
      const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
      if (arrivedLocoIds.length) {
        await manager.getRepository(Locomotive).update(
          { id: In(arrivedLocoIds) },
          { status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
        );
      }

      for (const slot of schedule.trainSet?.wagons ?? []) {
        if (!slot.physicalWagonId) continue;
        const wagon = await manager
          .getRepository(Wagon)
          .findOne({ where: { id: slot.physicalWagonId } });
        if (!wagon) continue;
        // A wagon that already alighted mid-route (unload released it, possibly
        // re-pinned elsewhere since) is no longer this schedule's to move.
        if (wagon.currentTrainScheduleId !== scheduleId) continue;
        // Dynamic consist: the wagon settles at its slot's alight yard, not
        // blanket at the train's destination.
        const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
        await manager.getRepository(Wagon).update(wagon.id, {
          currentTrainScheduleId: null,
          trainSetWagonId: null,
          // A wagon that belongs to a built train stays coupled to it (ASSIGNED);
          // only loose wagons return to the open AVAILABLE pool.
          status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available,
          currentYardId: settleYardId,
        });
        // Ledger: the wagon rode this schedule to its settle yard.
        const slotAllocations = slot.allocations ?? [];
        await manager.getRepository(WagonMovement).save(
          manager.getRepository(WagonMovement).create({
            wagonId: wagon.id,
            fromYardId: slot.boardYardId ?? schedule.originStationId,
            toYardId: settleYardId,
            trainScheduleId: scheduleId,
            bookingId: slotAllocations[0]?.bookingId ?? null,
            kind: slotAllocations.length
              ? WagonMovementKind.Loaded
              : WagonMovementKind.EmptyReposition,
            occurredAt: now,
          }),
        );
      }

      // Ensure a destination checkpoint exists so the timeline shows ARRIVED.
      const stations = await this.buildScheduleStations(schedule);
      const finalStation = stations[stations.length - 1];
      const [existingFinal] = await this.trainCheckpointEventsRepository.findAll({
        where: { trainScheduleId: scheduleId, sequenceNo: finalStation.sequenceNo },
      });
      if (!existingFinal) {
        await manager.getRepository(TrainCheckpointEvent).save(
          manager.getRepository(TrainCheckpointEvent).create({
            trainScheduleId: scheduleId,
            yardId: finalStation.yardId,
            sequenceNo: finalStation.sequenceNo,
            kind: TrainCheckpointKind.Arrived,
            occurredAt: now,
          }),
        );
      }
    });

    // Customer tracking: the train reached the corridor's far end. Restricted
    // to bookings destined for the FINAL yard — mid-corridor bookings get their
    // arrival milestone from their own operator unload at their own yard.
    if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
      void this.completeMilestonesForScheduleBookings(
        scheduleId,
        [schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
        { destinationYardId: schedule.destinationStationId },
      );
    }
    void this.notifyScheduleBookings(schedule, 'arrived');

    const detail = await this.getTrainScheduleById(scheduleId);
    const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId);
    return Object.assign(detail, { warehouseAutomation });
  }

  async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
    const { page, pageSize, skip, take } = normalizePagination(query);

    // Exact-match filters (enum/id semantics). Freight type is derived from
    // the bookings aboard — no column to match — so it rides on `id` as an
    // EXISTS fragment instead.
    const base: FindOptionsWhere<TrainSchedule> = {};
    if (query.status) base.status = query.status;
    if (query.originStationId) base.originStationId = query.originStationId;
    if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
    if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) 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, reference: like as never },
        { ...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>[];
    }

    // Newest-created first (the client can re-sort; this is the default order).
    const sortBy = query.sortBy ?? 'createdAt';
    const sortOrder = query.sortOrder ?? 'DESC';

    const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
      where,
      relations: {
        trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
        // Yards carry the route's display name used by mapScheduleListItem;
        // milestones (with yards) let it show the full corridor path.
        route: { originYard: true, destinationYard: true, milestones: { yard: true } },
        originStation: true,
        destinationStation: true,
        scheduleBookings: { booking: true },
      },
      order: { [sortBy]: sortOrder } as never,
      skip,
      take,
    });
    return {
      items: schedules.map((s) => this.mapScheduleListItem(s)),
      meta: buildPaginationMeta(total, page, pageSize),
    };
  }

  async getContainerTrainScheduleById(id: string) {
    return this.getTrainScheduleById(id);
  }

  async cancelTrainSchedule(id: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${id} not found`);
    }
    if (
      schedule.status !== TrainScheduleStatusEnum.Draft &&
      schedule.status !== TrainScheduleStatusEnum.Scheduled
    ) {
      throw new BadRequestException(
        `Cannot cancel a ${schedule.status} train; only DRAFT or SCHEDULED schedules may be cancelled`,
      );
    }

    const now = new Date();

    await this.dataSource.transaction(async (manager) => {
      await this.trainSchedulesRepository.updateStatus(
        id,
        TrainScheduleStatusEnum.Cancelled,
        {
          // Retire the booking window so a canceled schedule never lingers as an
          // "open window" in booking-window lists or the legacy batch fill.
          bookingWindowStatus: 'CLOSED',
          windowPhase: 'DONE',
          // Freeze the plan before the wagons below are released back to the yard.
          wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
            schedule,
            TrainScheduleStatusEnum.Cancelled,
            now,
          ),
        },
        manager,
      );
      if (schedule.trainSetId) {
        await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
      }
      // Cancelled run: the built train never left — re-derive its status
      // (back to AVAILABLE unless other runs still reference it).
      if (schedule.trainSet?.trainId) {
        await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId);
      }
      // Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
      // but never stomp a locomotive that is currently pulling another dispatched train.
      const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
      if (cancelledLocoIds.length) {
        const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
          cancelledLocoIds,
          id,
          manager,
        );
        const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
        if (releasable.length) {
          await manager
            .getRepository(Locomotive)
            .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
        }
      }
      for (const wagon of schedule.trainSet?.wagons ?? []) {
        if (wagon.physicalWagonId) {
          await manager.getRepository(Wagon).update(wagon.physicalWagonId, {
            currentTrainScheduleId: null,
            trainSetWagonId: null,
            // Built-train wagons stay coupled to their train (ASSIGNED); loose
            // wagons return to the open AVAILABLE pool.
            status: wagon.physicalWagon?.trainId
              ? WagonStatus.Assigned
              : WagonStatus.Available,
            // A cancelled train never left — its wagons stay/return at the origin
            // yard, free to be re-pinned onto another schedule from there.
            currentYardId: schedule.originStationId,
          });
        }
      }
      for (const sb of schedule.scheduleBookings ?? []) {
        const booking = await this.bookingsRepository.findById(sb.bookingId);
        // Detach from the cancelled schedule — clear the pointer so the freed
        // booking can be assigned to another train. Leaving it set orphans the
        // booking against a schedule that is about to be gone.
        await this.bookingsRepository.updateSchedulingFields(
          sb.bookingId,
          {
            schedulingStatus: this.resolvePostUnassignStatus(booking),
            trainScheduleId: null,
          },
          manager,
        );
      }
    });

    // Window retired (DONE) — remove the card from portal/GL lists right away.
    void this.emitWindowState(id);
    return this.getTrainScheduleById(id);
  }

  private async getTrainScheduleById(id: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${id} not found`);
    }
    return this.mapScheduleDetail(schedule);
  }

  private async validateBookingsForScheduling(
    dto: PreviewContainerTrainScheduleDto | PreviewBulkTrainScheduleDto | PreviewTrainScheduleDto,
    freightType: 'CONTAINER' | 'BULK' | null,
    forceAssign = false,
    containerPlacements: ContainerPlacementInput[] = [],
    requireContainerPlacements = false,
    trainLimits: Required<TrainLimitConfig>,
    targetScheduleId?: string,
  ) {
    const bookingIds = [...new Set(dto.bookingIds)];
    if (!bookingIds.length) {
      throw new BadRequestException('At least one booking is required');
    }

    const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
    const violations: string[] = [];
    const warnings: string[] = [];

    if (bookings.length !== bookingIds.length) {
      const foundIds = new Set(bookings.map((b) => b.id));
      violations.push(`Bookings not found: ${bookingIds.filter((id) => !foundIds.has(id)).join(', ')}`);
    }

    const scheduledLinks = await this.trainScheduleBookingsRepository.findByBookingIds(bookingIds);
    const conflictingLinks = targetScheduleId
      ? scheduledLinks.filter((link) => link.trainScheduleId !== targetScheduleId)
      : scheduledLinks;
    if (conflictingLinks.length > 0) {
      violations.push('One or more selected bookings are already assigned to a train schedule');
    }

    const bookingTypes = new Set(bookings.map((b) => b.freightType));
    const isMixed = bookingTypes.size > 1;
    const resolvedMode: 'CONTAINER' | 'BULK' | 'MIXED' =
      freightType ?? (isMixed ? 'MIXED' : ([...bookingTypes][0] as 'CONTAINER' | 'BULK'));

    if (freightType === 'CONTAINER' || freightType === 'BULK') {
      const wrongType = bookings.filter((b) => b.freightType !== freightType);
      if (wrongType.length) {
        violations.push(`Only ${freightType} bookings are supported`);
      }
    }

    const invalidStatus = bookings.filter(
      (b) =>
        !(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
        !SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') &&
        !b.isGovernment,
    );
    if (invalidStatus.length) {
      const statuses = [...new Set(invalidStatus.map((b) => b.status))];
      violations.push(
        `Only ${SCHEDULABLE_BOOKING_STATUSES.join(', ')} bookings can be scheduled; received: ${statuses.join(', ')}`,
      );
    }

    // Corridor-aware: a booking belongs on this train when its origin and
    // destination lie on the schedule's stop list in order — sub-corridor
    // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The
    // stop list is also what makes the wagon plan leg-aware below.
    let stops = [dto.originStationId, dto.destinationStationId];
    if (targetScheduleId) {
      const target = await this.trainSchedulesRepository.findById(targetScheduleId);
      if (target) stops = await this.stopYardsForSchedule(target);
    }
    if (
      bookings.some((b) => {
        if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
          return false;
        }
        const fromIdx = stops.indexOf(b.originYardId);
        const toIdx = stops.indexOf(b.destinationYardId);
        return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
      })
    ) {
      violations.push('Selected bookings must lie on the schedule route (origin before destination)');
    }

    // Day-match: a booking scheduled for a specific EAT day must board a train
    // departing that same day. forceAssign downgrades a mismatch to a warning
    // so staff can knowingly move a booking onto an adjacent-day train.
    const scheduleDay = eatDay(new Date(dto.scheduleDate));
    const dayMismatched = bookings.filter(
      (b) =>
        !(targetScheduleId && b.trainScheduleId === targetScheduleId) &&
        b.scheduledDate != null &&
        eatDay(new Date(b.scheduledDate)) !== scheduleDay,
    );
    if (dayMismatched.length) {
      const message = `Bookings scheduled for a different day than this train's departure (${scheduleDay}): ${dayMismatched
        .map((b) => b.reference ?? b.id)
        .join(', ')}`;
      if (forceAssign) {
        warnings.push(message);
      } else {
        violations.push(message);
      }
    }

    if (!forceAssign) {
      for (const booking of bookings) {
        if (this.isHoldActive(booking)) {
          warnings.push(
            `Booking ${booking.reference} is within the soft hold window (expires ${booking.holdExpiresAt?.toISOString()})`,
          );
        }
        // Overweight is the soft threshold (maxVgmTons): the customer already
        // paid the overweight surcharge at booking. The hard ceiling
        // (maxCapacityTons) blocks booking creation, so anything reaching
        // scheduling is shippable — warn the planner, never block allocation.
        const overweightLines = (booking.bookingContainers ?? []).filter((c) => c.isOverweight);
        if (overweightLines.length) {
          warnings.push(
            `Booking ${booking.reference} has ${overweightLines.length} overweight container line(s); overweight surcharge applied`,
          );
        }
      }
    }

    // Wagon-type resolution is list-based: each container/cargo type carries
    // the wagon types that can haul it, and the plan mixes wagon types within
    // one consist. A schedule created from a built train (Train Builder) plans
    // against ONLY that train's own wagons — full when every consist wagon is
    // allocated; legacy schedules plan against the boarding yards' pool.
    const allowed = await this.loadAllowedWagonTypes(bookings);
    const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId);

    const originYardId = dto.originStationId;
    let stock: WagonStock;
    if (builtTrainId) {
      stock = await this.builtTrainStock(builtTrainId);
    } else {
      // Dynamic consist: a slot's physical wagon may ride from the train's origin
      // OR already sit at the booking's own boarding yard and attach there — so
      // the usable fleet is the union across the origin and every boarding yard.
      const boardYardIds = [
        ...new Set(
          [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
        ),
      ];
      const fleetCountsByYard = await Promise.all(
        boardYardIds.map((yardId) =>
          this.countFleetAvailability(yardId, targetScheduleId),
        ),
      );
      const remainingByTypeId = new Map<string, number>();
      const codesByTypeId = new Map<string, string>();
      for (const rows of fleetCountsByYard) {
        for (const row of rows) {
          remainingByTypeId.set(
            row.wagonTypeId,
            (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available,
          );
          codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode);
        }
      }
      stock = { mode: 'YARD', remainingByTypeId, codesByTypeId };
    }

    // Leg-aware stock: each booking consumes wagons only on the edges it rides,
    // so a ride-along on an empty leg never competes with cargo on a full one.
    const legByBookingId = new Map(
      bookings.flatMap((b) => {
        const from = stops.indexOf(b.originYardId);
        const to = stops.indexOf(b.destinationYardId);
        return from >= 0 && to > from ? [[b.id, { from, to }] as const] : [];
      }),
    );
    const planned = planWagonsWithStock({
      bookings,
      allowed,
      stock,
      legs: legByBookingId,
      edgeCount: Math.max(1, stops.length - 1),
    });
    violations.push(...planned.configIssues);
    const fittingBookings = planned.fitting;
    const deferredBookings: DeferredBookingRow[] = planned.deferred;
    // Opt-in wagon-order reversal: flip the built plan's order (physically-last
    // wagon → position 1) BEFORE legs are stamped and the plan is persisted, so
    // the stored train order, allocations and snapshot all carry the reversed
    // order together. No-op unless the schedule set the flag.
    const wagonPlan = applyWagonOrderReversal(
      planned.plan,
      (dto as { reverseWagonOrder?: boolean }).reverseWagonOrder,
    );

    // Availability rows come from the BOUNDED plan — the one that actually
    // mixes wagon types against real stock. The old unbounded "pure demand"
    // plan had infinite stock of every allowed type, so its tie-break parked a
    // booking's ENTIRE need on one arbitrary type and produced false "Fleet
    // shortage: need 30 PW2" warnings for bookings the real plan fits fine by
    // mixing (e.g. 26 NW5 + 4 PW2). Genuine shortages still surface through
    // the deferred bookings' own shortage rows.
    const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability(
      planned.plan,
      stock.remainingByTypeId,
      stock.codesByTypeId,
    );
    warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings));
    this.stampSlotLegs(
      wagonPlan,
      fittingBookings,
      dto.originStationId,
      dto.destinationStationId,
    );

    violations.push(
      ...(await this.validatePhysicalFleetForPlan(
        wagonPlan,
        originYardId,
        targetScheduleId,
      )),
    );

    const placementRules = {
      max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
      max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
    };

    // With forceAssign, capacity-shaped rules (train limits, total weight,
    // locomotive capability) become warnings — staff owns the override. Physical
    // impossibilities (no wagon of the required type at the yard, wrong route,
    // wrong status) can never be forced and stay violations.
    const pushLimit = (issues: string[]) =>
      forceAssign ? warnings.push(...issues) : violations.push(...issues);

    // The plan can mix wagon types, so limit math always runs against the
    // distinct types actually planned (shortest length drives the wagon-count
    // fallback — validateMixedTrainLimits generalizes the single-type check).
    const plannedWagonTypes = [
      ...new Map(
        wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]),
      ).values(),
    ];
    pushLimit(
      validateMixedTrainLimitsPerEdge(
        wagonPlan,
        plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }],
        trainLimits,
        stops,
      ),
    );
    if (requireContainerPlacements && resolvedMode !== 'BULK') {
      const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
      violations.push(
        ...validateContainerPlacements(
          containerBookings,
          wagonPlan,
          containerPlacements,
          placementRules,
        ),
      );
      violations.push(
        ...(await this.validateFleetContainers(containerPlacements, containerBookings)),
      );
    }

    const totalWeightTons = totalAssignedWeight(fittingBookings);
    const totalTareTons = roundTons(
      wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
    );
    const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
    const totalLengthMeters = roundTons(
      wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
    );
    // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge
    // above — the whole-route totals here are informational (summary) only. The
    // locomotive checks below also compare the heaviest single edge: a train is
    // never heavier than its heaviest leg, so disjoint legs must not be summed.
    const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops);
    const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons);
    const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters);

    let assignedLocomotives: Locomotive[] = [];
    if (targetScheduleId) {
      const targetSchedule =
        await this.trainSchedulesRepository.findByIdWithFullGraph(targetScheduleId);
      assignedLocomotives = this.locomotivesOfTrainSet(targetSchedule?.trainSet);
    }

    if (assignedLocomotives.length) {
      // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
      // warning (it must arrive before dispatch), but a set too weak to pull the train
      // is a hard violation.
      const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
      const setLimits = minLocomotiveLimits(assignedLocomotives);
      if (offYard) {
        warnings.push(
          `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
        );
      }
      if (
        setLimits &&
        (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
          maxEdgeGrossTons ||
          setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
            maxEdgeLengthMeters)
      ) {
        pushLimit([
          'Assigned locomotives cannot support the total train weight and length',
        ]);
      }
    } else {
      const inServiceLocomotives = await this.locomotivesRepository.findAll({
        where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
      });
      if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
        warnings.push(
          'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
        );
      }
      if (
        !inServiceLocomotives.some(
          (l) =>
            Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
              maxEdgeGrossTons &&
            Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
              maxEdgeLengthMeters,
        )
      ) {
        pushLimit(['No locomotive can support the total train weight and length']);
      }
    }

    const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))];

    return {
      valid: violations.length === 0,
      violations,
      warnings,
      bookings: fittingBookings,
      wagonPlan,
      fleetAvailability,
      deferredBookings,
      summary: {
        totalBookings: fittingBookings.length,
        totalWeightTons,
        /** GROSS: cargo + the tare of every wagon in the plan. */
        grossWeightTons,
        totalTareTons,
        // Human-readable wagon type(s) of the plan — mixed consists list all.
        wagonType: plannedTypeCodes.join('/') || 'NONE',
        wagonsNeeded: wagonPlan.length,
        totalLengthMeters,
        freightMode: resolvedMode,
      },
    };
  }

  private async loadGlobalRulesRow(): Promise<TrainSchedulingGlobalRules | null> {
    try {
      const rows = await this.dataSource.getRepository(TrainSchedulingGlobalRules).find({
        order: { createdAt: 'ASC' },
        take: 1,
      });
      return rows[0] ?? null;
    } catch (err) {
      // A read failure here silently downgrades every booking window to the
      // hardcoded defaults (desk 8–17, duration 3h, lead 3) while the settings
      // UI keeps showing the saved row — a maddening mismatch. The usual cause
      // is a missing column (migrations not run on this database). Scream.
      this.logger.error(
        `Failed to read train-scheduling global rules — booking windows are ` +
          `running on HARDCODED DEFAULTS (8–17). Run pending migrations. ` +
          `Cause: ${(err as Error).message}`,
      );
      return null;
    }
  }

  private async resolveTrainLimitConfig(
    dto?: {
      maxTrainWeightTons?: number;
      maxTrainLengthMeters?: number;
      maxWagonsPerTrain?: number;
    },
    locomotive?: LocomotiveLimits | null,
  ): Promise<Required<TrainLimitConfig>> {
    const row = await this.loadGlobalRulesRow();
    const configured = this.configService?.get<{
      maxTrainWeightTons?: number;
      maxTrainLengthMeters?: number;
      maxWagonsPerTrain?: number;
    }>('app.trainScheduling');

    const ruleWeightCap =
      dto?.maxTrainWeightTons ??
      (row?.maxTrainWeightTons != null
        ? Number(row.maxTrainWeightTons)
        : configured?.maxTrainWeightTons);
    const ruleLengthCap =
      dto?.maxTrainLengthMeters ??
      (row?.maxTrainLengthMeters != null
        ? Number(row.maxTrainLengthMeters)
        : configured?.maxTrainLengthMeters);

    const wagonTypes = await this.loadSchedulingWagonTypeDimensions();

    if (locomotive) {
      // With a locomotive assigned its own limits are the single source of
      // truth — global-rules / env caps do not floor them (a mis-set global
      // row once capped every train at 14m). Only an explicit per-request dto
      // override still applies.
      const derived = deriveTrainCapacityFromLocomotive(
        {
          maxPullWeightTons: Number(locomotive.maxPullWeightTons),
          maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
          overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
          overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
        },
        wagonTypes,
        {
          maxTrainWeightTons: dto?.maxTrainWeightTons,
          maxTrainLengthMeters: dto?.maxTrainLengthMeters,
        },
      );
      return {
        maxWeightTons: derived.maxWeightTons,
        maxLengthMeters: derived.maxLengthMeters,
        maxWagonsPerTrain:
          dto?.maxWagonsPerTrain != null
            ? Math.floor(this.positiveNumber(dto.maxWagonsPerTrain, derived.maxWagonSlots))
            : derived.maxWagonSlots,
        max20ftContainerWeightTons: this.positiveNumber(
          undefined,
          Number(row?.max20ftContainerWeightTons) ||
            DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
        ),
        max20ftPairWeightDiffTons: this.positiveNumber(
          undefined,
          Number(row?.max20ftPairWeightDiffTons) ||
            DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
        ),
      };
    }

    const maxWeightTons = this.positiveNumber(
      dto?.maxTrainWeightTons,
      ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
    );
    const maxLengthMeters = this.positiveNumber(
      dto?.maxTrainLengthMeters,
      ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
    );
    const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
      { maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
      wagonTypes,
    );

    return {
      maxWeightTons,
      maxLengthMeters,
      maxWagonsPerTrain: Math.floor(
        this.positiveNumber(
          dto?.maxWagonsPerTrain,
          row?.maxWagonsPerTrain != null
            ? Number(row.maxWagonsPerTrain)
            : configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
        ),
      ),
      max20ftContainerWeightTons: this.positiveNumber(
        undefined,
        Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
      ),
      max20ftPairWeightDiffTons: this.positiveNumber(
        undefined,
        Number(row?.max20ftPairWeightDiffTons) ||
          DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
      ),
    };
  }

  /**
   * Every active wagon type: the slot count derives from the shortest wagon the
   * fleet can marshal, so sampling only NW5/CW3 would miss a shorter type (GW2 at
   * 12.228m) and under-report how many wagons the train length allows.
   */
  private async loadSchedulingWagonTypeDimensions(): 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,
      },
    ];
  }

  /**
   * Built train (Train Builder) behind a schedule's train set, if the schedule
   * was created by picking a train instead of loose locomotives.
   */
  private async builtTrainIdOfSchedule(
    scheduleId: string | undefined,
    manager?: EntityManager,
  ): Promise<string | null> {
    if (!scheduleId) return null;
    const runner = manager ?? this.dataSource;
    const rows: { train_id: string | null }[] = await runner.query(
      `SELECT tset.train_id
         FROM freight.train_schedules ts
         JOIN freight.train_sets tset ON tset.id = ts.train_set_id
        WHERE ts.id = $1`,
      [scheduleId],
    );
    return rows[0]?.train_id ?? null;
  }

  private async countFleetAvailability(
    originYardId: string,
    targetScheduleId?: string,
  ): Promise<Array<{ wagonTypeId: string; wagonTypeCode: string; available: number }>> {
    const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([
      this.dataSource.getRepository(Wagon).find(),
      this.dataSource.getRepository(WagonType).find(),
      this.builtTrainIdOfSchedule(targetScheduleId),
      this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
    ]);
    const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code]));
    const counts = new Map<string, { code: string; available: number }>();

    for (const wagon of wagons) {
      // Train-bound schedule: the built train's own consist IS the fleet — only
      // its wagons count (wherever they currently sit; they travel with the
      // train), and loose yard wagons never do.
      if (builtTrainId) {
        if (wagon.trainId !== builtTrainId) continue;
      } else {
        // Schedule-scoped availability: pins held by OTHER schedules never
        // consume a wagon here — the same physical wagon may serve the July 17
        // and the July 20 run. A wagon is unusable only when it is coupled to a
        // built train's consist, physically blocked, or out on a dispatched
        // train right now.
        const pinnedOnTarget = pinnedToTargetIds.has(wagon.id);
        if (wagon.trainId) continue;
        if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue;
        if (
          wagon.currentTrainScheduleId &&
          wagon.currentTrainScheduleId !== targetScheduleId
        ) {
          continue;
        }
        if (wagon.currentYardId !== originYardId) continue;
      }

      const typeId = wagon.wagonTypeId;
      const code = typeCodeById.get(typeId) ?? typeId;
      const existing = counts.get(typeId) ?? { code, available: 0 };
      existing.available += 1;
      counts.set(typeId, existing);
    }

    return [...counts.entries()].map(([wagonTypeId, value]) => ({
      wagonTypeId,
      wagonTypeCode: value.code,
      available: value.available,
    }));
  }

  /**
   * Freeze the schedule's live wagon plan into a snapshot. Built from the fully
   * hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
   * physical wagons, so the historical allocation survives those wagons being
   * re-pinned onto later trains. `capturedStatus` is the status being applied.
   */
  private buildWagonAllocationSnapshot(
    schedule: TrainSchedule,
    capturedStatus: TrainScheduleStatusEnum,
    capturedAt: Date,
  ): WagonAllocationSnapshot {
    const slots = [...(schedule.trainSet?.wagons ?? [])]
      .sort((a, b) => a.sequenceNo - b.sequenceNo)
      .map((wagon) => ({
        sequenceNo: wagon.sequenceNo,
        trainSetWagonId: wagon.id,
        physicalWagonId: wagon.physicalWagonId ?? null,
        physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
        wagonTypeId: wagon.wagonTypeId ?? null,
        wagonTypeCode: wagon.wagonType?.code ?? null,
        slotStatus: wagon.status ?? null,
        boardYardId: wagon.boardYardId ?? null,
        alightYardId: wagon.alightYardId ?? null,
        allocations: (wagon.allocations ?? []).map((allocation) => ({
          bookingId: allocation.bookingId,
          bookingReference: allocation.booking?.reference ?? null,
          allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
          loadType: allocation.loadType ?? null,
          containerNumbers: (allocation.containerItems ?? [])
            .map((item) => item.containerNumber)
            .filter((n): n is string => Boolean(n)),
        })),
      }));

    return {
      capturedStatus,
      capturedAt: capturedAt.toISOString(),
      slots,
    };
  }

  /**
   * A wagon in a blocked physical state can never be planned or pinned.
   * ASSIGNED no longer blocks: it only means the wagon is coupled to a built
   * train or stamped by a live run — schedule-level occupancy is tracked on
   * the schedule's own TrainSetWagon slots, never on the Wagon entity.
   */
  private isWagonPhysicallyUsable(wagon: Wagon): boolean {
    return (
      wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned
    );
  }

  /**
   * Physical wagons already pinned to THIS schedule's slots. Availability is
   * schedule-scoped: only a duplicate pin within the same schedule conflicts;
   * pins held by other schedules of the same train are irrelevant.
   */
  private async pinnedPhysicalWagonIdsForSchedule(
    scheduleId: string | undefined,
    manager?: EntityManager,
  ): Promise<Set<string>> {
    if (!scheduleId) return new Set();
    const runner = manager ?? this.dataSource;
    const rows: { physical_wagon_id: string }[] = await runner.query(
      `SELECT tsw.physical_wagon_id
         FROM freight.train_set_wagons tsw
         JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
        WHERE ts.id = $1
          AND ts.deleted_at IS NULL
          AND tsw.deleted_at IS NULL
          AND tsw.physical_wagon_id IS NOT NULL`,
      [scheduleId],
    );
    return new Set(rows.map((row) => row.physical_wagon_id));
  }

  /**
   * Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED)
   * schedule. Used to guard consist trims — the Wagon entity itself carries no
   * schedule-occupancy state anymore.
   */
  private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise<Set<string>> {
    const runner = manager ?? this.dataSource;
    const rows: { physical_wagon_id: string }[] = await runner.query(
      `SELECT DISTINCT tsw.physical_wagon_id
         FROM freight.train_set_wagons tsw
         JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
        WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
          AND ts.deleted_at IS NULL
          AND tsw.deleted_at IS NULL
          AND tsw.physical_wagon_id IS NOT NULL`,
    );
    return new Set(rows.map((row) => row.physical_wagon_id));
  }

  private async autoPinWagonsForSchedule(
    manager: EntityManager,
    scheduleId: string,
    originYardId: string,
    slots: TrainSetWagon[],
    reverseWagonOrder = false,
  ) {
    const wagons = await manager.getRepository(Wagon).find();
    const wagonTypes = await manager.getRepository(WagonType).find();
    const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager);
    const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule(
      scheduleId,
      manager,
    );
    const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code]));

    const planSlots = [...slots]
      .sort((a, b) => a.sequenceNo - b.sequenceNo)
      .map((slot) => ({
        sequenceNo: slot.sequenceNo,
        wagonTypeId: slot.wagonTypeId,
        wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
        trainSetWagonId: slot.id,
        boardYardId: slot.boardYardId ?? null,
      }));

    const unpinnable = this.findUnpinnableWagonSlots(
      planSlots,
      wagons,
      scheduleId,
      originYardId,
      builtTrainId,
      pinnedToScheduleIds,
    );
    if (unpinnable.length) {
      throw new BadRequestException({
        message: 'Insufficient physical wagons to pin all train slots',
        violations: unpinnable,
      });
    }

    const assignedPhysicalIds = new Set<string>();
    for (const slot of planSlots) {
      const physical = this.pickPhysicalWagonForSlot(
        slot,
        wagons,
        scheduleId,
        originYardId,
        assignedPhysicalIds,
        builtTrainId,
        pinnedToScheduleIds,
        reverseWagonOrder,
      );
      if (!physical) continue;

      // Pin lives ONLY on the schedule's own slot — the Wagon entity is never
      // touched here, so the same physical wagon stays free for every other
      // schedule (it gets stamped at dispatch, when it physically leaves).
      await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, {
        physicalWagonId: physical.id,
        status: 'RESERVED',
      });
      assignedPhysicalIds.add(physical.id);
    }
  }

  /** Pre-assign check: every planned slot must have a matching physical wagon. */
  private async validatePhysicalFleetForPlan(
    wagonPlan: WagonPlanSlot[],
    originYardId: string,
    targetScheduleId?: string,
  ): Promise<string[]> {
    if (!wagonPlan.length) return [];

    const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([
      this.dataSource.getRepository(Wagon).find(),
      this.builtTrainIdOfSchedule(targetScheduleId),
      this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId),
    ]);
    return this.findUnpinnableWagonSlots(
      wagonPlan.map((slot) => ({
        sequenceNo: slot.sequenceNo,
        wagonTypeId: slot.wagonTypeId,
        wagonTypeCode: slot.wagonTypeCode,
        boardYardId: slot.boardYardId ?? null,
      })),
      wagons,
      targetScheduleId,
      originYardId,
      builtTrainId,
      pinnedToScheduleIds,
    );
  }

  private findUnpinnableWagonSlots(
    slots: Array<{
      sequenceNo: number;
      wagonTypeId: string;
      wagonTypeCode: string;
      boardYardId?: string | null;
    }>,
    wagons: Wagon[],
    scheduleId: string | undefined,
    originYardId: string,
    builtTrainId: string | null = null,
    pinnedToScheduleIds: Set<string> = new Set(),
  ): string[] {
    const violations: string[] = [];
    const assignedPhysicalIds = new Set<string>();

    for (const slot of [...slots].sort((a, b) => a.sequenceNo - b.sequenceNo)) {
      const physical = this.pickPhysicalWagonForSlot(
        slot,
        wagons,
        scheduleId,
        originYardId,
        assignedPhysicalIds,
        builtTrainId,
        pinnedToScheduleIds,
      );
      if (!physical) {
        violations.push(
          `No ${slot.wagonTypeCode} wagon available at yard for slot #${slot.sequenceNo}`,
        );
        continue;
      }
      assignedPhysicalIds.add(physical.id);
    }

    return violations;
  }

  /**
   * Dynamic consist: a slot's wagon may either ride from the train's origin
   * yard (attaching there, possibly empty until the slot's board yard) or
   * already sit AT the slot's board yard and hook on when the train arrives.
   */
  private pickPhysicalWagonForSlot(
    slot: { wagonTypeId: string; boardYardId?: string | null },
    wagons: Wagon[],
    scheduleId: string | undefined,
    originYardId: string,
    assignedPhysicalIds: Set<string>,
    builtTrainId: string | null = null,
    pinnedToScheduleIds: Set<string> = new Set(),
    reverseWagonOrder = false,
  ): Wagon | undefined {
    const usable = (wagon: Wagon): boolean => {
      if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
      if (assignedPhysicalIds.has(wagon.id)) return false;
      // Loose pool never lends a wagon coupled to a built train's consist.
      if (wagon.trainId) return false;
      // Out on a dispatched train right now — physically gone.
      if (
        wagon.currentTrainScheduleId &&
        wagon.currentTrainScheduleId !== scheduleId
      ) {
        return false;
      }
      const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id);
      return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule;
    };
    // Train-bound schedule: ONLY the built train's own wagons may be pinned —
    // wherever they currently sit (they travel with the train), never a loose
    // yard wagon.
    if (builtTrainId) {
      // Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
      // consist views draw the schedule exactly like the train builder; a schedule
      // created with reverseWagonOrder pins back-to-front (physically-last wagon
      // takes slot #1). Unsequenced wagons sort after every sequenced one.
      const candidates = wagons
        .filter(
          (w) =>
            w.trainId === builtTrainId &&
            w.wagonTypeId === slot.wagonTypeId &&
            !assignedPhysicalIds.has(w.id),
        )
        .sort((a, b) => {
          if (a.sequenceNumber == null || b.sequenceNumber == null) {
            return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
          }
          return reverseWagonOrder
            ? b.sequenceNumber - a.sequenceNumber
            : a.sequenceNumber - b.sequenceNumber;
        });
      return candidates[0];
    }
    // Prefer a wagon already waiting at the slot's board yard (no empty haul);
    // fall back to one riding from the train's origin.
    if (slot.boardYardId) {
      const atBoardYard = wagons.find(
        (w) => usable(w) && w.currentYardId === slot.boardYardId,
      );
      if (atBoardYard) return atBoardYard;
    }
    return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
  }

  private positiveNumber(value: number | undefined, fallback: number): number {
    const numeric = Number(value);
    return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
  }

  private async validateFleetContainers(
    placements: ContainerPlacementInput[],
    containerBookings: Booking[],
  ): Promise<string[]> {
    const violations: string[] = [];
    const inventoryIds = [
      ...new Set(placements.map((p) => p.containerId).filter((id): id is string => Boolean(id))),
    ];
    if (!inventoryIds.length) return violations;

    const lineById = new Map(
      containerBookings.flatMap((b) =>
        (b.bookingContainers ?? []).map((line) => [line.id, line] as const),
      ),
    );

    const containers = await this.dataSource.getRepository(Container).find({
      where: { id: In(inventoryIds) },
    });
    const containerById = new Map(containers.map((c) => [c.id, c]));

    for (const placement of placements) {
      if (!placement.containerId) continue;
      const fleet = containerById.get(placement.containerId);
      if (!fleet) {
        violations.push(`Fleet container ${placement.containerId} not found`);
        continue;
      }
      if (fleet.status !== 'AVAILABLE') {
        violations.push(`Container ${fleet.containerNumber} is not available`);
      }
      const line = lineById.get(placement.bookingContainerId);
      if (line && fleet.containerTypeId !== line.containerTypeId) {
        violations.push(
          `Container ${fleet.containerNumber} type does not match booking line`,
        );
      }
      if (
        placement.containerNumber &&
        fleet.containerNumber.toUpperCase() !== placement.containerNumber.trim().toUpperCase()
      ) {
        violations.push(
          `Container number ${placement.containerNumber} does not match fleet record ${fleet.containerNumber}`,
        );
      }
    }

    return violations;
  }

  /**
   * Resolve the wagon type for a batch through the cargo-type / container-type
   * `wagon_type_id` FK (replaces the former load-type string matching). Throws
   * when the relevant type has no wagon type configured — scheduling is blocked
   * until an admin assigns one on the cargo-type / container-type config screen.
   */
  /**
   * Wagon types allowed to carry each container/cargo type on these bookings.
   * The many-to-many configuration lists (active wagon types only), keyed by
   * type id — the flexible planner mixes wagon types within one consist.
   * Bookings must arrive from findByIdsForScheduling so the `wagonTypes`
   * relations are loaded.
   */
  private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap {
    const byContainerTypeId = new Map<string, WagonType[]>();
    const byCargoTypeId = new Map<string, WagonType[]>();
    const active = (list?: WagonType[] | null) =>
      (list ?? []).filter((wt) => wt.isActive !== false);

    for (const booking of bookings) {
      for (const line of booking.bookingContainers ?? []) {
        const containerType = line.containerType;
        if (containerType && !byContainerTypeId.has(containerType.id)) {
          byContainerTypeId.set(containerType.id, active(containerType.wagonTypes));
        }
      }
      const cargoType = booking.cargoType;
      if (cargoType && !byCargoTypeId.has(cargoType.id)) {
        byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes));
      }
    }
    return { byContainerTypeId, byCargoTypeId };
  }

  /**
   * TRAIN-mode wagon stock: the built train's own consist, grouped by wagon
   * type. This is the whole plannable pool for its schedules — the plan is
   * full when every consist wagon is allocated.
   */
  private async builtTrainStock(builtTrainId: string): Promise<WagonStock> {
    const wagons = await this.dataSource.getRepository(Wagon).find({
      where: { trainId: builtTrainId },
      relations: { wagonType: true },
    });
    const remainingByTypeId = new Map<string, number>();
    const codesByTypeId = new Map<string, string>();
    for (const wagon of wagons) {
      remainingByTypeId.set(
        wagon.wagonTypeId,
        (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1,
      );
      if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code);
    }
    return { mode: 'TRAIN', remainingByTypeId, codesByTypeId };
  }

  /**
   * Stamp each plan slot with the leg it occupies (dynamic consist): the
   * boarding/alighting yards of the bookings it carries. Null means the
   * schedule's own endpoint (whole-route slot, legacy behavior). A slot
   * carrying bookings with mixed corridors stays whole-route (conservative).
   */
  private stampSlotLegs(
    wagonPlan: WagonPlanSlot[],
    bookings: Booking[],
    scheduleOriginYardId: string,
    scheduleDestinationYardId: string,
  ): void {
    const bookingById = new Map(bookings.map((b) => [b.id, b]));
    for (const slot of wagonPlan) {
      const slotBookings = [
        ...new Set(slot.allocations.map((a) => a.bookingId)),
      ]
        .map((id) => bookingById.get(id))
        .filter((b): b is Booking => Boolean(b));
      if (!slotBookings.length) continue;
      const [first] = slotBookings;
      const sameCorridor = slotBookings.every(
        (b) =>
          b.originYardId === first.originYardId &&
          b.destinationYardId === first.destinationYardId,
      );
      if (!sameCorridor) continue;
      slot.boardYardId =
        first.originYardId === scheduleOriginYardId ? null : first.originYardId;
      slot.alightYardId =
        first.destinationYardId === scheduleDestinationYardId
          ? null
          : first.destinationYardId;
    }
  }

  private async persistTrainSetWagons(
    manager: EntityManager,
    trainSetId: string,
    wagonPlan: WagonPlanSlot[],
  ) {
    const wagons = wagonPlan.map((slot) =>
      manager.getRepository(TrainSetWagon).create({
        trainSetId,
        wagonTypeId: slot.wagonTypeId,
        sequenceNo: slot.sequenceNo,
        capacityTons: slot.capacityTons,
        lengthMeters: slot.lengthMeters,
        assignedWeightTons: slot.assignedWeightTons,
        status: 'PLANNED',
        boardYardId: slot.boardYardId ?? null,
        alightYardId: slot.alightYardId ?? null,
      }),
    );
    return manager.getRepository(TrainSetWagon).save(wagons);
  }

  private async persistAllocationsAndLoads(
    manager: EntityManager,
    savedWagons: TrainSetWagon[],
    wagonPlan: WagonPlanSlot[],
    bookings: Booking[],
    containerPlacements: ContainerPlacementInput[] = [],
  ) {
    const bookingById = new Map(bookings.map((b) => [b.id, b]));
    const lineById = new Map(
      bookings.flatMap((b) =>
        (b.bookingContainers ?? []).map((line) => [line.id, { line, bookingId: b.id }] as const),
      ),
    );
    const allocationBySlotBooking = new Map<string, string>();

    const containerItems: Array<{
      wagonBookingAllocationId: string;
      bookingContainerId: string;
      containerTypeId: string | null;
      grossWeightTons: number;
      positionOnWagon: number | null;
      containerId?: string | null;
      containerNumber?: string | null;
      sealNumber?: string | null;
    }> = [];
    const bulkLoads: Array<{
      wagonBookingAllocationId: string;
      bookingId: string;
      cargoTypeId: string | null;
      cargoDescription: string | null;
      weightTons: number;
      quantity: number;
    }> = [];

    for (let i = 0; i < savedWagons.length; i += 1) {
      const slot = wagonPlan[i];
      const trainSetWagon = savedWagons[i];
      if (!slot || !trainSetWagon) continue;

      for (const alloc of slot.allocations) {
        const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
          manager.getRepository(WagonBookingAllocation).create({
            trainSetWagonId: trainSetWagon.id,
            bookingId: alloc.bookingId,
            allocatedWeightTons: alloc.allocatedWeightTons,
            loadType: alloc.loadType,
            status: 'PLANNED',
          }),
        );

        allocationBySlotBooking.set(
          `${slot.sequenceNo}:${alloc.bookingId}`,
          savedAllocation.id,
        );

        const booking = bookingById.get(alloc.bookingId);
        if (!booking) continue;

        if (alloc.loadType === AllocationLoadType.Bulk) {
          bulkLoads.push({
            wagonBookingAllocationId: savedAllocation.id,
            bookingId: booking.id,
            cargoTypeId: booking.cargoTypeId ?? null,
            cargoDescription: booking.cargoFreeText ?? null,
            weightTons: alloc.allocatedWeightTons,
            quantity: 1,
          });
        }
      }
    }

    for (const placement of containerPlacements) {
      const lineEntry = lineById.get(placement.bookingContainerId);
      if (!lineEntry) continue;

      // Durably persist the container number on the booking container line first, so it
      // survives a refresh regardless of whether a wagon allocation slot can be matched
      // below. booking_container is the source of truth re-read into the preview units.
      if (placement.containerNumber && placement.containerNumber.trim()) {
        await manager.getRepository(BookingContainer).update(placement.bookingContainerId, {
          containerNumber: placement.containerNumber.trim(),
        });
      }

      const allocationId = allocationBySlotBooking.get(
        `${placement.sequenceNo}:${lineEntry.bookingId}`,
      );
      if (!allocationId) continue;

      const { line } = lineEntry;
      containerItems.push({
        wagonBookingAllocationId: allocationId,
        bookingContainerId: placement.bookingContainerId,
        containerTypeId: line.containerTypeId ?? null,
        grossWeightTons: Number(line.vgmPerUnitTons),
        positionOnWagon: placement.unitIndex + 1,
        containerId: placement.containerId ?? null,
        containerNumber: placement.containerNumber?.trim() ?? null,
        sealNumber: placement.sealNumber ?? null,
      });

      if (placement.containerId) {
        await manager.getRepository(Container).update(placement.containerId, {
          status: 'LOADED',
          bookingId: lineEntry.bookingId,
          wagonBookingAllocationId: allocationId,
          bookingContainerId: placement.bookingContainerId,
        });
      }
    }

    if (containerItems.length) {
      await this.wagonAllocationContainerItemsRepository.createMany(containerItems, manager);
    }
    if (bulkLoads.length) {
      await this.wagonAllocationBulkLoadsRepository.createMany(bulkLoads, manager);
    }
  }

  /**
   * All locomotives attached to a loaded train set. Prefers the `locomotives`
   * link rows; falls back to the legacy single `locomotive` for train sets
   * created before multi-loco support.
   */
  private locomotivesOfTrainSet(
    trainSet: TrainSet | null | undefined,
  ): Locomotive[] {
    if (!trainSet) return [];
    const linked = (trainSet.locomotives ?? [])
      .map((link) => link.locomotive)
      .filter((loco): loco is Locomotive => Boolean(loco));
    if (linked.length) return linked;
    return trainSet.locomotive ? [trainSet.locomotive] : [];
  }

  /**
   * Locomotive ids (among the given ones) that are attached to a DISPATCHED train
   * other than `excludeScheduleId`. Covers both the multi-loco link rows and the
   * legacy single-locomotive column on the train set.
   */
  private async findLocomotiveIdsDispatchedElsewhere(
    locomotiveIds: string[],
    excludeScheduleId: string,
    manager?: EntityManager,
  ): Promise<Set<string>> {
    if (!locomotiveIds.length) return new Set();
    const runner = manager ?? this.dataSource;
    const rows: { locomotive_id: string }[] = await runner.query(
      `SELECT DISTINCT loco.locomotive_id
         FROM freight.train_schedules ts
         JOIN freight.train_sets tset ON tset.id = ts.train_set_id
         JOIN (
           SELECT tsl.train_set_id, tsl.locomotive_id
             FROM freight.train_set_locomotives tsl
            WHERE tsl.deleted_at IS NULL
           UNION
           SELECT t.id AS train_set_id, t.locomotive_id
             FROM freight.train_sets t
            WHERE t.locomotive_id IS NOT NULL
         ) loco ON loco.train_set_id = tset.id
        WHERE ts.status = 'DISPATCHED'
          AND ts.deleted_at IS NULL
          AND ts.id <> $1
          AND loco.locomotive_id = ANY($2)`,
      [excludeScheduleId, locomotiveIds],
    );
    return new Set(rows.map((r) => r.locomotive_id));
  }

  private async assertLocomotivesNotDispatchedElsewhere(
    locomotiveIds: string[],
    excludeScheduleId: string,
  ): Promise<void> {
    const busy = await this.findLocomotiveIdsDispatchedElsewhere(
      locomotiveIds,
      excludeScheduleId,
    );
    if (!busy.size) return;
    const locos = await this.dataSource
      .getRepository(Locomotive)
      .find({ where: { id: In([...busy]) } });
    const codes = locos.map((l) => l.code).join(', ');
    throw new ConflictException(
      `Locomotive(s) ${codes} are currently out on another dispatched train`,
    );
  }

  async selectOrValidateLocomotive(
    locomotiveId: string,
    totalWeightTons: number,
    totalLengthMeters: number,
  ) {
    const locomotive = await this.locomotivesRepository.findById(locomotiveId);
    if (!locomotive) {
      throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
    }
    if (locomotive.status !== 'AVAILABLE') {
      throw new BadRequestException(`Locomotive ${locomotive.code} is not available`);
    }
    if (
      Number(locomotive.maxPullWeightTons) + (Number(locomotive.overageToleranceTons) || 0) <
      totalWeightTons
    ) {
      throw new BadRequestException(`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`);
    }
    if (
      Number(locomotive.maxTrainLengthMeters) +
        (Number(locomotive.overageToleranceMeters) || 0) <
      totalLengthMeters
    ) {
      throw new BadRequestException(
        `Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
      );
    }
    return locomotive;
  }

  private async buildEmptyTrainSet(
    manager: EntityManager,
    locomotives: Locomotive[],
    builtTrainId: string | null = null,
  ) {
    const [primary] = locomotives;
    const trainSet = manager.getRepository(TrainSet).create({
      // `locomotiveId` retained as the primary locomotive for single-loco read paths.
      locomotiveId: primary.id,
      // Built fleet train this set was formed from (Train Builder), if any.
      trainId: builtTrainId,
      totalWeightTons: 0,
      totalLengthMeters: 0,
      wagonCount: 0,
      status: 'DRAFT',
    });
    const saved = await manager.getRepository(TrainSet).save(trainSet);

    const links = locomotives.map((loco, index) =>
      manager.getRepository(TrainSetLocomotive).create({
        trainSetId: saved.id,
        locomotiveId: loco.id,
        sequenceNo: index,
      }),
    );
    await manager.getRepository(TrainSetLocomotive).save(links);

    return saved;
  }

  private async getSchedulableRoute(routeId: string) {
    const route = await this.dataSource.getRepository(Route).findOne({
      where: { id: routeId },
      relations: { originYard: true, destinationYard: true },
    });
    if (!route) throw new NotFoundException(`Route ${routeId} not found`);
    if (route.status !== 'AVAILABLE') {
      throw new BadRequestException(
        `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
      );
    }
    // Intercity (same-country) service is not offered yet — only import/export
    // trains can be scheduled.
    if (this.resolveRouteDirection(route) === 'DOMESTIC') {
      throw new BadRequestException(
        `Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
      );
    }
    return route;
  }

  /** Stored route direction, deriving from yard countries for pre-migration rows. */
  private resolveRouteDirection(route: Route) {
    return (
      route.direction ??
      deriveScheduleDirection(
        route.originYard ?? { country: null },
        route.destinationYard ?? { country: null },
      )
    );
  }

  private mapEligibleBooking(
    booking: Booking,
    tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
  ) {
    return {
      id: booking.id,
      reference: booking.reference,
      freightType: booking.freightType,
      customer: booking.company?.name ?? booking.company?.email ?? 'Unknown customer',
      priorityScore: booking.priorityScore,
      schedulingStatus: booking.schedulingStatus,
      containerType:
        booking.bookingContainers
          ?.map((c) => c.containerType?.label ?? c.containerType?.code ?? 'Container')
          .join(', ') ?? (booking.cargoType?.cargoTypeName ?? 'Bulk'),
      quantity:
        booking.bookingContainers?.reduce((sum, c) => sum + Number(c.quantity ?? 0), 0) ?? 0,
      // GROSS: cargo + tare of the wagons the booking occupies.
      weightTons: this.grossBookingWeightTons(booking, tareDims),
      origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
      destination:
        booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
      preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
      status: booking.status,
    };
  }

  /**
   * WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
   * of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
   * BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
   * Schedules with no bookings (type null) match nothing. Applied to `id` so
   * the list query stays on findAndCount instead of a query-builder rewrite.
   */
  private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
    const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
      'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
      'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
      `WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
      `AND b.freight_type ${cmp} :${param})`;
    if (freightType === 'MIXED') {
      return Raw(
        (alias) =>
          `${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
        { ftContainer: 'CONTAINER', ftBulk: 'BULK' },
      );
    }
    return Raw(
      (alias) =>
        `${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
      { ftIs: freightType, ftNot: freightType },
    );
  }

  private resolveScheduleFreightType(
    schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
  ): 'CONTAINER' | 'BULK' | 'MIXED' | null {
    const types = new Set(
      (schedule.scheduleBookings ?? [])
        .map((sb) => sb.booking?.freightType)
        .filter((t): t is string => Boolean(t)),
    );
    if (types.size === 1) return [...types][0] as 'CONTAINER' | 'BULK';
    if (types.size > 1) return 'MIXED';
    return null;
  }

  /**
   * Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
   * past a concurrent insert that grabbed the same sequence (the unique index
   * rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
   * runs inside the caller's transaction manager so the row joins the same commit.
   */
  private async insertScheduleWithReference(
    manager: EntityManager,
    build: (reference: string) => TrainSchedule,
  ): Promise<TrainSchedule> {
    const year = new Date().getFullYear();
    const repo = manager.getRepository(TrainSchedule);
    for (let attempt = 0; attempt < 5; attempt += 1) {
      const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
      const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
      try {
        return await repo.save(build(reference));
      } catch (err) {
        // 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
        const code = (err as { driverError?: { code?: string } })?.driverError?.code;
        if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
          continue;
        }
        throw err;
      }
    }
    // Unreachable — the loop either returns or throws — but satisfies the compiler.
    throw new ConflictException('Could not allocate a unique schedule reference');
  }

  private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
    return {
      id: schedule.id,
      reference: schedule.reference ?? null,
      createdAt: schedule.createdAt ?? null,
      scheduleDate: schedule.scheduledDepartureDate,
      trainNumber: schedule.trainNumber ?? null,
      direction: schedule.direction ?? null,
      routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
      origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
      destination:
        schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
      // Built train (Train Builder) behind this departure, when scheduled by train.
      train: schedule.trainSet?.train
        ? {
            id: schedule.trainSet.train.id,
            code: schedule.trainSet.train.code,
            trainName: schedule.trainSet.train.trainName ?? null,
          }
        : null,
      locomotive: schedule.trainSet?.locomotive
        ? {
            id: schedule.trainSet.locomotive.id,
            code: schedule.trainSet.locomotive.code,
            name: schedule.trainSet.locomotive.name ?? null,
            currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
          }
        : null,
      locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
        id: loco.id,
        code: loco.code,
        name: loco.name ?? null,
        currentYardId: loco.currentYardId ?? null,
      })),
      wagonCount: schedule.trainSet?.wagonCount ?? 0,
      totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)),
      totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)),
      bookingsCount: schedule.scheduleBookings?.length ?? 0,
      freightType: this.resolveScheduleFreightType(schedule),
      status: schedule.status,
      bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
      maxWagons: schedule.maxWagons ?? 0,
      remainingWagons: Math.max(
        0,
        (schedule.maxWagons ?? 0) - (schedule.trainSet?.wagonCount ?? 0),
      ),
    };
  }

  /** AVAILABLE locomotives at the route's origin yard. */
  /**
   * All in-service locomotives, annotated for the schedule-creation picker.
   * Advance scheduling means nothing is filtered out — staff see status, whether the
   * locomotive is at the origin yard yet, and how many future schedules it already has.
   */
  async getAvailableLocomotivesForRoute(routeId: string) {
    const route = await this.getSchedulableRoute(routeId);

    const locomotives = await this.locomotivesRepository.findAll({
      where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
      order: { code: 'ASC' },
    });

    const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
      ? await this.dataSource.query(
          `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
             FROM freight.train_schedules ts
             JOIN freight.train_sets tset ON tset.id = ts.train_set_id
             JOIN (
               SELECT tsl.train_set_id, tsl.locomotive_id
                 FROM freight.train_set_locomotives tsl
                WHERE tsl.deleted_at IS NULL
               UNION
               SELECT t.id AS train_set_id, t.locomotive_id
                 FROM freight.train_sets t
                WHERE t.locomotive_id IS NOT NULL
             ) loco ON loco.train_set_id = tset.id
            WHERE ts.status IN ('DRAFT', 'SCHEDULED')
              AND ts.deleted_at IS NULL
              AND loco.locomotive_id = ANY($1)
            GROUP BY loco.locomotive_id`,
          [locomotives.map((l) => l.id)],
        )
      : [];
    const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));

    return locomotives.map((loco) => ({
      ...loco,
      atOriginYard: loco.currentYardId === route.originYardId,
      futureScheduleCount: futureCounts.get(loco.id) ?? 0,
    }));
  }

  /**
   * All schedulable built trains (Train Builder), annotated for the
   * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling
   * philosophy: nothing serviceable is filtered out — staff see the status,
   * whether the train sits at the origin yard yet, and its future schedules.
   * Trains with fewer than two locomotives are omitted (never schedulable).
   */
  async getAvailableTrainsForRoute(routeId: string) {
    const route = await this.getSchedulableRoute(routeId);

    const trains = await this.dataSource.getRepository(Train).find({
      where: {
        status: Not(
          In([
            Freight.TrainStatus.OutOfService,
            Freight.TrainStatus.UnderMaintenance,
            Freight.TrainStatus.Deactivated,
          ]),
        ),
      },
      relations: {
        currentYard: true,
        locomotives: { locomotive: true },
        wagons: { wagonType: true },
      },
      order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } },
    });

    const counts: { train_id: string; future_count: string }[] = trains.length
      ? await this.dataSource.query(
          `SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count
             FROM freight.train_schedules ts
             JOIN freight.train_sets tset ON tset.id = ts.train_set_id
            WHERE ts.status IN ('DRAFT', 'SCHEDULED')
              AND ts.deleted_at IS NULL
              AND tset.train_id = ANY($1)
            GROUP BY tset.train_id`,
          [trains.map((t) => t.id)],
        )
      : [];
    const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)]));

    return trains
      .filter((train) => (train.locomotives ?? []).length >= 2)
      .map((train) => {
        const wagons = train.wagons ?? [];
        return {
          id: train.id,
          code: train.code,
          trainName: train.trainName ?? null,
          status: train.status,
          importTrainNumber: train.importTrainNumber ?? null,
          exportTrainNumber: train.exportTrainNumber ?? null,
          currentYardId: train.currentYardId ?? null,
          currentYard: train.currentYard
            ? {
                id: train.currentYard.id,
                code: train.currentYard.code,
                label: train.currentYard.label,
              }
            : null,
          locomotives: (train.locomotives ?? [])
            .filter((link) => link.locomotive)
            .map((link) => ({
              id: link.locomotive!.id,
              code: link.locomotive!.code,
              name: link.locomotive!.name ?? null,
            })),
          wagonCount: wagons.length,
          totalTareTons: roundTons(
            wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
          ),
          totalLengthMeters: roundTons(
            wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
          ),
          maxPullWeightTons: roundTons(Number(train.capacityTons)),
          atOriginYard: train.currentYardId === route.originYardId,
          futureScheduleCount: futureCounts.get(train.id) ?? 0,
        };
      });
  }

  /**
   * Consist snapshot for the adjust-consist UI: the built train's wagons with
   * loaded/removable flags, gross weight (cargo + FULL consist tare) and length
   * against the locomotive limits incl. overage tolerance, addable yard wagons,
   * and the adjustment history.
   */
  async getScheduleConsist(scheduleId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    const builtTrain = schedule.trainSet?.train;
    if (!builtTrain) {
      throw new BadRequestException(
        'This schedule was not created from a built train — its consist cannot be adjusted here',
      );
    }

    const wagons = await this.dataSource.getRepository(Wagon).find({
      where: { trainId: builtTrain.id },
      relations: { wagonType: true },
      order: { sequenceNumber: 'ASC' },
    });
    const addableWagons = await this.dataSource.getRepository(Wagon).find({
      where: {
        trainId: IsNull(),
        status: WagonStatus.Available,
        currentYardId: builtTrain.currentYardId ?? undefined,
      },
      relations: { wagonType: true },
      order: { wagonNumber: 'ASC' },
    });
    const adjustments = await this.dataSource
      .getRepository(ScheduleWagonAdjustmentLog)
      .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 });

    // Slots with cargo aboard — their physical wagons are "loaded" and can
    // never be trimmed.
    const loadedWagonIds = new Set(
      (schedule.trainSet?.wagons ?? [])
        .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
        .map((slot) => slot.physicalWagonId as string),
    );
    const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules();

    const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
    const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0));
    const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0);
    const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0));
    const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0);

    const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
    const consistTareTons = roundTons(
      wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0),
    );
    const consistLengthMeters = roundTons(
      wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
    );

    // Wagon-slot picture for the dialog: the consist IS the schedule's booking
    // capacity, so trimming/coupling wagons moves the FULL line live.
    const wagonUsage =
      (await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;

    const mapWagon = (wagon: Wagon) => ({
      id: wagon.id,
      wagonNumber: wagon.wagonNumber,
      sequenceNumber: wagon.sequenceNumber,
      wagonType: wagon.wagonType
        ? {
            id: wagon.wagonType.id,
            code: wagon.wagonType.code,
            tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)),
            capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)),
            lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)),
          }
        : null,
    });

    return {
      schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status },
      train: {
        id: builtTrain.id,
        code: builtTrain.code,
        trainName: builtTrain.trainName ?? null,
        currentYardId: builtTrain.currentYardId ?? null,
      },
      limits: {
        maxPullWeightTons,
        overageToleranceTons,
        pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons),
        maxTrainLengthMeters,
        overageToleranceMeters,
        lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters),
      },
      totals: {
        wagonCount: wagons.length,
        cargoTons,
        consistTareTons,
        grossTons: roundTons(cargoTons + consistTareTons),
        consistLengthMeters,
      },
      scheduleCapacity: wagonUsage
        ? {
            ...wagonUsage,
            bookingWindowStatus: schedule.bookingWindowStatus ?? null,
          }
        : null,
      wagons: wagons.map((wagon) => ({
        ...mapWagon(wagon),
        loaded: loadedWagonIds.has(wagon.id),
        // Free = not pinned to any live run's slot; only free wagons can be trimmed.
        removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id),
      })),
      addableWagons: addableWagons.map(mapWagon),
      adjustments: adjustments.map((log) => ({
        id: log.id,
        action: log.action,
        wagonId: log.wagonId,
        wagonNumber: log.wagonNumber,
        adjustedByUserId: log.adjustedByUserId,
        occurredAt: log.occurredAt,
      })),
      editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status),
    };
  }

  /**
   * Permanently adjust the built train's consist from a schedule: trim free
   * wagons (their tare no longer rides — the usual fix when gross weight beats
   * the pull limit) and/or couple extra AVAILABLE yard wagons while weight and
   * length headroom remain (limits incl. overage tolerance). The built train
   * updates in place, the schedule's wagon cap follows, and every change is
   * logged for the schedule's history.
   */
  async adjustScheduleConsist(
    scheduleId: string,
    dto: AdjustScheduleConsistDto,
    userId?: string | null,
  ) {
    const addWagonIds = [...new Set(dto.addWagonIds ?? [])];
    const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])];
    if (!addWagonIds.length && !removeWagonIds.length) {
      throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove');
    }
    const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id));
    if (overlap.length) {
      throw new BadRequestException('A wagon cannot be added and removed in the same adjustment');
    }

    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException(
        'The consist is frozen once the train is dispatched — adjust before departure',
      );
    }
    const builtTrainRef = schedule.trainSet?.train;
    if (!builtTrainRef) {
      throw new BadRequestException(
        'This schedule was not created from a built train — its consist cannot be adjusted here',
      );
    }
    const loadedWagonIds = new Set(
      (schedule.trainSet?.wagons ?? [])
        .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0)
        .map((slot) => slot.physicalWagonId as string),
    );
    const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet));
    const pullCapTons = roundTons(
      Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0),
    );
    const lengthCapMeters = roundTons(
      Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0),
    );

    await this.dataSource.transaction(async (manager) => {
      const train = await manager.getRepository(Train).findOne({
        where: { id: builtTrainRef.id },
        lock: { mode: 'pessimistic_write' },
      });
      if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`);

      const consist = await manager.getRepository(Wagon).find({
        where: { trainId: train.id },
        relations: { wagonType: true },
        order: { sequenceNumber: 'ASC' },
      });
      const consistById = new Map(consist.map((w) => [w.id, w]));

      // --- validate removals: must be coupled and free (no cargo, no pin) ---
      const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager);
      const removed: Wagon[] = [];
      for (const wagonId of removeWagonIds) {
        const wagon = consistById.get(wagonId);
        if (!wagon) {
          throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`);
        }
        if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) {
          throw new ConflictException(
            `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`,
          );
        }
        removed.push(wagon);
      }

      // --- validate additions: AVAILABLE, loose, standing in the train's yard ---
      const added: Wagon[] = [];
      for (const wagonId of addWagonIds) {
        // No `relations` on this query: Postgres refuses FOR UPDATE through the
        // nullable side of the wagonType LEFT JOIN ("FOR UPDATE cannot be
        // applied to the nullable side of an outer join"). Lock the row alone,
        // then attach its type with a separate unlocked lookup.
        const wagon = await manager.getRepository(Wagon).findOne({
          where: { id: wagonId },
          lock: { mode: 'pessimistic_write' },
        });
        if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`);
        if (wagon.trainId) {
          throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`);
        }
        if (wagon.status !== WagonStatus.Available) {
          throw new ConflictException(
            `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`,
          );
        }
        if (wagon.currentYardId !== train.currentYardId) {
          throw new BadRequestException(
            `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`,
          );
        }
        wagon.wagonType =
          (await manager
            .getRepository(WagonType)
            .findOne({ where: { id: wagon.wagonTypeId } })) ?? undefined;
        added.push(wagon);
      }

      // --- headroom check (only additions can push the train over a cap) ---
      const removedIds = new Set(removed.map((w) => w.id));
      const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added];
      const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0);
      const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0);
      const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0));
      const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0));
      const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0));
      const finalGrossTons = roundTons(cargoTons + finalTareTons);
      if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) {
        throw new BadRequestException(
          `Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`,
        );
      }
      if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) {
        throw new BadRequestException(
          `Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`,
        );
      }

      // --- apply: detach trims, couple additions, compact the sequence ---
      for (const wagon of removed) {
        await manager.getRepository(Wagon).update(wagon.id, {
          trainId: null,
          sequenceNumber: null,
          status: WagonStatus.Available,
        });
      }
      const remaining = consist.filter((w) => !removedIds.has(w.id));
      for (let i = 0; i < remaining.length; i++) {
        if (remaining[i].sequenceNumber !== i + 1) {
          await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 });
        }
      }
      let sequence = remaining.length;
      for (const wagon of added) {
        sequence += 1;
        await manager.getRepository(Wagon).update(wagon.id, {
          trainId: train.id,
          sequenceNumber: sequence,
          status: WagonStatus.Assigned,
        });
      }

      // The schedule is full when every consist wagon is allocated.
      await manager
        .getRepository(TrainSchedule)
        .update(scheduleId, { maxWagons: finalConsist.length });

      const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog);
      const now = new Date();
      await logRepo.save(
        [
          ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })),
          ...added.map((wagon) => ({ action: 'ADD' as const, wagon })),
        ].map(({ action, wagon }) =>
          logRepo.create({
            trainScheduleId: scheduleId,
            trainId: train.id,
            action,
            wagonId: wagon.id,
            wagonNumber: wagon.wagonNumber,
            adjustedByUserId: userId ?? null,
            occurredAt: now,
          }),
        ),
      );
    });

    // The consist IS the schedule's booking capacity, so an edit moves the
    // FULL line: freeing slots on a FULL schedule reopens its window, taking
    // the last slot closes it. Staff may shrink below what is already
    // committed — allowed, but reported back as a warning (never silently).
    const warnings: string[] = [];
    const wasFull = schedule.bookingWindowStatus === 'FULL';
    const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
    if (usage) {
      const nowFull = usage.remainingSlots <= 0;
      if (usage.overAllocatedBy > 0) {
        warnings.push(
          `The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
            `${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
            'Couple more wagons or free bookings before departure.',
        );
      }
      if (wasFull && !nowFull) {
        await this.bookingBatchService?.refreshWindowStatus(scheduleId);
        warnings.push(
          `This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
            'so it is no longer FULL and can take bookings again.',
        );
      } else if (!wasFull && nowFull) {
        await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
        warnings.push(
          'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
        );
      }
    }

    return { ...(await this.getScheduleConsist(scheduleId)), warnings };
  }

  /**
   * Re-derive a built train's lifecycle status from its schedules after one of
   * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
   * SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
   * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
   * / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
   */
  private async syncBuiltTrainAfterScheduleChange(
    manager: EntityManager,
    trainId: string,
    moveToYardId?: string | null,
  ): Promise<void> {
    const train = await manager.getRepository(Train).findOne({ where: { id: trainId } });
    if (!train) return;

    const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {};
    const managed = [
      Freight.TrainStatus.Available,
      Freight.TrainStatus.Scheduled,
      Freight.TrainStatus.InService,
    ];
    if (!managed.includes(train.status)) {
      if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch);
      return;
    }

    const rows: { status: string }[] = await manager.query(
      `SELECT DISTINCT ts.status
         FROM freight.train_schedules ts
         JOIN freight.train_sets tset ON tset.id = ts.train_set_id
        WHERE tset.train_id = $1
          AND ts.deleted_at IS NULL
          AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
      [trainId],
    );
    const statuses = new Set(rows.map((r) => r.status));
    const next = statuses.has('DISPATCHED')
      ? Freight.TrainStatus.InService
      : statuses.size
        ? Freight.TrainStatus.Scheduled
        : Freight.TrainStatus.Available;
    await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch });
  }

  /**
   * A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a
   * FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s
   * milestones with the destination stop AFTER the origin stop — OR (routes with
   * no milestones recorded) the pair equals the schedule's own endpoints. This
   * mirrors the sub-leg matching the create-booking path already does
   * (`getBookableScheduleEntities`), so a through train (Djibouti → Kality →
   * Dire) is announced and bookable for every leg it actually serves
   * (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two
   * endpoints. Static SQL fragment (no user input) interpolated into the window
   * queries below; `ts` and `cr` must be the schedule and contract_route aliases.
   */
  private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `(
                EXISTS (
                  SELECT 1
                    FROM freight.route_milestones mo
                    JOIN freight.route_milestones md
                      ON md.route_id = mo.route_id
                     AND md.sequence_no > mo.sequence_no
                   WHERE mo.route_id = ts.route_id
                     AND mo.yard_id = cr.origin_yard_id
                     AND md.yard_id = cr.destination_yard_id
                )
                OR (cr.origin_yard_id = ts.origin_station_id
                    AND cr.destination_yard_id = ts.destination_station_id)
              )`;

  /**
   * Upcoming/open booking windows announced on the portal home "booking
   * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead)
   * are listed so every customer sees what is opening — not just those on their
   * contract lanes; DOMESTIC trains are always open and need no announcement.
   *
   * When `companyId` is given, a matching active contract on the lane is
   * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling
   * "Book now"); customers with no covering contract still see the window with a
   * null contract, and the portal routes them to the contract list to get one.
   * A contract covering any FORWARD sub-leg of the corridor counts as covering
   * the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`).
   */
  async getBookingWindowsForCompany(companyId: string | null) {
    const rows: Array<BookingWindowRow> = await this.dataSource.query(
      `SELECT DISTINCT ON (ts.id)
              ts.id AS schedule_id,
              ts.reference AS reference,
              cr.contract_id AS contract_id,
              c.contract_kind AS contract_kind,
              ts.direction,
              ts.window_phase,
              ts.window_opens_at,
              ts.window_closes_at,
              ts.doc_review_ends_at,
              ts.payment_phase_ends_at,
              ts.booking_window_status,
              ts.booking_cycle_no,
              ts.scheduled_departure_date,
              oy.label AS origin_label, oy.code AS origin_code,
              dy.label AS destination_label, dy.code AS destination_code,
              (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
                 FROM freight.route_milestones rm
                 JOIN freight.yards rmy ON rmy.id = rm.yard_id
                WHERE rm.route_id = ts.route_id) AS route_stations
         FROM freight.train_schedules ts
         LEFT JOIN freight.contract_routes cr
           ON cr.deleted_at IS NULL
          AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
         LEFT JOIN freight.contracts c
           ON c.id = cr.contract_id
          AND c.company_id = $1
          AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
          AND c.deleted_at IS NULL
         LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
         LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
        WHERE ts.deleted_at IS NULL
          AND ts.status IN ('DRAFT', 'SCHEDULED')
          AND ts.window_phase IS NOT NULL
          AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
          AND ts.scheduled_departure_date >= now()
        ORDER BY ts.id, c.id NULLS LAST, ts.scheduled_departure_date ASC NULLS LAST`,
      [companyId],
    );
    // Nearest dispatch (departure) date first — the DISTINCT ON above forces a
    // per-row ordering, so re-sort the mapped rows by departure for the client.
    return rows
      .map((r) => this.mapBookingWindowRow(r))
      .sort((a, b) => {
        const ta = a.departureDate ? new Date(a.departureDate).getTime() : Infinity;
        const tb = b.departureDate ? new Date(b.departureDate).getTime() : Infinity;
        return ta - tb;
      });
  }

  /**
   * Upcoming/open booking windows on a single contract's routes. Used to gate the
   * booking form for the customer AND Ethiopian GL (who books on the customer's
   * behalf): no window row with isOpenNow=true → booking entry is hidden.
   */
  async getBookingWindowsForContract(contractId: string) {
    const rows: Array<BookingWindowRow> = await this.dataSource.query(
      `SELECT DISTINCT ts.id AS schedule_id,
              ts.reference AS reference,
              cr.contract_id AS contract_id,
              c.contract_kind AS contract_kind,
              ts.direction,
              ts.window_phase,
              ts.window_opens_at,
              ts.window_closes_at,
              ts.doc_review_ends_at,
              ts.payment_phase_ends_at,
              ts.booking_window_status,
              ts.booking_cycle_no,
              ts.scheduled_departure_date,
              oy.label AS origin_label, oy.code AS origin_code,
              dy.label AS destination_label, dy.code AS destination_code,
              (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
                 FROM freight.route_milestones rm
                 JOIN freight.yards rmy ON rmy.id = rm.yard_id
                WHERE rm.route_id = ts.route_id) AS route_stations
         FROM freight.train_schedules ts
         JOIN freight.contract_routes cr
           ON cr.contract_id = $1
          AND cr.deleted_at IS NULL
          AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE}
         JOIN freight.contracts c
           ON c.id = cr.contract_id
          AND c.deleted_at IS NULL
         LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
         LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
        WHERE ts.deleted_at IS NULL
          AND ts.status IN ('DRAFT', 'SCHEDULED')
          AND ts.window_phase IS NOT NULL
          AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
          AND ts.scheduled_departure_date >= now()
        ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
      [contractId],
    );
    return rows.map((r) => this.mapBookingWindowRow(r));
  }

  /**
   * All announced booking windows across every lane — import window cycles AND
   * export FCFS lead windows — for staff dashboards (GL clearance queue). Same
   * phase filter as the customer-facing lists, no contract scoping.
   */
  async listAllBookingWindows() {
    const rows: Array<
      Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
        train_number: string | null;
      }
    > = await this.dataSource.query(
      `SELECT ts.id AS schedule_id,
              ts.reference AS reference,
              ts.train_number,
              ts.direction,
              ts.window_phase,
              ts.window_opens_at,
              ts.window_closes_at,
              ts.doc_review_ends_at,
              ts.payment_phase_ends_at,
              ts.booking_window_status,
              ts.booking_cycle_no,
              ts.scheduled_departure_date,
              oy.label AS origin_label, oy.code AS origin_code,
              dy.label AS destination_label, dy.code AS destination_code,
              (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no)
                 FROM freight.route_milestones rm
                 JOIN freight.yards rmy ON rmy.id = rm.yard_id
                WHERE rm.route_id = ts.route_id) AS route_stations
         FROM freight.train_schedules ts
         LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
         LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
        WHERE ts.deleted_at IS NULL
          AND ts.status IN ('DRAFT', 'SCHEDULED')
          AND ts.window_phase IS NOT NULL
          AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
          AND ts.scheduled_departure_date >= now()
        ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
    );
    return rows.map((r) => ({
      ...this.mapBookingWindowRow({
        ...r,
        contract_id: null,
        contract_kind: null,
      }),
      trainNumber: r.train_number,
    }));
  }

  private mapBookingWindowRow(r: BookingWindowRow) {
    const origin = r.origin_label ?? r.origin_code ?? null;
    const destination = r.destination_label ?? r.destination_code ?? null;
    // Full corridor from the route's milestones (origin → stops → destination).
    // Falls back to the schedule's origin/destination when no milestones exist.
    const milestoneStops = (r.route_stations ?? []).filter(
      (s): s is string => Boolean(s),
    );
    const routeStations =
      milestoneStops.length >= 2
        ? milestoneStops
        : [origin, destination].filter((s): s is string => Boolean(s));
    return {
      scheduleId: r.schedule_id,
      reference: r.reference ?? null,
      contractId: r.contract_id,
      contractKind: r.contract_kind,
      direction: r.direction,
      windowPhase: r.window_phase,
      isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
      windowOpensAt: r.window_opens_at,
      windowClosesAt: r.window_closes_at,
      docReviewEndsAt: r.doc_review_ends_at,
      paymentPhaseEndsAt: r.payment_phase_ends_at,
      bookingWindowStatus: r.booking_window_status,
      bookingCycleNo: r.booking_cycle_no,
      departureDate: r.scheduled_departure_date,
      origin,
      destination,
      routeStations,
    };
  }

  /** OPEN schedules a new booking may target (with rough remaining capacity).
   * Supports sub-route matching: if originYardId and/or destinationYardId are provided,
   * returns schedules whose route passes through both yards in the correct order.
   */
  /**
   * Raw OPEN same-route schedule entities a new booking may target (with the
   * relations needed for capacity/fleet checks). Shared by getBookableSchedules
   * (which maps to list items) and getAvailableDaysForCargo (which needs the raw
   * originStationId / scheduledDepartureDate / trainSet).
   */
  private async getBookableScheduleEntities(
    originYardId?: string,
    destinationYardId?: string,
  ): Promise<
    import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
  > {
    const schedules = await this.trainSchedulesRepository.findAll({
      where: {
        bookingWindowStatus: 'OPEN',
      },
      relations: {
        trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true },
        route: { milestones: true },
        originStation: true,
        destinationStation: true,
        scheduleBookings: { booking: true },
      },
      order: { scheduledDepartureDate: 'ASC' },
    });

    // A train that has already departed can never be booked, even if the window
    // engine hasn't yet flipped its bookingWindowStatus off OPEN. Mirror the
    // `scheduled_departure_date >= now()` guard the booking-window SQL uses so a
    // past-departure schedule never leaks into the portal day pool, the schedule
    // calendar, or the ET GL create-booking gate.
    const now = new Date();
    return schedules
      .filter(
        (s) =>
          s.scheduledDepartureDate != null &&
          s.scheduledDepartureDate > now,
      )
      .filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
      .filter((s) => {
        // Build the full stop list: origin -> milestones (ordered) -> destination
        const milestones = s.route?.milestones ?? [];
        const sortedMilestones = [...milestones].sort((a, b) => a.sequenceNo - b.sequenceNo);
        const stopYardIds = [s.originStationId, ...sortedMilestones.map((m) => m.yardId), s.destinationStationId];

        // Remove duplicates while preserving order (in case origin/destination appears in milestones)
        const uniqueStopYardIds: string[] = [];
        for (const yardId of stopYardIds) {
          if (!uniqueStopYardIds.includes(yardId)) {
            uniqueStopYardIds.push(yardId);
          }
        }

        // Check origin yard filter
        if (originYardId) {
          if (!uniqueStopYardIds.includes(originYardId)) {
            return false;
          }
        }

        // Check destination yard filter
        if (destinationYardId) {
          if (!uniqueStopYardIds.includes(destinationYardId)) {
            return false;
          }
          // Ensure destination comes after origin (if both are specified)
          if (originYardId) {
            const originIndex = uniqueStopYardIds.indexOf(originYardId);
            const destIndex = uniqueStopYardIds.indexOf(destinationYardId);
            if (destIndex <= originIndex) {
              return false;
            }
          }
        }

        return true;
      });
  }

  async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
    const schedules = await this.getBookableScheduleEntities(
      originYardId,
      destinationYardId,
    );
    return schedules.map((s) => this.mapScheduleListItem(s));
  }

  /**
   * Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
   * departure on the route. Customers pick a DAY (not a train) — so this returns
   * only the day strings, no capacity, counts or train info.
   */
  async getAvailableDays(
    originYardId?: string,
    destinationYardId?: string,
  ): Promise<{ days: string[] }> {
    const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
    const days = new Set<string>();
    for (const s of schedules) {
      if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
    }
    return { days: [...days].sort() };
  }

  /**
   * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day
   * is selectable when ≥1 OPEN schedule on the route that day still has remaining
   * train capacity (not fully allocated) AND its wagon stock can physically carry
   * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately
   * NOT gated — a booking bigger than the free capacity is accepted and the batch
   * engine offers a partial split later. No counts are exposed: same
   * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY,
   * not a train.
   */
  async getAvailableDaysForCargo(input: {
    originYardId?: string;
    destinationYardId?: string;
    freightType: 'CONTAINER' | 'BULK';
    cargoTypeId?: string | null;
    cargoTypeCode?: string | null;
    totalWeightTons?: number;
    containers?: Array<{ containerSize: string; quantity: number }>;
    containerTypeIds?: string[];
  }): Promise<{ days: string[] }> {
    const schedules = await this.getBookableScheduleEntities(
      input.originYardId,
      input.destinationYardId,
    );
    if (schedules.length === 0) return { days: [] };

    const withCapacity = schedules.filter(
      (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0,
    );
    const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input);

    const days = new Set<string>();
    for (const s of compatible) {
      if (s.scheduledDepartureDate)
        days.add(eatDay(new Date(s.scheduledDepartureDate)));
    }
    return { days: [...days].sort() };
  }

  /**
   * Wagon-TYPE compatibility gate (customer booking): keep only the schedules
   * whose wagon stock can physically carry the selected cargo — every container
   * line (or the bulk cargo type) must map to at least one wagon type the
   * schedule's stock actually has. Stock = the built train's own consist, or the
   * origin yard's loose pool for schedules assembled from loose locomotives.
   * QUANTITY is deliberately ignored: an over-sized booking is allowed and gets
   * a partial split offer from the batch engine later.
   */
  private async filterCargoCompatibleSchedules(
    schedules: TrainSchedule[],
    cargo: {
      freightType: 'CONTAINER' | 'BULK';
      cargoTypeId?: string | null;
      cargoTypeCode?: string | null;
      containers?: Array<{ containerSize: string; quantity: number }>;
      containerTypeIds?: string[];
    },
  ): Promise<TrainSchedule[]> {
    if (!schedules.length) return schedules;
    const required = await this.requiredWagonTypeSets(cargo);
    // No cargo identity supplied — nothing to gate on (legacy callers).
    if (required === null) return schedules;

    const stockByScheduleId = await this.scheduleWagonTypeStock(schedules);
    return schedules.filter((s) => {
      const stock = stockByScheduleId.get(s.id) ?? new Set<string>();
      return required.every((set) => {
        for (const typeId of set) if (stock.has(typeId)) return true;
        return false;
      });
    });
  }

  /**
   * One Set of allowed wagon-type ids per required cargo dimension: per
   * container line's type (or per container size when only sizes are known),
   * or a single set for the bulk cargo type. `null` = no cargo identity given,
   * skip gating. An EMPTY set means "nothing can carry this" (no wagon types
   * configured) — the gate then blocks every schedule, mirroring the hard
   * config violation scheduling raises for the same state.
   */
  private async requiredWagonTypeSets(cargo: {
    freightType: 'CONTAINER' | 'BULK';
    cargoTypeId?: string | null;
    cargoTypeCode?: string | null;
    containers?: Array<{ containerSize: string; quantity: number }>;
    containerTypeIds?: string[];
  }): Promise<Set<string>[] | null> {
    if (cargo.freightType === 'CONTAINER') {
      const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))];
      if (typeIds.length) {
        const rows: { container_type_id: string; wagon_type_id: string | null }[] =
          await this.dataSource.query(
            `SELECT ct.id AS container_type_id, wt.id AS wagon_type_id
               FROM freight.container_types ct
               LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
               LEFT JOIN freight.wagon_types wt
                 ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
              WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`,
            [typeIds],
          );
        const byType = new Map<string, Set<string>>(typeIds.map((id) => [id, new Set()]));
        for (const row of rows) {
          if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id);
        }
        return [...byType.values()];
      }
      // Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when
      // ANY active container type of that size has a matching wagon type.
      const sizes = [
        ...new Set(
          (cargo.containers ?? [])
            .map((line) => parseInt(String(line.containerSize), 10))
            .filter((n) => Number.isFinite(n) && n > 0),
        ),
      ];
      if (!sizes.length) return null;
      const rows: { size_ft: number; wagon_type_id: string | null }[] =
        await this.dataSource.query(
          `SELECT ct.size_ft, wt.id AS wagon_type_id
             FROM freight.container_types ct
             LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id
             LEFT JOIN freight.wagon_types wt
               ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
            WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL
              AND (ct.is_active IS DISTINCT FROM false)`,
          [sizes],
        );
      const bySize = new Map<number, Set<string>>(sizes.map((s) => [s, new Set()]));
      for (const row of rows) {
        if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id);
      }
      return [...bySize.values()];
    }

    if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null;
    const rows: { wagon_type_id: string | null }[] = await this.dataSource.query(
      `SELECT wt.id AS wagon_type_id
         FROM freight.cargo_types c
         LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id
         LEFT JOIN freight.wagon_types wt
           ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true
        WHERE c.deleted_at IS NULL
          AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`,
      [cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null],
    );
    const set = new Set<string>();
    for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id);
    return [set];
  }

  /**
   * Wagon-type ids each schedule's stock can offer: the built train's own
   * consist for train-bound schedules, the origin yard's loose usable pool
   * otherwise. Batched — two queries for the whole schedule list.
   */
  private async scheduleWagonTypeStock(
    schedules: TrainSchedule[],
  ): Promise<Map<string, Set<string>>> {
    const builtTrainIds = [
      ...new Set(
        schedules
          .map((s) => s.trainSet?.trainId)
          .filter((id): id is string => Boolean(id)),
      ),
    ];
    const looseOriginYardIds = [
      ...new Set(
        schedules
          .filter((s) => !s.trainSet?.trainId)
          .map((s) => s.originStationId)
          .filter(Boolean),
      ),
    ];

    const [trainRows, yardRows] = await Promise.all([
      builtTrainIds.length
        ? (this.dataSource.query(
            `SELECT train_id, wagon_type_id
               FROM freight.wagons
              WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL
              GROUP BY train_id, wagon_type_id`,
            [builtTrainIds],
          ) as Promise<{ train_id: string; wagon_type_id: string }[]>)
        : Promise.resolve([] as { train_id: string; wagon_type_id: string }[]),
      looseOriginYardIds.length
        ? (this.dataSource.query(
            `SELECT current_yard_id, wagon_type_id
               FROM freight.wagons
              WHERE train_id IS NULL AND deleted_at IS NULL
                AND status IN ('AVAILABLE', 'ASSIGNED')
                AND current_yard_id = ANY($1::uuid[])
              GROUP BY current_yard_id, wagon_type_id`,
            [looseOriginYardIds],
          ) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>)
        : Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]),
    ]);

    const byTrain = new Map<string, Set<string>>();
    for (const row of trainRows) {
      const set = byTrain.get(row.train_id) ?? new Set<string>();
      set.add(row.wagon_type_id);
      byTrain.set(row.train_id, set);
    }
    const byYard = new Map<string, Set<string>>();
    for (const row of yardRows) {
      const set = byYard.get(row.current_yard_id) ?? new Set<string>();
      set.add(row.wagon_type_id);
      byYard.set(row.current_yard_id, set);
    }

    const result = new Map<string, Set<string>>();
    for (const s of schedules) {
      const trainId = s.trainSet?.trainId;
      result.set(
        s.id,
        trainId
          ? byTrain.get(trainId) ?? new Set()
          : byYard.get(s.originStationId) ?? new Set(),
      );
    }
    return result;
  }

  /**
   * Booking-time gate for a chosen day: does the route have an OPEN departure
   * that day at all, and can any of that day's departures physically carry the
   * cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a
   * partial split offer instead).
   */
  async checkDayCargoCompatibility(
    originYardId: string,
    destinationYardId: string,
    day: string,
    cargo: {
      freightType: 'CONTAINER' | 'BULK';
      cargoTypeId?: string | null;
      containerTypeIds?: string[];
    },
  ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
    const schedules = await this.getBookableScheduleEntities(
      originYardId,
      destinationYardId,
    );
    const onDay = schedules.filter(
      (s) =>
        s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day,
    );
    if (!onDay.length) return { hasDeparture: false, hasCompatible: false };
    const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo);
    return { hasDeparture: true, hasCompatible: compatible.length > 0 };
  }

  /**
   * Ordered stop yards of a schedule's route: origin → milestones → destination,
   * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
   * has no route milestones. Shared by corridor (sub-leg) validation everywhere.
   */
  async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
    let milestoneYards: string[] = [];
    if (schedule.route?.milestones?.length) {
      milestoneYards = [...schedule.route.milestones]
        .sort((a, b) => a.sequenceNo - b.sequenceNo)
        .map((m) => m.yardId);
    } else if (schedule.routeId) {
      const milestones = await this.dataSource
        .getRepository(RouteMilestone)
        .find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
      milestoneYards = milestones.map((m) => m.yardId);
    }
    const raw = milestoneYards.length >= 2
      ? milestoneYards
      : [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
    const unique: string[] = [];
    for (const yardId of raw) {
      if (yardId && !unique.includes(yardId)) unique.push(yardId);
    }
    return unique;
  }

  /** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
  async existsOpenScheduleOnRouteDay(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): Promise<boolean> {
    const { days } = await this.getAvailableDays(originYardId, destinationYardId);
    return days.includes(day);
  }

  /**
   * Enforce the config-driven booking window at booking-create time.
   *
   * A booking is only allowed when the route has an OPEN departure the customer
   * can join for the requested day — which, because the window engine keeps
   * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
   *  - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
   *    `importWindowLeadDays` before departure, for `windowDurationHours`).
   *  - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
   *
   * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
   * both gates are satisfied by checking that route for open departures. When a
   * specific day is requested, require an open departure on that EAT day; when no
   * day is given, require at least one open departure on the route at all.
   * Throws `BadRequestException` when the window is closed. No-ops when the route
   * yards are unknown (nothing to gate against).
   */
  async assertBookingWindowOpen(input: {
    originYardId?: string | null;
    destinationYardId?: string | null;
    scheduledDate?: Date | string | null;
    direction?: string | null;
  }): Promise<void> {
    const { originYardId, destinationYardId } = input;
    if (!originYardId || !destinationYardId) return;

    const { days } = await this.getAvailableDays(originYardId, destinationYardId);
    if (days.length === 0) {
      throw new BadRequestException(
        input.direction === 'EXPORT'
          ? 'The export booking window for this route is not open yet'
          : 'The import booking window for this route is closed right now',
      );
    }

    if (input.scheduledDate) {
      const day = eatDay(new Date(input.scheduledDate));
      if (!days.includes(day)) {
        throw new BadRequestException(
          input.direction === 'EXPORT'
            ? 'No departure is within the export booking window on the selected day'
            : 'The import booking window is not open for the selected day',
        );
      }
    }
  }

  /**
   * Per-wagon tare/payload for every wagon type, keyed by id, with the batch
   * engine's representative fallbacks for bookings whose cargo/container type
   * has no wagon type configured. Loaded once per request before mapping.
   */
  /** Wagon types are near-static reference data — a short TTL cache spares one
   * table scan per detail/board request without letting edits go stale long. */
  private wagonTareDimsCache: {
    value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
    expiresAt: number;
  } | null = null;

  private async loadWagonTareDims(): Promise<{
    byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
    bulk: { tareWeightTons: number; capacityTons: number };
    container: { tareWeightTons: number; capacityTons: number };
  }> {
    if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
      return this.wagonTareDimsCache.value;
    }
    const types = await this.dataSource.getRepository(WagonType).find();
    const byWagonTypeId = new Map(
      types.map((t) => [
        t.id,
        {
          tareWeightTons: Number(t.tareWeightTons) || 0,
          capacityTons: Number(t.capacityTons) || 0,
        },
      ]),
    );
    const value = {
      byWagonTypeId,
      bulk: {
        tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
        capacityTons: DEFAULT_BULK_WAGON_CAPACITY_TONS,
      },
      container: {
        tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
        capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
      },
    };
    this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
    return value;
  }

  /**
   * Booking weight as the train actually hauls it: cargo VGM plus the tare of
   * every wagon the booking occupies — the same gross axis the batch engine
   * spends against the locomotive's pull limit. Wagon count mirrors the batch
   * engine's sizing (stored wagonsRequired, TEU geometry for containers,
   * tons ÷ payload for bulk — whichever is largest).
   */
  private grossBookingWeightTons(
    booking: Pick<
      Booking,
      | 'freightType'
      | 'cargoTotalWeightVgm'
      | 'wagonsRequired'
      | 'bookingContainers'
      | 'cargoType'
    >,
    tareDims: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>,
  ): number {
    const cargo = bookingCargoTons(booking);
    const fallback =
      booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container;
    // Same first-configured-type resolution the batch engine's dimsFor uses.
    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 typed = wagonTypeId ? tareDims.byWagonTypeId.get(wagonTypeId) : undefined;
    const dims = {
      tareWeightTons: typed?.tareWeightTons || fallback.tareWeightTons,
      capacityTons: typed?.capacityTons || fallback.capacityTons,
    };

    const stored =
      booking.wagonsRequired && booking.wagonsRequired > 0
        ? Math.ceil(booking.wagonsRequired)
        : 0;
    const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
    const byWeight =
      cargo > 0 && dims.capacityTons > 0 ? Math.ceil(cargo / dims.capacityTons) : 0;
    const wagons = Math.max(1, stored, byLength, byWeight);
    return roundTons(cargo + wagons * dims.tareWeightTons);
  }

  private async mapScheduleDetail(
    schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
  ) {
    const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
      (w) => w.allocations ?? [],
    );
    const allocationIds = allocations.map((a) => a.id);
    const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));

    // Import-from-Djibouti trains can only dispatch once loading is confirmed
    // (loadedOnTrainAt on the operation). Other directions have no departure
    // loading gate, so the workspace shows the confirm button as already done.
    const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);

    // Snapshot state decides below whether the live consist may be drawn at
    // all, so it is derived before the consist wagons are fetched.
    // Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
    // and re-pinned onto later trains — the live wagon↔slot joins no longer
    // describe THIS train. If a frozen snapshot was captured at the transition,
    // the per-slot wagon number + booking allocations are read from it instead.
    const snapshot = schedule.wagonAllocationSnapshot ?? null;
    const isWagonAllocationFrozen = Boolean(
      snapshot &&
        schedule.status !== TrainScheduleStatusEnum.Draft &&
        schedule.status !== TrainScheduleStatusEnum.Scheduled,
    );
    const snapshotSlotByTrainSetWagonId = new Map(
      (snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
    );

    // All independent lookups fired at once — they used to run one after
    // another, stacking round-trips onto every detail request.
    // tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
    // number the locomotive actually hauls against its pull limit.
    const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
      await Promise.all([
        this.loadWagonTareDims(),
        requiresLoadingConfirmation
          ? this.dataSource
              .getRepository(ImportDjiboutiOperation)
              .findOne({ where: { trainScheduleId: schedule.id } })
          : null,
        this.getWindowConfig(),
        allocationIds.length
          ? this.wagonAllocationContainerItemsRepository.findAll({
              where: { wagonBookingAllocationId: In(allocationIds) },
              relations: { containerType: true, bookingContainer: true },
            })
          : [],
        allocationIds.length
          ? this.wagonAllocationBulkLoadsRepository.findAll({
              where: { wagonBookingAllocationId: In(allocationIds) },
              relations: { cargoType: true },
            })
          : [],
        schedule.trainSet?.trainId && !isWagonAllocationFrozen
          ? this.dataSource.getRepository(Wagon).find({
              where: { trainId: schedule.trainSet.trainId },
              relations: { wagonType: true },
              // Mirror the pinning direction: a reverse-order schedule draws the
              // whole consist back-to-front, empties included.
              order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
            })
          : [],
      ]);
    const loadingConfirmed = requiresLoadingConfirmation
      ? Boolean(importOp?.loadedOnTrainAt)
      : true;

    const containerItemsByAllocation = new Map<string, typeof containerItems>();
    for (const item of containerItems) {
      const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
      list.push(item);
      containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
    }
    const bulkLoadsByAllocation = new Map(
      bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
    );

    // The trainSet slots below are the PLANNED wagons (one per allocation). A
    // schedule tied to a built train hauls EVERY coupled wagon — empty ones
    // included (the pull-limit check already counts their tare) — so append the
    // train's remaining wagons as consist-only entries and the composition views
    // (scheduling-v2 finalize, batch-board composition tab) draw the train as it
    // really is: loaded slots first, then the empty consist. Skipped for frozen
    // (dispatched/arrived) schedules: their wagons are released and re-pinned to
    // later trains, so the live consist no longer describes THIS departure.
    const coveredPhysicalIds = new Set<string>();
    for (const slot of schedule.trainSet?.wagons ?? []) {
      const frozenSlot = isWagonAllocationFrozen
        ? snapshotSlotByTrainSetWagonId.get(slot.id)
        : undefined;
      const physicalId = frozenSlot
        ? frozenSlot.physicalWagonId
        : slot.physicalWagonId ?? null;
      if (physicalId) coveredPhysicalIds.add(physicalId);
    }
    // Fallback only — real empty rows below carry the wagon's OWN physical
    // sequenceNumber, not an invented tail position (see emptyConsistWagons).
    const maxSlotSequenceNo = Math.max(
      0,
      ...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
    );
    const emptyConsistWagons = rawConsistWagons
      .filter((wagon) => !coveredPhysicalIds.has(wagon.id))
      .map((wagon, index) => ({
        // Physical wagon id — there is no TrainSetWagon slot behind this
        // row, so remove/edit affordances must stay disabled (consistOnly).
        id: wagon.id,
        // The wagon's REAL coupling position, so an empty wagon in the middle
        // of the train draws in the middle — not appended after every loaded
        // slot. Falls back to a tail position only if the wagon somehow has
        // no sequence number of its own.
        sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
        capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
        lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
        assignedWeightTons: 0,
        tareWeightTons: wagon.wagonType
          ? roundTons(Number(wagon.wagonType.tareWeightTons))
          : null,
        status: 'EMPTY',
        physicalWagonId: wagon.id,
        physicalWagonNumber: wagon.wagonNumber ?? null,
        wagonType: wagon.wagonType
          ? {
              id: wagon.wagonType.id,
              code: wagon.wagonType.code,
              name: wagon.wagonType.name,
            }
          : null,
        allocations: [],
        consistOnly: true,
      }));

    // The consist is DRAWN in the built train's real coupling order (rawConsistWagons
    // is already ASC/DESC per reverseWagonOrder), not in slot order — see
    // consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
    // the slot's own stored value.
    const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
      list: T[],
    ) =>
      orderConsistWagons(list, {
        physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
        reverseWagonOrder: schedule.reverseWagonOrder,
      });

    return {
      id: schedule.id,
      reference: schedule.reference ?? null,
      status: schedule.status,
      freightType: this.resolveScheduleFreightType(schedule),
      trainNumber: schedule.trainNumber ?? null,
      maxWagons: schedule.maxWagons ?? null,
      direction: schedule.direction ?? null,
      reverseWagonOrder: schedule.reverseWagonOrder ?? false,
      requiresLoadingConfirmation,
      loadingConfirmed,
      // Booking-window phase + phase deadlines drive the countdown timers in the
      // operations workspace (display only — the window engine enforces them).
      windowPhase: schedule.windowPhase ?? null,
      windowOpensAt: schedule.windowOpensAt
        ? schedule.windowOpensAt.toISOString()
        : null,
      windowClosesAt: schedule.windowClosesAt
        ? schedule.windowClosesAt.toISOString()
        : null,
      docReviewEndsAt: schedule.docReviewEndsAt
        ? schedule.docReviewEndsAt.toISOString()
        : null,
      paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
        ? schedule.paymentPhaseEndsAt.toISOString()
        : null,
      // Per-schedule booking-window rule snapshot — powers the "Booking window
      // settings" editor on the ops board (prefill + save one schedule's
      // override). docReview/payment are not snapshotted per schedule (only their
      // sum, as the frozen reopen gap), so the editor prefills them from live config.
      windowRule: {
        windowOpenHour: schedule.ruleWindowOpenHour ?? null,
        windowCloseHour: schedule.ruleWindowCloseHour ?? null,
        windowDurationHours:
          schedule.ruleWindowDurationHours != null
            ? Number(schedule.ruleWindowDurationHours)
            : null,
        importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
        exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
        docReviewMinutes: windowCfg.docReviewMinutes,
        paymentWindowMinutes: windowCfg.paymentWindowMinutes,
      },
      route: schedule.route
        ? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
        : null,
      scheduledDepartureDate: schedule.scheduledDepartureDate,
      scheduledArrivalDate: schedule.scheduledArrivalDate,
      actualDepartureAt: schedule.actualDepartureAt ?? null,
      originStation: schedule.originStation,
      destinationStation: schedule.destinationStation,
      // Built train (Train Builder) behind this departure, when scheduled by train.
      train: schedule.trainSet?.train
        ? {
            id: schedule.trainSet.train.id,
            code: schedule.trainSet.train.code,
            trainName: schedule.trainSet.train.trainName ?? null,
          }
        : null,
      trainSet: schedule.trainSet
        ? {
            id: schedule.trainSet.id,
            status: schedule.trainSet.status,
            wagonCount: schedule.trainSet.wagonCount,
            totalWeightTons: roundTons(Number(schedule.trainSet.totalWeightTons)),
            totalLengthMeters: roundTons(Number(schedule.trainSet.totalLengthMeters)),
            locomotive: schedule.trainSet.locomotive
              ? {
                  id: schedule.trainSet.locomotive.id,
                  code: schedule.trainSet.locomotive.code,
                  name: schedule.trainSet.locomotive.name,
                  status: schedule.trainSet.locomotive.status,
                  currentYardId: schedule.trainSet.locomotive.currentYardId ?? null,
                  maxPullWeightTons: roundTons(
                    Number(schedule.trainSet.locomotive.maxPullWeightTons),
                  ),
                  maxTrainLengthMeters: roundTons(
                    Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
                  ),
                }
              : null,
            locomotives: this.locomotivesOfTrainSet(schedule.trainSet).map((loco) => ({
              id: loco.id,
              code: loco.code,
              name: loco.name ?? null,
              status: loco.status,
              currentYardId: loco.currentYardId ?? null,
              maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
              maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
            })),
            wagons: drawConsist(
              (schedule.trainSet.wagons ?? [])
              .map((wagon) => {
                // Frozen schedules read the wagon number + allocations from the
                // snapshot slot; the immutable slot geometry (capacity/type) still
                // comes live. Falls back to live if a slot is missing from the snap.
                const frozenSlot = isWagonAllocationFrozen
                  ? snapshotSlotByTrainSetWagonId.get(wagon.id)
                  : undefined;
                // Draw the slot at its physical wagon's REAL coupling position,
                // not the planning-time slot index — the two diverge once a
                // load has been dragged onto a different wagon (moveWagonLoad
                // repoints physicalWagonId but a slot keeps its own sequenceNo),
                // or once wagon types were interleaved at pinning time. Frozen
                // and not-yet-pinned slots have no live physical wagon to trust,
                // so they keep their own slot sequence.
                const sequenceNo =
                  frozenSlot || !wagon.physicalWagon
                    ? wagon.sequenceNo
                    : (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
                return {
                  id: wagon.id,
                  sequenceNo,
                  capacityTons: roundTons(Number(wagon.capacityTons)),
                  lengthMeters: roundTons(Number(wagon.lengthMeters)),
                  assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
                  // Empty-wagon weight — the pull limit hauls tare + cargo, so the
                  // frontend needs it to show the gross train weight.
                  tareWeightTons: wagon.wagonType
                    ? roundTons(Number(wagon.wagonType.tareWeightTons))
                    : null,
                  status: wagon.status,
                  physicalWagonId: frozenSlot
                    ? frozenSlot.physicalWagonId
                    : wagon.physicalWagonId ?? null,
                  physicalWagonNumber: frozenSlot
                    ? frozenSlot.physicalWagonNumber
                    : wagon.physicalWagon?.wagonNumber ?? null,
                  wagonType: wagon.wagonType
                    ? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
                    : null,
                  allocations: frozenSlot
                    ? frozenSlot.allocations.map((allocation) => ({
                        id: null,
                        bookingId: allocation.bookingId,
                        bookingReference: allocation.bookingReference,
                        allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
                        loadType: allocation.loadType,
                        status: null,
                        // Frozen: container detail collapses to the captured numbers;
                        // per-container geometry isn't re-derivable post-release.
                        containerItems: allocation.containerNumbers.map((containerNumber) => ({
                          id: null,
                          containerNumber,
                          containerTypeId: null,
                          grossWeightTons: null,
                          containerId: null,
                          positionOnWagon: null,
                          bookingContainerId: null,
                        })),
                        bulkLoad: null,
                      }))
                    : wagon.allocations?.map((allocation) => ({
                        id: allocation.id,
                        bookingId: allocation.bookingId,
                        bookingReference: allocation.booking?.reference ?? null,
                        allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
                        loadType: allocation.loadType ?? null,
                        status: allocation.status,
                        containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
                          (item) => ({
                            id: item.id,
                            containerNumber: item.containerNumber ?? null,
                            containerTypeId: item.containerTypeId,
                            grossWeightTons: item.grossWeightTons ?? null,
                            containerId: item.containerId ?? null,
                            positionOnWagon: item.positionOnWagon ?? null,
                            bookingContainerId: item.bookingContainerId ?? null,
                          }),
                        ),
                        bulkLoad: bulkLoadsByAllocation.get(allocation.id)
                          ? {
                              id: bulkLoadsByAllocation.get(allocation.id)!.id,
                              weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
                              cargoDescription:
                                bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
                            }
                          : null,
                      })) ?? [],
                };
              })
              .concat(emptyConsistWagons),
            ),
          }
        : null,
      bookings:
        schedule.scheduleBookings?.map((sb) => ({
          id: sb.booking?.id ?? sb.bookingId,
          reference: sb.booking?.reference ?? null,
          customer: sb.booking?.company?.name ?? sb.booking?.company?.email ?? null,
          weightTons: sb.booking
            ? this.grossBookingWeightTons(sb.booking, tareDims)
            : 0,
          status: sb.booking?.status ?? null,
          schedulingStatus: sb.booking?.schedulingStatus ?? null,
          freightType: sb.booking?.freightType ?? null,
          // Which leg of the corridor this booking rides — the workspace can't
          // tell a ride-along (intercity) or sub-corridor booking from through
          // cargo without it.
          tradeDirection: sb.booking?.tradeDirection ?? null,
          originYardId: sb.booking?.originYardId ?? null,
          destinationYardId: sb.booking?.destinationYardId ?? null,
          origin:
            sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null,
          destination:
            sb.booking?.destinationYard?.label ??
            sb.booking?.destinationYard?.code ??
            null,
          wagonsRequired:
            sb.booking?.wagonsRequired != null
              ? Number(sb.booking.wagonsRequired)
              : null,
          loadedAt: sb.booking?.loadedAt?.toISOString() ?? null,
          arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null,
          // Loaded/unloaded is tracked on the schedule↔booking link, not the
          // booking itself — staff flip it per booking in the workspace before
          // dispatch. Defaults UNLOADED for links written before the column.
          loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
          wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
        })) ?? [],
      // Ordered corridor stops (route milestones; falls back to the two
      // endpoints) — lets the UI draw per-segment occupancy and label legs.
      stops: this.mapScheduleStops(schedule),
      // Gross ceiling the validator holds each leg to: the set's weakest
      // locomotive pull limit plus its overage tolerance. Booking weightTons
      // above are gross too, so the strip can sum them per leg against this.
      maxGrossWeightTons: (() => {
        const setLimits = trainSetLocomotiveLimits(schedule.trainSet);
        return setLimits
          ? roundTons(
              Number(setLimits.maxPullWeightTons) +
                (Number(setLimits.overageToleranceTons) || 0),
            )
          : null;
      })(),
      // True when the wagon plan above is served from the frozen snapshot (schedule
      // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
      // it "historical" and skip re-pin affordances.
      isWagonAllocationFrozen,
      wagonAllocationSnapshot: snapshot,
    };
  }

  /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */
  private mapScheduleStops(
    schedule: TrainSchedule,
  ): Array<{ yardId: string; label: string }> {
    const milestones = [...(schedule.route?.milestones ?? [])].sort(
      (a, b) => a.sequenceNo - b.sequenceNo,
    );
    const raw = milestones.length >= 2
      ? milestones.map((m) => ({
          yardId: m.yardId,
          label: m.yard?.label ?? m.yard?.code ?? m.yardId,
        }))
      : [
          {
            yardId: schedule.originStationId,
            label:
              schedule.originStation?.label ??
              schedule.originStation?.code ??
              schedule.originStationId,
          },
          {
            yardId: schedule.destinationStationId,
            label:
              schedule.destinationStation?.label ??
              schedule.destinationStation?.code ??
              schedule.destinationStationId,
          },
        ];
    const seen = new Set<string>();
    return raw.filter((stop) => {
      if (!stop.yardId || seen.has(stop.yardId)) return false;
      seen.add(stop.yardId);
      return true;
    });
  }

  private isHoldActive(booking: Booking): boolean {
    if (!booking.holdExpiresAt) return false;
    return booking.holdExpiresAt.getTime() > Date.now();
  }

  private resolvePostUnassignStatus(booking: Booking | null): string {
    if (!booking) return SchedulingStatus.NotScheduled;
    if (booking.holdExpiresAt && booking.holdExpiresAt.getTime() > Date.now()) {
      return SchedulingStatus.Holding;
    }
    return SchedulingStatus.Eligible;
  }

  /** Assign one linked-but-unallocated booking onto wagons, preserving existing wagon assignments. */
  async assignUnassignedBookingToWagons(scheduleId: string, bookingId: string) {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!schedule.trainSet?.locomotive) {
      throw new BadRequestException('Schedule has no locomotive — cannot assign booking');
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException(
        `Cannot assign bookings to schedule in status ${schedule.status}`,
      );
    }

    const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
    if (!booking) {
      throw new NotFoundException(`Booking ${bookingId} not found`);
    }
    if (booking.trainScheduleId !== scheduleId) {
      throw new BadRequestException('Booking is not linked to this schedule');
    }
    if (!this.isReadyToLoadBooking(booking)) {
      throw new BadRequestException('Booking is not paid and ready to load');
    }

    const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
    if (wagonAssignedIds.has(bookingId)) {
      throw new BadRequestException('Booking is already assigned to a wagon');
    }

    const allBookingIds = [...wagonAssignedIds, bookingId];
    const previewDto = {
      bookingIds: allBookingIds,
      scheduleDate: schedule.scheduledDepartureDate.toISOString(),
      originStationId: schedule.originStationId,
      destinationStationId: schedule.destinationStationId,
    };
    const limits = await this.resolveTrainLimitConfig(
      undefined,
      trainSetLocomotiveLimits(schedule.trainSet),
    );

    const validation = await this.validateBookingsForScheduling(
      previewDto,
      null,
      false,
      [],
      false,
      limits,
      scheduleId,
    );

    if (!validation.valid) {
      throw new BadRequestException({
        message: `Booking validation failed: ${validation.violations.join('; ')}`,
        violations: validation.violations,
        warnings: validation.warnings,
      });
    }

    if (!validation.bookings.some((b) => b.id === bookingId)) {
      const deferred = validation.deferredBookings.find((d) => d.id === bookingId);
      throw new BadRequestException({
        message: deferred?.reason ?? 'Booking does not fit on available fleet wagons',
        violations: validation.violations,
        warnings: validation.warnings,
        deferredBookings: validation.deferredBookings,
      });
    }

    const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
    const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
    const slots = getContainerSlotSequenceNos(validation.wagonPlan);
    const placements = autoFillPlacements(units, slots);
    const missingForBooking = findMissingContainerNumberIssues(units, placements).find(
      (m) => m.bookingId === bookingId,
    );
    if (missingForBooking) {
      throw new BadRequestException({
        message: missingForBooking.issue,
        violations: [missingForBooking.issue],
      });
    }

    const assignableSet = new Set(validation.bookings.map((b) => b.id));
    const assignPlacements = placementsForBookings(placements, assignableSet, units);
    const needsPlacements = containerBookings.length > 0;

    return this.assignBookingsToSchedule(
      scheduleId,
      {
        bookingIds: validation.bookings.map((b) => b.id),
        containerPlacements: needsPlacements ? assignPlacements : undefined,
      },
      undefined,
    );
  }

  /** Preview wagon allocation issues per linked booking without mutating the schedule. */
  async previewAllocationForSchedule(
    scheduleId: string,
    // Callers that already hold the full schedule graph (batch board detail)
    // pass it in so the preview doesn't re-load the same heavy graph.
    preloadedSchedule?: TrainSchedule,
  ): Promise<WagonAllocationAttemptResult> {
    const schedule =
      preloadedSchedule ??
      (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    return this.buildAllocationAttempt(schedule, false);
  }

  /** Assign all eligible linked bookings to wagons; returns per-booking issues. */
  async tryAutoWagonAllocation(
    scheduleId: string,
  ): Promise<WagonAllocationAttemptResult> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    return this.buildAllocationAttempt(schedule, true);
  }

  private async buildAllocationAttempt(
    schedule: TrainSchedule,
    performAssign: boolean,
  ): Promise<WagonAllocationAttemptResult> {
    const empty: WagonAllocationAttemptResult = {
      assignedBookingIds: [],
      deferred: [],
      issues: [],
      violations: [],
    };

    if (!schedule.trainSet?.locomotive) {
      return { ...empty, violations: ['Schedule has no locomotive — cannot allocate wagons'] };
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      return {
        ...empty,
        violations: [`Cannot allocate wagons for schedule in status ${schedule.status}`],
      };
    }

    const linkedBookings = (schedule.scheduleBookings ?? [])
      .map((sb) => sb.booking)
      .filter((b): b is Booking => Boolean(b));
    const eligible = linkedBookings.filter(
      (b) => SCHEDULABLE_BOOKING_STATUSES.includes(b.status as 'PAID') || b.isGovernment,
    );
    if (!eligible.length) return empty;

    const wagonAssignedIds = await this.getWagonAssignedBookingIds(
      schedule.id,
      schedule,
    );
    const previewDto = {
      bookingIds: eligible.map((b) => b.id),
      scheduleDate: schedule.scheduledDepartureDate.toISOString(),
      originStationId: schedule.originStationId,
      destinationStationId: schedule.destinationStationId,
    };
    const limits = await this.resolveTrainLimitConfig(
      undefined,
      trainSetLocomotiveLimits(schedule.trainSet),
    );

    let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
    try {
      validation = await this.validateBookingsForScheduling(
        previewDto,
        null,
        false,
        [],
        false,
        limits,
        schedule.id,
      );
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Validation failed';
      return {
        ...empty,
        violations: [message],
        issues: eligible.map((b) => ({
          bookingId: b.id,
          status: 'FAILED' as const,
          issue: message,
        })),
      };
    }

    const fittingIds = new Set(validation.bookings.map((b) => b.id));
    const deferredMap = new Map(
      validation.deferredBookings.map((d) => [d.id, d.reason]),
    );
    const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
    const units: ContainerUnitForPlacement[] = expandBookingContainerUnits(containerBookings);
    const slots = getContainerSlotSequenceNos(validation.wagonPlan);
    const placements = autoFillPlacements(units, slots);
    const missingNumbers = findMissingContainerNumberIssues(units, placements);
    const missingByBooking = new Map<string, string>();
    for (const m of missingNumbers) {
      if (!missingByBooking.has(m.bookingId)) missingByBooking.set(m.bookingId, m.issue);
    }
    const placeholderWarnings = new Map<string, string>();
    for (const p of placements) {
      if (!isPlaceholderContainerNumber(p.containerNumber)) continue;
      const unit = units.find(
        (u) => u.bookingContainerId === p.bookingContainerId && u.unitIndex === p.unitIndex,
      );
      if (unit && !placeholderWarnings.has(unit.bookingId)) {
        placeholderWarnings.set(
          unit.bookingId,
          'Container number auto-assigned — verify before dispatch.',
        );
      }
    }

    const assignableIds = validation.bookings
      .filter((b) => !missingByBooking.has(b.id))
      .map((b) => b.id);
    const assignableSet = new Set(assignableIds);
    const assignPlacements = placementsForBookings(
      placements,
      assignableSet,
      units,
    );

    const issues: BookingWagonAllocationIssue[] = eligible.map((b) => {
      const placeholderIssue = placeholderWarnings.get(b.id) ?? null;
      if (wagonAssignedIds.has(b.id) && assignableSet.has(b.id)) {
        return { bookingId: b.id, status: 'ASSIGNED', issue: placeholderIssue };
      }
      if (missingByBooking.has(b.id)) {
        return { bookingId: b.id, status: 'FAILED', issue: missingByBooking.get(b.id)! };
      }
      if (deferredMap.has(b.id)) {
        return { bookingId: b.id, status: 'DEFERRED', issue: deferredMap.get(b.id)! };
      }
      if (!fittingIds.has(b.id)) {
        const refIssue = validation.violations.find((v) => v.includes(b.reference ?? b.id));
        return {
          bookingId: b.id,
          status: 'FAILED',
          issue: refIssue ?? 'Does not fit train capacity or fleet constraints',
        };
      }
      if (wagonAssignedIds.has(b.id)) {
        return { bookingId: b.id, status: 'ASSIGNED', issue: null };
      }
      return { bookingId: b.id, status: 'NOT_ATTEMPTED', issue: null };
    });

    const result: WagonAllocationAttemptResult = {
      assignedBookingIds: [],
      deferred: validation.deferredBookings,
      issues,
      violations: validation.violations,
    };

    if (!performAssign || !assignableIds.length) return result;

    const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
    if (needsPlacements && !assignPlacements.length) {
      return {
        ...result,
        violations: [...result.violations, 'Container placements could not be generated'],
      };
    }

    try {
      await this.assignBookingsToSchedule(
        schedule.id,
        {
          bookingIds: assignableIds,
          containerPlacements: needsPlacements ? assignPlacements : undefined,
        },
        undefined,
      );
      result.assignedBookingIds = assignableIds;
      for (const issue of result.issues) {
        if (assignableSet.has(issue.bookingId)) {
          issue.status = 'ASSIGNED';
          issue.issue = placeholderWarnings.get(issue.bookingId) ?? null;
        }
      }
    } catch (err) {
      const message =
        err instanceof BadRequestException
          ? ((err.getResponse() as { message?: string; violations?: string[] }).violations?.join(
              '; ',
            ) ??
            (err.getResponse() as { message?: string }).message ??
            err.message)
          : err instanceof Error
            ? err.message
            : 'Allocation failed';
      result.violations = [...result.violations, message];
      for (const issue of result.issues) {
        if (assignableSet.has(issue.bookingId) && issue.status !== 'ASSIGNED') {
          issue.status = 'FAILED';
          issue.issue = message;
        }
      }
    }

    return result;
  }

  async removeTrainSetWagonSlot(scheduleId: string, trainSetWagonId: string): Promise<any> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
      throw new BadRequestException('Cannot remove wagon slots from a finalized or dispatched schedule');
    }

    const wagon = (schedule.trainSet?.wagons ?? []).find((w) => w.id === trainSetWagonId);
    if (!wagon) {
      throw new NotFoundException(`Train set wagon ${trainSetWagonId} not found in this schedule`);
    }

    if ((wagon.allocations ?? []).length > 0) {
      throw new BadRequestException(
        'Cannot remove a wagon slot that has active allocations; remove the booking first',
      );
    }

    await this.dataSource.transaction(async (manager) => {
      await manager.getRepository(TrainSetWagon).delete(trainSetWagonId);
      // Recount from the slot rows rather than decrementing the cached counter.
      // A blind `wagonCount - 1` desyncs the moment two removals race or the
      // in-memory schedule graph is stale, and the counter is what the schedule
      // capacity math reads.
      const remaining = await manager.getRepository(TrainSetWagon).find({
        where: { trainSetId: schedule.trainSetId },
        select: { id: true, lengthMeters: true },
      });
      await manager.getRepository(TrainSet).update(schedule.trainSetId, {
        wagonCount: remaining.length,
        totalLengthMeters: roundTons(
          remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0),
        ),
      });
    });

    return this.getTrainScheduleById(scheduleId);
  }

  async updateContainerItem(
    scheduleId: string,
    itemId: string,
    dto: UpdateContainerItemDto,
  ): Promise<{ id: string; containerNumber: string | null }> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (schedule.status === 'DISPATCHED') {
      throw new BadRequestException('Cannot edit a dispatched schedule');
    }

    const item = await this.dataSource.getRepository(WagonAllocationContainerItem).findOne({
      where: { id: itemId },
      relations: ['wagonBookingAllocation', 'wagonBookingAllocation.trainSetWagon'],
    });

    if (!item) {
      throw new NotFoundException(`Container item ${itemId} not found`);
    }

    const wagonId = item.wagonBookingAllocationId;
    const wagonAllocation = await this.dataSource.getRepository(WagonBookingAllocation).findOne({
      where: { id: wagonId },
      relations: ['trainSetWagon'],
    });

    if (!wagonAllocation?.trainSetWagon) {
      throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
    }

    const trainSetWagonId = wagonAllocation.trainSetWagon.id;
    const wagonIds = (schedule.trainSet?.wagons ?? []).map((w) => w.id);
    if (!wagonIds.includes(trainSetWagonId)) {
      throw new NotFoundException(`Container item ${itemId} does not belong to this schedule`);
    }

    await this.dataSource.getRepository(WagonAllocationContainerItem).update(itemId, {
      containerNumber: dto.containerNumber ?? null,
    });

    return { id: itemId, containerNumber: dto.containerNumber ?? null };
  }

  /**
   * Staff rearrange: relocate a wagon's ENTIRE load (all its allocations —
   * a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train.
   * Whole-load moves keep every packing rule intact by construction (a valid
   * load stays valid on any wagon whose type supports it), which is what lets
   * a 20ft pair travel together and swap places with a 40ft, and lets bulk
   * swap with containers.
   *
   * Three shapes, picked from the target:
   * - target is an empty consist-only wagon (coupled on the built train, no
   *   slot row): REPIN — the source slot simply points at that physical wagon
   *   (type/capacity/length follow), and the wagon it left shows as empty.
   * - target is an empty slot: allocations repoint to it and the load-coupled
   *   slot fields (assigned weight, status, board/alight leg) move across.
   * - target is a loaded slot: the two loads swap wagons the same way.
   *
   * Validated per direction: the receiving wagon's type must support the
   * incoming load type, and the incoming cargo must fit its rated payload.
   */
  async moveWagonLoad(
    scheduleId: string,
    sourceWagonId: string,
    dto: MoveWagonLoadDto,
  ): Promise<any> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }
    if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) {
      throw new BadRequestException('Cannot rearrange loads on a dispatched train');
    }
    if (sourceWagonId === dto.targetWagonId) {
      return this.getTrainScheduleById(scheduleId);
    }

    const slots = schedule.trainSet?.wagons ?? [];
    const source = slots.find((w) => w.id === sourceWagonId);
    if (!source) {
      throw new NotFoundException('Source wagon is not part of this schedule');
    }

    const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
    const loadAllocations = (trainSetWagonId: string) =>
      allocRepo.find({ where: { trainSetWagonId } });
    const sourceAllocs = await loadAllocations(source.id);
    if (!sourceAllocs.length) {
      throw new BadRequestException('Source wagon has no load to move');
    }

    // Target: a slot of this train set, or an empty consist-only wagon of the
    // built train (physical wagon with no slot row yet).
    const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
    const wagonForTarget = slotById
      ? null
      : schedule.trainSet?.trainId
        ? await this.dataSource.getRepository(Wagon).findOne({
            where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
            relations: { wagonType: true },
          })
        : null;
    if (!slotById && !wagonForTarget) {
      throw new NotFoundException('Target wagon is not part of this schedule');
    }
    // A physical wagon holds at most one slot. When the caller addressed the
    // wagon directly but a slot is already pinned to it, move into that slot
    // rather than minting a second one on the same wagon.
    const targetSlot =
      slotById ??
      (wagonForTarget
        ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
        : null);
    const consistWagon = targetSlot ? null : wagonForTarget;
    const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
    if (targetSlot && targetSlot.id === source.id) {
      return this.getTrainScheduleById(scheduleId);
    }

    const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
      ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
    ];
    const cargoOf = (allocs: WagonBookingAllocation[]) =>
      allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
    // Name wagons by their physical number — the consist is drawn in the train's
    // coupling order, so a slot's sequenceNo is not the position staff can see.
    const slotLabel = (slot: TrainSetWagon) =>
      slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
    const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
      slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
    const checkReceives = (
      allocs: WagonBookingAllocation[],
      label: string,
      wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
      capacityTons: number,
    ) => {
      const incoming = loadTypesOf(allocs);
      // Unknown type or no declared support list → staff decides; don't block.
      if (wagonType) {
        const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase());
        for (const loadType of incoming) {
          const ok =
            supported.includes(loadType) ||
            (loadType === 'CONTAINER' && wagonType.supportsContainer) ||
            supported.length === 0;
          if (!ok) {
            throw new BadRequestException(
              `Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`,
            );
          }
        }
      }
      const cargo = cargoOf(allocs);
      if (capacityTons > 0 && cargo > capacityTons + 0.001) {
        throw new BadRequestException(
          `Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`,
        );
      }
    };

    // What the target must be able to receive…
    checkReceives(
      sourceAllocs,
      wagonLabel(targetSlot, consistWagon),
      targetSlot ? targetSlot.wagonType : consistWagon?.wagonType,
      Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)),
    );
    // …and, on a swap, what comes back to the source.
    if (targetAllocs.length) {
      checkReceives(
        targetAllocs,
        slotLabel(source),
        source.wagonType,
        Number(source.capacityTons),
      );
    }

    await this.dataSource.transaction(async (manager) => {
      const slotRepo = manager.getRepository(TrainSetWagon);
      const allocs = manager.getRepository(WagonBookingAllocation);

      // Load-coupled slot fields travel with the load; wagon identity stays.
      const loadFieldsOf = (slot: TrainSetWagon) => ({
        assignedWeightTons: slot.assignedWeightTons,
        status: slot.status,
        boardYardId: slot.boardYardId ?? null,
        alightYardId: slot.alightYardId ?? null,
      });
      const emptyLoadFields = {
        assignedWeightTons: 0,
        status: 'PLANNED',
        boardYardId: null,
        alightYardId: null,
      };
      const sourceLoadFields = loadFieldsOf(source);

      // Empty consist wagon with no slot row yet: give it one, then move the
      // load into it. Repinning the SOURCE slot onto that wagon would have been
      // fewer writes, but it renames the wagons instead of moving the load —
      // the loaded slot becomes wagon B and B's identity pops out as an empty
      // wagon where A used to be. Staff read that as the train re-ordering
      // itself. A wagon must never change place because a container moved.
      if (consistWagon) {
        const { maxSequenceNo } = (await slotRepo
          .createQueryBuilder('slot')
          .select('COALESCE(MAX(slot.sequence_no), 0)', 'maxSequenceNo')
          .where('slot.train_set_id = :trainSetId', { trainSetId: source.trainSetId })
          .getRawOne<{ maxSequenceNo: string | number }>()) ?? { maxSequenceNo: 0 };

        const created = await slotRepo.save(
          slotRepo.create({
            trainSetId: source.trainSetId,
            wagonTypeId: consistWagon.wagonTypeId,
            physicalWagonId: consistWagon.id,
            // Plan-order key only — the consist is drawn in the train's coupling
            // order (wagons.sequence_number), so appending here moves nothing.
            // It just has to clear the (train_set_id, sequence_no) unique index.
            sequenceNo: Number(maxSequenceNo) + 1,
            capacityTons: roundTons(
              Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons),
            ),
            lengthMeters: roundTons(
              Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters),
            ),
            ...sourceLoadFields,
          }),
        );

        for (const alloc of sourceAllocs) {
          await allocs.update(alloc.id, { trainSetWagonId: created.id });
        }
        await slotRepo.update(source.id, emptyLoadFields);
        return;
      }

      const target = targetSlot as TrainSetWagon;
      const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields;

      for (const alloc of sourceAllocs) {
        await allocs.update(alloc.id, { trainSetWagonId: target.id });
      }
      for (const alloc of targetAllocs) {
        await allocs.update(alloc.id, { trainSetWagonId: source.id });
      }
      await slotRepo.update(target.id, sourceLoadFields);
      await slotRepo.update(source.id, targetLoadFields);
    });

    return this.getTrainScheduleById(scheduleId);
  }

  async getUnassignedBookings(scheduleId: string): Promise<UnassignedBookingsResponse> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule) {
      throw new NotFoundException(`Train schedule ${scheduleId} not found`);
    }

    const allBookings = await this.bookingsRepository.findAll({
      where: { trainScheduleId: scheduleId },
      select: [
        'id',
        'reference',
        'freightType',
        'priorityScore',
        'cargoTotalWeightVgm',
        'status',
        'schedulingStatus',
        'paymentStatus',
        'isGovernment',
      ],
    });

    const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);

    const unassigned = allBookings
      .filter((b) => !wagonAssignedIds.has(b.id) && this.isReadyToLoadBooking(b))
      .sort((a, b) => (b.priorityScore ?? 0) - (a.priorityScore ?? 0));

    const fleetCounts = await this.countFleetAvailability(
      schedule.originStationId,
      scheduleId,
    );
    const fleetByTypeId = new Map(
      fleetCounts.map((row) => [
        row.wagonTypeId,
        { code: row.wagonTypeCode, available: row.available },
      ]),
    );
    const fleetAtOrigin: FleetAvailabilityRow[] = fleetCounts.map((row) => ({
      wagonTypeId: row.wagonTypeId,
      wagonTypeCode: row.wagonTypeCode,
      needed: 0,
      available: row.available,
      shortfall: 0,
    }));

    // Gross weight needs the scheduling graph (containers, cargo type, wagon
    // types) that the trimmed select above deliberately skips.
    const tareDims = await this.loadWagonTareDims();
    const fullById = new Map(
      (await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
        (b) => [b.id, b],
      ),
    );

    const bookings = await Promise.all(
      unassigned.map(async (b) => {
        const assignability = await this.previewUnassignedBookingAssignability(
          schedule,
          wagonAssignedIds,
          b as Booking,
          fleetByTypeId,
        );
        return {
          id: b.id,
          reference: b.reference ?? null,
          freightType: b.freightType ?? null,
          priorityScore: b.priorityScore ?? 0,
          cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
          // GROSS: cargo + tare of the wagons the booking occupies.
          grossWeightTons: this.grossBookingWeightTons(
            (fullById.get(b.id) ?? b) as Booking,
            tareDims,
          ),
          status: b.status ?? null,
          schedulingStatus: b.schedulingStatus ?? null,
          ...assignability,
        };
      }),
    );

    return { fleetAtOrigin, bookings };
  }

  private async previewUnassignedBookingAssignability(
    schedule: TrainSchedule,
    wagonAssignedIds: Set<string>,
    booking: Booking,
    fleetByTypeId: Map<string, { code: string; available: number }>,
  ): Promise<{
    wagonsRequired: number;
    requiredWagonTypeCode: string;
    yardWagonsAvailable: number;
    canAssign: boolean;
    blockReason: string | null;
    shortage: BookingWagonShortage | null;
  }> {
    if (!schedule.trainSet?.locomotive) {
      return {
        wagonsRequired: 0,
        requiredWagonTypeCode: '',
        yardWagonsAvailable: 0,
        canAssign: false,
        blockReason: 'Schedule has no locomotive',
        shortage: null,
      };
    }

    const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER';
    const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]);
    const resolvedBooking = fullBooking ?? booking;
    // List-based resolution: every wagon type allowed for the booking's
    // container/cargo type counts toward its availability.
    const allowed = this.loadAllowedWagonTypes([resolvedBooking]);
    const candidates =
      freightType === 'BULK'
        ? [...allowed.byCargoTypeId.values()].flat()
        : [...allowed.byContainerTypeId.values()].flat();
    if (!candidates.length) {
      return {
        wagonsRequired: 0,
        requiredWagonTypeCode: '',
        yardWagonsAvailable: 0,
        canAssign: false,
        blockReason: 'No suitable wagon type found',
        shortage: null,
      };
    }

    const bulkCapacity =
      freightType === 'BULK'
        ? Math.max(...candidates.map((wt) => Number(wt.capacityTons)))
        : undefined;
    const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity);
    const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/');
    const yardWagonsAvailable = candidates.reduce(
      (sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0),
      0,
    );

    const allBookingIds = [...wagonAssignedIds, booking.id];
    const previewDto = {
      bookingIds: allBookingIds,
      scheduleDate: schedule.scheduledDepartureDate.toISOString(),
      originStationId: schedule.originStationId,
      destinationStationId: schedule.destinationStationId,
    };
    const limits = await this.resolveTrainLimitConfig(
      undefined,
      trainSetLocomotiveLimits(schedule.trainSet),
    );

    let validation: Awaited<ReturnType<TrainSchedulingService['validateBookingsForScheduling']>>;
    try {
      validation = await this.validateBookingsForScheduling(
        previewDto,
        null,
        false,
        [],
        false,
        limits,
        schedule.id,
      );
    } catch (err) {
      return {
        wagonsRequired,
        requiredWagonTypeCode,
        yardWagonsAvailable,
        canAssign: false,
        blockReason: err instanceof Error ? err.message : 'Validation failed',
        shortage: null,
      };
    }

    if (!validation.valid) {
      return {
        wagonsRequired,
        requiredWagonTypeCode,
        yardWagonsAvailable,
        canAssign: false,
        blockReason: validation.violations[0] ?? 'Booking validation failed',
        shortage: null,
      };
    }

    const fittingIds = new Set(validation.bookings.map((b) => b.id));
    if (!fittingIds.has(booking.id)) {
      const deferred = validation.deferredBookings.find((d) => d.id === booking.id);
      const yardShortfall =
        yardWagonsAvailable < wagonsRequired
          ? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)`
          : null;
      return {
        wagonsRequired,
        requiredWagonTypeCode,
        yardWagonsAvailable,
        canAssign: false,
        blockReason:
          deferred?.reason ??
          yardShortfall ??
          `Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`,
        shortage:
          deferred?.shortage ??
          (yardShortfall
            ? {
                wagonTypeCodes: requiredWagonTypeCode,
                wagonsNeeded: wagonsRequired,
                wagonsAvailable: yardWagonsAvailable,
                wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable),
              }
            : null),
      };
    }

    const containerBookings = validation.bookings.filter((b) => b.freightType === 'CONTAINER');
    if (containerBookings.some((b) => b.id === booking.id)) {
      const units = expandBookingContainerUnits(containerBookings);
      const slots = getContainerSlotSequenceNos(validation.wagonPlan);
      const placements = autoFillPlacements(units, slots);
      const missing = findMissingContainerNumberIssues(units, placements).find(
        (m) => m.bookingId === booking.id,
      );
      if (missing) {
        return {
          wagonsRequired,
          requiredWagonTypeCode,
          yardWagonsAvailable,
          canAssign: false,
          blockReason: missing.issue,
          shortage: null,
        };
      }
    }

    return {
      wagonsRequired,
      requiredWagonTypeCode,
      yardWagonsAvailable,
      canAssign: true,
      blockReason: null,
      shortage: null,
    };
  }

  /**
   * Fleet-shortage preflight for a PAID booking targeting a schedule: the
   * structured per-type shortage this booking would hit if placed on top of the
   * schedule's current wagon assignments, or null when it fits (or is blocked
   * by something other than missing wagons — those keep the legacy link-then-
   * fix-manually path).
   */
  async previewPaidBookingWagonShortage(
    scheduleId: string,
    bookingId: string,
  ): Promise<BookingWagonShortage | null> {
    const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
    if (!schedule?.trainSet?.locomotive) return null;
    if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null;

    const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]);
    if (!booking) return null;

    const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
    const fleetCounts = await this.countFleetAvailability(
      schedule.originStationId,
      scheduleId,
    );
    const fleetByTypeId = new Map(
      fleetCounts.map((row) => [
        row.wagonTypeId,
        { code: row.wagonTypeCode, available: row.available },
      ]),
    );

    const assignability = await this.previewUnassignedBookingAssignability(
      schedule,
      wagonAssignedIds,
      booking,
      fleetByTypeId,
    );
    return assignability.shortage;
  }

  /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */
  private isReadyToLoadBooking(booking: {
    status: string;
    paymentStatus?: string | null;
    isGovernment?: boolean;
  }): boolean {
    if (booking.status === 'EXPIRED') return false;
    if (booking.status === 'SELECTED_FOR_BATCH' || booking.status === 'AWAITING_PAYMENT') {
      return false;
    }
    if (booking.status === 'PAID' || booking.paymentStatus === 'PAID') return true;
    if (booking.isGovernment) return true;
    return false;
  }

  async getCompositionRemovals(scheduleId: string): Promise<any[]> {
    return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
  }

  private async getWagonAssignedBookingIds(
    scheduleId: string,
    // Pass when the caller already holds the schedule with trainSet.wagons —
    // only wagon ids are read here, the old full-graph reload was pure waste.
    preloadedSchedule?: TrainSchedule,
  ): Promise<Set<string>> {
    const schedule =
      preloadedSchedule ??
      (await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
    const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
    if (!wagonIds.length) return new Set();

    const allocations = await this.dataSource.getRepository(WagonBookingAllocation).find({
      where: { trainSetWagonId: In(wagonIds) },
      select: ['bookingId'],
    });
    return new Set(allocations.map((a) => a.bookingId));
  }
}
