import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Inject,
  Injectable,
  Logger,
  NotFoundException,
  forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';

import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';

import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import {
  CreateBookingContainerLineDto,
  CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';

/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];

/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];

const round3 = (n: number): number => Math.round(n * 1000) / 1000;

export interface CreateBookingUnderContractResult {
  booking: Booking;
  warnings: string[];
}

/**
 * Outstanding split remainder of a contract: what was booked in the first split
 * booking's pre-split snapshot MINUS everything currently booked. Container
 * contracts report per size; bulk reports one tonnage figure. `null` when the
 * contract has no live split chain. Consumed by the remainder-placement engine
 * to size the auto-created remainder booking.
 */
export type SplitOutstanding = {
  bySize: Map<string, { total: number; outstanding: number }>;
  bulk: { total: number; outstanding: number } | null;
};

/**
 * The single create path for shipment bookings under a contract.
 *
 * - Path A (transport only): the customer creates the booking once the contract
 *   is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
 * - Path B (customs clearance): only GL Ethiopia creates the booking, once the
 *   contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
 *   shipment data.
 *
 * From booking creation onward the existing batch/payment/allocation pipeline
 * runs unchanged. See docs/new-doc.md §8, §13.
 */
@Injectable()
export class ContractBookingService {
  private readonly logger = new Logger(ContractBookingService.name);

  constructor(
    private readonly contractsRepository: ContractsRepository,
    private readonly bookingsRepository: BookingsRepository,
    private readonly bookingPricingService: BookingPricingService,
    private readonly consolidationService: ConsolidationService,
    private readonly containerTypesService: ContainerTypesService,
    private readonly ruleEngineService: RuleEngineService,
    private readonly milestoneService: ClearanceMilestoneService,
    private readonly workflowService: ClearanceWorkflowService,
    private readonly invoiceService: BookingInvoiceService,
    private readonly bookingNotifier: BookingLifecycleNotifierService,
    private readonly dataSource: DataSource,
    @Inject(forwardRef(() => TrainSchedulingService))
    private readonly trainSchedulingService: TrainSchedulingService,
    @Inject(forwardRef(() => BookingBatchService))
    private readonly bookingBatchService: BookingBatchService,
    @Inject(forwardRef(() => BookingTransitionService))
    private readonly bookingTransitionService: BookingTransitionService,
  ) {}

  async createUnderContract(
    contractId: string,
    dto: CreateBookingUnderContractDto,
    user?: { id?: string } | null,
    actorPermissions?: unknown,
  ): Promise<CreateBookingUnderContractResult> {
    const contract = await this.contractsRepository.findByIdWithRelations(contractId);
    if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);

    // A contract whose quantity cap was fully booked is completed — no further
    // bookings, even while contract validity and a booking window are still
    // open. Capacity released after closure (a cancelled/expired booking)
    // reopens the contract on the next booking attempt. A ONE_TIME contract
    // only closes via a finished split chain, so its room is the outstanding
    // split remainder rather than a cap line.
    if (contract.status === 'CONTRACT_CLOSED') {
      let hasRoom: boolean;
      if (contract.contractKind !== 'GENERAL') {
        const outstanding = await this.splitOutstanding(contract);
        hasRoom = outstanding
          ? contract.freightType === 'CONTAINER'
            ? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
            : (outstanding.bulk?.outstanding ?? 0) > 0.001
          : false;
      } else {
        const capacity = await this.computeCapacity(contract);
        hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
      }
      if (!hasRoom) {
        throw new BadRequestException(
          'This contract is completed — the full contracted quantity has been booked.',
        );
      }
      await this.contractsRepository.update(contract.id, {
        status: 'CONTRACT_ACTIVE',
      } as never);
      contract.status = 'CONTRACT_ACTIVE';
    }

