import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  forwardRef,
  GoneException,
  Inject,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
  BookingEvaluationInput,
  RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';

import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
  BOOKING_STATUSES,
  CUSTOMER_EDITABLE_STATUSES,
  FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { PdfRenderService } from '../billing/documents/pdf-render.service';
import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util';

/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
  items: Booking[];
  total: number;
  meta: {
    page: number;
    pageSize: number;
    total: number;
    totalPages: number;
    hasNextPage: boolean;
    hasPreviousPage: boolean;
  };
}

const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
  'SUBMITTED',
  'PENDING_APPROVAL',
  'APPROVED_PENDING_SIGNATURE',
] as const;

/**
 * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
 * the total cargo it's a portion of, and is never negative.
 */
function clampToCargo(value: number | undefined, cargoAmount: number): number {
  const v = Number(value ?? 0);
  if (!Number.isFinite(v) || v <= 0) return 0;
  const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
  return Math.min(v, cap);
}

@Injectable()
export class BookingsService {
  constructor(
    @InjectDataSource() private readonly dataSource: DataSource,
    private readonly bookingsRepository: BookingsRepository,
    private readonly filesService: FilesService,
    private readonly minioService: MinioService,
    // private readonly customersService: CustomersService,
    private readonly companiesService: CompaniesService,
    @Inject(forwardRef(() => TrainSchedulingService))
    private readonly trainSchedulingService: TrainSchedulingService,
    private readonly ruleEngineService: RuleEngineService,
    private readonly containerTypesService: ContainerTypesService,
    private readonly consolidationService: ConsolidationService,
    private readonly vehiclesService: VehiclesService,
    private readonly pdfRender: PdfRenderService,
    private readonly events: EventEmitter2,
  ) {}

  async assignCustomerTruck(
    bookingId: string,
    dto: CustomerTruckAssignmentDto,
  ): Promise<Booking> {
    const booking = await this.findById(bookingId);
    const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
    const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
    const usesMileService =
      booking.tradeDirection === 'IMPORT'
        ? hasLastMile
        : booking.tradeDirection === 'EXPORT'
          ? hasFirstMile
          : hasFirstMile || hasLastMile;
    if (usesMileService) {
      throw new BadRequestException(
        'Customer truck assignment is only allowed when first/last mile delivery is not selected',
      );
    }
    if (booking.customerTruckAssignedAt) {
      throw new ConflictException('Customer truck assignment is already submitted and locked');
    }
    if (booking.paymentStatus !== 'PAID') {
      throw new BadRequestException('Booking must be paid before assigning an external customer truck');
    }

    await this.bookingsRepository.update(bookingId, {
      status: 'TRUCK_ASSIGNED',
      customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
      customerTruckDriverName: dto.driverName.trim(),
      customerTruckType: dto.truckType.trim(),
      customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
      customerTruckAssignedAt: new Date(),
    });

    return this.findById(bookingId);
  }

  /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
  static readonly FREIGHT_ORDER_EXTRA_COPIES = [
    'Original 1 (for Issuing Carrier)',
    'Original 2 (for Consignee)',
    'Original 3 (for Shipper)',
    'Copy 4 (Delivery Receipt)',
    'Copy 5 (Extra Copy)',
    'Copy 6 (Extra Copy)',
    'Copy 7 (Extra Copy)',
    'Copy 8 (for Agent)',
  ] as const;

