import {
  BadRequestException,
  forwardRef,
  Inject,
  Injectable,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';

import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from '../booking-orders/road.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';

@Injectable()
export class BookingTransitionService {
  constructor(
    private readonly bookingsRepository: BookingsRepository,
    private readonly ruleEngineService: RuleEngineService,
    private readonly pricingService: BookingPricingService,
    private readonly contractService: BookingContractService,
    private readonly filesService: FilesService,
    private readonly fileUploadSettingsService: FileUploadSettingsService,
    @Inject(forwardRef(() => BookingBatchService))
    private readonly bookingBatchService: BookingBatchService,
    @Inject(forwardRef(() => BookingsService))
    private readonly bookingsService: BookingsService,
  ) {}

  async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);

    if (Number(booking.totalAmount) <= 0) {
      throw new BadRequestException(
        'Generate a price before submitting (POST /bookings/:id/generate-price)',
      );
    }

    const computed = await this.pricingService.computePriceForBooking(booking);
    this.ruleEngineService.assertNoHardBlocks({
      priorityScore: computed.priorityScore,
      appliedModifiers: computed.appliedModifiers,
      containerWeightResults: [],
      warnings: computed.warnings,
      hardBlocked: computed.hardBlocked,
      requiresDirectorApproval: false,
    });

    const stored = booking.pricingBreakdown as {
      lineItems?: PriceLineItemDto[];
      totalAmount?: number;
    } | null;
    const unchanged = this.pricingService.pricesMatch(stored, computed);
    const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);

    if (unchanged) {
      await this.pricingService.createPricingSnapshots(
        bookingId,
        computed.usedRates,
        computed.appliedModifiers,
      );

      const updated = await this.bookingsRepository.update(bookingId, {
        status: 'SUBMITTED',
        priorityScore,
      } as never);

      // Auto-consolidate now: a partial-wagon booking either pairs with a waiting
      // partner (both → SUBMITTED) or is parked as PENDING_CONSOLIDATION until one
      // arrives. The returned status reflects that outcome.
      const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
        updated!.id,
      );
      return {
        bookingId: finalBooking.id,
        status: finalBooking.status,
        priceChanged: false,
        totalAmount: Number(finalBooking.totalAmount),
        currency: finalBooking.paymentCurrency,
        lineItems: computed.lineItems,
      };
    }

    const previousTotalAmount = Number(booking.totalAmount);
    await this.bookingsRepository.update(bookingId, {
      totalAmount: computed.totalAmount,
      priorityScore: computed.priorityScore,
      pricingBreakdown: {
        lineItems: computed.lineItems,
        totalAmount: computed.totalAmount,
        currency: computed.currency,
        generatedAt: new Date().toISOString(),
      },
      status: 'PRICE_CHANGED_PENDING_CONFIRM',
    } as never);

    const updatedBooking = await this.bookingsService.findById(bookingId);
    return {
      bookingId: updatedBooking.id,
      status: updatedBooking.status,
      priceChanged: true,
      previousTotalAmount,
      totalAmount: computed.totalAmount,
      currency: computed.currency,
      lineItems: computed.lineItems,
      message: 'Price has changed since preview. Confirm to submit with the updated price.',
    };
  }

  async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);

    if (Number(booking.totalAmount) <= 0) {
      throw new BadRequestException('No price to confirm');
    }

    const computed = await this.pricingService.computePriceForBooking(booking);
    this.ruleEngineService.assertNoHardBlocks({
      priorityScore: computed.priorityScore,
      appliedModifiers: computed.appliedModifiers,
      containerWeightResults: [],
      warnings: computed.warnings,
      hardBlocked: computed.hardBlocked,
      requiresDirectorApproval: false,
    });

    await this.pricingService.createPricingSnapshots(
      bookingId,
      computed.usedRates,
      computed.appliedModifiers,
    );

    const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'SUBMITTED',
      priorityScore,
      totalAmount: computed.totalAmount,
      pricingBreakdown: {
        lineItems: computed.lineItems,
        totalAmount: computed.totalAmount,
        currency: computed.currency,
        generatedAt: new Date().toISOString(),
      },
    } as never);

    // Same consolidation treatment as the direct submit path.
    const finalBooking = await this.bookingsService.runConsolidationOnSubmit(
      updated!.id,
    );
    return {
      bookingId: finalBooking.id,
      status: finalBooking.status,
      priceChanged: false,
      totalAmount: Number(finalBooking.totalAmount),
      currency: finalBooking.paymentCurrency,
      lineItems: computed.lineItems,
      message: 'Booking submitted with confirmed price.',
    };
  }

  async requestChanges(
    bookingId: string,
    note: string,
    actorId: string,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['SUBMITTED']);

    await this.bookingsRepository.createReviewNote(
      bookingId,
      note,
      'CHANGES_REQUESTED',
      actorId,
    );

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'CHANGES_REQUESTED',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  /** Auto-create booking approval steps from system rules when none exist yet. */
  private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
    if ((booking.approvalSteps?.length ?? 0) > 0) return;

    await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
      freightType: booking.freightType as 'CONTAINER' | 'BULK',
      cargoTypeId: booking.cargoTypeId,
    });
  }

  async acceptIntake(
    bookingId: string,
    actorId: string,
    validityDays: number,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    // Only SUBMITTED bookings are acceptable. A booking that still needs
    // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
    // is therefore never offered for accept until a partner moves it to SUBMITTED.
    assertBookingStatus(booking, ['SUBMITTED']);

    // The backoffice must define how long the accepted contract stays valid.
    // Without a window the contract has no end date and cannot be relied on, so
    // accept is blocked until a positive number of days is supplied.
    if (!Number.isInteger(validityDays) || validityDays < 1) {
      throw new BadRequestException(
        'A contract validity (in days) is required to accept this booking.',
      );
    }

    // Validity runs from the accept moment through accept + N days.
    const validFrom = new Date();
    const validUntil = new Date(validFrom);
    validUntil.setDate(validUntil.getDate() + validityDays);

    await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
      freightType: booking.freightType as 'CONTAINER' | 'BULK',
      cargoTypeId: booking.cargoTypeId,
    });

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'PENDING_APPROVAL',
      approvedByStaffId: actorId,
      approvedByStaffAt: validFrom,
      contractValidityDays: validityDays,
      contractValidFrom: validFrom,
      contractValidUntil: validUntil,
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async staffReject(
    bookingId: string,
    reason: string,
    actorId: string,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);

    await this.bookingsRepository.createReviewNote(
      bookingId,
      reason,
      'REJECTION',
      actorId,
    );

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'REJECTED',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async approveStep(
    bookingId: string,
    stepId: string,
    actorId: string,
    requiredRole: string,
    authUser?: TCurrentUser,
  ): Promise<Booking> {
    if (authUser) {
      assertCanApproveBookingStep(authUser, requiredRole);
    }

    let booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, [
      'PENDING_APPROVAL',
      'APPROVED_PENDING_SIGNATURE',
    ]);

    if ((booking.approvalSteps?.length ?? 0) === 0) {
      await this.ensureBookingApprovalSteps(booking);
      booking = await this.bookingsService.findById(bookingId);
    }

    const step = await this.bookingsRepository.findApprovalStepById(
      bookingId,
      stepId,
    );
    if (!step || step.status !== 'PENDING') {
      throw new BadRequestException('Approval step not found or already actioned');
    }

    const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
    if (!next || next.id !== step.id) {
      throw new BadRequestException(
        'Approval steps must be completed in order',
      );
    }

    if (step.requiredRole !== requiredRole) {
      throw new BadRequestException(
        `Step requires role ${step.requiredRole}, not ${requiredRole}`,
      );
    }

    const blocksRole = step.blocksRole;
    if (blocksRole && blocksRole === requiredRole) {
      throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
    }

    await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');

    const updates: Record<string, unknown> = {};
    const now = new Date();

    if (requiredRole === 'LINE_STAFF') {
      updates.status = 'APPROVED_PENDING_SIGNATURE';
      updates.approvedByStaffId = actorId;
      updates.approvedByStaffAt = now;
    } else if (requiredRole === 'DIRECTOR') {
      updates.signedByDirectorId = actorId;
      updates.signedByDirectorAt = now;
    } else if (requiredRole === 'CEO') {
      updates.signedByCeoId = actorId;
      updates.signedByCeoAt = now;
    }

    const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
    if (allDone) {
      updates.status = 'APPROVED';
    }

    if (Object.keys(updates).length > 0) {
      await this.bookingsRepository.update(bookingId, updates as never);
    }

    if (allDone) {
      const generated = await this.contractService.generateContract(bookingId);
      return this.bookingsService.findById(generated.id);
    }

    return this.bookingsService.findById(bookingId);
  }

  async rejectStep(
    bookingId: string,
    stepId: string,
    actorId: string,
    reason: string,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);

    const step = await this.bookingsRepository.findApprovalStepById(
      bookingId,
      stepId,
    );
    if (!step) throw new BadRequestException('Approval step not found');

    await this.bookingsRepository.completeApprovalStep(
      step.id,
      actorId,
      'REJECTED',
      reason,
    );

    await this.bookingsRepository.createReviewNote(
      bookingId,
      reason,
      'REJECTION',
      actorId,
    );

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'REJECTED',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async customerSign(bookingId: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['CONTRACT_READY']);

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'SIGNED_CUSTOMER',
      customerSignedAt: new Date(),
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['SIGNED_CUSTOMER']);

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'FULLY_EXECUTED',
      fullyExecutedAt: new Date(),
      marketingApprovedById: actorId,
      marketingApprovedAt: new Date(),
      lockedAt: new Date(),
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async startTransit(bookingId: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['PAID']);

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'IN_TRANSIT',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async complete(bookingId: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['IN_TRANSIT']);

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'COMPLETED',
      endDate: new Date(),
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  async cancel(bookingId: string, reason: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, [
      'DRAFT',
      'SUBMITTED',
      'PRICE_CHANGED_PENDING_CONFIRM',
      'CHANGES_REQUESTED',
      'PENDING_APPROVAL',
      'CONTRACT_READY',
    ]);

    await this.bookingsRepository.createReviewNote(
      bookingId,
      reason,
      'REJECTION',
    );

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'CANCELLED',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  /**
   * Customer rejects the priced booking at the confirm step. The booking becomes
   * REJECTED (terminal) — the customer starts a new booking rather than editing
   * this one. Only a not-yet-committed booking can be rejected this way.
   */
  async reject(bookingId: string, reason?: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, [
      'DRAFT',
      'SUBMITTED',
      'PRICE_CHANGED_PENDING_CONFIRM',
      'PENDING_CONSOLIDATION',
    ]);

    await this.bookingsRepository.createReviewNote(
      bookingId,
      reason?.trim() || 'Customer rejected the price estimate.',
      'REJECTION',
    );

    const updated = await this.bookingsRepository.update(bookingId, {
      status: 'REJECTED',
    } as never);
    return this.bookingsService.findById(updated!.id);
  }

  /**
   * Staff adjusts a booking's total price. Stores an override (with who/when/why)
   * that supersedes the computed total for the customer, who sees an
   * "Adjusted by EDR" badge. Passing null clears the adjustment.
   */
  async adjustPrice(
    bookingId: string,
    amount: number | null,
    staffId: string,
    reason?: string,
  ): Promise<Booking> {
    await this.bookingsService.findById(bookingId);
    if (amount != null && amount < 0) {
      throw new BadRequestException('Adjusted amount cannot be negative');
    }
    await this.bookingsRepository.update(bookingId, {
      adjustedTotalAmount: amount,
      adjustedByStaffId: amount == null ? null : staffId,
      adjustedAt: amount == null ? null : new Date(),
      adjustmentReason: amount == null ? null : (reason ?? null),
    } as never);
    return this.bookingsService.findById(bookingId);
  }

  // ── Document clearance gate (post counter-sign) ───────────────────────────

  /**
   * The clearance document grid for a booking: each required field from the
   * resolved customer-input set (and the GL-output set for customs) with its
   * uploaded file and GL review status. Drives both portals' clearance UI.
   */
  async getClearanceView(bookingId: string): Promise<{
    status: string;
    includesCustoms: boolean;
    inputCode: string | null;
    outputCode: string | null;
    documents: Array<{
      fileKey: string;
      label: string;
      required: boolean;
      uploadedBy: 'customer' | 'gl';
      settingCode: string;
      file: { id: string; name: string; url: string } | null;
      reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
      note: string | null;
    }>;
    allApproved: boolean;
  }> {
    const booking = await this.bookingsService.findById(bookingId);
    const { inputCode, outputCode, includesCustoms } =
      clearanceCodesForBooking(booking);

    const files = await this.filesService.findByResource(bookingId, 'bookings');
    const fileByCode = new Map(files.map((f) => [f.code, f]));
    const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
    const reviewByKey = new Map(
      reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
    );

    const documents: Awaited<
      ReturnType<BookingTransitionService['getClearanceView']>
    >['documents'] = [];

    const pushSetting = async (
      code: string | null,
      uploadedBy: 'customer' | 'gl',
    ) => {
      if (!code) return;
      let setting;
      try {
        setting = await this.fileUploadSettingsService.getByCode(code);
      } catch {
        return; // setting not seeded — skip gracefully
      }
      for (const field of setting.fields ?? []) {
        const file = fileByCode.get(field.fileKey) ?? null;
        const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
        documents.push({
          fileKey: field.fileKey,
          label: field.fileLabel,
          required: field.isRequired,
          uploadedBy,
          settingCode: code,
          file: file
            ? { id: file.id, name: file.name, url: file.url }
            : null,
          reviewStatus: review?.status ?? null,
          note: review?.note ?? null,
        });
      }
    };

    await pushSetting(inputCode, 'customer');
    await pushSetting(outputCode, 'gl');

    // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
    for (const f of files) {
      if (!f.code?.startsWith('custom_')) continue;
      const review = reviewByKey.get(`custom:${f.code}`) ?? null;
      documents.push({
        fileKey: f.code,
        label: f.name,
        required: false,
        uploadedBy: 'customer',
        settingCode: 'custom',
        file: { id: f.id, name: f.name, url: f.url },
        reviewStatus: review?.status ?? null,
        note: review?.note ?? null,
      });
    }

    const allApproved = await this.isClearanceFullyApproved(booking);

    return {
      status: booking.status,
      includesCustoms,
      inputCode,
      outputCode,
      documents,
      allApproved,
    };
  }

  /**
   * True when every REQUIRED field of the booking's customer-input clearance set
   * has an APPROVED review row. The 100% gate before clearance can be finalized.
   */
  private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
    const { inputCode } = clearanceCodesForBooking(booking);
    if (!inputCode) return true; // no gate applies (e.g. domestic)
    let setting;
    try {
      setting = await this.fileUploadSettingsService.getByCode(inputCode);
    } catch {
      return false;
    }
    const required = (setting.fields ?? []).filter((f) => f.isRequired);
    if (required.length === 0) return true;
    const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
    return required.every((field) =>
      reviews.some(
        (r) =>
          r.settingCode === inputCode &&
          r.fileKey === field.fileKey &&
          r.status === 'APPROVED',
      ),
    );
  }

  /**
   * Customer uploads clearance documents. Each multipart file's fieldname is the
   * field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
   * per-document review rows to PENDING, and moves the booking into review.
   */
  async submitClearanceDocuments(
    bookingId: string,
    files: Express.Multer.File[],
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
    const { inputCode } = clearanceCodesForBooking(booking);
    if (!inputCode) {
      throw new BadRequestException('This booking has no document-clearance step');
    }
    if (files.length === 0) {
      throw new BadRequestException('No documents uploaded');
    }

    for (const file of files) {
      const record = await this.filesService.upsertByCode({
        resourceId: bookingId,
        resource: 'bookings',
        code: file.fieldname,
        file,
      });
      // Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
      const settingCode = file.fieldname.startsWith('custom_')
        ? 'custom'
        : inputCode;
      await this.bookingsRepository.upsertDocumentReviewPending({
        bookingId,
        settingCode,
        fileKey: file.fieldname,
        fileRecordId: record.id,
      });
    }

    await this.bookingsRepository.update(bookingId, {
      status: 'DOCUMENTS_UNDER_REVIEW',
    } as never);
    return this.bookingsService.findById(bookingId);
  }

  /** GL reviews a single document: APPROVED or QUERIED (with a note). */
  async reviewDocument(
    bookingId: string,
    fileKey: string,
    status: 'APPROVED' | 'QUERIED',
    staffId: string,
    note?: string,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
    const { inputCode, outputCode } = clearanceCodesForBooking(booking);

    const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
    const match = existing.find((r) => r.fileKey === fileKey);
    const settingCode =
      match?.settingCode ??
      (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));

    if (status === 'QUERIED' && !note?.trim()) {
      throw new BadRequestException('A note is required when querying a document');
    }

    await this.bookingsRepository.setDocumentReviewStatus(
      bookingId,
      settingCode,
      fileKey,
      status,
      staffId,
      note,
    );
    if (status === 'QUERIED') {
      await this.bookingsRepository.createReviewNote(
        bookingId,
        `Document "${fileKey}" queried: ${note}`,
        'CHANGES_REQUESTED',
        staffId,
      );
    }
    return this.bookingsService.findById(bookingId);
  }

  /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
  async uploadClearanceOutputDocuments(
    bookingId: string,
    files: Express.Multer.File[],
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
    const { outputCode } = clearanceCodesForBooking(booking);
    if (!outputCode) {
      throw new BadRequestException('This booking has no customs output documents');
    }
    if (files.length === 0) {
      throw new BadRequestException('No documents uploaded');
    }
    for (const file of files) {
      await this.filesService.upsertByCode({
        resourceId: bookingId,
        resource: 'bookings',
        code: file.fieldname,
        file,
      });
    }
    return this.bookingsService.findById(bookingId);
  }

  /**
   * GL confirms clearance: requires every customer document APPROVED (100% gate)
   * and, for customs, the required output documents present → CLEARANCE_READY.
   */
  async finalizeClearance(bookingId: string): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);

    const approved = await this.isClearanceFullyApproved(booking);
    if (!approved) {
      throw new BadRequestException(
        'All required documents must be approved before clearance can be finalized',
      );
    }

    const { outputCode } = clearanceCodesForBooking(booking);
    if (outputCode) {
      const setting = await this.fileUploadSettingsService.getByCode(outputCode);
      const files = await this.filesService.findByResource(bookingId, 'bookings');
      const uploaded = new Set(files.map((f) => f.code));
      const missing = (setting.fields ?? []).filter(
        (f) => f.isRequired && !uploaded.has(f.fileKey),
      );
      if (missing.length > 0) {
        throw new BadRequestException(
          `Upload all required customs output documents first: ${missing
            .map((m) => m.fileLabel)
            .join(', ')}`,
        );
      }
    }

    await this.bookingsRepository.update(bookingId, {
      status: 'CLEARANCE_READY',
    } as never);
    return this.bookingsService.findById(bookingId);
  }

  /**
   * Customer proceeds to operation once clearance is ready. They pick the
   * schedule day (the train departure day) for the shipment; the request then
   * sits at OPERATION_REQUEST_PENDING for the operations team to review
   * (capacity, documents, route) before it enters the batch holding pool.
   *
   * Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED
   * (resubmit after the operations team returned it for changes).
   */
  async requestOperation(
    bookingId: string,
    scheduledDate: string,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);

    const date = new Date(scheduledDate);
    if (Number.isNaN(date.getTime())) {
      throw new BadRequestException('A valid schedule date is required');
    }

    await this.bookingsRepository.update(bookingId, {
      status: 'OPERATION_REQUEST_PENDING',
      scheduledDate: date,
    } as never);
    return this.bookingsService.findById(bookingId);
  }

  /**
   * Operations team reviews a pending operation request (capacity, documents,
   * route). Three outcomes:
   *  - ACCEPT          → booking enters the batch holding pool (FULLY_EXECUTED).
   *  - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
   *  - ADJUST_PRICE    → a new total is set; the customer must re-confirm it
   *                      before the booking can enter the pool.
   */
  async reviewOperationRequest(
    bookingId: string,
    decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
    actorId: string,
    options: { note?: string; amount?: number } = {},
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);

    if (decision === 'REQUEST_CHANGES') {
      if (!options.note?.trim()) {
        throw new BadRequestException(
          'A note is required when requesting changes',
        );
      }
      await this.bookingsRepository.createReviewNote(
        bookingId,
        options.note,
        'CHANGES_REQUESTED',
        actorId,
      );
      await this.bookingsRepository.update(bookingId, {
        status: 'OPERATION_CHANGES_REQUESTED',
      } as never);
      return this.bookingsService.findById(bookingId);
    }

    if (decision === 'ADJUST_PRICE') {
      if (options.amount == null || options.amount < 0) {
        throw new BadRequestException(
          'A non-negative adjusted amount is required to adjust the price',
        );
      }
      await this.bookingsRepository.update(bookingId, {
        adjustedTotalAmount: options.amount,
        adjustedByStaffId: actorId,
        adjustedAt: new Date(),
        adjustmentReason: options.note ?? null,
        status: 'OPERATION_PRICE_PENDING_CONFIRM',
      } as never);
      return this.bookingsService.findById(bookingId);
    }

    // ACCEPT — enter the batch holding pool.
    return this.acceptOperationRequest(booking);
  }

  /**
   * Customer re-confirms (or rejects) an operations price adjustment. Accepting
   * pushes the booking into the pool; rejecting returns it to the customer as an
   * operation change request so they can resubmit or cancel.
   */
  async confirmOperationPrice(
    bookingId: string,
    accept: boolean,
  ): Promise<Booking> {
    const booking = await this.bookingsService.findById(bookingId);
    assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);

    if (!accept) {
      await this.bookingsRepository.update(bookingId, {
        status: 'OPERATION_CHANGES_REQUESTED',
      } as never);
      return this.bookingsService.findById(bookingId);
    }

    return this.acceptOperationRequest(booking);
  }

  /**
   * Move a reviewed operation request forward after Marketing accepts.
   *
   *  - Train services enter the batch holding pool: the pool query
   *    (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
   *    set those and kick the day-level fill immediately instead of waiting for
   *    cron.
   *  - Road (truck) services skip the train batch entirely and wait for truck
   *    dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
   */
  private async acceptOperationRequest(booking: Booking): Promise<Booking> {
    const now = new Date();

    if (isRoadService(booking.serviceType)) {
      await this.bookingsRepository.update(booking.id, {
        status: 'ROAD_DISPATCH_PENDING',
        fullyExecutedAt: now,
        lockedAt: booking.lockedAt ?? now,
      } as never);
      return this.bookingsService.findById(booking.id);
    }

    await this.bookingsRepository.update(booking.id, {
      status: 'FULLY_EXECUTED',
      fullyExecutedAt: now,
      lockedAt: booking.lockedAt ?? now,
    } as never);

    if (booking.scheduledDate) {
      this.bookingBatchService.enqueueRouteDayProcessing(
        booking.originYardId,
        booking.destinationYardId,
        eatDay(new Date(booking.scheduledDate)),
      );
    }
    return this.bookingsService.findById(booking.id);
  }

  async enrichBookingResponse(booking: Booking): Promise<Booking & {
    latestChangeRequestNote?: string | null;
    contractSummary?: string | null;
    nextStep: BookingNextStep | null;
  }> {
    const note = await this.bookingsRepository.findLatestReviewNote(
      booking.id,
      'CHANGES_REQUESTED',
    );
    const summary =
      booking.contractSummary ??
      this.contractService.buildContractSummary(booking);
    const nextPending =
      booking.status === 'PENDING_APPROVAL' ||
      booking.status === 'APPROVED_PENDING_SIGNATURE'
        ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
        : null;
    const nextStep = computeNextStep(booking, nextPending);
    return {
      ...booking,
      latestChangeRequestNote: note?.note ?? null,
      contractSummary: summary,
      nextStep,
    };
  }
}