import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm';
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';

import { BillingService } from '../billing/billing.service';
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
import {
  ClearanceIncident,
  IncidentType,
} from './entities/clearance-incident.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import {
  persistExportTransportUploads,
  persistT1TransportUploads,
} from './phased-clearance.util';

/**
 * Maps a GL post-booking document `code` to the milestone it auto-completes when
 * uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
 * marks the milestone done so the timeline advances without a separate click.
 */
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
  release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
  delivery_order: 'DO_COLLECTED', // import — GL DJ
  // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
  // when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
  import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
  full_in_interchange: 'OFFLOADED', // export — GL DJ
  final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
  export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
};

/**
 * Operational Global Logistics actions that hang off a shipment booking after GL
 * creates it: station routing, damage/incident reporting, and the phased GL
 * document uploads (Release Order, Delivery Order, T1, etc.) that advance
 * doc-triggered milestones. See docs/new-doc.md §11–§13, gap matrix #14/#16/#18.
 */
@Injectable()
export class GlOperationsService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly filesService: FilesService,
    private readonly milestoneService: ClearanceMilestoneService,
    private readonly billingService: BillingService,
    private readonly notifier: BookingLifecycleNotifierService,
  ) {}

  private get bookings() {
    return this.dataSource.getRepository(Booking);
  }

  private get incidents() {
    return this.dataSource.getRepository(ClearanceIncident);
  }

  private async getBooking(bookingId: string): Promise<Booking> {
    // company is loaded so customer notifications have a phone/email to target.
    const booking = await this.bookings.findOne({
      where: { id: bookingId },
      relations: { company: true },
    });
    if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
    return booking;
  }

  /**
   * Route a shipment to an origin station and (optionally) bind a GL staff user
   * to it (GL US-02). Setting both moves the shipment to that station's queue.
   */
  async assignStation(
    bookingId: string,
    input: { stationYardId: string; staffId?: string },
  ): Promise<Booking> {
    const booking = await this.getBooking(bookingId);
    booking.glStationYardId = input.stationYardId;
    if (input.staffId) {
      booking.glAssignedStaffId = input.staffId;
      booking.glAssignedAt = new Date();
    }
    return this.bookings.save(booking);
  }

  /** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
  async reportIncident(
    bookingId: string,
    input: {
      incidentType: IncidentType;
      description: string;
      files: Express.Multer.File[];
    },
    userId?: string,
  ): Promise<ClearanceIncident> {
    await this.getBooking(bookingId);
    if (!input.description?.trim()) {
      throw new BadRequestException('A description is required for an incident report.');
    }
    const photoFileIds: string[] = [];
    for (const file of input.files ?? []) {
      const record = await this.filesService.upload({
        resourceId: bookingId,
        resource: 'bookings',
        code: 'incident_photo',
        file,
      });
      photoFileIds.push(record.id);
    }
    const incident = this.incidents.create({
      bookingId,
      incidentType: input.incidentType,
      description: input.description.trim(),
      photoFileIds,
      reportedByUserId: userId ?? null,
      reportedAt: new Date(),
    });
    return this.incidents.save(incident);
  }

  async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
    return this.incidents.find({
      where: { bookingId },
      order: { reportedAt: 'DESC' },
    });
  }

  /**
   * Customer uploads the duty/tax payment slip after GL advised the amount. The
   * slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
   */
  async uploadDutySlip(
    bookingId: string,
    file: Express.Multer.File,
  ): Promise<{ milestoneCompleted: boolean }> {
    await this.getBooking(bookingId);
    if (!file) throw new BadRequestException('No payment slip uploaded');
    await this.filesService.upsertByCode({
      resourceId: bookingId,
      resource: 'bookings',
      code: 'duty_tax_receipt',
      file,
    });
    await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
    return { milestoneCompleted: true };
  }

  /**
   * GL uploads a post-booking operational document (DO, RO, T1, import release,
   * interchange…). The file attaches to the booking; if the code maps to a
   * doc-triggered milestone, that milestone auto-completes.
   */
  async uploadDocuments(
    bookingId: string,
    files: Express.Multer.File[],
  ): Promise<{ uploaded: number; completedMilestones: string[] }> {
    await this.getBooking(bookingId);
    if (!files?.length) throw new BadRequestException('No documents uploaded');

    const completedMilestones: string[] = [];
    for (const file of files) {
      await this.filesService.upsertByCode({
        resourceId: bookingId,
        resource: 'bookings',
        code: file.fieldname,
        file,
      });
      const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
      if (milestoneCode) {
        await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
        completedMilestones.push(milestoneCode);
      }
    }
    return { uploaded: files.length, completedMilestones };
  }

  /** Wagon-allocation + train-schedule actuals for a booking (both directions). */
  async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
    const booking = await this.getBooking(bookingId);
    const milestones = await this.milestoneService.listForBooking(bookingId);

    const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
    const wagonAllocated =
      wagonMilestone?.status === 'COMPLETED' ||
      booking.schedulingStatus === 'SCHEDULED' ||
      booking.schedulingStatus === 'DISPATCHED' ||
      Boolean(booking.trainScheduleId);

    let schedule: TrainSchedule | null = null;
    if (booking.trainScheduleId) {
      schedule = await this.dataSource
        .getRepository(TrainSchedule)
        .findOne({ where: { id: booking.trainScheduleId } });
    }

    // Per-booking journey first: a booking rides only its own leg, so ITS
    // loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that
    // unloaded at its own destination clears while the train keeps rolling,
    // and a booking still on board does NOT clear just because the train
    // arrived. The schedule actuals remain only as fallback for legacy
    // in-flight bookings that predate per-booking load/unload (no loadedAt).
    const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null;
    const arrivedAt =
      booking.arrivedAt ??
      (booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null));

    return {
      scheduleId: schedule?.id ?? null,
      wagonAllocated,
      departedAt: departedAt ? new Date(departedAt).toISOString() : null,
      arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
    };
  }

  /**
   * Gate pass status for a booking, sourced from the train schedule's Djibouti
   * gate-pass operation (secured via the train-scheduling "Save as Secured"
   * action) rather than a clearance milestone. For EXPORT bookings this also
   * backfills the arrival-chain milestones once secured, same as the retired
   * clearance-side grant action used to.
   */
  async gatepassForBooking(
    bookingId: string,
  ): Promise<{ granted: boolean; grantedAt: string | null }> {
    const train = await this.trainState(bookingId);
    if (!train.scheduleId) return { granted: false, grantedAt: null };
    const operation = await this.dataSource
      .getRepository(ImportDjiboutiOperation)
      .findOne({ where: { trainScheduleId: train.scheduleId } });
    const grantedAt = operation?.gatepassGrantedAt
      ? new Date(operation.gatepassGrantedAt).toISOString()
      : null;

    if (grantedAt) {
      const booking = await this.getBooking(bookingId);
      if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
        const milestones = await this.milestoneService.listForBooking(bookingId);
        const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
        for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
          if (byCode.get(code)?.status === 'PENDING') {
            await this.milestoneService.completeForBooking(bookingId, code);
          }
        }
      }
    }

    return { granted: Boolean(grantedAt), grantedAt };
  }

  /**
   * T1 transit-document lifecycle state for an import shipment booking. The
   * gate pass (secured on the train schedule after wagon allocation) opens the
   * upload window; train departure locks it; train arrival lets GL Ethiopia
   * close (accept) the T1 set.
   */
  async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
    const train = await this.trainState(bookingId);
    const milestones = await this.milestoneService.listForBooking(bookingId);

    const closedMilestone = milestones.find(
      (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
    );

    return {
      bookingId,
      wagonAllocated: train.wagonAllocated,
      trainDepartedAt: train.departedAt,
      trainArrivedAt: train.arrivedAt,
      closed: Boolean(closedMilestone),
      closedAt: closedMilestone?.triggeredAt
        ? new Date(closedMilestone.triggeredAt).toISOString()
        : null,
    };
  }

  /**
   * GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
   * is secured on the train schedule (which itself follows wagon allocation).
   * Replaces the previous batch; locked only once GL Ethiopia closes the T1.
   */
  async uploadT1Documents(
    bookingId: string,
    files: Express.Multer.File[],
  ): Promise<{ uploaded: number }> {
    const booking = await this.getBooking(bookingId);
    if (booking.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('T1 transport documents apply to import shipments only.');
    }

    const state = await this.t1State(bookingId);
    if (!state.wagonAllocated) {
      throw new BadRequestException(
        'Wagons must be allocated before T1 transport documents can be uploaded.',
      );
    }
    const gatepass = await this.gatepassForBooking(bookingId);
    if (!gatepass.granted) {
      throw new BadRequestException(
        'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
      );
    }
    if (state.closed) {
      throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
    }
    // Departure no longer locks T1 docs — GL DJ may replace them any time until
    // GL Ethiopia closes/accepts the T1.

    await persistT1TransportUploads(this.filesService, bookingId, files);
    return { uploaded: files.length };
  }

  /**
   * Close (accept) the T1/transport document set.
   * Import: GL Ethiopia closes once the train has arrived (T1 files required).
   * Export: GL Djibouti closes once the train arrives at Djibouti (transport
   * document required).
   */
  async closeT1(
    bookingId: string,
    userId?: string,
  ): Promise<Freight.ClearanceT1State> {
    const booking = await this.getBooking(bookingId);
    const tradeDirection = booking.tradeDirection ?? 'IMPORT';

    const state = await this.t1State(bookingId);
    if (state.closed) return state;

    if (tradeDirection === 'IMPORT') {
      if (!state.trainArrivedAt) {
        throw new BadRequestException(
          'The train has not arrived yet — T1 can be closed only after arrival.',
        );
      }
      const files = await this.filesService.findByResource(bookingId, 'bookings');
      const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
      if (!hasT1) {
        throw new BadRequestException(
          'No T1 transport documents on file — GL Djibouti must upload them first.',
        );
      }
    } else {
      const milestones = await this.milestoneService.listForBooking(bookingId);
      const done = (code: string) =>
        milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED';
      if (!done('EXPORT_TRANSPORT_ISSUED')) {
        throw new BadRequestException(
          'The transport document must be uploaded before T1 can be closed.',
        );
      }
      if (!state.trainArrivedAt) {
        throw new BadRequestException(
          'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.',
        );
      }
      // Export bookings seeded before T1_CLOSED joined the catalog lack the row.
      await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
    }

    await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
    return this.t1State(bookingId);
  }

  /** Milestones GL DJ implicitly confirms when granting an export gate pass. */
  private static readonly EXPORT_ARRIVAL_CHAIN = [
    'CARGO_ARRIVED',
    'READY_FOR_LOADING',
    'LOADED',
    'DEPARTED_TO_DJIBOUTI',
    'ARRIVED_AT_DJIBOUTI',
  ];


  /**
   * GL Djibouti raises the post-offload final invoice (export): manual amount +
   * attached invoice document. The customer pays offline and attaches a slip;
   * GL (ET or DJ) then confirms to settle it.
   */
  async createFinalInvoice(
    bookingId: string,
    input: { amount: number; currency: string; description?: string },
    file: Express.Multer.File,
    userId?: string,
  ): Promise<Freight.ClearanceFinalInvoiceSummary> {
    const booking = await this.getBooking(bookingId);
    if (!booking.customsClearingEnabled) {
      throw new BadRequestException('Final invoice applies to customs bookings only.');
    }
    if (!(input.amount > 0)) {
      throw new BadRequestException('Invoice amount must be greater than zero.');
    }
    if (!file) throw new BadRequestException('Attach the invoice document.');

    // Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
    // DJ doc milestone that may never be recorded — once the Djibouti gate pass
    // is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
    const milestones = await this.milestoneService.listForBooking(bookingId);
    const offloaded = milestones.find(
      (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
    );
    if (!offloaded) {
      const gatepass = await this.gatepassForBooking(bookingId);
      if (!gatepass.granted) {
        throw new BadRequestException(
          'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
        );
      }
    }

    const existing = await this.billingService.findInvoice(
      Freight.InvoiceSource.Booking,
      bookingId,
      GL_FINAL_INVOICE_TYPE,
    );
    if (
      existing &&
      existing.status !== Freight.InvoiceStatus.Cancelled &&
      existing.status !== Freight.InvoiceStatus.Expired
    ) {
      throw new ConflictException('A final invoice already exists for this shipment.');
    }

    const description = input.description?.trim() || 'Post-offload charges (Djibouti)';
    await this.billingService.generateInvoice({
      source: Freight.InvoiceSource.Booking,
      sourceId: bookingId,
      type: GL_FINAL_INVOICE_TYPE,
      companyId: booking.companyId,
      companyProfileId: booking.companyProfileId,
      currency: input.currency,
      lines: [
        {
          chargeType: GL_FINAL_INVOICE_TYPE,
          description,
          quantity: 1,
          unitRate: input.amount,
          amount: input.amount,
        },
      ],
      status: Freight.InvoiceStatus.Issued,
    });

    await this.filesService.upsertByCode({
      resourceId: bookingId,
      resource: 'bookings',
      code: 'final_invoice',
      file,
    });

    // Export clearance is administratively done once the final invoice goes out.
    await this.dataSource
      .getRepository(ContractClearanceCycle)
      .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() });

    void userId;
    const summary = await this.finalInvoiceSummary(bookingId);
    if (!summary) throw new NotFoundException('Final invoice could not be created.');
    this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
    return summary;
  }

  /** Customer attaches the payment slip for the final invoice. */
  async uploadFinalInvoiceSlip(
    bookingId: string,
    file: Express.Multer.File,
  ): Promise<{ uploaded: boolean }> {
    const booking = await this.getBooking(bookingId);
    if (!file) throw new BadRequestException('No payment slip uploaded');

    const invoice = await this.billingService.findInvoice(
      Freight.InvoiceSource.Booking,
      bookingId,
      GL_FINAL_INVOICE_TYPE,
    );
    if (!invoice) {
      throw new BadRequestException('No final invoice has been issued for this shipment.');
    }
    if (invoice.status === Freight.InvoiceStatus.Paid) {
      throw new BadRequestException('The final invoice is already paid.');
    }
    if (
      invoice.status === Freight.InvoiceStatus.Cancelled ||
      invoice.status === Freight.InvoiceStatus.Expired
    ) {
      throw new BadRequestException('The final invoice is no longer payable.');
    }

    await this.filesService.upsertByCode({
      resourceId: bookingId,
      resource: 'bookings',
      code: 'final_invoice_slip',
      file,
    });
    this.notifier.dutySlipUploadedToStaff(booking, 'final');
    return { uploaded: true };
  }

  /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */
  async confirmFinalInvoicePaid(
    bookingId: string,
    userId?: string,
  ): Promise<Freight.ClearanceFinalInvoiceSummary> {
    const booking = await this.getBooking(bookingId);
    const invoice = await this.billingService.findInvoice(
      Freight.InvoiceSource.Booking,
      bookingId,
      GL_FINAL_INVOICE_TYPE,
    );
    if (!invoice) {
      throw new BadRequestException('No final invoice has been issued for this shipment.');
    }
    if (invoice.status !== Freight.InvoiceStatus.Paid) {
      const files = await this.filesService.findByResource(bookingId, 'bookings');
      if (!files.some((f) => f.code === 'final_invoice_slip')) {
        throw new BadRequestException(
          'The customer has not attached a payment slip yet.',
        );
      }
      await this.billingService.markInvoiceAsPaid(invoice.id);
      this.notifier.finalInvoicePaid(booking);
    }

    void userId;
    const summary = await this.finalInvoiceSummary(bookingId);
    if (!summary) throw new NotFoundException('Final invoice not found.');
    return summary;
  }

  /**
   * GL ET advises (or skips) the post-arrival additional duty/tax round (import).
   * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
   */
  async adviseSecondDuty(
    bookingId: string,
    input: {
      dutyRequired: boolean;
      amount?: number;
      currency?: string;
      declarationSerial?: string;
    },
    attachment?: Express.Multer.File,
    userId?: string,
  ): Promise<{ advised: boolean; skipped: boolean }> {
    const booking = await this.getBooking(bookingId);
    if (!booking.customsClearingEnabled) {
      throw new BadRequestException('Additional duty applies to customs bookings only.');
    }
    const tradeDirection = booking.tradeDirection ?? 'IMPORT';
    if (tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Additional duty applies to import shipments only.');
    }

    await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
    await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);

    if (!input.dutyRequired) {
      await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
      await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
      return { advised: false, skipped: true };
    }

    if (!input.amount || input.amount <= 0) {
      throw new BadRequestException('Duty amount must be greater than zero.');
    }
    const files = await this.filesService.findByResource(bookingId, 'bookings');
    const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
    if (!attachment && !hasNotice) {
      throw new BadRequestException('Attach the additional duty/tax notice.');
    }
    if (attachment) {
      await this.filesService.upsertByCode({
        resourceId: bookingId,
        resource: 'bookings',
        code: 'duty_tax_notice_2',
        file: attachment,
      });
    }

    await this.milestoneService.completeWithMetadataForBooking(
      bookingId,
      'SECOND_DUTY_ADVISED',
      {
        dutyAmount: input.amount,
        dutyCurrency: input.currency ?? 'ETB',
        declarationSerial: input.declarationSerial,
      },
      userId,
    );
    this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
    return { advised: true, skipped: false };
  }

  /** Customer attaches the payment slip for the additional duty round. */
  async uploadSecondDutySlip(
    bookingId: string,
    file: Express.Multer.File,
  ): Promise<{ milestoneCompleted: boolean }> {
    const booking = await this.getBooking(bookingId);
    if (!file) throw new BadRequestException('No payment slip uploaded');

    const milestones = await this.milestoneService.listForBooking(bookingId);
    const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
    if (advised?.status !== 'COMPLETED') {
      throw new BadRequestException('No additional duty has been advised for this shipment.');
    }

    await this.filesService.upsertByCode({
      resourceId: bookingId,
      resource: 'bookings',
      code: 'duty_tax_receipt_2',
      file,
    });

    await this.milestoneService.ensureForBooking(
      bookingId,
      'SECOND_DUTY_PAID',
      booking.tradeDirection ?? 'IMPORT',
    );
    await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
    this.notifier.dutySlipUploadedToStaff(booking, 'second');
    return { milestoneCompleted: true };
  }

  /** Second duty round state for clearance views. */
  secondDutyState(
    milestones: Array<{
      milestoneCode: string;
      status: string;
      metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
    }>,
    files: Array<{ code?: string | null; id: string; name: string; url: string }>,
  ): Freight.ClearanceSecondDuty | null {
    const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
    const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
    if (!advised && !paid) return null;

    const toRef = (code: string) => {
      const f = files.find((x) => x.code === code);
      return f ? { id: f.id, name: f.name, url: f.url } : null;
    };

    return {
      advised: advised?.status === 'COMPLETED',
      skipped: advised?.status === 'SKIPPED',
      amount: advised?.metadata?.dutyAmount ?? null,
      currency: advised?.metadata?.dutyCurrency ?? null,
      declarationSerial: advised?.metadata?.declarationSerial ?? null,
      noticeFile: toRef('duty_tax_notice_2'),
      slipFile: toRef('duty_tax_receipt_2'),
      paid: paid?.status === 'COMPLETED',
    };
  }

  /** Final-invoice state joined with its document + slip files, for clearance views. */
  async finalInvoiceSummary(
    bookingId: string,
  ): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
    const invoice = await this.billingService.findInvoice(
      Freight.InvoiceSource.Booking,
      bookingId,
      GL_FINAL_INVOICE_TYPE,
    );
    if (!invoice) return null;

    const files = await this.filesService.findByResource(bookingId, 'bookings');
    const toRef = (code: string) => {
      const f = files.find((x) => x.code === code);
      return f ? { id: f.id, name: f.name, url: f.url } : null;
    };
    const line = await this.dataSource
      .getRepository(InvoiceLine)
      .findOne({ where: { invoiceId: invoice.id } });

    return {
      id: invoice.id,
      invoiceNumber: invoice.invoiceNumber,
      status: invoice.status,
      totalAmount: Number(invoice.totalAmount),
      currency: invoice.currency,
      description: line?.description ?? null,
      invoiceFile: toRef('final_invoice'),
      slipFile: toRef('final_invoice_slip'),
      confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
    };
  }

  /**
   * GL ET uploads export transport document after wagon allocation (export ONE_TIME).
   */
  async uploadTransportDocument(
    bookingId: string,
    files: Express.Multer.File[],
  ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
    const booking = await this.getBooking(bookingId);
    if (booking.tradeDirection !== 'EXPORT') {
      throw new BadRequestException('Transport document upload applies to export shipments only.');
    }

    const milestones = await this.milestoneService.listForBooking(bookingId);
    const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
    const wagonDone =
      wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
    if (!wagonDone) {
      throw new BadRequestException(
        'Wagon must be allocated before the transport document can be uploaded.',
      );
    }

    if (files.length === 0) {
      throw new BadRequestException('No transit permit documents uploaded');
    }

    await persistExportTransportUploads(this.filesService, bookingId, files);

    if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
      await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
    }

    await this.milestoneService.completeByDocTrigger(
      { bookingId },
      'EXPORT_TRANSPORT_ISSUED',
    );

    return { uploaded: true, milestoneCompleted: true };
  }
}
