import {
  BadRequestException,
  ConflictException,
  forwardRef,
  Inject,
  Injectable,
  Logger,
  Optional,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter";

import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from './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 { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.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';
import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { ContractDocPhase } from '@edr/types';

import { BookingInvoiceService } from "./booking-invoice.service";

@Injectable()
export class BookingTransitionService {
  private readonly logger = new Logger(BookingTransitionService.name);
  constructor(
    private readonly bookingsRepository: BookingsRepository,
    private readonly ruleEngineService: RuleEngineService,
    private readonly pricingService: BookingPricingService,
    @Inject(forwardRef(() => BookingContractService))
    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,
    @Inject(forwardRef(() => BookingClearanceService))
    private readonly bookingClearanceService: BookingClearanceService,
    @Inject(forwardRef(() => ClearanceWorkflowService))
    private readonly workflowService: ClearanceWorkflowService,
        private readonly invoiceService: BookingInvoiceService,
    private readonly containerValidationService: ContainerValidationService,
    private readonly notifier: BookingLifecycleNotifierService,
    @Optional() private readonly milestoneService?: ClearanceMilestoneService,
  ) {}

  private isPhasedGeneralCustoms(booking: Booking): boolean {
    return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
  }

  /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
  private async assert20ftPairable(booking: Booking): Promise<void> {
    const violations =
      await this.containerValidationService.validate20ftPairing(booking);
    if (violations.length) {
      throw new BadRequestException(
        `Cannot submit — 20ft containers cannot be paired on wagons: ${violations
          .map((v) => v.message)
          .join(' ')}`,
      );
    }
  }

  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,
    });

    // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
    // If no balanced pairing exists the booking cannot proceed (overweight only
    // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
    await this.assert20ftPairable(booking);

    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,
      );
      if (finalBooking.status === "SUBMITTED") {
        this.notifier.submittedToStaff(finalBooking);
      }
      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.assert20ftPairable(booking);

    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,
    );
    if (finalBooking.status === "SUBMITTED") {
      this.notifier.submittedToStaff(finalBooking);
    }
    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);
    const fresh = await this.bookingsService.findById(updated!.id);
    this.notifier.changesRequested(fresh, note);
    return fresh;
  }

  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);

    // Bookings no longer run a multi-step approval chain — accepting the intake
    // approves the booking outright and generates its contract. (The approval
    // chain is a contract-only concern now; see contract-transition.service.)
    await this.bookingsRepository.update(bookingId, {
      status: "APPROVED",
      approvedByStaffId: actorId,
      approvedByStaffAt: validFrom,
      contractValidityDays: validityDays,
      contractValidFrom: validFrom,
      contractValidUntil: validUntil,
    } as never);

    // Generating the contract is best-effort: the acceptance is already
    // committed, so a failure here must not roll it back. The booking stays
    // APPROVED and staff can retry generation from the booking page.
    try {
      await this.contractService.generateContract(bookingId);
    } catch (err) {
      this.logger.warn(
        `Contract generation failed after accepting booking ${bookingId}: ${err}. ` +
          `The booking is APPROVED — retry generation from the booking page.`,
      );
    }

    const fresh = await this.bookingsService.findById(bookingId);
    this.notifier.accepted(fresh);
    this.notifier.approved(fresh);
    return fresh;
  }

  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);
    const fresh = await this.bookingsService.findById(updated!.id);
    this.notifier.rejected(fresh, reason);
    return fresh;
  }

  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);
    const fresh = await this.bookingsService.findById(updated!.id);
    this.notifier.customerSignedToStaff(fresh);
    return fresh;
  }

  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);
    const fresh = await this.bookingsService.findById(updated!.id);
    this.notifier.inTransit(fresh);
    return fresh;
  }

  /**
   * Import EDR last-mile: every handover signed + every truck departed ⇒ the
   * warehouses module delivered the goods and asks the booking to complete.
   * Best-effort — a booking already COMPLETED (or not yet in transit) just logs.
   */
  @OnEvent('import.handover.completed')
  async onImportHandoverCompleted(payload: { bookingId: string }): Promise<void> {
    try {
      await this.complete(payload.bookingId);
    } catch (err) {
      this.logger.log(
        `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`,
      );
    }
  }

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

    const updated = await this.bookingsRepository.update(bookingId, {
      status: "COMPLETED",
      endDate: new Date(),
    } as never);
    const fresh = await this.bookingsService.findById(updated!.id);
    this.notifier.completed(fresh);
    // Customer tracking: close out the tail milestones so a finished shipment
    // never shows a forever-pending timeline. EXIT_NOTE/PROCESS_COMPLETED are
    // implied by delivery; a storage invoice that was never raised is skipped
    // (storage billing does not apply to every shipment). All doc-trigger /
    // best-effort — a booking without milestone rows is untouched.
    if (this.milestoneService) {
      for (const code of ["IMPORT_PROCESS_COMPLETED", "EXIT_NOTE_GENERATED"]) {
        try {
          await this.milestoneService.completeByDocTrigger({ bookingId }, code);
        } catch {
          /* tracking must never block completion */
        }
      }
      try {
        await this.milestoneService.skipForBooking(bookingId, "STORAGE_INVOICE_RAISED");
      } catch {
        /* no such milestone row (export / non-customs) — fine */
      }
    }
    return fresh;
  }

  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",
      "OPERATION_REQUEST_PENDING",
    ]);

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

    // Stop the open-invoice leak: a cancelled booking must not leave a payable
    // invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
    await this.invoiceService.expireOpenInvoices(bookingId);

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

  /**
   * 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",
    );

    // Stop the open-invoice leak: a rejected booking must not leave a payable
    // invoice open. Mirror the pay-window-expiry path (billing.expirePayable).
    await this.invoiceService.expireOpenInvoices(bookingId);

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

  // ── 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;
    phase?: string | null;
    milestones?: unknown[];
    nextAction?: unknown;
    dutyRequired?: boolean | null;
    roHold?: boolean;
    roHoldReason?: string | null;
    vesselDepartureDate?: string | null;
    operationReady?: boolean;
  }> {
    const booking = await this.bookingsService.findById(bookingId);
    if (this.isPhasedGeneralCustoms(booking)) {
      return this.bookingClearanceService.getClearanceView(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");
    }

    // First submission (nothing in review yet): every required input field must
    // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
    // is only fixing queried/pending docs, so the already-uploaded required docs
    // stay in place and we don't re-gate on the full required set.
    if (booking.status === "AWAITING_DOCUMENTS") {
      await this.assertRequiredInputsPresent(bookingId, inputCode, files);
    }

    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);

    if (this.isPhasedGeneralCustoms(booking)) {
      await this.workflowService.onCustomerDocsUploadedForBooking(
        bookingId,
        booking.tradeDirection ?? 'IMPORT',
      );
      await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
      await this.bookingsRepository.update(bookingId, {
        clearanceCurrentPhase: ContractDocPhase.GlEtReview,
      } as never);
    }

    const fresh = await this.bookingsService.findById(bookingId);
    this.notifier.clearanceDocsUploadedToStaff(fresh);
    return fresh;
  }

  /**
   * Guard for the first clearance submission: every required field of the
   * booking's customer-input set must be covered, either by a file already on
   * the booking or by one in this upload batch. Keeps the customer from starting
   * review with required documents missing.
   */
  private async assertRequiredInputsPresent(
    bookingId: string,
    inputCode: string,
    files: Express.Multer.File[],
  ): Promise<void> {
    let setting;
    try {
      setting = await this.fileUploadSettingsService.getByCode(inputCode);
    } catch {
      return; // setting not seeded — nothing to enforce
    }
    const required = (setting.fields ?? []).filter((f) => f.isRequired);
    if (required.length === 0) return;

    const existing = await this.filesService.findByResource(
      bookingId,
      "bookings",
    );
    const presentKeys = new Set<string>([
      ...existing.map((f) => f.code),
      ...files.map((f) => f.fieldname),
    ]);

    const missing = required.filter((f) => !presentKeys.has(f.fileKey));
    if (missing.length > 0) {
      const labels = missing.map((f) => f.fileLabel).join(", ");
      throw new BadRequestException(
        `Please upload all required documents before submitting: ${labels}`,
      );
    }
  }

  /** 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",
      );
    }
    if (
      status === 'QUERIED' &&
      this.isPhasedGeneralCustoms(booking) &&
      booking.preClearanceFinalizedAt
    ) {
      throw new BadRequestException(
        'Customer documents cannot be queried after pre-clearance is finalized.',
      );
    }

    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,
      );
      if (this.isPhasedGeneralCustoms(booking)) {
        await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
        await this.bookingsRepository.update(bookingId, {
          clearanceCurrentPhase: ContractDocPhase.GlEtReview,
        } as never);
      }
    }

    const updated = await this.bookingsService.findById(bookingId);
    if (status === "QUERIED") {
      this.notifier.documentQueried(updated, fileKey, note ?? '');
    }
    if (this.isPhasedGeneralCustoms(updated)) {
      const allApproved = await this.isClearanceFullyApproved(updated);
      if (allApproved) {
        await this.workflowService.onAllDocsApprovedForBooking(bookingId);
        const phase =
          updated.tradeDirection === 'EXPORT'
            ? ContractDocPhase.GlDjCollection
            : ContractDocPhase.GlEtOutput;
        await this.bookingsRepository.update(bookingId, {
          clearanceCurrentPhase: phase,
        } as never);
      }
    }

    return updated;
  }

  /** 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);
    if (this.isPhasedGeneralCustoms(booking)) {
      throw new BadRequestException(
        'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
      );
    }
    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(", ")}`,
        );
      }
    }

    // Intercity: there is no shipment-day request step — an approved booking
    // goes straight to FULLY_EXECUTED, which is what the intercity ride-along
    // pool keys on. Staff then accept it onto a passing train (that accept
    // opens the pay window).
    if (booking.tradeDirection === "DOMESTIC") {
      const now = new Date();
      await this.bookingsRepository.update(bookingId, {
        status: "FULLY_EXECUTED",
        fullyExecutedAt: now,
        lockedAt: booking.lockedAt ?? now,
      } as never);
      const fresh = await this.bookingsService.findById(bookingId);
      this.notifier.intercityDocumentsApproved(fresh);
      return fresh;
    }

    await this.bookingsRepository.update(bookingId, {
      status: "CLEARANCE_READY",
    } as never);
    const fresh = await this.bookingsService.findById(bookingId);
    this.notifier.clearanceReady(fresh);
    return fresh;
  }

  /**
   * 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",
    ]);

    // A bare initiated instance (clearance-first flow) carries no cargo or
    // price — it must go through the contract completion endpoint, which
    // persists cargo, prices, invoices and only then lands here itself.
    if (booking.contractId && !(Number(booking.totalAmount) > 0)) {
      throw new BadRequestException(
        "This booking must be completed (cargo and shipment day) before requesting operation.",
      );
    }

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

    // The binding shipment day must have at least one OPEN departure on the
    // route — only schedule-backed days are selectable — AND some departure
    // that day must be able to physically carry this cargo type (wagon-TYPE
    // gate; quantity never blocks — oversized bookings get a partial split
    // offer). The batch engine assigns the specific train within that
    // (route, day) pool later.
    const { hasDeparture, hasCompatible } =
      await this.bookingsService.checkDayCompatibilityForBooking(
        booking,
        eatDay(date),
      );
    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",
      );
    }

    // Export is FCFS and never splits — a booking must ride one train whole. So
    // the free-space check belongs HERE, the moment the customer commits to a
    // shipment day, not later at staff operation-accept. Blocking now stops the
    // customer booking more wagons than any single export train that day can
    // still carry; `exportSpaceReport` throws a 409 whose message carries the
    // largest bookable leftover ("reduce to N wagons or pick another day").
    // Import/domestic bookings are batched + splittable, so they are NOT gated
    // here — they get an advisory count below and the batch engine sizes them.
    const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
    const isExportTrain =
      booking.tradeDirection === "EXPORT" &&
      !isRoadService(booking.serviceType);
    if (isExportTrain) {
      // With export split ON the booking no longer has to ride ONE train whole:
      // the largest fitting part is offered and the leftover rebooks on the next
      // train. So the day is only unbookable when NO export train that day has
      // any room at all — reject on the day total, not on a single-train fit.
      // With the flag off this stays the strict whole-booking gate.
      if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
        const fitting = await this.bookingBatchService.fittingTrainsForDay(
          scheduledBooking,
          eatDay(date),
          "EXPORT",
        );
        if (!fitting.length) {
          throw new ConflictException(
            "No export train on this day has space left — pick another shipment day.",
          );
        }
      } else {
        await this.bookingBatchService.pickExportSchedule(scheduledBooking);
      }
    }

    await this.bookingsRepository.update(bookingId, {
      status: "OPERATION_REQUEST_PENDING",
      scheduledDate: date,
    } as never);
    const fresh = await this.bookingsService.findById(bookingId);
    this.notifier.operationRequestedToStaff(fresh);
    return fresh;
  }

  /**
   * Advisory availability for a shipment day the customer is considering — a
   * planning hint for the day picker, computed but never enforced. For EXPORT it
   * mirrors the real request-time gate: `fits` is whether a single open train
   * that day can carry the WHOLE booking (export never splits), and `freeWagons`
   * is the largest single-train leftover. For IMPORT/DOMESTIC `freeWagons` is the
   * TOTAL room across the day's trains for the booking's wagon type (the batch
   * engine may still split or defer a remainder), and `fits` is whether that
   * total covers the booking. `trainsForDay` is false when no departure carries
   * the leg — the day is unbookable regardless of space.
   */
  async dayAvailabilityForBooking(
    bookingId: string,
    scheduledDate: string,
  ): Promise<{ fits: boolean; freeWagons: number; trainsForDay: boolean }> {
    const booking = await this.bookingsService.findById(bookingId);
    const date = new Date(scheduledDate);
    if (Number.isNaN(date.getTime())) {
      throw new BadRequestException("A valid schedule date is required");
    }
    const day = eatDay(date);
    const isExportTrain =
      booking.tradeDirection === "EXPORT" &&
      !isRoadService(booking.serviceType);

    if (isExportTrain) {
      const scheduledBooking = { ...booking, scheduledDate: date } as Booking;
      const report =
        await this.bookingBatchService.exportSpaceReport(scheduledBooking);
      return {
        fits: report.scheduleId != null,
        freeWagons: report.bestAvailable?.wagons ?? 0,
        trainsForDay: report.trainsForDay && report.corridorMatched,
      };
    }

    const { freeWagons, need, trainsForDay } =
      await this.bookingBatchService.dayImportAvailability(booking, day);
    return { fits: freeWagons >= need, freeWagons, trainsForDay };
  }

  /**
   * Operations team reviews a pending operation request (capacity, documents,
   * route). Two outcomes:
   *  - ACCEPT          → booking enters the batch holding pool (FULLY_EXECUTED).
   *  - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
   *
   * The booking price is computed from the contract and is never adjusted here.
   */
  async reviewOperationRequest(
    bookingId: string,
    decision: "ACCEPT" | "REQUEST_CHANGES",
    actorId: string,
    options: { note?: string } = {},
  ): 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);
      const fresh = await this.bookingsService.findById(bookingId);
      this.notifier.operationChangesRequested(fresh, options.note);
      return fresh;
    }

    // ACCEPT — enter the batch holding pool.
    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();

    // Export is FCFS: fail the accept up-front (409) when no export train on the
    // booking's day still has capacity — nothing below runs and the request stays
    // pending for staff to move/decline. (For a consolidated pair this is a rough
    // solo pre-check; the real combined-capacity reservation happens after the
    // booking is FULLY_EXECUTED, once both partners are ready.)
    const isExportTrain =
      booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
    if (isExportTrain) {
      await this.bookingBatchService.pickExportSchedule(booking);
    }

    // Mint the booking's invoice (DRAFT) so the priced order carries its billing
    // record from accept onward. It is deliberately NOT issued here: accepting an
    // operation only puts the booking in the batch holding pool — no slot has been
    // offered and no pay window exists yet. Issuing at this point made the invoice
    // payable straight away (portal invoice list/detail gate on invoice status
    // alone), letting a customer pay before being selected for a batch, while the
    // booking page correctly still showed it as not payable. The batch engine
    // issues it in `reserve` (SELECTED_FOR_BATCH), which is where the pay window
    // and the real deadline are created — matching the portal's `canPay` gate.
    const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
    this.logger.log(
      `Generated draft invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id} — issued on batch selection`,
    );
    // TODO: road (truck) orders are an incomplete feature — they stop at the
    // dead-end ROAD_DISPATCH_PENDING status below (no dispatch transition, no
    // per-km pricing wired via roadKmPrice, no pay surface in the portal). They
    // skip the train batch, so they never reach `reserve` and their invoice stays
    // DRAFT / unpayable. When the road flow is built, issue its invoice
    // (billing.issuePayable) at whatever transition opens the road pay window.
    if (isRoadService(booking.serviceType)) {
      await this.bookingsRepository.update(booking.id, {
        status: "ROAD_DISPATCH_PENDING",
        fullyExecutedAt: now,
        lockedAt: booking.lockedAt ?? now,
      } as never);
      const roadFresh = await this.bookingsService.findById(booking.id);
      this.notifier.operationAccepted(roadFresh);
      return roadFresh;
    }

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

    if (isExportTrain) {
      // FCFS: reserve the slot and send the payment notification immediately;
      // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
      // only reserve once both partners are FULLY_EXECUTED (handled inside).
      const fresh = await this.bookingsService.findById(booking.id);
      try {
        await this.bookingBatchService.acceptExportBooking(fresh);
      } catch (err) {
        // The status update above already committed. Without compensation the
        // client gets an error for a booking that reads as accepted after a
        // refresh — half-applied state. Put the request back so staff can retry.
        await this.bookingsRepository.update(booking.id, {
          status: "OPERATION_REQUEST_PENDING",
          fullyExecutedAt: null,
          lockedAt: booking.lockedAt ?? null,
        } as never);
        this.logger.warn(
          `Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
        );
        throw err;
      }
    }
    // IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
    // batch runs after the window closes + staff document review, never at accept
    // time. (Legacy pre-migration schedules with no window phase are still served
    // by the periodic legacy fill.)
    const trainFresh = await this.bookingsService.findById(booking.id);
    this.notifier.operationAccepted(trainFresh);
    return trainFresh;
  }

  async enrichBookingResponse(booking: Booking): Promise<
    Booking & {
      latestChangeRequestNote?: string | null;
      contractSummary?: string | null;
      nextStep: BookingNextStep | null;
      activeBatchOffer?: {
        offeredWagons: number;
        totalWagons: number;
        offeredAmount: number;
        paymentDeadline: Date;
      } | null;
      /** Flat list of physical container numbers on this booking (for the
       *  customer truck-assignment container picker). */
      containerNumbers: string[];
    }
  > {
    // This enrichment runs AFTER the transition has committed. A failure here
    // must never 500 the response — the client would report "failed" for a
    // transition that actually succeeded (visible only after a refresh).
    // Degrade each fragile field to null instead.
    let note: Awaited<
      ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
    > = null;
    try {
      note = await this.bookingsRepository.findLatestReviewNote(
        booking.id,
        "CHANGES_REQUESTED",
      );
    } catch (err) {
      this.logger.warn(
        `enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
      );
    }
    let summary: string | null = booking.contractSummary ?? null;
    try {
      summary =
        booking.contractSummary ??
        this.contractService.buildContractSummary(booking);
    } catch (err) {
      this.logger.warn(
        `enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
      );
    }
    let nextStep: BookingNextStep | null = null;
    try {
      // Bookings no longer carry an approval chain, so there is never a pending
      // approval step to hint at.
      nextStep = computeNextStep(booking, null);
    } catch (err) {
      this.logger.warn(
        `enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
      );
    }
    let activeBatchOffer: Awaited<
      ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
    > = null;
    try {
      activeBatchOffer =
        booking.status === "SELECTED_FOR_BATCH"
          ? await this.bookingBatchService.getOpenOfferSummary(booking.id)
          : null;
    } catch (err) {
      this.logger.warn(
        `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
      );
    }
    // Physical container numbers entered at booking time (booking_container
    // units), flattened for the customer truck-assignment container picker.
    const containerNumbers = (booking.bookingContainers ?? [])
      .flatMap((bc) => bc.units ?? [])
      .map((unit) => unit.containerNumber)
      .filter((n): n is string => Boolean(n));

    return {
      ...booking,
      latestChangeRequestNote: note?.note ?? null,
      contractSummary: summary,
      nextStep,
      activeBatchOffer,
      containerNumbers,
    };
  }
}