  async customerTruckFreightOrderCopies(
    bookingId: string,
    extraCopyIndexes: number[] = [],
  ): Promise<{ filename: string; buffer: Buffer }> {
    const booking = await this.findById(bookingId);
    if (!booking.customerTruckAssignedAt) {
      throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
    }

    const trucks: Array<{
      plateNumber: string;
      driverName: string;
      truckType: string;
      arrivedAt: string | null;
      containers: string | null;
    }> = await this.dataSource.query(
      `SELECT a.plate_number AS "plateNumber",
              a.driver_name AS "driverName",
              a.truck_type AS "truckType",
              a.arrived_at AS "arrivedAt",
              string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
         FROM freight.customer_truck_assignments a
         LEFT JOIN freight.customer_truck_containers c
           ON c.assignment_id = a.id AND c.deleted_at IS NULL
        WHERE a.booking_id = $1 AND a.deleted_at IS NULL
        GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
        ORDER BY a.assigned_at`,
      [bookingId],
    );

    // The 2 gate copies are ALWAYS printed; the waybill-style copies are
    // whatever the customer ticked (indexes into the fixed catalog).
    const extraCopies = [...new Set(extraCopyIndexes)]
      .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
      .filter(Boolean);
    const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
    // Chromium when available; otherwise the styled tabular fallback (never the
    // generic text dump — the freight order is an outward-facing gate document).
    const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
      label: 'freight order',
      fallback: (prepared) => buildTabularFallbackPdf(prepared),
    });
    return {
      filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
      buffer,
    };
  }

  /** Resolve trade direction from yard countries; reject client mismatch. */
  /**
   * An intercity corridor is valid when both yards are Ethiopian and at least
   * one non-retired route passes the origin strictly before the destination in
   * its milestone order — that is the corridor an import/export train can
   * serve the booking on.
   */
  private async assertIntercityCorridorExists(
    originYardId: string,
    destinationYardId: string,
  ): Promise<void> {
    const yards = await this.dataSource.getRepository(Yard).find({
      where: { id: In([originYardId, destinationYardId]) },
    });
    if (yards.some((y) => y.country !== 'Ethiopia')) {
      throw new BadRequestException(
        'Intercity bookings only run between Ethiopian yards',
      );
    }
    const rows: Array<{ id: string }> = await this.dataSource.query(
      `SELECT r.id
         FROM freight.routes r
         JOIN freight.route_milestones mo
           ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
         JOIN freight.route_milestones md
           ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
        WHERE mo.sequence_no < md.sequence_no
          AND r.status = 'AVAILABLE'
          AND r.deleted_at IS NULL
        LIMIT 1`,
      [originYardId, destinationYardId],
    );
    if (rows.length === 0) {
      throw new BadRequestException(
        'No route passes through this origin and destination in order — intercity service is not available on this corridor',
      );
    }
  }

  private async resolveTradeDirectionForBooking(
    originYardId: string,
    destinationYardId: string,
    provided?: string,
  ): Promise<string> {
    const yards = await this.dataSource.getRepository(Yard).find({
      where: { id: In([originYardId, destinationYardId]) },
    });
    const origin = yards.find((y) => y.id === originYardId);
    const destination = yards.find((y) => y.id === destinationYardId);
    if (!origin) {
      throw new BadRequestException(`Origin yard ${originYardId} not found`);
    }
    if (!destination) {
      throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
    }
    if (originYardId === destinationYardId) {
      throw new BadRequestException('Origin and destination yards must differ');
    }

    const expected = deriveTradeDirection(origin, destination);
    if (provided && provided !== expected) {
      throw new BadRequestException(
        `tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
      );
    }
    return expected;
  }

  /** Generate a unique booking reference number. */
  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')}`;
  }

  private buildCustomerTruckFreightOrderHtml(
    booking: Booking,
    trucks: Array<{
      plateNumber: string;
      driverName: string;
      truckType: string;
      arrivedAt: string | null;
      containers: string | null;
    }>,
    extraCopies: string[] = [],
  ): string {
    const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
    const assignedAt = booking.customerTruckAssignedAt
      ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
      : '-';

    // Fall back to the legacy single-truck booking columns when there are no
    // multi-truck rows (bookings assigned before the multi-truck feature).
    const truckList =
      trucks.length > 0
        ? trucks
        : booking.customerTruckPlateNumber
          ? [
              {
                plateNumber: booking.customerTruckPlateNumber,
                driverName: booking.customerTruckDriverName ?? '',
                truckType: booking.customerTruckType ?? '',
                arrivedAt: booking.customerTruckArrivedAt
                  ? String(booking.customerTruckArrivedAt)
                  : null,
                containers: booking.customerTruckContainerNumber ?? null,
              },
            ]
          : [];

    const truckRows = truckList
      .map(
        (t, i) => `<tr>
          <td class="num">${i + 1}</td>
          <td>${esc(t.plateNumber)}</td>
          <td>${esc(t.driverName)}</td>
          <td>${esc(t.truckType)}</td>
          <td>${esc(t.containers)}</td>
          <td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
        </tr>`,
      )
      .join('');

    const copy = (watermark: string) => `
      <section class="copy">
        <div class="watermark">${esc(watermark)}</div>
        <div class="top">
          <div>
            <div class="brand">Ethio-Djibouti Railway S.C.</div>
            <h1>Freight Order</h1>
            <div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
          </div>
          <div class="meta">
            Booking
            <strong>${esc(booking.reference)}</strong>
            Generated: ${esc(new Date().toLocaleString('en-GB'))}
          </div>
        </div>
        <div class="summary">
          <div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
          <div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
          <div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
          <div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
          <div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
          <div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
        </div>
        <table>
          <thead>
            <tr>
              <th class="num">#</th>
              <th>Truck plate</th>
              <th>Driver</th>
              <th>Truck type</th>
              <th>Containers loaded</th>
              <th>Arrival</th>
            </tr>
          </thead>
          <tbody>${truckRows}</tbody>
        </table>
        <div class="notice">
          Present this freight order at the warehouse gate. Each truck may only collect the
          containers listed against it; the handover must be signed before any truck leaves.
        </div>
        <div class="signatures">
          <div class="line">Customer / Carrier signature — date</div>
          <div class="line">Port operations verification — date</div>
          <div class="line">Gate security verification — date</div>
        </div>
      </section>`;

    return `<!doctype html>
      <html>
        <head>
          <meta charset="utf-8" />
          <title>Freight Order</title>
          <style>
            @page { size: A4 portrait; margin: 10mm; }
            * { box-sizing: border-box; }
            body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
            .copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
            .watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
            .top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
            .brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
            h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
            .subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
            .meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
            .meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
            .summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
            .tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
            .tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
            .tile strong { font-size: 11px; }
            table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
            th { background: #f8fafc; color: #475569; text-align: left; }
            th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
            .num { text-align: right; width: 26px; }
            .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
            .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
            .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
          </style>
        </head>
        <body>
          ${copy('Copy 1: Port Operations Copy')}
          ${copy('Copy 2: Gate Security & Carrier Copy')}
          ${extraCopies.map((label) => copy(label)).join('')}
        </body>
      </html>`;
  }

  private escapeHtml(value: string): string {
    return value
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  /** Build evaluation input from booking freight shape. */
  /**
   * Whether a service type bundles customs clearance. This is the single source
   * of truth for a booking's `customsClearingEnabled` — the customer cannot
   * diverge from it, and it decides who clears the documents (GL vs Marketing).
   */
  private async resolveIncludesCustoms(serviceTypeId: string): Promise<boolean> {
    const serviceType = await this.dataSource
      .getRepository(ServiceType)
      .findOne({ where: { id: serviceTypeId } });
    return serviceType?.includesCustoms ?? false;
  }

  private async buildEvalInput(dto: {
    freightType: FreightType;
    cargoTypeId?: string | null;
    serviceTypeId: string;
    paymentCurrency: string;
    tradeDirection: string;
    isHazardous?: boolean;
    isReefer?: boolean;
    isGovernment?: boolean;
    shippingLineId?: string | null;
    originYardId?: string | null;
    destinationYardId?: string | null;
    bulkTons?: number;
    containers: CreateBookingContainerDto[];
  }): Promise<BookingEvaluationInput> {
    const containerLines =
      dto.freightType === 'CONTAINER' ? dto.containers : [];

    const containers = await Promise.all(
      containerLines.map(async (c) => {
        const ct = await this.containerTypesService.findById(c.containerTypeId);
        const totalVgmTons = c.quantity * c.vgmPerUnitTons;
        return {
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          totalVgmTons,
          isReefer: ct.isReefer,
          wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
        };
      }),
    );
    const totalWagons = Math.ceil(
      containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
    );

    // Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger
    // fires whenever a container line leaves a wagon partially filled. There is
    // no customer opt-in — partial-wagon cargo always consolidates.
    const allowConsolidation =
      dto.freightType === 'CONTAINER'
        ? await this.needsConsolidation(dto.containers)
        : false;

    return {
      freightType: dto.freightType,
      cargoTypeId: dto.cargoTypeId ?? null,
      serviceTypeId: dto.serviceTypeId,
      paymentCurrency: dto.paymentCurrency,
      tradeDirection: dto.tradeDirection,
      isHazardous: dto.isHazardous ?? false,
      // Bulk reefer comes from the customer toggle; container reefer is derived
      // from the container type and ORed in by the engine.
      isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
      isGovernment: dto.isGovernment ?? false,
      allowConsolidation,
      shippingLineId: dto.shippingLineId,
      originYardId: dto.originYardId ?? null,
      destinationYardId: dto.destinationYardId ?? null,
      totalWagons,
      bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
      containers,
    };
  }

  /**
   * True when any container line leaves a wagon partially filled (e.g. 1×20ft on
   * a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize;
   * cargo that already fills whole wagons never does. This is computed from the
   * container quantities alone — there is no customer-facing opt-in flag.
   */
  private async needsConsolidation(
    containers: CreateBookingContainerDto[],
  ): Promise<boolean> {
    return this.consolidationService.needsConsolidation(
      containers.map((c) => ({
        containerTypeId: c.containerTypeId,
        quantity: c.quantity,
      })),
    );
  }

  /** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
  private async tryAutoConsolidate(booking: Booking): Promise<{
    booking: Booking;
    messages: string[];
  }> {
    const messages: string[] = [];

    if (booking.consolidationPartnerId) {
      return { booking, messages };
    }

    // Only partial-wagon container lines produce slots; full-wagon (and bulk)
    // bookings return none and need no consolidation.
    const slots = await this.consolidationService.slotsFromBooking(booking);
    if (slots.length === 0) {
      return { booking, messages };
    }

    // H9: find + pair must be atomic. Run both inside one transaction where the
    // finder holds a write lock on the candidate partner row and pairing
    // re-asserts both rows are still unpaired before writing — otherwise two
    // concurrent bookings can claim the same partner (or pair an
    // already-paired booking). `didPair` is false when a concurrent flow won
    // the partner, in which case we fall through to parking below.
    const partner = await this.dataSource.transaction(async (manager) => {
      const candidate = await this.bookingsRepository.findConsolidationPartner(
        booking,
        slots,
        manager,
      );
      if (!candidate) return null;
      const didPair = await this.bookingsRepository.pairConsolidationIfUnpaired(
        booking.id,
        candidate.id,
        manager,
      );
      return didPair ? candidate : null;
    });

    if (partner) {
      const paired = await this.findById(booking.id);
      messages.push(
        this.consolidationService.describePaired(partner.reference, slots),
      );
      // Let deferred owners (e.g. contract drawdowns whose invoice/milestones
      // were held while the booking waited) finalize now that a whole wagon
      // exists. Fire-and-forget: a listener failure must not undo the pairing.
      this.events
        .emitAsync('booking.consolidation.paired', {
          bookingIds: [booking.id, partner.id],
        })
        .catch(() => undefined);
      return { booking: paired, messages };
    }

    // No partner yet — park the booking so it waits. Applies both pre-submit
    // (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
    // reach this method.
    if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
      await this.bookingsRepository.parkForConsolidation(booking.id);
    }

    const pending = await this.findById(booking.id);
    messages.push(this.consolidationService.describePending(pending, slots));
    return { booking: pending, messages };
  }

  /**
   * Run consolidation right after a booking reaches SUBMITTED. If a complementary
   * partner already exists, both are paired and moved (back) to SUBMITTED so staff
   * can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
   * waits for a later complementary booking to complete the wagon.
   *
   * Returns the re-fetched booking, so callers can reflect the resulting status
   * (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
   */
  async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
    const booking = await this.findById(bookingId);

    // Already paired (e.g. a partner submitted first) — nothing to do.
    if (booking.consolidationPartnerId) {
      return booking;
    }

    const result = await this.tryAutoConsolidate(booking);
    return result.booking;
  }

  /** Create a new freight booking. */
  async create(
    dto: CreateBookingDto,
    files: Express.Multer.File[],
    userId?: string,
  ): Promise<{ booking: Booking; warnings: string[] }> {
    const warnings: string[] = [];

    // Contract–booking separation: contracts are no longer created through the
    // booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated — clients
    // must use POST /contracts (and create shipments via POST /contracts/:id/bookings).
    if (dto.bookingType === 'GENERAL_CONTRACT') {
      throw new GoneException(
        'General contracts are no longer created here. Use POST /contracts instead.',
      );
    }

    // let customerId = dto.customerId;
    // if (!customerId) {
    //   if (!userId) {
    //     throw new BadRequestException(
    //       'customerId is required or must be resolvable from auth token',
    //     );
    //   }
    //   const customer = await this.customersService.findByUserId(userId);
    //   customerId = customer.id;
    // }

    const isGovernment = dto.isGovernment === true;

    let companyId: string | null | undefined = dto.companyId;
    if (isGovernment) {
      // Government bookings bill to a real seeded government company + an
      // explicitly-chosen importer/exporter profile (no more null company +
      // free-text institution).
      if (!dto.companyId) {
        throw new BadRequestException('A government company is required for government bookings');
      }
      const govCompany = await this.companiesService.findCompanyById(dto.companyId);
      if (govCompany.kind !== CompanyKind.Government) {
        throw new BadRequestException('Selected company is not a government entity');
      }
      if (govCompany.status !== CompanyStatus.Active) {
        throw new BadRequestException('Selected government company is not active');
      }
      if (!dto.companyProfileId) {
        throw new BadRequestException('A government company profile is required for government bookings');
      }
      companyId = govCompany.id;
    } else if (!companyId) {
      if (!userId) {
        throw new BadRequestException(
          'companyId is required or must be resolvable from auth token',
        );
      }
      const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
      // A customer can only book once their company has been approved; the
      // helper names the real status (suspended/blacklisted) when it isn't.
      this.companiesService.assertCompanyActiveFor(company, 'bookings');
      companyId = company.id;
    }

    if (dto.trainScheduleId) {
      // Staff manual pin: the schedule must be OPEN and on the same route.
      const schedule = await this.dataSource
        .getRepository(TrainSchedule)
        .findOne({ where: { id: dto.trainScheduleId } });
      if (!schedule) {
        throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
      }
      if (schedule.bookingWindowStatus !== 'OPEN') {
        throw new BadRequestException('Selected schedule is no longer accepting bookings');
      }
      // Corridor-aware: the booking's leg must lie on the schedule's route in
      // stop order — sub-corridor pins (Dire→Djibouti on an Addis→Djibouti
      // train) are valid.
      const stops = await this.trainSchedulingService.stopYardsForSchedule(
        schedule,
      );
      const fromIdx = stops.indexOf(dto.originYardId);
      const toIdx = stops.indexOf(dto.destinationYardId);
      if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
        throw new BadRequestException('Selected schedule is not on the booking route');
      }
    } else if (dto.scheduledDate) {
      // A real (binding) scheduledDate was supplied (e.g. staff pinning a day
      // directly). Require that the route has at least one OPEN departure on
      // that EAT day AND that some departure that day can physically carry the
      // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get
      // a partial split offer later). The booking wizard does NOT send
      // scheduledDate at creation — it captures a non-binding
      // estimatedShipmentDate instead, and the binding day is chosen later at
      // the operation-request step. General contracts also skip this (each
      // drawdown order validates its own day).
      const day = eatDay(new Date(dto.scheduledDate));
      const { hasDeparture, hasCompatible } =
        await this.trainSchedulingService.checkDayCargoCompatibility(
          dto.originYardId,
          dto.destinationYardId,
          day,
          {
            freightType: dto.freightType as 'CONTAINER' | 'BULK',
            cargoTypeId: dto.cargoTypeId,
            containerTypeIds: (dto.containers ?? [])
              .map((c) => c.containerTypeId)
              .filter((id): id is string => Boolean(id)),
          },
        );
      if (!hasDeparture) {
        throw new BadRequestException(
          'No departures available on the selected day for this route',
        );
      }
      if (!hasCompatible) {
        throw new BadRequestException(
          'No wagon on the selected day can carry this cargo type — please choose another day',
        );
      }
    }

    const containers = dto.containers ?? [];
    assertFreightShape({
      freightType: dto.freightType,
      cargoTypeId: dto.cargoTypeId,
      containers,
    });

    const tradeDirection = await this.resolveTradeDirectionForBooking(
      dto.originYardId,
      dto.destinationYardId,
      dto.tradeDirection,
    );

    // Intercity (DOMESTIC) bookings never get their own train — they ride on a
    // passing import/export train, so there is no booking window and no date to
    // pin. All we require at creation is that the corridor actually lies on a
    // route (origin before destination in some route's milestone order); staff
    // accept the booking onto a concrete train at finalize time.
    if (tradeDirection === 'DOMESTIC') {
      if (dto.scheduledDate || dto.trainScheduleId) {
        throw new BadRequestException(
          'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
        );
      }
      await this.assertIntercityCorridorExists(
        dto.originYardId,
        dto.destinationYardId,
      );
    }

    // Stamp the operational profile this booking belongs to (importer/exporter)
    // so the customer portal can scope lists/KPIs to the active mode. Best-effort
    // for non-government bookings with a resolved company; never blocks creation.
    let companyProfileId: string | null = null;
    if (dto.companyProfileId && companyId) {
      // Explicit profile pin (government booking, or staff booking on behalf):
      // must belong to the chosen company and be active.
      const profile =
        await this.companiesService.getActiveCompanyProfileForBooking(
          companyId,
          dto.companyProfileId,
        );
      companyProfileId = profile.id;
    } else if (companyId) {
      // No explicit profile pin: resolve from the booking's trade direction
      // (import→importer, export→exporter; otherwise the first profile). A
      // forwarder booking sends dto.companyProfileId and takes the branch above.
      companyProfileId =
        await this.companiesService.resolveCompanyProfileIdForBooking(
          companyId,
          tradeDirection,
        );

      // A customer booking under their own account may only do so once the
      // resolved operational profile has been approved by the backoffice. Staff-
      // and government-initiated bookings (companyId supplied explicitly) bypass
      // this gate.
      const customerSelfBooking = !dto.companyId && !!userId;
      if (customerSelfBooking && companyProfileId) {
        await this.companiesService.assertCompanyProfileApprovedForBooking(
          companyProfileId,
        );
      }
    }

    // Every booking must link to a company and a company profile.
    if (!companyId) {
      throw new BadRequestException('A company is required to create a booking');
    }
    if (!companyProfileId) {
      throw new BadRequestException(
        'A company profile is required to create a booking — none could be resolved for this company',
      );
    }

    const needsConsolidation =
      dto.freightType === 'CONTAINER'
        ? await this.needsConsolidation(containers)
        : false;

    const evalInput = await this.buildEvalInput({
      freightType: dto.freightType as FreightType,
      cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
      serviceTypeId: dto.serviceTypeId,
      paymentCurrency: dto.paymentCurrency,
      tradeDirection,
      isHazardous: dto.isHazardous,
      isReefer: dto.isReefer,
      isGovernment,
      shippingLineId: dto.shippingLineId,
      originYardId: dto.originYardId,
      destinationYardId: dto.destinationYardId,
      bulkTons: dto.cargoTotalWeightVgm,
      containers,
    });
    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    this.ruleEngineService.assertNoHardBlocks(ruleResult);

    warnings.push(...ruleResult.warnings);

    // Customs clearing is owned by the service type, not the customer: when the
    // service includes customs, EDR/GL clears it (no external agent); otherwise
    // the customer clears it themselves and may name their broker.
    const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);

    // Explicit reference is caller-chosen (a collision is a real conflict);
    // auto-generated references retry past a concurrent same-sequence insert.
    const insertBooking = (reference: string) =>
      this.bookingsRepository.create({
      reference,
      companyId,
      companyProfileId,
      isGovernment,
      governmentInstitution: dto.governmentInstitution?.trim() || null,
      trainId: dto.trainId,
      trainScheduleId: dto.trainScheduleId ?? null,
      contractType: dto.contractType,
      serviceTypeId: dto.serviceTypeId,
      firstMilePickupAddress: dto.firstMilePickupAddress,
      firstMilePickupLat: dto.firstMilePickupLat ?? null,
      firstMilePickupLng: dto.firstMilePickupLng ?? null,
      lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
      lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
      lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
      customsClearingEnabled: includesCustoms,
      customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
      equipmentReturn: dto.equipmentReturn,
      originYardId: dto.originYardId,
      destinationYardId: dto.destinationYardId,
      tradeDirection,
      freightType: dto.freightType,
      cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
      cargoFreeText: dto.cargoFreeText,
      shippingLineId: dto.shippingLineId,
      cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
      isHazardous: dto.isHazardous ?? false,
      // Bulk reefer is the customer's toggle; container reefer is derived from
      // the container type at pricing time, so the booking-level flag stays off
      // for container freight to avoid double-counting.
      isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
      // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
      // freight tracks this per line, so these are 0 for CONTAINER.
      bulkHazardousQuantity:
        dto.freightType === 'BULK'
          ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
          : 0,
      bulkReeferQuantity:
        dto.freightType === 'BULK'
          ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
          : 0,
      paymentCurrency: dto.paymentCurrency,
      pnrCode: dto.pnrCode,
      financialTerms: dto.financialTerms,
      scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
      estimatedShipmentDate: dto.estimatedShipmentDate
        ? new Date(dto.estimatedShipmentDate)
        : null,
      startDate: dto.startDate ? new Date(dto.startDate) : undefined,
      endDate: dto.endDate ? new Date(dto.endDate) : undefined,
      status: 'DRAFT',
      priorityScore: ruleResult.priorityScore,
      totalAmount: 0,
      paymentStatus: 'PENDING',
      });

    const booking = dto.reference
      ? await insertBooking(dto.reference)
      : await insertWithGeneratedReference(
          () => this.generateReference(),
          insertBooking,
        );

    if (dto.freightType === 'CONTAINER') {
      await this.bookingsRepository.createContainers(
        booking.id,
        containers.map((c, i) => ({
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          hazardousQuantity: c.hazardousQuantity,
          reeferQuantity: c.reeferQuantity,
          weightResult: ruleResult.containerWeightResults[i],
        })),
      );
      const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
      warnings.push(`Estimated wagons required: ${wagonCount}`);
    }

    if (files.length > 0) {
      try {
        await this.filesService.uploadMany(booking.id, 'bookings', files);
      } catch {
        warnings.push('File upload failed — booking was created without attached files.');
      }
    }

    // Reuse the booking profile's onboarding documents instead of asking the
    // customer to re-upload. Snapshot them onto the booking now (by reference),
    // so a later active-profile switch never changes this booking's documents.
    //
    // Skip this when the customer uploaded documents for this booking — those
    // per-booking files take precedence, so auto-attaching the profile snapshots
    // would create duplicates.
    if (companyProfileId && files.length === 0) {
      try {
        const onboardingFiles =
          await this.companiesService.getProfileOnboardingFiles(companyProfileId);
        if (onboardingFiles.length > 0) {
          await this.filesService.attachExistingFiles(
            booking.id,
            'bookings',
            onboardingFiles.map((f, i) => ({
              code: `onboarding_document_${i + 1}`,
              name: f.name,
              url: f.url,
              size: f.size,
              mimeType: f.mimeType,
            })),
          );
        }
      } catch {
        warnings.push(
          'Could not attach onboarding documents — they can be added from the booking page.',
        );
      }
    }

    let full = await this.findById(booking.id);

    if (needsConsolidation) {
      const consolidation = await this.tryAutoConsolidate(full);
      full = consolidation.booking;
      warnings.push(...consolidation.messages);
    }

    return { booking: full, warnings };
  }

  /** Update a draft booking. */
  async update(
    id: string,
    dto: UpdateBookingDto,
    files: Express.Multer.File[],
  ): Promise<{ booking: Booking; warnings: string[] }> {
    const existing = await this.findById(id);
    if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
      throw new BadRequestException(
        'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
      );
    }

    const warnings: string[] = [];
    const freightType = (dto.freightType ?? existing.freightType) as FreightType;
    let containers =
      dto.containers ??
      (existing.bookingContainers ?? [])
        .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
        .map((bc) => ({
          containerTypeId: bc.containerTypeId,
          quantity: bc.quantity,
          vgmPerUnitTons: Number(bc.vgmPerUnitTons),
        }));

    let cargoTypeId =
      dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;

    if (freightType === 'BULK') {
      containers = [];
      if (dto.containers !== undefined) {
        await this.bookingsRepository.deleteContainers(id);
      }
    } else {
      cargoTypeId = null;
      if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
        throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
      }
    }

    assertFreightShape({ freightType, cargoTypeId, containers });

    const originYardId = dto.originYardId ?? existing.originYardId;
    const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
    const tradeDirection = await this.resolveTradeDirectionForBooking(
      originYardId,
      destinationYardId,
      dto.tradeDirection,
    );

    const needsConsolidation =
      freightType === 'CONTAINER'
        ? await this.needsConsolidation(containers)
        : false;

    const evalInput = await this.buildEvalInput({
      freightType,
      cargoTypeId,
      serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
      paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
      tradeDirection,
      isHazardous: dto.isHazardous ?? existing.isHazardous,
      isReefer: dto.isReefer ?? existing.isReefer,
      shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
      originYardId: dto.originYardId ?? existing.originYardId,
      destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
      bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
      containers,
    });

    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    this.ruleEngineService.assertNoHardBlocks(ruleResult);
    warnings.push(...ruleResult.warnings);

    const pricingFieldsChanged = await this.pricingRelevantFieldsChanged(
      existing,
      dto,
      freightType,
      cargoTypeId,
      containers,
    );

    const cargoAmount =
      dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
    const updates: Record<string, unknown> = {
      ...dto,
      freightType,
      cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
      // Booking-level reefer is only meaningful for bulk; container reefer is
      // derived from the container type at pricing time.
      isReefer:
        freightType === 'BULK'
          ? (dto.isReefer ?? existing.isReefer ?? false)
          : false,
      // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
      // container freight (per-line on the containers instead).
      bulkHazardousQuantity:
        freightType === 'BULK'
          ? clampToCargo(
              dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
              cargoAmount,
            )
          : 0,
      bulkReeferQuantity:
        freightType === 'BULK'
          ? clampToCargo(
              dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
              cargoAmount,
            )
          : 0,
      priorityScore: ruleResult.priorityScore,
      tradeDirection,
    };
    // If the route (hence trade direction) changed, re-stamp the operational
    // profile so an edited draft doesn't get stranded under the wrong profile.
    if (
      tradeDirection !== existing.tradeDirection &&
      !existing.isGovernment &&
      existing.companyId
    ) {
      updates.companyProfileId =
        await this.companiesService.resolveCompanyProfileIdForBooking(
          existing.companyId,
          tradeDirection,
        );
    }
    if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
    if (dto.estimatedShipmentDate)
      updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
    if (dto.startDate) updates.startDate = new Date(dto.startDate);
    if (dto.endDate) updates.endDate = new Date(dto.endDate);
    delete updates.containers;

    // Customs clearing always mirrors the (possibly changed) service type — never
    // the client payload — so it can't diverge from the service's customs scope.
    const includesCustoms = await this.resolveIncludesCustoms(
      dto.serviceTypeId ?? existing.serviceTypeId,
    );
    updates.customsClearingEnabled = includesCustoms;
    updates.customsClearingAgent = includesCustoms
      ? null
      : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null);

    await this.bookingsRepository.update(id, updates);

    if (freightType === 'CONTAINER' && dto.containers) {
      await this.bookingsRepository.deleteContainers(id);
      await this.bookingsRepository.createContainers(
        id,
        dto.containers.map((c, i) => ({
          containerTypeId: c.containerTypeId,
          quantity: c.quantity,
          vgmPerUnitTons: c.vgmPerUnitTons,
          hazardousQuantity: c.hazardousQuantity,
          reeferQuantity: c.reeferQuantity,
          weightResult: ruleResult.containerWeightResults[i],
        })),
      );
    }

    if (pricingFieldsChanged) {
      await this.bookingsRepository.invalidatePricingPreview(id);
    }

    if (files.length > 0) {
      await this.filesService.uploadMany(id, 'bookings', files);
    }

    let booking = await this.findById(id);

    if (needsConsolidation && !booking.consolidationPartnerId) {
      const consolidation = await this.tryAutoConsolidate(booking);
      booking = consolidation.booking;
      warnings.push(...consolidation.messages);
    }

    return { booking, warnings };
  }

  /** Parse comma-separated scheduling status query values. */
  private parseSchedulingStatusFilter(filter: FilterBookingDto): {
    schedulingStatuses?: string[];
  } {
    const raw = filter.schedulingStatuses;
    if (!raw) return {};
    const schedulingStatuses = raw
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean);
    return schedulingStatuses.length ? { schedulingStatuses } : {};
  }

  /** Parse comma-separated or repeated status query values. */
  private parseStatusFilter(filter: FilterBookingDto): {
    statuses?: string[];
    status?: string;
  } {
    const allowed = new Set<string>(BOOKING_STATUSES);
    const raw = filter.statuses;
    const statusList = raw
      ? raw
          .split(',')
          .map((s) => s.trim())
          .filter((s) => allowed.has(s))
      : [];

    if (statusList.length > 0) {
      return { statuses: statusList };
    }
    if (filter.status && allowed.has(filter.status)) {
      return { status: filter.status };
    }
    return {};
  }

  /** Return a paginated list of bookings matching the filter. */
  /**
   * Whether a route has at least one OPEN train departure on the given EAT day.
   * Used to validate the binding shipment day chosen at the operation-request
   * step (only days with a schedule are selectable).
   */
  async hasOpenDepartureOnDay(
    originYardId: string,
    destinationYardId: string,
    day: string,
  ): Promise<boolean> {
    return this.trainSchedulingService.existsOpenScheduleOnRouteDay(
      originYardId,
      destinationYardId,
      day,
    );
  }

  /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */
  private cargoIdentityOf(booking: Booking): {
    freightType: 'CONTAINER' | 'BULK';
    cargoTypeId?: string | null;
    containerTypeIds?: string[];
  } {
    return {
      freightType: booking.freightType as 'CONTAINER' | 'BULK',
      cargoTypeId: booking.cargoTypeId ?? null,
      containerTypeIds: (booking.bookingContainers ?? [])
        .map((line) => line.containerTypeId)
        .filter((id): id is string => Boolean(id)),
    };
  }

  /**
   * Day gate for a specific booking: OPEN departure exists AND some departure
   * that day can physically carry the booking's cargo/container type.
   * Quantity never blocks — oversized bookings get a partial split offer.
   */
  async checkDayCompatibilityForBooking(
    booking: Booking,
    day: string,
  ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> {
    return this.trainSchedulingService.checkDayCargoCompatibility(
      booking.originYardId,
      booking.destinationYardId,
      day,
      this.cargoIdentityOf(booking),
    );
  }

  /**
   * Days the customer may pick for THIS booking (operation-request step):
   * cargo-aware — only days whose departures can carry the booking's cargo
   * type. Returns days only, no capacity counts.
   */
  async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> {
    const booking = await this.findById(bookingId);
    return this.trainSchedulingService.getAvailableDaysForCargo({
      originYardId: booking.originYardId,
      destinationYardId: booking.destinationYardId,
      ...this.cargoIdentityOf(booking),
    });
  }

  /**
   * Batched version of the findById flag: marks each page item whose booking
   * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list
   * rows (portal dashboard) can show "Approve delivery" for exactly the
   * generated→signed window. One query for the whole page.
   */
  private async attachHandoverFlags(bookings: Booking[]): Promise<void> {
    const ids = bookings.map((b) => b.id);
    if (!ids.length) return;
    const rows: Array<{ bookingId: string }> = await this.dataSource.query(
      `SELECT DISTINCT booking_id AS "bookingId"
         FROM freight.booking_handovers
        WHERE booking_id = ANY($1::uuid[])
          AND signed_at IS NULL AND deleted_at IS NULL`,
      [ids],
    );
    const pending = new Set(rows.map((r) => r.bookingId));
    for (const b of bookings) {
      (b as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
        pending.has(b.id);
    }
  }

  async findAll(
    filter: FilterBookingDto,
    forceCompanyId?: string,
    forceCompanyProfileId?: string,
  ): Promise<PaginatedBookings> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);
    const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);

    const result = await this.bookingsRepository.findAllPaginated({
      page,
      pageSize,
      ...statusFilter,
      ...schedulingStatusFilter,
      assignedToSchedule: filter.assignedToSchedule,
      // A forced company scope (portal/customer) overrides any caller-provided
      // companyId so a customer can only ever see their own company's bookings.
      // The company guard always applies; the optional companyProfileId filter
      // (from the per-page service filter) narrows WITHIN the company — the repo
      // ANDs both, so cross-company access is impossible.
      companyId: forceCompanyId ?? filter.companyId,
      companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
      contractType: filter.contractType,
      serviceTypeId: filter.serviceTypeId,
      cargoTypeId: filter.cargoTypeId,
      freightType: filter.freightType,
      bookingType: filter.bookingType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      paymentStatus: filter.paymentStatus,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
      scheduledFrom: filter.scheduledFrom,
      scheduledTo: filter.scheduledTo,
      originYardId: filter.originYardId,
      destinationYardId: filter.destinationYardId,
      isGovernment: filter.isGovernment,
      consolidationPaired: filter.consolidationPaired,
      // DTO carries 'true'/'false' strings (query params); the repo option is a
      // real boolean — convert, preserving "not filtered" when absent.
      customsClearingEnabled:
        filter.customsClearingEnabled === undefined
          ? undefined
          : filter.customsClearingEnabled === 'true',
      search: filter.search,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
    await this.attachHandoverFlags(result.items ?? []);
    return result;
  }

  /** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
  private static readonly PAYABLE_STATUSES = [
    'FULLY_EXECUTED',
    'SELECTED_FOR_BATCH',
    'AWAITING_PAYMENT',
  ];

  /**
   * Booking statuses that belong to the customs document-clearance queue. The
   * Global Logistics role is scoped to ONLY these — it never sees the general
   * booking-request list.
   */
  private static readonly CLEARANCE_STATUSES = [
    'AWAITING_DOCUMENTS',
    'DOCUMENTS_UNDER_REVIEW',
    'CLEARANCE_READY',
  ];

  /**
   * List bookings in the customs document-clearance queue. Used by Global
   * Logistics (clearance:view) which has no general bookings:view — so the
   * status set is force-scoped to clearance statuses and can't be widened to
   * arbitrary bookings by a caller-supplied status filter.
   */
  async findClearanceQueue(
    filter: FilterBookingDto,
  ): Promise<PaginatedBookings> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 100;
    // Honour a caller status filter only if it's within the clearance set;
    // otherwise fall back to the full clearance status list.
    const requested = filter.status;
    const statuses =
      requested && BookingsService.CLEARANCE_STATUSES.includes(requested)
        ? [requested]
        : BookingsService.CLEARANCE_STATUSES;

    return this.bookingsRepository.findAllPaginated({
      page,
      pageSize,
      statuses,
      // Global Logistics only clears customs bookings; non-customs clearance is
      // reviewed by Marketing from the booking detail, not this queue.
      customsClearingEnabled: true,
      search: filter.search,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /**
   * List the current customer's bookings that are ready for payment:
   * payable status AND not yet PAID. Company scope is derived from the
   * authenticated user and cannot be widened by the caller.
   */
  async findMyPayable(
    userId: string,
    filter: FilterBookingDto,
  ): Promise<PaginatedBookings> {
    const { company } = await this.companiesService.getCompanyInfoByUserId(userId);

    return this.bookingsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 20,
      statuses: BookingsService.PAYABLE_STATUSES,
      excludePaymentStatus: 'PAID',
      // Company-wide: payables span all of the customer's services.
      companyId: company.id,
      companyProfileId: filter.companyProfileId,
      search: filter.search,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /**
   * Resolve the company a customer user belongs to, for scoping their own
   * bookings. Returns null when no profile/company is linked yet.
   */
  async resolveCustomerCompanyId(userId: string): Promise<string | null> {
    try {
      const { company } =
        await this.companiesService.getCompanyInfoByUserId(userId);
      return company?.id ?? null;
    } catch {
      return null;
    }
  }

  /**
   * Authorize a customer's access to a single booking. Staff are scoped at the
   * controller (they pass `isStaff`); for a customer, the booking must belong
   * to the company the authenticated user is linked to — otherwise it is hidden
   * behind a NotFound so booking IDs can't be probed.
   */
  async assertCustomerCanAccessBooking(
    userId: string | undefined,
    booking: Booking,
  ): Promise<void> {
    if (!userId) {
      throw new ForbiddenException('Authentication required');
    }
    const companyId = await this.resolveCustomerCompanyId(userId);
    if (!companyId || booking.companyId !== companyId) {
      // Don't reveal that the booking exists for another company.
      throw new NotFoundException(`Booking ${booking.id} not found`);
    }
  }

  /**
   * Build the customer-facing shipment tracking payload for a booking from the
   * train schedule it is assigned to and the live checkpoint log. The caller is
   * responsible for authorizing access to the booking first.
   *
   * When the booking has not been assigned to a train yet, returns a valid
   * "no schedule" payload so the UI can show a pre-dispatch state.
   */
  async getBookingTracking(
    bookingId: string,
  ): Promise<Freight.IBookingTracking> {
    const booking = await this.findById(bookingId);

    const journey = {
      bookingStatus: booking.status ?? null,
      bookingOriginYardId: booking.originYardId ?? null,
      bookingDestinationYardId: booking.destinationYardId ?? null,
      loadedAt: booking.loadedAt ? new Date(booking.loadedAt).toISOString() : null,
      arrivedAt: booking.arrivedAt ? new Date(booking.arrivedAt).toISOString() : null,
    };

    const empty: Freight.IBookingTracking = {
      bookingId: booking.id,
      bookingReference: booking.reference,
      hasSchedule: false,
      scheduleId: null,
      trainNumber: null,
      scheduleStatus: null,
      direction: null,
      origin: null,
      destination: null,
      stations: [],
      checkpoints: [],
      currentSequenceNo: -1,
      actualDepartureAt: null,
      actualArrivalAt: null,
      scheduledDepartureAt: null,
      scheduledArrivalAt: null,
      ...journey,
    };

    if (!booking.trainScheduleId) {
      return empty;
    }

    // Pull the live corridor + checkpoints for the assigned schedule. If the
    // schedule was removed, fall back to the pre-dispatch state rather than 500.
    let track: Awaited<
      ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
    >;
    try {
      track = await this.trainSchedulingService.getScheduleCheckpoints(
        booking.trainScheduleId,
      );
    } catch {
      return empty;
    }

    return {
      bookingId: booking.id,
      bookingReference: booking.reference,
      hasSchedule: true,
      scheduleId: track.scheduleId,
      trainNumber: track.trainNumber,
      scheduleStatus: track.status as Freight.TrainScheduleStatus,
      direction: track.direction,
      origin: track.origin,
      destination: track.destination,
      stations: track.stations,
      checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
      currentSequenceNo: track.currentSequenceNo,
      actualDepartureAt: track.actualDepartureAt,
      actualArrivalAt: track.actualArrivalAt,
      scheduledDepartureAt: track.scheduledDepartureAt,
      scheduledArrivalAt: track.scheduledArrivalAt,
      ...journey,
    };
  }

  /** Aggregate metrics and tab counts for the backoffice booking list. */
  async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 20;
    const statusFilter = this.parseStatusFilter(filter);
    const listFilter = {
      ...statusFilter,
      companyId: filter.companyId,
      contractType: filter.contractType,
      serviceTypeId: filter.serviceTypeId,
      cargoTypeId: filter.cargoTypeId,
      freightType: filter.freightType,
      bookingType: filter.bookingType,
      tradeDirection: filter.tradeDirection,
      paymentCurrency: filter.paymentCurrency,
      paymentStatus: filter.paymentStatus,
      createdFrom: filter.createdFrom,
      createdTo: filter.createdTo,
      scheduledFrom: filter.scheduledFrom,
      scheduledTo: filter.scheduledTo,
      originYardId: filter.originYardId,
      destinationYardId: filter.destinationYardId,
      isGovernment: filter.isGovernment,
      consolidationPaired: filter.consolidationPaired,
    };

    const [statusCounts, metrics] = await Promise.all([
      this.bookingsRepository.getStatusCounts(),
      this.bookingsRepository.getListSummaryMetrics({
        ...listFilter,
        page,
        pageSize,
        needsActionStatuses: NEEDS_ACTION_STATUSES,
        urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
      }),
    ]);

    return {
      metrics,
      tabs: mapStatusCountsToTabs(statusCounts),
    };
  }

  /** Get a single booking by ID with files. */
  async findById(id: string): Promise<Booking> {
    const booking = await this.bookingsRepository.findByIdWithFiles(id);
    if (!booking) {
      throw new NotFoundException(`Booking ${id} not found`);
    }

    if (booking.files && booking.files.length > 0) {
      booking.files = await Promise.all(
        booking.files.map(async (file: FileRecord) => {
          const objectName = this.minioService.getObjectNameFromUrl(file.url);
          const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
          return { ...file, signedUrl };
        }),
      );
    }

    // Surface the parent contract's reference for drawdown bookings — the
    // portal detail header shows it (the entity has no contract relation, so
    // the list attaches it via a raw join and the detail attaches it here).
    if (booking.contractId) {
      const contract = await this.dataSource.getRepository(Contract).findOne({
        where: { id: booking.contractId },
        select: { reference: true },
      });
      (booking as Booking & { contractReference?: string | null }).contractReference =
        contract?.reference ?? null;
    }

    // Surface the assigned train's operational status so the portal stepper
    // can show the Arrival stage: the booking status stays IN_TRANSIT from
    // dispatch until delivery, so arrival is only knowable from the schedule.
    if (booking.trainScheduleId) {
      const schedule = await this.dataSource
        .getRepository(TrainSchedule)
        .findOne({ where: { id: booking.trainScheduleId } });
      (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus =
        schedule?.status ?? null;
    }

    // A generated-but-unsigned handover means the customer must approve delivery
    // from the portal. Self-haul: booking-based, one per booking. EDR last-mile:
    // per delivering truck (generated on truck exit), signed one by one.
    const [pendingHandover] = await this.dataSource.query(
      `SELECT 1 FROM freight.booking_handovers
        WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL
        LIMIT 1`,
      [id],
    );
    (booking as Booking & { handoverAwaitingSignature?: boolean }).handoverAwaitingSignature =
      Boolean(pendingHandover);

    return booking;
  }

  async findByReference(reference: string): Promise<Booking> {
    const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
    if (!booking) {
      throw new NotFoundException(`Booking with reference "${reference}" not found`);
    }
    return this.findById(booking.id);
  }

  /** Upload documents for a DRAFT booking. */
  async uploadDocuments(
    id: string,
    files: Express.Multer.File[],
  ): Promise<Booking> {
    const booking = await this.findById(id);
    if (booking.status !== 'DRAFT') {
      throw new BadRequestException(
        'Documents can only be uploaded for DRAFT bookings',
      );
    }
    await this.filesService.uploadMany(id, 'bookings', files);
    return this.findById(id);
  }

  async remove(id: string): Promise<void> {
    const booking = await this.findById(id);
    if (booking.status !== 'DRAFT') {
      throw new BadRequestException('Only DRAFT bookings can be deleted');
    }
    await this.bookingsRepository.softDelete(id);
  }

  async findQueue(
    queue: string,
    filter: FilterBookingDto,
    options?: { excludeBulk?: boolean },
  ): Promise<{ items: Booking[]; total: number }> {
    const statusMap: Record<string, string | string[]> = {
      intake: 'SUBMITTED',
      approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
      signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
      contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
      marketing: 'SIGNED_CUSTOMER',
      finance: 'FULLY_EXECUTED',
    };

    const status = statusMap[queue];
    if (!status) {
      throw new BadRequestException(`Unknown queue: ${queue}`);
    }

    return this.bookingsRepository.findQueue({
      status,
      page: filter.page,
      pageSize: filter.pageSize,
      excludeBulk: options?.excludeBulk ?? queue === 'approval',
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  async requestConsolidation(id: string): Promise<{
    booking: Booking;
    partner: Booking | null;
    paired: boolean;
    message: string;
  }> {
    const booking = await this.findById(id);

    const needs = await this.consolidationService.needsConsolidationFromBooking(
      booking,
    );
    if (!needs) {
      throw new BadRequestException(
        'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
      );
    }

    if (booking.consolidationPartnerId) {
      throw new ConflictException('Booking is already paired for consolidation');
    }

    const result = await this.tryAutoConsolidate(booking);
    const partner = result.booking.consolidationPartnerId
      ? await this.findById(result.booking.consolidationPartnerId)
      : null;

    return {
      booking: result.booking,
      partner,
      paired: partner !== null,
      message: result.messages[0] ?? '',
    };
  }

  async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
    const booking = await this.findById(id);
    if (!booking.consolidationPartnerId) {
      throw new BadRequestException('Booking has no consolidation partner');
    }

    const partnerId = booking.consolidationPartnerId;
    await this.bookingsRepository.unpairConsolidation(id, partnerId);

    return {
      booking: await this.findById(id),
      partner: await this.findById(partnerId),
    };
  }

  async getConsolidationDetails(id: string): Promise<{
    booking: Booking;
    partner: Booking | null;
    splitBilling: { bookingShare: number; partnerShare: number } | null;
    wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
    statusMessage: string;
  }> {
    const booking = await this.findById(id);
    const wagonSlots = await this.consolidationService.slotsFromBooking(booking);

    if (!booking.consolidationPartnerId) {
      const statusMessage =
        booking.status === 'PENDING_CONSOLIDATION'
          ? this.consolidationService.describePending(booking, wagonSlots)
          : wagonSlots.length > 0
            ? 'Consolidation may be required; no partner paired yet.'
            : 'No wagon consolidation needed.';
      return {
        booking,
        partner: null,
        splitBilling: null,
        wagonSlots,
        statusMessage,
      };
    }

    const partner = await this.findById(booking.consolidationPartnerId);
    return {
      booking,
      partner,
      splitBilling: {
        bookingShare: Number(booking.totalAmount),
        partnerShare: Number(partner.totalAmount),
      },
      wagonSlots,
      statusMessage: this.consolidationService.describePaired(
        partner.reference,
        wagonSlots,
      ),
    };
  }

  private async pricingRelevantFieldsChanged(
    existing: Booking,
    dto: UpdateBookingDto,
    freightType: FreightType,
    cargoTypeId: string | null | undefined,
    containers: CreateBookingContainerDto[],
  ): Promise<boolean> {
    if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
      return true;
    }
    if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
      return true;
    }
    if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
      return true;
    }
    if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
      return true;
    }
    if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
      return true;
    }
    if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
      return true;
    }
    // Container lines drive both the base price and the consolidation surcharge
    // (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices.
    if (dto.containers !== undefined) {
      const existingContainers = (existing.bookingContainers ?? [])
        .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
        .map((bc) => ({
          containerTypeId: bc.containerTypeId,
          quantity: bc.quantity,
          vgmPerUnitTons: Number(bc.vgmPerUnitTons),
        }));
      if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
        return true;
      }
    }
    if (
      freightType !== existing.freightType ||
      (cargoTypeId ?? null) !== (existing.cargoTypeId ?? null)
    ) {
      return true;
    }
    return false;
  }

  /** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
  async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
    const booking = await this.findById(id);
    if (!booking.isGovernment) {
      throw new BadRequestException('Only government bookings can be expedited');
    }
    const blocked = ['PAID', 'IN_TRANSIT', 'ARRIVED', 'COMPLETED', 'CANCELLED', 'REJECTED'];
    if (blocked.includes(booking.status)) {
      throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
    }

    await this.bookingsRepository.update(id, {
      status: 'PAID',
      paymentStatus: 'PAID',
      schedulingStatus: SchedulingStatus.Eligible,
      holdStartedAt: null,
      holdExpiresAt: null,
    });
    await this.bookingsRepository.createReviewNote(
      id,
      `Government booking expedited to PAID by staff (${staffUserId})`,
      'STAFF_NOTE',
      staffUserId,
    );

    return this.findById(id);
  }

  async findCustomerBookings(companyId: string): Promise<{
    id: string;
    reference: string;
    status: string;
    tradeDirection: string;
    freightType: string;
    originLabel: string;
    destinationLabel: string;
    totalAmount: number;
    currency: string;
    scheduledDate: Date | null;
    createdAt: Date;
  }[]> {
    const { items } = await this.bookingsRepository.findAllPaginated({
      page: 1,
      pageSize: 500,
      companyId,
    });
    return items.map((b) => ({
      id: b.id,
      reference: b.reference,
      status: b.status,
      tradeDirection: b.tradeDirection,
      freightType: b.freightType,
      originLabel: b.originYard?.label ?? '',
      destinationLabel: b.destinationYard?.label ?? '',
      totalAmount: Number(b.totalAmount),
      currency: b.paymentCurrency,
      scheduledDate: b.scheduledDate ?? null,
      createdAt: b.createdAt,
    }));
  }

  async allocateContainers(
    bookingId: string,
    allocations: Array<{ containerId: string; vehicleId: string }>,
  ) {
    const booking = await this.findById(bookingId);
    if (!booking) {
      throw new NotFoundException(`Booking ${bookingId} not found`);
    }

    const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, {
      where: {
        bookingId,
        containerId: In(allocations.map((a) => a.containerId)),
      },
    });
    const previousVehicleIds = previousAllocations
      .map((a) => a.vehicleId)
      .filter((id): id is string => Boolean(id));

    await this.dataSource.transaction(async (manager) => {
      for (const allocation of allocations) {
        await manager.delete(BookingContainerAllocation, {
          bookingId,
          containerId: allocation.containerId,
        });
        await manager.insert(BookingContainerAllocation, {
          bookingId,
          containerId: allocation.containerId,
          vehicleId: allocation.vehicleId,
          containerType: 'CONTAINER',
          quantity: 1,
        });
      }
    });

    const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
    await Promise.all(
      [...vehicleIds].map((vehicleId) =>
        this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY),
      ),
    );
    await this.vehiclesService.releaseIfUnused(
      previousVehicleIds.filter((id) => !vehicleIds.has(id)),
    );

    return {
      success: true,
      allocated: allocations.length,
    };
  }
}