    // GL Ethiopia is identified by the dedicated contract create-booking permission
    // (granted to the edr_gl_ethiopia preset).
    const isGlActor =
      actorPermissions != null &&
      hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);

    const createdByRole = await this.assertGate(contract, isGlActor);

    // Validity window must still be open.
    if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
      throw new BadRequestException('Contract validity has expired — no new bookings.');
    }

    // ONE_TIME: a single shipment at a time. The slot frees only if the prior
    // booking reached a terminal state (e.g. payment expired without shipping),
    // letting the customer re-book within contract validity (doc §10.4).
    // EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
    // the slot for the leftover, but the next booking must take the WHOLE
    // remainder; the customer cannot start any other booking on the contract.
    // If the remainder splits again the same rule repeats until the cap is
    // exhausted and the contract completes.
    if (contract.contractKind === 'ONE_TIME') {
      if (await this.hasSplitBooking(contractId)) {
        await this.assertExactRemainder(contract, dto);
      } else {
        const active = await this.countActiveBookings(contractId);
        if (active > 0) {
          throw new BadRequestException(
            'This one-time contract already has an active booking.',
          );
        }
      }
    } else {
      // GENERAL: draw down against the cargo quantity cap until it is full.
      await this.assertWithinQuantityCap(contract, dto);
    }

    const route = await this.resolveRoute(contract, dto.contractRouteId);
    const warnings: string[] = [];

    const freightType = contract.freightType;

    // GENERAL + customs (Path B) runs per-booking clearance: the booking starts
    // in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
    // operations, and there is NO contract-level clearance cycle to link.
    const generalCustoms =
      contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);

    // GENERAL without customs (Path A) ALSO clears per booking: the customer
    // uploads his own clearance proof on each booking and Operations reviews it
    // (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
    // requestOperation machine). GENERAL intercity (DOMESTIC) follows the same
    // per-booking gate with the intercity document set — ops finalize then puts
    // the booking straight into the ride-along pool (FULLY_EXECUTED), since
    // intercity has no shipment-day request step.
    const generalSelfClear =
      contract.contractKind === 'GENERAL' && !contract.customsClearingEnabled;

    // Intercity (DOMESTIC) bookings ride on a passing import/export train:
    // there is no window and no date — staff accept them onto a train at
    // finalize time, so both the window gate and scheduledDate are skipped.
    const isIntercity = contract.tradeDirection === 'DOMESTIC';
    if (isIntercity && dto.scheduledDate) {
      throw new BadRequestException(
        'Intercity bookings do not pick a date — staff assign them to a passing train',
      );
    }
    // Every other direction keeps the binding shipment day (the DTO field went
    // optional only for intercity).
    if (!isIntercity && !dto.scheduledDate) {
      throw new BadRequestException('A binding shipment day is required');
    }

    // Booking-window gate (config-driven): an operations booking may only be
    // created while the route's booking window is open — import: the day's window
    // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
    // export: within exportBookingLeadHours of departure. Bookings that enter the
    // clearance gate first (Path B customs AND Path A per-booking self-clearance)
    // are scheduled later, so they are not gated here.
    if (!generalCustoms && !generalSelfClear && !isIntercity) {
      await this.trainSchedulingService.assertBookingWindowOpen({
        originYardId: route?.originYardId ?? null,
        destinationYardId: route?.destinationYardId ?? null,
        scheduledDate: dto.scheduledDate ?? null,
        direction: contract.tradeDirection ?? null,
      });
      // EXPORT rides whole or not at all (no split concept): reject the booking
      // up front when no single open train on the day can carry it, telling the
      // customer how much space is still bookable.
      await this.assertExportTrainSpace(contract, route, dto);
    }

    // Hard capacity gate: a container line whose total weight exceeds the
    // container type's max capacity can never be booked — no surcharge path,
    // no override. Checked before any row is written.
    if (freightType === 'CONTAINER') {
      await this.assertWithinMaxCapacity(contract, dto);
      // 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
      // ≤ the cap, and drawdown bookings never pass through submit — so this is
      // their only chance to hard-block an unbalanceable set. Entry order is
      // irrelevant (the check sorts by weight before pairing).
      await this.assert20ftPairableAtCreate(dto);
      // A container number may appear once per train (same day + route).
      await this.assertContainerNumbersAvailable(dto, {
        originYardId: route?.originYardId ?? null,
        destinationYardId: route?.destinationYardId ?? null,
      });
    }

    // Denormalize route/direction/freight onto the booking for the scheduling engine.
    // Retry past a concurrent insert that grabbed the same BK sequence number.
    const booking = await insertWithGeneratedReference(
      () => this.generateReference(),
      (reference) =>
        this.bookingsRepository.create({
      reference,
      companyId: contract.companyId ?? null,
      companyProfileId: contract.companyProfileId ?? null,
      isGovernment: contract.isGovernment,
      governmentInstitution: contract.governmentInstitution ?? null,
      status:
        generalCustoms || generalSelfClear
          ? 'AWAITING_DOCUMENTS'
          : 'OPERATION_REQUEST_PENDING',
      bookingType: 'ONE_TIME',
      contractId: contract.id,
      contractRouteId: route?.id ?? null,
      contractKind: contract.contractKind,
      createdByRole,
      createdByUserId: user?.id ?? null,
      scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
      serviceTypeId: contract.serviceTypeId,
      paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
      contractType: 'NEW',
      customsClearingEnabled: contract.customsClearingEnabled,
      customsClearingAgent: contract.customsClearingAgent ?? null,
      equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
      originYardId: route?.originYardId ?? null,
      destinationYardId: route?.destinationYardId ?? null,
      tradeDirection: contract.tradeDirection,
      freightType,
      cargoTypeId: this.resolveCargoTypeId(contract, dto),
      cargoFreeText: dto.cargoFreeText?.trim() || null,
      isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
      isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
      cargoTotalWeightVgm: this.resolveBulkTons(dto),
      firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
      firstMilePickupLat: contract.firstMilePickupLat ?? null,
      firstMilePickupLng: contract.firstMilePickupLng ?? null,
      lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
      lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
      lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
        } as never),
    );

    // Everything between the insert and the priced update must be all-or-nothing:
    // a throw part-way (container persist, weight rules, pricing) would otherwise
    // leave a 0-price, container-less row in OPERATION_REQUEST_PENDING that
    // occupies the one-time contract's single active-booking slot until the
    // doc-review sweep expires it — and the clearance cycle still points at the
    // previous booking, so the hub keeps offering "Rebook" against a dead draft.
    try {
      // Persist container lines + per-unit container numbers (container freight only).
      if (freightType === 'CONTAINER') {
        await this.persistContainers(booking.id, contract, dto);
      }

      // Reload with containers to compute the total from contract unit rates × qty.
      const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
      if (loaded) {
        if (freightType === 'CONTAINER') {
          await this.applyWeightResults(loaded);
        }
        const computed = await this.bookingPricingService.computePriceForBooking(loaded);
        // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has
        // a positive total, so the zero-price gate below misses it — enforce
        // the pricing hard blocks first. The catch below rolls everything back.
        if (computed.hardBlocked.length > 0) {
          throw new BadRequestException(computed.hardBlocked.join('; '));
        }
        // Reject a zero-price booking outright. A total of 0 means no contract rate
        // matched the route/container (or the rate is unset), so the booking is not
        // valid to ship or invoice. The catch below rolls back the row + its lines.
        if (!(computed.totalAmount > 0)) {
          throw new BadRequestException(
            'Booking price came out as 0 — no contract rate matches this ' +
              'route/cargo. Set the contract rate and try again.',
          );
        }
        await this.bookingsRepository.update(booking.id, {
          totalAmount: computed.totalAmount,
          priorityScore: computed.priorityScore,
          pricingBreakdown: {
            lineItems: computed.lineItems,
            totalAmount: computed.totalAmount,
            currency: computed.currency,
            generatedAt: new Date().toISOString(),
          },
        } as never);
        await this.bookingPricingService.createPricingSnapshots(
          booking.id,
          computed.usedRates,
          computed.appliedModifiers,
        );
        warnings.push(...computed.warnings);
      }
    } catch (err) {
      await this.bookingsRepository
        .deleteContainers(booking.id)
        .catch(() => undefined);
      await this.bookingsRepository.hardDelete(booking.id).catch(() => undefined);
      throw err;
    }

    // Wagon consolidation gate. A container drawdown whose lines leave a partial
    // wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
    // before it can ship. Direct bookings do this at submit; drawdowns have no
    // submit step, so we run it here — BEFORE invoicing/milestones. When it parks
    // for a partner the booking is NOT invoiced or scheduled: those steps run
    // later in finalizeContractBooking, triggered by the pairing event. When it
    // pairs (or needs no consolidation) we finalize inline.
    const withContainers = await this.bookingsRepository.findByIdWithFiles(
      booking.id,
    );

    // Tell staff the booking exists. Placed after the zero-price rollback (which
    // hard-deletes the row) and before the consolidation gate, so it fires
    // exactly once whether the booking parks for a partner or finalizes inline.
    this.bookingNotifier.createdToStaff(withContainers ?? booking);

    const intendedStatus =
      generalCustoms || generalSelfClear
        ? 'AWAITING_DOCUMENTS'
        : 'OPERATION_REQUEST_PENDING';
    if (
      withContainers &&
      freightType === 'CONTAINER' &&
      (await this.consolidationService.needsConsolidationFromBooking(
        withContainers,
      ))
    ) {
      const parked = await this.consolidateDrawdown(
        withContainers,
        intendedStatus,
      );
      warnings.push(parked.message);
      if (!parked.paired) {
        // Waiting for a partner — stop here. The booking sits in
        // PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
        // A parked booking still holds contract capacity, so the cap may
        // already be exhausted by it.
        await this.maybeCompleteContract(contract);
        const pendingResult = await this.bookingsRepository.findByIdWithFiles(
          booking.id,
        );
        return { booking: pendingResult ?? booking, warnings };
      }
    }

    await this.finalizeContractBooking(
      booking.id,
      contract,
      generalCustoms,
    );

    await this.maybeCompleteContract(contract);

    const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
    return { booking: result ?? booking, warnings };
  }

  /**
   * Initiate a BARE booking instance under a GENERAL non-customs contract
   * (Path A per-booking self-clearance). One click, zero input: no schedule
   * date, no cargo, no window check, no pricing. The instance starts in the
   * clearance gate (AWAITING_DOCUMENTS); the customer uploads clearance docs,
   * Operations reviews and finalizes, and only then does the customer complete
   * the booking (cargo + binding day + window check) via
   * {@link completeUnderContract} — the same machinery a one-time shipment uses.
   */
  async initiateUnderContract(
    contractId: string,
    dto: Pick<CreateBookingUnderContractDto, 'contractRouteId'>,
    user?: { id?: string } | null,
    actorPermissions?: unknown,
  ): Promise<CreateBookingUnderContractResult> {
    const contract = await this.contractsRepository.findByIdWithRelations(contractId);
    if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);

    const generalSelfClear =
      contract.contractKind === 'GENERAL' &&
      !contract.customsClearingEnabled &&
      contract.tradeDirection !== 'DOMESTIC';
    if (!generalSelfClear) {
      throw new BadRequestException(
        'Initiate booking applies only to general import/export contracts without customs clearing.',
      );
    }

    if (contract.status === 'CONTRACT_CLOSED') {
      throw new BadRequestException(
        'This contract is completed — the full contracted quantity has been booked.',
      );
    }

    const isGlActor =
      actorPermissions != null &&
      hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
    const createdByRole = await this.assertGate(contract, isGlActor);

    if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
      throw new BadRequestException('Contract validity has expired — no new bookings.');
    }

    const route = await this.resolveRoute(contract, dto.contractRouteId);

    // Bare instance: no cargo, no date, no price. Draws no contract capacity
    // until the customer completes it after clearance.
    const booking = await insertWithGeneratedReference(
      () => this.generateReference(),
      (reference) =>
        this.bookingsRepository.create({
          reference,
          companyId: contract.companyId ?? null,
          companyProfileId: contract.companyProfileId ?? null,
          isGovernment: contract.isGovernment,
          governmentInstitution: contract.governmentInstitution ?? null,
          status: 'AWAITING_DOCUMENTS',
          bookingType: 'ONE_TIME',
          contractId: contract.id,
          contractRouteId: route?.id ?? null,
          contractKind: contract.contractKind,
          createdByRole,
          createdByUserId: user?.id ?? null,
          scheduledDate: null,
          serviceTypeId: contract.serviceTypeId,
          paymentCurrency: this.resolveShipmentCurrency(contract, null),
          contractType: 'NEW',
          customsClearingEnabled: contract.customsClearingEnabled,
          customsClearingAgent: contract.customsClearingAgent ?? null,
          equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
          originYardId: route?.originYardId ?? null,
          destinationYardId: route?.destinationYardId ?? null,
          tradeDirection: contract.tradeDirection,
          freightType: contract.freightType,
          cargoTypeId: this.resolveCargoTypeId(contract, {}),
          isHazardous: contract.isHazardous,
          isReefer: contract.isReefer,
          cargoTotalWeightVgm: 0,
          firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
          firstMilePickupLat: contract.firstMilePickupLat ?? null,
          firstMilePickupLng: contract.firstMilePickupLng ?? null,
          lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
          lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
          lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
        } as never),
    );

    const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
    this.bookingNotifier.createdToStaff(result ?? booking);
    return { booking: result ?? booking, warnings: [] };
  }

  /**
   * Initiate a BARE booking instance for a GENERAL + customs shipment request
   * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it
   * validated the contract (general customs, active, capacity) — the request
   * itself carries the quantities; the instance carries none. Pre-booking
   * customs milestones are seeded immediately so the instance enters the same
   * phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking.
   * GL completes the booking (cargo + day) via {@link completeUnderContract}
   * once the clearance reaches CLEARANCE_READY.
   */
  async initiateForShipmentRequest(
    contract: Contract,
    opts: {
      contractRouteId?: string;
      userId?: string | null;
      /** Billing currency the customer chose on the shipment request. */
      paymentCurrency?: string | null;
    },
  ): Promise<Booking> {
    const generalCustoms =
      contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
    if (!generalCustoms) {
      throw new BadRequestException(
        'Shipment-request initiation applies only to general customs contracts.',
      );
    }
    if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
      throw new BadRequestException('Contract validity has expired — no new bookings.');
    }

    const route = await this.resolveRoute(contract, opts.contractRouteId);

    // No prepay gate: the clearance service fee is billed on the booking
    // invoice at completion, so the document step opens immediately.
    const booking = await insertWithGeneratedReference(
      () => this.generateReference(),
      (reference) =>
        this.bookingsRepository.create({
          reference,
          companyId: contract.companyId ?? null,
          companyProfileId: contract.companyProfileId ?? null,
          isGovernment: contract.isGovernment,
          governmentInstitution: contract.governmentInstitution ?? null,
          status: 'AWAITING_DOCUMENTS',
          bookingType: 'ONE_TIME',
          contractId: contract.id,
          contractRouteId: route?.id ?? null,
          contractKind: contract.contractKind,
          createdByRole: 'CUSTOMER',
          createdByUserId: opts.userId ?? null,
          scheduledDate: null,
          serviceTypeId: contract.serviceTypeId,
          paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
          contractType: 'NEW',
          customsClearingEnabled: contract.customsClearingEnabled,
          customsClearingAgent: contract.customsClearingAgent ?? null,
          equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
          originYardId: route?.originYardId ?? null,
          destinationYardId: route?.destinationYardId ?? null,
          tradeDirection: contract.tradeDirection,
          freightType: contract.freightType,
          cargoTypeId: this.resolveCargoTypeId(contract, {}),
          isHazardous: contract.isHazardous,
          isReefer: contract.isReefer,
          cargoTotalWeightVgm: 0,
          firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
          firstMilePickupLat: contract.firstMilePickupLat ?? null,
          firstMilePickupLng: contract.firstMilePickupLng ?? null,
          lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
          lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
          lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
        } as never),
    );

    // Pre-booking phase only — the post-booking milestones (loading, transit)
    // are seeded when GL completes the booking, mirroring the ONE_TIME flow
    // where GL's booking creation seeds them.
    await this.milestoneService.seedPreBookingMilestonesOnBooking(
      booking.id,
      contract.tradeDirection,
    );

    const created =
      (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
    this.bookingNotifier.createdToStaff(created);
    return created;
  }

  /**
   * Complete a bare initiated booking after its per-booking clearance is
   * finalized (CLEARANCE_READY) or operations returned it for changes
   * (OPERATION_CHANGES_REQUESTED). This is the deferred half of
   * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking
   * window + open-departure checks, pricing, consolidation and invoicing all run
   * here — the same gates a one-time shipment passes at creation.
   *
   * Actor rules mirror {@link assertGate}: a customs (Path B) instance is
   * completed by GL Ethiopia only; a non-customs (Path A) instance by the
   * customer (or staff).
   */
  async completeUnderContract(
    contractId: string,
    bookingId: string,
    dto: CreateBookingUnderContractDto,
    actorPermissions?: unknown,
  ): Promise<CreateBookingUnderContractResult> {
    const contract = await this.contractsRepository.findByIdWithRelations(contractId);
    if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);

    const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
    if (!booking || booking.contractId !== contract.id) {
      throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
    }
    if (
      !['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
        booking.status,
      )
    ) {
      throw new BadRequestException(
        'Clearance must be finalized before the booking can be completed.',
      );
    }
    // An unpaid booking that expired at train dispatch keeps its finished
    // per-booking clearance — GL rebooks it onto a new shipment day instead of
    // forcing the customer through a new shipment request + clearance fee.
    if (booking.status === 'EXPIRED') {
      // Only a booking that completed once (it has a price, so its clearance
      // finished and cargo is persisted) can be rebooked after expiry.
      if (!(Number(booking.totalAmount) > 0)) {
        throw new BadRequestException(
          'Only a previously completed booking can be rebooked after it expires.',
        );
      }
      // Expiry released the booking's contract-capacity hold; if the payload
      // re-states the cargo, make sure the released share is still free.
      if (dto.containers?.length || dto.bulkLines?.length) {
        await this.assertWithinQuantityCap(contract, dto);
      }
      // Drop the departed train's link and fall into the day-only resubmit
      // path below — same machinery as OPERATION_CHANGES_REQUESTED.
      await this.bookingsRepository.update(booking.id, {
        status: 'OPERATION_CHANGES_REQUESTED',
        trainScheduleId: null,
      } as never);
      booking.status = 'OPERATION_CHANGES_REQUESTED';
      booking.trainScheduleId = null;
    }
    // Path B: only GL Ethiopia completes a customs instance — the customer
    // never enters shipment data on a customs contract.
    if (contract.customsClearingEnabled) {
      const isGlActor =
        actorPermissions != null &&
        hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
      if (!isGlActor) {
        throw new ForbiddenException(
          'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.',
        );
      }
    }
    if (!dto.scheduledDate) {
      throw new BadRequestException('A binding shipment day is required');
    }
    if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
      throw new BadRequestException('Contract validity has expired — no new bookings.');
    }

    // Completion is booking time: the route's booking window must be open —
    // the same config-driven gate a direct one-time booking passes at create.
    await this.trainSchedulingService.assertBookingWindowOpen({
      originYardId: booking.originYardId ?? null,
      destinationYardId: booking.destinationYardId ?? null,
      scheduledDate: dto.scheduledDate,
      direction: contract.tradeDirection ?? null,
    });

    const freightType = contract.freightType;
    const hasCargo =
      (booking.bookingContainers?.length ?? 0) > 0 ||
      Number(booking.cargoTotalWeightVgm) > 0;
    const warnings: string[] = [];

    // EXPORT rides whole or not at all (no split concept): the chosen day must
    // have a single open train that carries the whole booking. First completion
    // sizes from the dto's cargo; a changes-requested resubmit (cargo already
    // persisted, only the day re-picked) sizes from the booking itself.
    if (contract.tradeDirection === 'EXPORT') {
      if (hasCargo) {
        const probe = Object.assign(
          Object.create(Object.getPrototypeOf(booking)),
          booking,
          { scheduledDate: new Date(dto.scheduledDate) },
        ) as Booking;
        const report = await this.bookingBatchService.exportSpaceReport(probe);
        if (!report.scheduleId) {
          throw new BadRequestException(
            report.fullMessage ?? 'Not enough train space for this day.',
          );
        }
      } else {
        await this.assertExportTrainSpace(contract, null, dto, {
          originYardId: booking.originYardId ?? null,
          destinationYardId: booking.destinationYardId ?? null,
        });
      }
    }

    // First completion persists cargo and draws contract capacity; a resubmit
    // after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
    // the shipment day.
    if (!hasCargo) {
      await this.assertWithinQuantityCap(contract, dto);
      if (freightType === 'CONTAINER') {
        await this.assertWithinMaxCapacity(contract, dto);
        await this.assert20ftPairableAtCreate(dto);
        // A container number may appear once per train (same day + route).
        await this.assertContainerNumbersAvailable(
          dto,
          {
            originYardId: booking.originYardId,
            destinationYardId: booking.destinationYardId,
          },
          booking.id,
        );
        await this.persistContainers(booking.id, contract, dto);
      }
      await this.bookingsRepository.update(booking.id, {
        cargoTypeId: this.resolveCargoTypeId(contract, dto),
        cargoFreeText: dto.cargoFreeText?.trim() || null,
        cargoTotalWeightVgm: this.resolveBulkTons(dto),
        equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
        // Completion is where the cargo — and therefore the price — is fixed, so
        // it is also where the billing currency is chosen. A bare instance was
        // created before the customer had any figure to look at.
        paymentCurrency: this.resolveShipmentCurrency(
          contract,
          dto.paymentCurrency ?? booking.paymentCurrency,
        ),
      } as never);

      const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
      if (loaded) {
        if (freightType === 'CONTAINER') {
          await this.applyWeightResults(loaded);
        }
        const computed = await this.bookingPricingService.computePriceForBooking(loaded);
        // A zero price means no contract rate matches — roll the cargo back so
        // the instance stays CLEARANCE_READY and can be completed again once
        // the contract rates are fixed (the clearance work is not lost). A
        // pricing hard block (e.g. one of two container sizes has no rate)
        // rolls back the same way: a partially-priced total is positive but
        // the booking must not proceed.
        if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
          await this.bookingsRepository.deleteContainers(booking.id);
          await this.bookingsRepository.update(booking.id, {
            cargoTotalWeightVgm: 0,
          } as never);
          throw new BadRequestException(
            computed.hardBlocked.length > 0
              ? computed.hardBlocked.join('; ')
              : 'Booking price came out as 0 — no contract rate matches this ' +
                'route/cargo. Set the contract rate and try again.',
          );
        }
        await this.bookingsRepository.update(booking.id, {
          totalAmount: computed.totalAmount,
          priorityScore: computed.priorityScore,
          pricingBreakdown: {
            lineItems: computed.lineItems,
            totalAmount: computed.totalAmount,
            currency: computed.currency,
            generatedAt: new Date().toISOString(),
          },
        } as never);
        await this.bookingPricingService.createPricingSnapshots(
          booking.id,
          computed.usedRates,
          computed.appliedModifiers,
        );
        warnings.push(...computed.warnings);
      }

      // Wagon consolidation gate — a partial-wagon 20ft set parks for a partner
      // exactly like a drawdown created with cargo does. The shipment day is
      // stored first so the pairing event can resume straight into the
      // operations queue.
      const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
      if (
        withContainers &&
        freightType === 'CONTAINER' &&
        (await this.consolidationService.needsConsolidationFromBooking(withContainers))
      ) {
        await this.bookingsRepository.update(booking.id, {
          scheduledDate: new Date(dto.scheduledDate),
        } as never);
        const parked = await this.consolidateDrawdown(
          withContainers,
          'OPERATION_REQUEST_PENDING',
        );
        warnings.push(parked.message);
        if (!parked.paired) {
          await this.maybeCompleteContract(contract);
          const pendingResult = await this.bookingsRepository.findByIdWithFiles(booking.id);
          return { booking: pendingResult ?? booking, warnings };
        }
      }

      // Invoice the now-priced booking and, for a customs instance, seed the
      // post-booking milestones (pre-booking ones exist since initiation —
      // ensure* fills only what is missing). Idempotent, non-blocking.
      const generalCustoms =
        contract.contractKind === 'GENERAL' &&
        Boolean(contract.customsClearingEnabled);
      await this.finalizeContractBooking(booking.id, contract, generalCustoms);
      await this.maybeCompleteContract(contract);
    } else if (freightType === 'CONTAINER') {
      // Resubmit only re-picks the shipment day — the persisted container
      // numbers must be free on the newly chosen train day too.
      await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
    }

    // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
    // and the staff notification — the exact machine a one-time booking uses.
    const completed = await this.bookingTransitionService.requestOperation(
      booking.id,
      dto.scheduledDate,
    );
    return { booking: completed, warnings };
  }

  /**
   * Search for a complementary partner for a parked-eligible drawdown, pair it or
   * park it in PENDING_CONSOLIDATION with the resume status it should return to.
   * Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
   * emits booking.consolidation.paired, which finalizes any deferred contract
   * booking. Returns whether a partner was found plus a customer-facing message.
   */
  private async consolidateDrawdown(
    booking: Booking,
    resumeStatus: string,
  ): Promise<{ paired: boolean; message: string }> {
    const slots = await this.consolidationService.slotsFromBooking(booking);
    if (!slots.length) {
      return { paired: false, message: '' };
    }

    const partner = await this.bookingsRepository.findConsolidationPartner(
      booking,
      slots,
    );

    if (partner) {
      await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
      return {
        paired: true,
        message: this.consolidationService.describePaired(
          partner.reference,
          slots,
        ),
      };
    }

    await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
    return {
      paired: false,
      message: this.consolidationService.describePending(booking, slots),
    };
  }

  /**
   * Finalize a contract booking once it is cleared to proceed (needed no
   * consolidation, or has just paired): seed clearance milestones / link the
   * contract cycle, then generate the invoice. Idempotent — safe to call again
   * for a booking that pairs after having waited. Skips a booking that is still
   * PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
   */
  private async finalizeContractBooking(
    bookingId: string,
    contract: Contract,
    generalCustoms: boolean,
  ): Promise<void> {
    const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
    if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;

    // ONE_TIME customs (legacy contract-cycle path): link the contract clearance
    // cycle to this booking, seed post-booking milestones, and lock the contract
    // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
    // and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
    if (contract.customsClearingEnabled && !generalCustoms) {
      const cycle = await this.contractsRepository.currentCycle(contract.id);
      if (cycle) {
        await this.contractsRepository.linkBooking(cycle.id, bookingId);
      }
      await this.milestoneService.seedPostBookingMilestones(
        bookingId,
        contract.tradeDirection,
      );
      await this.contractsRepository.update(contract.id, {
        status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
        clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
      } as never);
    } else if (generalCustoms) {
      // Per-booking clearance: seed the full milestone timeline on the booking.
      // ensure* skips codes that already exist — an initiated instance carries
      // its pre-booking milestones from initiation, and a consolidation pairing
      // replay must not duplicate the timeline.
      await this.milestoneService.ensureBookingMilestones(
        bookingId,
        contract.tradeDirection,
      );
    }

    // Contract bookings are born past the billable gate (the contract is already
    // executed), so the invoice is generated here — they never pass through the
    // legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
    // Idempotent and non-blocking: a billing hiccup must not undo the booking.
    // Skips silently when unbillable (no company / no priced amount).
    await this.invoiceService
      .ensureInvoiceForBooking(booking)
      .catch((err) =>
        this.logger.error(
          `Failed to generate invoice for contract booking ${booking.reference}: ${
            err instanceof Error ? err.message : String(err)
          }`,
        ),
      );

    // On a customs contract the customer never books — GL Ethiopia does it for
    // them (assertGate enforces that) — so tell them their shipment now exists.
    //
    // Gated on the contract, NOT on booking.createdByRole: a GENERAL customs
    // instance is stamped CUSTOMER when the customer's shipment request opens
    // it, yet it is GL who later completes it with cargo and a price. Keying on
    // the role would silently skip exactly that case.
    //
    // Sent from here because this is the single funnel every contract booking
    // passes through exactly once (create, complete, and the deferred
    // consolidation-pairing replay), and it runs after invoicing so the message
    // can quote the priced total.
    if (contract.customsClearingEnabled) {
      // Never let a notification failure read as a finalize failure — the
      // booking is already committed by this point.
      try {
        const priced = await this.bookingsRepository.findByIdWithFiles(bookingId);
        this.bookingNotifier.createdByGlForCustomer(priced ?? booking);
      } catch (err) {
        this.logger.warn(
          `Could not notify the customer that GL created booking ${booking.reference}: ${
            err instanceof Error ? err.message : String(err)
          }`,
        );
      }
    }
  }

  /**
   * A parked drawdown just paired — finalize whichever partner is a contract
   * booking that was waiting (invoice + milestones deferred at creation). The
   * pairing already resumed the booking's status from consolidationResumeStatus;
   * this runs the create-time tail that was skipped. Non-contract partners have
   * their own finalize path (staff accept) and are ignored here.
   */
  @OnEvent('booking.consolidation.paired')
  async onConsolidationPaired(payload: {
    bookingIds: string[];
  }): Promise<void> {
    for (const id of payload.bookingIds ?? []) {
      const booking = await this.bookingsRepository.findByIdWithFiles(id);
      if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
        continue;
      }
      const contract = await this.contractsRepository.findByIdWithRelations(
        booking.contractId,
      );
      if (!contract) continue;
      const generalCustoms =
        contract.contractKind === 'GENERAL' &&
        Boolean(contract.customsClearingEnabled);
      await this.finalizeContractBooking(id, contract, generalCustoms).catch(
        (err) =>
          this.logger.error(
            `Failed to finalize paired contract booking ${booking.reference}: ${
              err instanceof Error ? err.message : String(err)
            }`,
          ),
      );
    }
  }

  /**
   * Returns the role to stamp on the booking, or throws if the caller is not
   * allowed to create one for this contract's execution path.
   */
  private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
    if (contract.customsClearingEnabled) {
      // Path B — Global Logistics creates the booking ON BEHALF OF the customer.
      // The customer never books a customs contract himself.
      if (!isGlActor) {
        throw new ForbiddenException(
          'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
        );
      }
      if (contract.contractKind === 'GENERAL') {
        // GENERAL customs has NO contract clearance cycle — GL books per accepted
        // shipment request while the contract is active; clearance is per booking.
        if (contract.status !== 'CONTRACT_ACTIVE') {
          throw new BadRequestException(
            'Contract must be active to book a shipment.',
          );
        }
        return 'GL_ET';
      }
      // ONE_TIME customs — pre-booking boundary milestone must be complete.
      const boundaryOk = await this.workflowService.isBoundaryComplete(
        contract.id,
        contract.tradeDirection,
      );
      if (!boundaryOk) {
        throw new BadRequestException(
          'Pre-booking clearance is not complete — booking cannot be created yet.',
        );
      }
      return 'GL_ET';
    }

    // Path A — customer (or staff) once the contract is executed.
    if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
      throw new BadRequestException(
        'Contract must be fully executed before booking a shipment.',
      );
    }
    return isGlActor ? 'STAFF' : 'CUSTOMER';
  }

  private async countActiveBookings(contractId: string): Promise<number> {
    return this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('b')
      .where('b.contract_id = :contractId', { contractId })
      .andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
      .getCount();
  }

  /**
   * Whether the contract is in split-remainder mode: some booking on it was
   * reduced by a paid partial batch offer and still holds capacity. A split
   * booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
   * hold and the contract falls back to the plain single-slot rule — the
   * customer can rebook the whole quantity again.
   */
  private async hasSplitBooking(contractId: string): Promise<boolean> {
    const count = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('b')
      .where('b.contract_id = :contractId', { contractId })
      .andWhere('b.is_split = true')
      .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
      .getCount();
    return count > 0;
  }

  /**
   * Outstanding split remainder of a ONE_TIME contract: what the FIRST split
   * booking carried before its reduction (its pre_split_quantities snapshot —
   * one-time contracts have no quantity cap to derive this from) minus
   * everything currently booked on the contract. Bookings that never shipped
   * (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
   * contract has no live split booking.
   */
  /**
   * Public: the remainder-placement engine reads this to size the auto-created
   * remainder booking. Returns `null` when there is no live split chain.
   */
  async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
    const first = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('b')
      .where('b.contract_id = :contractId', { contractId: contract.id })
      .andWhere('b.is_split = true')
      .andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
      .orderBy('b.created_at', 'ASC')
      .getOne();
    if (!first?.preSplitQuantities) return null;

    const booked = await this.bookedQuantities(contract);
    if (contract.freightType === 'CONTAINER') {
      const bySize = new Map<string, { total: number; outstanding: number }>();
      for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
        bySize.set(size, {
          total: Number(total),
          outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
        });
      }
      return { bySize, bulk: null };
    }
    const total = Number(first.preSplitQuantities.bulkTons ?? 0);
    return {
      bySize: new Map(),
      bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
    };
  }

  /**
   * EXPORT whole-booking single-train gate. Export bookings never split — the
   * entire booking must ride ONE open train on the chosen day. When no train
   * fits it whole (trying every fillable train on the corridor, earliest
   * first), reject BEFORE anything is written, with the largest still-bookable
   * space (tons for bulk via the cargo type's wagon type; wagons/containers
   * for container freight) so the customer knows what he CAN book.
   */
  private async assertExportTrainSpace(
    contract: Contract,
    route: ContractRoute | null,
    dto: CreateBookingUnderContractDto,
    yards?: { originYardId: string | null; destinationYardId: string | null },
  ): Promise<void> {
    if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
    const probe = await this.buildExportProbe(contract, route, dto, yards);
    const report = await this.bookingBatchService.exportSpaceReport(probe);
    if (report.scheduleId) return;

    // With export split ON a booking no longer has to ride ONE train whole: the
    // largest fitting part is offered and the leftover is rebooked on the next
    // train. Rejecting on the single-train fit here would block exactly the
    // bookings the split exists to serve — including the auto-created remainder,
    // which by definition did not fit the train it was split off. Fall back to
    // the day total: unbookable only when NO export train that day has room.
    if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
      const fitting = await this.bookingBatchService.fittingTrainsForDay(
        probe,
        eatDay(new Date(dto.scheduledDate)),
        'EXPORT',
      );
      if (fitting.length > 0) return;
      throw new BadRequestException(
        'No export train on this day has space left — pick another shipment day.',
      );
    }

    throw new BadRequestException(
      report.fullMessage ?? 'Not enough train space for this day.',
    );
  }

  /**
   * Unsaved booking twin carrying exactly what the batch engine's capacity
   * math reads: yards + day for the leg, container lines WITH their container
   * type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
   * (wagon-type FK) for tons→wagons conversion.
   */
  private async buildExportProbe(
    contract: Contract,
    route: ContractRoute | null,
    dto: CreateBookingUnderContractDto,
    yards?: { originYardId: string | null; destinationYardId: string | null },
  ): Promise<Booking> {
    const probe = new Booking();
    probe.freightType = contract.freightType;
    probe.tradeDirection = contract.tradeDirection;
    probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
    // Entity types are non-nullable; a missing yard just makes legOf() match no
    // train, which surfaces as "no export train for this day" — the right failure.
    probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
    probe.destinationYardId = (yards?.destinationYardId ??
      route?.destinationYardId) as string;

    if (contract.freightType === 'CONTAINER') {
      const lines = await Promise.all(
        (dto.containers ?? []).map(async (line) => {
          const ct = await this.resolveContainerTypeForSize(
            line.containerSize,
            contract.isReefer || (line.reeferQuantity ?? 0) > 0,
          );
          const bc = new BookingContainer();
          bc.containerSize = line.containerSize;
          bc.quantity = line.quantity;
          bc.containerTypeId = ct.id;
          bc.containerType = ct;
          bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt));
          bc.totalVgmTons = (line.units ?? []).reduce(
            (sum, u) => sum + Number(u.vgmTons ?? 0),
            0,
          );
          return bc;
        }),
      );
      probe.bookingContainers = lines;
      probe.cargoTotalWeightVgm = lines.reduce(
        (sum, l) => sum + Number(l.totalVgmTons ?? 0),
        0,
      );
      return probe;
    }

    probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
    const cargoTypeId = this.resolveCargoTypeId(contract, dto);
    probe.cargoTypeId = cargoTypeId;
    if (cargoTypeId) {
      probe.cargoType =
        (await this.dataSource
          .getRepository(CargoType)
          .findOne({ where: { id: cargoTypeId } })) ?? undefined;
    }
    return probe;
  }

  /**
   * ONE_TIME split chain: the next booking must take the WHOLE outstanding
   * remainder — a one-time contract is a single shipment, so the only way it
   * fragments is the system splitting it on train capacity, never the customer
   * choosing a partial amount.
   */
  private async assertExactRemainder(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): Promise<void> {
    const outstanding = await this.splitOutstanding(contract);
    if (!outstanding) return; // no live split booking — nothing to pin the remainder to

    if (contract.freightType === 'CONTAINER') {
      const sizes = new Set<string>([
        ...outstanding.bySize.keys(),
        ...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
      ]);
      for (const size of sizes) {
        const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
        const requested = (dto.containers ?? [])
          .filter((l) => (l.containerSize ?? '') === size)
          .reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
        if (requested !== remaining) {
          throw new BadRequestException(
            `This one-time contract was split — the next booking must take the whole remainder: ` +
              `${remaining} × ${size || 'container'} container(s), got ${requested}.`,
          );
        }
      }
      return;
    }

    const requested =
      (dto.bulkLines ?? []).reduce(
        (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
        0,
      ) || this.resolveBulkTons(dto) || 0;
    const remaining = outstanding.bulk?.outstanding ?? 0;
    // 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
    if (Math.abs(requested - remaining) > 0.001) {
      throw new BadRequestException(
        `This one-time contract was split — the next booking must take the whole ` +
          `remaining ${remaining} tons, got ${requested}.`,
      );
    }
  }

  // ── GENERAL contract quantity cap (draw-down) ──────────────────────────────

  /**
   * Reject a GENERAL booking whose cargo would exceed the contract's quantity
   * cap. Container caps are per size; bulk is a single tons/items cap. Bookings
   * that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
   */
  /**
   * Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
   * {@link assertWithinQuantityCap} but reads the request's quantity shape.
   */
  async assertRequestWithinCapacity(
    contract: Contract,
    lines: {
      containers?: Array<{ containerSize: string; quantity: number }>;
      bulk?: { cargoWeightTons?: number; itemCount?: number };
    },
  ): Promise<void> {
    const capacity = await this.computeCapacity(contract);
    if (capacity.length === 0) return; // uncapped contract

    if (contract.freightType === 'CONTAINER') {
      for (const line of lines.containers ?? []) {
        const cap = capacity.find((c) => c.containerSize === line.containerSize);
        if (!cap || cap.remaining == null) continue;
        if (line.quantity > cap.remaining) {
          throw new BadRequestException(
            `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
          );
        }
      }
    } else {
      const requested =
        (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
      const cap = capacity.find((c) => c.cap != null);
      if (cap && cap.remaining != null && requested > cap.remaining) {
        throw new BadRequestException(
          `Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
        );
      }
    }
  }

  private async assertWithinQuantityCap(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): Promise<void> {
    const capacity = await this.computeCapacity(contract);
    if (capacity.length === 0) return; // uncapped contract

    if (contract.freightType === 'CONTAINER') {
      for (const line of dto.containers ?? []) {
        const cap = capacity.find((c) => c.containerSize === line.containerSize);
        if (!cap || cap.remaining == null) continue; // size uncapped
        if (line.quantity > cap.remaining) {
          throw new BadRequestException(
            `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
          );
        }
      }
    } else {
      const requested =
        (dto.bulkLines ?? []).reduce(
          (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
          0,
        ) || this.resolveBulkTons(dto) || 0;
      const cap = capacity.find((c) => c.cap != null);
      if (cap && cap.remaining != null && requested > cap.remaining) {
        throw new BadRequestException(
          `Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
        );
      }
    }
  }

  /**
   * Remaining bookable quantity per cargo-scope line: cap minus what prior
   * bookings already consumed. Returns [] when the contract has no caps.
   */
  async computeCapacity(
    contract: Contract,
  ): Promise<
    Array<{
      containerSize?: string | null;
      cargoTypeId?: string | null;
      cap: number | null;
      booked: number;
      remaining: number | null;
    }>
  > {
    const scope = contract.cargoScope ?? [];
    const capped = scope.filter((s) => s.quantityCap != null);
    if (capped.length === 0) return [];

    const booked = await this.bookedQuantities(contract);
    return capped.map((s) => {
      const cap = Number(s.quantityCap);
      const used =
        contract.freightType === 'CONTAINER'
          ? (booked.bySize.get(s.containerSize ?? '') ?? 0)
          : booked.bulk;
      return {
        containerSize: s.containerSize,
        cargoTypeId: s.cargoTypeId,
        cap,
        booked: used,
        remaining: Math.max(0, cap - used),
      };
    });
  }

  /**
   * Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
   * lines as-is, or — for a ONE_TIME contract in split-remainder mode —
   * synthesized lines whose cap is the first split booking's pre-split
   * snapshot and whose remaining is the outstanding remainder, i.e. the exact
   * quantity the next booking must take.
   */
  async capacityView(
    contract: Contract,
  ): Promise<
    Array<{
      containerSize?: string | null;
      cargoTypeId?: string | null;
      cap: number | null;
      booked: number;
      remaining: number | null;
    }>
  > {
    const capacity = await this.computeCapacity(contract);
    if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
      return capacity;
    }
    const outstanding = await this.splitOutstanding(contract);
    if (!outstanding) return capacity;
    if (contract.freightType === 'CONTAINER') {
      return [...outstanding.bySize.entries()].map(([size, s]) => ({
        containerSize: size,
        cargoTypeId: null,
        cap: s.total,
        booked: s.total - s.outstanding,
        remaining: s.outstanding,
      }));
    }
    const bulk = outstanding.bulk;
    if (!bulk) return [];
    return [
      {
        containerSize: null,
        cargoTypeId: null,
        cap: bulk.total,
        booked: round3(bulk.total - bulk.outstanding),
        remaining: bulk.outstanding,
      },
    ];
  }

  /**
   * Complete the contract once its quantity cap is fully consumed. Runs after
   * every booking created under a GENERAL contract, and under a ONE_TIME
   * contract in split-remainder mode (a split remainder being rebooked): when
   * no capped scope line has capacity left, the contract moves to
   * CONTRACT_CLOSED even though its validity window is still open — blocking
   * further bookings and shipment requests, including inside an open booking
   * window. Never throws: a status hiccup must not undo the booking that was
   * just created.
   */
  private async maybeCompleteContract(contract: Contract): Promise<void> {
    try {
      if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;

      // ONE_TIME contracts are governed by the single-active-booking slot, so
      // they normally complete by expiry — EXCEPT once a booking was split: the
      // remainder chain draws down the split booking's pre-split snapshot, and
      // the contract completes when the outstanding remainder hits zero.
      // (An unsplit ONE_TIME never completes here, so re-booking after an
      // expired unpaid booking keeps working.)
      if (contract.contractKind !== 'GENERAL') {
        const outstanding = await this.splitOutstanding(contract);
        if (!outstanding) return;
        // 0.001 tolerance absorbs bulk-ton float rounding (split weights round
        // to 3 decimals); container quantities are integers and unaffected.
        const exhausted =
          contract.freightType === 'CONTAINER'
            ? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
            : (outstanding.bulk?.outstanding ?? 0) <= 0.001;
        if (!exhausted) return;
      } else {
        const capacity = await this.computeCapacity(contract);
        if (capacity.length === 0) return; // uncapped — completes only by expiry
        const exhausted = capacity.every(
          (c) => c.remaining != null && c.remaining <= 0.001,
        );
        if (!exhausted) return;
      }

      await this.contractsRepository.update(contract.id, {
        status: 'CONTRACT_CLOSED',
      } as never);
      this.logger.log(
        `Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
      );
    } catch (err) {
      this.logger.error(
        `Could not evaluate completion for contract ${contract.id}: ${
          err instanceof Error ? err.message : String(err)
        }`,
      );
    }
  }

  /**
   * Quantities already booked under a contract that still hold capacity. Excludes
   * bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
   */
  private async bookedQuantities(
    contract: Contract,
  ): Promise<{ bySize: Map<string, number>; bulk: number }> {
    const releasing = RELEASING_BOOKING_STATUSES;
    if (contract.freightType === 'CONTAINER') {
      const rows = await this.dataSource
        .getRepository(BookingContainer)
        .createQueryBuilder('bc')
        .innerJoin(Booking, 'b', 'b.id = bc.booking_id')
        .select('bc.container_size', 'size')
        .addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
        .where('b.contract_id = :contractId', { contractId: contract.id })
        .andWhere('b.status NOT IN (:...releasing)', { releasing })
        .groupBy('bc.container_size')
        .getRawMany<{ size: string | null; qty: string }>();
      const bySize = new Map<string, number>();
      for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
      return { bySize, bulk: 0 };
    }

    const row = await this.dataSource
      .getRepository(Booking)
      .createQueryBuilder('b')
      .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
      .where('b.contract_id = :contractId', { contractId: contract.id })
      .andWhere('b.status NOT IN (:...releasing)', { releasing })
      .getRawOne<{ tons: string }>();
    return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
  }

  private async resolveRoute(
    contract: Contract,
    contractRouteId?: string,
  ): Promise<ContractRoute | null> {
    const routes = contract.routes ?? [];
    if (contractRouteId) {
      const found = routes.find((r) => r.id === contractRouteId);
      if (!found) {
        throw new BadRequestException('Selected route is not part of this contract.');
      }
      return found;
    }
    // ONE_TIME (or single-route GENERAL): auto-select the only route.
    if (routes.length === 1) return routes[0];
    if (routes.length === 0) return null;
    throw new BadRequestException(
      'contractRouteId is required for multi-route general contracts.',
    );
  }

  private resolveCargoTypeId(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): string | null {
    if (contract.freightType === 'BULK') {
      const bulk = dto.bulkLines?.[0];
      if (bulk?.cargoTypeId) return bulk.cargoTypeId;
      const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
      return scope?.cargoTypeId ?? null;
    }
    const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
    return scope?.cargoTypeId ?? null;
  }

  private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
    if (!dto.bulkLines?.length) return 0;
    return dto.bulkLines.reduce(
      (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
      0,
    );
  }

  /**
   * Per-line handling counts. Each physical container carries its own hazardous
   * / reefer / return switch (entered next to its VGM), so the count is however
   * many units opted in. Forms that predate per-unit switches send line-level
   * counts and no unit flags — those are honoured as-is.
   */
  private handlingCounts(line: CreateBookingContainerLineDto): {
    hazardousQuantity: number;
    reeferQuantity: number;
    returnQuantity: number;
  } {
    const units = line.units ?? [];
    const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
    if (!flagged) {
      return {
        hazardousQuantity: Number(line.hazardousQuantity ?? 0),
        reeferQuantity: Number(line.reeferQuantity ?? 0),
        returnQuantity: Number(line.returnQuantity ?? 0),
      };
    }
    return {
      hazardousQuantity: units.filter((u) => u.isHazardous).length,
      reeferQuantity: units.filter((u) => u.isReefer).length,
      returnQuantity: units.filter((u) => u.isReturn).length,
    };
  }

  /**
   * Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
   * per-container opt-ins decide whether THIS shipment actually uses it. A
   * container contract that allows hazardous but a booking where nobody ticked
   * the switch is not a hazardous booking, and must not fire the surcharge.
   * Bulk keeps the contract flag — it has its own bulk*Quantity fields.
   */
  private resolveShipmentHandlingFlag(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
    field: 'hazardousQuantity' | 'reeferQuantity',
  ): boolean {
    const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
    if (!gated) return false;
    if (contract.freightType !== 'CONTAINER') return true;
    const lines = dto.containers ?? [];
    if (!lines.length) return Boolean(gated);
    return lines.some((l) => this.handlingCounts(l)[field] > 0);
  }

  /**
   * Resolve the booking's equipment return from the per-line return quantities
   * (container freight). The CONTRACT gates the service — like hazardous:
   *  - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
   *    line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
   *  - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
   *    booking-level override (dto.equipmentReturn ?? contract default) applies.
   * Bulk freight keeps the legacy behaviour untouched.
   */
  /**
   * The billing currency for a shipment under this contract.
   *
   * A contract quotes in USD only — the currency is a per-shipment choice now.
   * Precedence: intercity is always ETB (domestic transport is invoiced in
   * birr), then the customer's explicit choice, then the contract's own
   * currency, which is USD for contracts created under the current rule and the
   * grandfathered value for older ones.
   */
  private resolveShipmentCurrency(
    contract: Contract,
    requested?: string | null,
  ): string {
    if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
    return requested?.trim() || contract.paymentCurrency || 'USD';
  }

  private resolveShipmentEquipmentReturn(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): string {
    const legacy =
      dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
    if (contract.freightType !== 'CONTAINER') return legacy;

    const lines = dto.containers ?? [];
    for (const line of lines) {
      const qty = this.handlingCounts(line).returnQuantity;
      if (qty === 0) continue;
      if (contract.equipmentReturn !== 'WITH_RETURN') {
        throw new BadRequestException(
          'This contract was not created with the empty-container return ' +
            'service — return quantities are not allowed on its bookings.',
        );
      }
      if (qty > line.quantity) {
        throw new BadRequestException(
          `Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
        );
      }
    }

    if (contract.equipmentReturn === 'WITH_RETURN') {
      const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
      return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
    }
    return legacy;
  }

  /**
   * Map each contract-scope container size to a concrete container type and
   * persist the booking_container line + its per-unit container numbers. Weight
   * rule results are filled in afterward by {@link applyWeightResults} once all
   * lines exist (a single rule-engine pass over the booking).
   */
  private async persistContainers(
    bookingId: string,
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): Promise<void> {
    const lines = dto.containers ?? [];
    if (!lines.length) {
      throw new BadRequestException('At least one container line is required.');
    }

    // Size strings arrive in mixed formats ("20ft" from the contract scope,
    // bare "20" from the rebook seed) — compare numerically so format never
    // fails a size that IS in scope.
    const allowedSizesFt = new Set(
      (contract.cargoScope ?? [])
        .map((c) => parseInt(c.containerSize ?? '', 10))
        .filter((n) => Number.isFinite(n)),
    );

    const containerRepo = this.dataSource.getRepository(BookingContainer);
    const unitRepo = this.dataSource.getRepository(BookingContainerUnit);

    for (const line of lines) {
      if (
        allowedSizesFt.size &&
        !allowedSizesFt.has(parseInt(line.containerSize, 10))
      ) {
        throw new BadRequestException(
          `Container size ${line.containerSize} is outside the contract scope.`,
        );
      }

      const counts = this.handlingCounts(line);
      const containerType = await this.resolveContainerTypeForSize(
        line.containerSize,
        contract.isReefer || counts.reeferQuantity > 0,
      );

      const vgmPerUnit = line.units.length
        ? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
        : 0;
      const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);

      const containerRow = await containerRepo.save(
        containerRepo.create({
          bookingId,
          containerTypeId: containerType.id,
          containerSize: line.containerSize,
          quantity: line.quantity,
          hazardousQuantity: counts.hazardousQuantity,
          reeferQuantity: counts.reeferQuantity,
          returnQuantity:
            contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
          vgmPerUnitTons: vgmPerUnit,
          totalVgmTons: totalVgm,
          wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
          isOverweight: false,
          overweightExcessTons: null,
        } as Partial<BookingContainer>),
      );

      let sortOrder = 0;
      for (const unit of line.units) {
        await unitRepo.save(
          unitRepo.create({
            bookingContainerId: containerRow.id,
            containerNumber: unit.containerNumber,
            sealNumber: unit.sealNumber ?? null,
            vgmTons: unit.vgmTons,
            isHazardous: unit.isHazardous ?? false,
            isReefer: unit.isReefer ?? false,
            isReturn:
              contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
            sortOrder: sortOrder++,
          }),
        );
      }
    }
  }

  /**
   * Run the rule engine once over the freshly-created booking and persist the
   * overweight result per container line (same ordering the engine returns).
   */
  private async applyWeightResults(booking: Booking): Promise<void> {
    const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    const containers = booking.bookingContainers ?? [];
    const containerRepo = this.dataSource.getRepository(BookingContainer);
    for (let i = 0; i < containers.length; i++) {
      const wr = ruleResult.containerWeightResults[i];
      if (!wr) continue;
      await containerRepo.update(containers[i].id, {
        weightLimitRuleId: wr.weightLimitRuleId,
        isOverweight: wr.isOverweight,
        overweightExcessTons: wr.overweightExcessTons,
      });
    }
  }

  /**
   * Pre-create validation + authoritative price preview for the shipment form:
   * build an UNSAVED booking shaped exactly like {@link createUnderContract}
   * would persist it and run the same BookingPricingService compute over it —
   * base rail freight, first/last-mile trucking, and every rule-engine surcharge
   * (overweight, hazard, reefer, consolidation, …). The portal and the GL
   * backoffice form call this from the price-confirm modal, so the breakdown the
   * user confirms is line-for-line what the booking will be charged. Also runs
   * the 20ft weight-pairing rule, which hard-blocks creation.
   */
  async validateShipment(
    contractId: string,
    dto: CreateBookingUnderContractDto,
  ): Promise<{
    overweightLines: Array<{
      containerTypeCode: string;
      totalVgmTons: number;
      maxAllowedTons: number;
      excessTons: number;
    }>;
    overweightSurchargeAmount: number;
    currency: string | null;
    pairingErrors: string[];
    capacityErrors: string[];
    containerClashErrors: string[];
    spaceErrors: string[];
    lineItems: PriceLineItemDto[];
    totalAmount: number;
  }> {
    const contract = await this.contractsRepository.findByIdWithRelations(contractId);
    if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);

    const lines = dto.containers ?? [];
    if (contract.freightType === 'CONTAINER' && !lines.length) {
      return {
        overweightLines: [],
        overweightSurchargeAmount: 0,
        currency: null,
        pairingErrors: [],
        capacityErrors: [],
        containerClashErrors: [],
        spaceErrors: [],
        lineItems: [],
        totalAmount: 0,
      };
    }

    // Resolve each container line's type + total VGM (sum of unit weights) —
    // mirrors persistContainers so the preview lines match the persisted ones.
    const resolved = await Promise.all(
      lines.map(async (line) => {
        const ct = await this.resolveContainerTypeForSize(
          line.containerSize,
          contract.isReefer || (line.reeferQuantity ?? 0) > 0,
        );
        const totalVgmTons = (line.units ?? []).reduce(
          (s, u) => s + Number(u.vgmTons ?? 0),
          0,
        );
        return { line, ct, totalVgmTons };
      }),
    );

    // Same size-scope gate persistContainers enforces at create, surfaced as a
    // blocking preview error so the form can't confirm a size the contract does
    // not cover. Numeric compare — "20" and "20ft" are the same size.
    const allowedSizesFt = new Set(
      (contract.cargoScope ?? [])
        .map((c) => parseInt(c.containerSize ?? '', 10))
        .filter((n) => Number.isFinite(n)),
    );
    const scopeErrors = allowedSizesFt.size
      ? [
          ...new Set(
            lines
              .map((l) => l.containerSize)
              .filter((s) => !allowedSizesFt.has(parseInt(s, 10))),
          ),
        ].map((s) => `Container size ${s} is outside the contract scope.`)
      : [];

    // The unsaved twin of the booking createUnderContract would write: same
    // denormalized contract fields, same container-line math. No id → the
    // pricing service derives wagon counts from the in-memory lines.
    const route = await this.resolveRoute(contract, dto.contractRouteId);
    const previewBooking = Object.assign(new Booking(), {
      // contractId makes the preview price off the contract's frozen rate
      // snapshots exactly like the persisted booking will — without it the
      // preview total is 0 on a leg with no live rate and the form blocks.
      contractId: contract.id,
      freightType: contract.freightType,
      tradeDirection: contract.tradeDirection,
      paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
      serviceTypeId: contract.serviceTypeId,
      cargoTypeId: this.resolveCargoTypeId(contract, dto),
      isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
      isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
      equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
      isGovernment: contract.isGovernment,
      shippingLineId: null,
      contractRouteId: route?.id ?? null,
      originYardId: route?.originYardId ?? null,
      destinationYardId: route?.destinationYardId ?? null,
      cargoTotalWeightVgm: this.resolveBulkTons(dto),
      firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
      lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
      bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
        Object.assign(new BookingContainer(), {
          containerTypeId: ct.id,
          containerSize: line.containerSize,
          quantity: line.quantity,
          hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
          reeferQuantity: this.handlingCounts(line).reeferQuantity,
          returnQuantity:
            contract.equipmentReturn === 'WITH_RETURN'
              ? this.handlingCounts(line).returnQuantity
              : 0,
          vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
          totalVgmTons,
          wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
        }),
      ),
    }) as Booking;

    const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);

    // The overweight surcharge line is already currency-converted; surface its
    // amount separately so the warning alert can reference the exact charge.
    const overweightSurchargeAmount =
      computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;

    // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
    const twentyFtUnits = resolved
      .filter((r) => (r.line.containerSize ?? '').includes('20'))
      .flatMap((r) =>
        (r.line.units ?? []).map((u, idx) => ({
          label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
          grossWeightTons: Number(u.vgmTons ?? 0),
        })),
      );
    const maxDiff = await this.max20ftPairDiffTons();
    const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
      (v) => v.message,
    );

    // Hard capacity ceiling — a non-empty result means the create call will be
    // rejected, so the form can block submit up front.
    const capacityErrors = await this.ruleEngineService.capacityViolations(
      resolved.map(({ line, ct, totalVgmTons }) => ({
        containerTypeId: ct.id,
        quantity: line.quantity,
        totalVgmTons,
      })),
      contract.tradeDirection,
    );

    // A physical container rides one train only — surface a clash with another
    // active booking on the same day + route in the preview, so the form can
    // hard-block before the create call rejects with the same rule.
    let containerClashErrors: string[] = [];
    if (dto.scheduledDate) {
      const numbers = lines.flatMap((line) =>
        (line.units ?? [])
          .map((u) => (u.containerNumber ?? '').trim().toUpperCase())
          .filter((n) => n.length > 0),
      );
      const clashes = await this.findContainerClashesOnTrain(
        [...new Set(numbers)],
        dto.scheduledDate,
        {
          originYardId: route?.originYardId,
          destinationYardId: route?.destinationYardId,
        },
      );
      containerClashErrors = clashes.map(
        (c) =>
          `${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
      );
    }

    // EXPORT rides whole or not at all — surface the single-train space check
    // in the preview so the form hard-blocks BEFORE the create call rejects
    // with the same message (including how much space is still bookable).
    let spaceErrors: string[] = [];
    if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
      const probe = await this.buildExportProbe(contract, route, dto);
      const report = await this.bookingBatchService.exportSpaceReport(probe);
      if (!report.scheduleId) {
        spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
      }
    }

    return {
      overweightLines: computed.overweightLines,
      overweightSurchargeAmount,
      currency: computed.currency,
      pairingErrors,
      // Pricing hard blocks (missing rate for a container size / requested
      // service) ride the capacity-errors channel so the form hard-blocks in
      // the preview instead of failing at the create call.
      capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked],
      containerClashErrors,
      spaceErrors,
      lineItems: computed.lineItems,
      totalAmount: computed.totalAmount,
    };
  }

  /**
   * Throws when any container line's total weight exceeds the hard capacity
   * ceiling of its weight limit rule. Mirrors validateShipment's line
   * resolution so the gate matches what the form preview reported.
   */
  private async assertWithinMaxCapacity(
    contract: Contract,
    dto: CreateBookingUnderContractDto,
  ): Promise<void> {
    const lines = dto.containers ?? [];
    if (!lines.length) return;

    const containers = await Promise.all(
      lines.map(async (line) => {
        const ct = await this.resolveContainerTypeForSize(
          line.containerSize,
          contract.isReefer || (line.reeferQuantity ?? 0) > 0,
        );
        const totalVgmTons = (line.units ?? []).reduce(
          (s, u) => s + Number(u.vgmTons ?? 0),
          0,
        );
        return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
      }),
    );

    const violations = await this.ruleEngineService.capacityViolations(
      containers,
      contract.tradeDirection,
    );
    if (violations.length) {
      throw new BadRequestException(violations.join('; '));
    }
  }

  /**
   * Hard-block booking creation when the 20ft container weights cannot be
   * balanced onto wagons (pair diff over the global cap). Same rule the
   * shipment-form preview reports as `pairingErrors`, enforced server-side.
   */
  /**
   * A physical container rides one train only. Reject the submission when a
   * container number is entered twice in the same booking (the portal checks
   * this client-side, the API must not trust it) or already sits on another
   * customer's active booking for the same train — same shipment day AND same
   * route (origin/destination yards).
   */
  private async assertContainerNumbersAvailable(
    dto: CreateBookingUnderContractDto,
    route: { originYardId?: string | null; destinationYardId?: string | null },
    excludeBookingId?: string,
  ): Promise<void> {
    const numbers = (dto.containers ?? []).flatMap((line) =>
      (line.units ?? [])
        .map((u) => (u.containerNumber ?? '').trim().toUpperCase())
        .filter((n) => n.length > 0),
    );
    if (!numbers.length) return;

    const seen = new Set<string>();
    const withinBooking = new Set<string>();
    for (const n of numbers) {
      if (seen.has(n)) withinBooking.add(n);
      seen.add(n);
    }
    if (withinBooking.size) {
      throw new BadRequestException(
        `Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
      );
    }

    // Intercity bookings have no shipment day yet — nothing to clash with.
    if (!dto.scheduledDate) return;

    await this.assertNumbersFreeOnTrain(
      numbers,
      dto.scheduledDate,
      route,
      excludeBookingId,
    );
  }

  /**
   * Same train guard for a booking whose containers are already persisted
   * (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
   * stored numbers must be free on the newly chosen day for its route.
   */
  private async assertPersistedContainersAvailable(
    booking: Booking,
    scheduledDate: string,
  ): Promise<void> {
    const rows: Array<{ containerNumber: string }> = await this.dataSource
      .getRepository(BookingContainerUnit)
      .createQueryBuilder('unit')
      .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
      .select('unit.container_number', 'containerNumber')
      .where('line.booking_id = :bookingId', { bookingId: booking.id })
      .getRawMany();
    const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
    if (!numbers.length) return;
    await this.assertNumbersFreeOnTrain(
      numbers,
      scheduledDate,
      {
        originYardId: booking.originYardId,
        destinationYardId: booking.destinationYardId,
      },
      booking.id,
    );
  }

  /**
   * Reject when any of `numbers` sits on another active booking of the same
   * train — same day and same route. Bookings without route yards (legacy
   * rows) are matched on the day alone rather than let through.
   */
  private async assertNumbersFreeOnTrain(
    numbers: string[],
    scheduledDate: string,
    route: { originYardId?: string | null; destinationYardId?: string | null },
    excludeBookingId?: string,
  ): Promise<void> {
    const clashes = await this.findContainerClashesOnTrain(
      numbers,
      scheduledDate,
      route,
      excludeBookingId,
    );

    if (clashes.length) {
      const detail = clashes
        .map((c) => `${c.containerNumber} (booking ${c.reference})`)
        .join(', ');
      throw new ConflictException(
        `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
          'A container can only be on one booking per train — remove it or pick another shipment day.',
      );
    }
  }

  /**
   * Container numbers among `numbers` that already sit on another active
   * booking of the same train — same day and same route. One row per clashing
   * number. Bookings without route yards (legacy rows) match on the day alone
   * rather than let through.
   */
  private async findContainerClashesOnTrain(
    numbers: string[],
    scheduledDate: string,
    route: { originYardId?: string | null; destinationYardId?: string | null },
    excludeBookingId?: string,
  ): Promise<Array<{ containerNumber: string; reference: string }>> {
    if (!numbers.length) return [];
    const qb = this.dataSource
      .getRepository(BookingContainerUnit)
      .createQueryBuilder('unit')
      .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
      .innerJoin(Booking, 'b', 'b.id = line.booking_id')
      .select('unit.container_number', 'containerNumber')
      .addSelect('b.reference', 'reference')
      .where('unit.container_number IN (:...numbers)', { numbers })
      .andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
      .andWhere('b.status NOT IN (:...terminal)', {
        terminal: TERMINAL_BOOKING_STATUSES,
      })
      .andWhere('b.deleted_at IS NULL');
    if (route.originYardId && route.destinationYardId) {
      // Same train = same day + same corridor. A clashing booking whose yards
      // were never denormalized still blocks (NULL yards match any route).
      qb.andWhere(
        '(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
        { originYardId: route.originYardId },
      ).andWhere(
        '(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
        { destinationYardId: route.destinationYardId },
      );
    }
    if (excludeBookingId) {
      qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
    }
    const clashes: Array<{ containerNumber: string; reference: string }> =
      await qb.getRawMany();
    return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
  }

  private async assert20ftPairableAtCreate(
    dto: CreateBookingUnderContractDto,
  ): Promise<void> {
    const twentyFtUnits = (dto.containers ?? [])
      .filter((line) => (line.containerSize ?? '').includes('20'))
      .flatMap((line, lineIdx) =>
        (line.units ?? []).map((u, idx) => ({
          label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
          grossWeightTons: Number(u.vgmTons ?? 0),
        })),
      );
    if (twentyFtUnits.length < 2) return;

    const maxDiff = await this.max20ftPairDiffTons();
    const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
    if (violations.length) {
      throw new BadRequestException(
        `Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
          .map((v) => v.message)
          .join(' ')}`,
      );
    }
  }

  private async max20ftPairDiffTons(): Promise<number> {
    const row = await this.dataSource
      .getRepository(TrainSchedulingGlobalRules)
      .find({ order: { createdAt: 'ASC' }, take: 1 })
      .then((rows) => rows[0] ?? null)
      .catch(() => null);
    const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
    return Number.isFinite(n) ? n : 10;
  }

  /** Pick the default container type for a size; prefer reefer when requested. */
  private async resolveContainerTypeForSize(
    size: string,
    preferReefer: boolean,
  ): Promise<ContainerType> {
    const sizeFt = parseInt(size, 10);
    const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
    const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
    if (!types.length) {
      throw new BadRequestException(`No container type configured for size ${size}.`);
    }
    if (preferReefer) {
      const reefer = types.find((t) => t.isReefer);
      if (reefer) return reefer;
    }
    return types.find((t) => !t.isReefer) ?? types[0];
  }

  private async generateReference(): Promise<string> {
    const year = new Date().getFullYear();
    const seq = await this.bookingsRepository.maxReferenceSequence(year);
    return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
  }
}
