import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import {
  ContractDocPhase,
  type ClearanceFinalInvoiceSummary,
  type ClearanceSecondDuty,
  type ClearanceT1State,
  type ClearanceTrainState,
} from '@edr/types';

import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service';
import { BookingsService } from '../bookings/bookings.service';
import {
  assertDoCollectionDates,
  contractClearanceCodes,
} from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractNotifierService } from './contract-notifier.service';
import { GlOperationsService } from './gl-operations.service';
import {
  ClearanceMilestone,
  type RiskAssignmentRecord,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';

const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';

export interface ContractClearanceDocument {
  fileKey: string;
  label: string;
  required: boolean;
  uploadedBy: 'customer' | 'gl';
  settingCode: string;
  file: { id: string; name: string; url: string } | null;
  reviewStatus: ContractDocReviewStatus | null;
  note: string | null;
  /** When the review decision (approve/query) was recorded. */
  reviewedAt: string | null;
  /** Staff id that recorded the decision (no user directory to resolve names). */
  reviewedByStaffId: string | null;
}

export interface ContractClearanceView {
  contractId: string;
  status: string;
  clearanceStatus: string;
  cycleNumber: number;
  includesCustoms: boolean;
  inputCode: string | null;
  outputCode: string | null;
  documents: ContractClearanceDocument[];
  allApproved: boolean;
  phase?: string | null;
  milestones?: Array<{
    id: string;
    milestoneCode: string;
    milestoneLabel: string;
    status: string;
    ownerRegion?: string | null;
    metadata?: Record<string, unknown> | null;
    sortOrder: number;
  }>;
  nextAction?: {
    actor: string;
    action: string;
    milestoneCode?: string | null;
    blockedReason?: string | null;
  } | null;
  dutyRequired?: boolean | null;
  /**
   * Pre-declaration handshake with GL Djibouti: who will handle the shipment in
   * transit. `name` is null until Djibouti answers, and the declaration step is
   * shut until it is set.
   */
  transitAssignee?: {
    requestedAt: string | null;
    requestNote: string | null;
    name: string | null;
    assignedAt: string | null;
  } | null;
  roHold?: boolean;
  roHoldReason?: string | null;
  vesselDepartureDate?: string | null;
  /** Import DO dates recorded by GL Djibouti on upload. */
  vesselArrivalDate?: string | null;
  doCollectedDate?: string | null;
  roAmendmentRequestedAt?: string | null;
  bookingReady?: boolean;
  preClearanceFinalized?: boolean;
  /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
  exportClearanceFinalized?: boolean;
  linkedBookingId?: string | null;
  /** Reference + status of the GL-created shipment booking, once it exists. */
  linkedBookingReference?: string | null;
  linkedBookingStatus?: string | null;
  /**
   * Operations' latest "needs changes" note on that booking. GL created the
   * booking, so GL is the one who has to act on it — surfaced here because the
   * clearance page is where GL works, not the portal.
   */
  linkedBookingReviewNote?: string | null;
  /** Shipment day the booking currently holds — the default when GL resubmits. */
  linkedBookingScheduledDate?: string | null;
  dutyAdvice?: {
    amount: number;
    currency: string;
    declarationSerial?: string | null;
    noticeFile?: { id: string; name: string; url: string } | null;
  } | null;
  /**
   * The customer's open objection to the advised duty — present only while GL
   * has not re-advised (the advice milestone is back to PENDING). `rounds` is
   * how many times it has been sent back, so both sides can see the loop.
   */
  dutyDispute?: {
    note: string;
    raisedAt: string;
    rounds: number;
  } | null;
  workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
  /** Import post-allocation T1 transit document state (null until a booking is linked). */
  t1?: ClearanceT1State | null;
  /** Train link state for the booking (both directions; null until a booking is linked). */
  train?: ClearanceTrainState | null;
  gatepassGranted?: boolean;
  gatepassAt?: string | null;
  t1Closed?: boolean;
  t1ClosedAt?: string | null;
  offloaded?: boolean;
  /** GL Djibouti post-offload final invoice (export). */
  finalInvoice?: ClearanceFinalInvoiceSummary | null;
  /** Customs risk level assigned by GL ET (import; visible to the customer). */
  riskLevel?: string | null;
  riskAssignedAt?: string | null;
  /** Every risk decision, oldest first; the last entry is the current level. */
  riskHistory?: RiskAssignmentRecord[];
  /** Post-arrival additional duty/tax round (import). */
  secondDuty?: ClearanceSecondDuty | null;
  importReleaseGranted?: boolean;
}

@Injectable()
export class ContractClearanceService {
  constructor(
    private readonly contractsRepository: ContractsRepository,
    private readonly contractsService: ContractsService,
    private readonly bookingsService: BookingsService,
    private readonly filesService: FilesService,
    private readonly fileUploadSettingsService: FileUploadSettingsService,
    private readonly workflowService: ClearanceWorkflowService,
    private readonly milestoneService: ClearanceMilestoneService,
    private readonly dropdownSettingsService: DropdownSettingsService,
    private readonly glOperationsService: GlOperationsService,
    private readonly notifier: ContractNotifierService,
  ) {}

  private isPhasedCustoms(contract: Contract): boolean {
    return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
  }

  private assertPhasedCustoms(contract: Contract): void {
    if (!this.isPhasedCustoms(contract)) {
      throw new BadRequestException(
        'Phased clearance (Phase 1) applies to one-time customs contracts.',
      );
    }
  }

  /**
   * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
   * phased milestones. Revert that state so declaration / DO steps can proceed.
   */
  private async reconcilePrematureBookingReady(
    contractId: string,
    contract: Contract,
    bookingReady: boolean,
  ): Promise<Contract> {
    if (
      !this.isPhasedCustoms(contract) ||
      contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
      bookingReady
    ) {
      return contract;
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    await this.contractsRepository.update(contractId, {
      status: 'CLEARANCE_UNDER_REVIEW',
      clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
    } as never);
    if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
      await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
    }
    return this.contractsService.findById(contractId);
  }

  /** The pre-booking clearance document grid for a contract (Path B). */
  async getClearanceView(contractId: string): Promise<ContractClearanceView> {
    let contract = await this.contractsService.findById(contractId);
    const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
    const cycle = await this.contractsRepository.currentCycle(contractId);

    const files = await this.filesService.findByResource(contractId, 'contracts');
    const fileByCode = new Map(files.map((f) => [f.code, f]));
    const reviews = await this.contractsRepository.findDocumentReviews(
      contractId,
      cycle?.id ?? null,
    );
    const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));

    const documents: ContractClearanceDocument[] = [];

    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,
          reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
          reviewedByStaffId: review?.reviewedByStaffId ?? 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,
        reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
        reviewedByStaffId: review?.reviewedByStaffId ?? null,
      });
    }

    const allApproved = await this.isClearanceFullyApproved(contract);
    const milestones = await this.workflowService.listMilestones(contractId);
    let boundary = await this.workflowService.isBoundaryComplete(
      contractId,
      contract.tradeDirection,
    );
    contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
    const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
    const dutyAdvice = this.buildDutyAdvice(files, milestones);
    const dutyDispute = await this.buildDutyDispute(contractId, milestones);
    const transitAssignee = cycle
      ? {
          requestedAt: cycle.transitAssigneeRequestedAt
            ? cycle.transitAssigneeRequestedAt.toISOString()
            : null,
          requestNote: cycle.transitAssigneeRequestNote ?? null,
          name: cycle.transitAssigneeName ?? null,
          assignedAt: cycle.transitAssigneeAssignedAt
            ? cycle.transitAssigneeAssignedAt.toISOString()
            : null,
        }
      : null;
    let workflowFiles = buildWorkflowFiles(
      files,
      contract.tradeDirection ?? 'IMPORT',
    );
    let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
    if (cycle?.bookingId) {
      bookingFiles = await this.filesService.findByResource(
        cycle.bookingId,
        'bookings',
      );
      const bookingWorkflow = buildWorkflowFiles(
        bookingFiles,
        contract.tradeDirection ?? 'IMPORT',
      );
      const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
      for (const row of bookingWorkflow) {
        if (row.file) byCode.set(row.code, row);
      }
      workflowFiles = [...byCode.values()];
    }

    let t1: ClearanceT1State | null = null;
    if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
      try {
        t1 = await this.glOperationsService.t1State(cycle.bookingId);
      } catch {
        t1 = null; // linked booking missing — view stays usable
      }
    }

    let train: ClearanceTrainState | null = null;
    let bookingMilestones: ClearanceMilestone[] = [];
    let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
    if (cycle?.bookingId) {
      try {
        train = await this.glOperationsService.trainState(cycle.bookingId);
      } catch {
        train = null;
      }
      bookingMilestones = await this.workflowService.listMilestonesForBooking(
        cycle.bookingId,
      );
      finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
    }
    const bookingMilestone = (code: string) =>
      bookingMilestones.find((m) => m.milestoneCode === code);
    const gatepass = cycle?.bookingId
      ? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
      : { granted: false, grantedAt: null };
    const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
    const riskMilestone = bookingMilestone('RISK_ASSIGNED');
    const secondDuty = this.glOperationsService.secondDutyState(
      bookingMilestones,
      bookingFiles,
    );

    let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
    // Once GL creates the shipment booking, surface its reference + status so the
    // customer sees the concrete booking instead of a stale "will be created
    // shortly" message. Reuse the export booking load; fetch for import too.
    let linkedBookingReference: string | null = null;
    let linkedBookingStatus: string | null = null;
    let linkedBookingReviewNote: string | null = null;
    let linkedBookingScheduledDate: string | null = null;
    if (cycle?.bookingId) {
      const booking = await this.bookingsService.findById(cycle.bookingId);
      if (booking) {
        linkedBookingReference = booking.reference ?? null;
        linkedBookingStatus = booking.status ?? null;
        linkedBookingScheduledDate = booking.scheduledDate
          ? new Date(booking.scheduledDate).toISOString()
          : null;
        // Newest changes-requested note (reviewNotes ride along on findById).
        linkedBookingReviewNote =
          [...(booking.reviewNotes ?? [])]
            .filter((n) => n.type === 'CHANGES_REQUESTED')
            .sort(
              (a, b) =>
                new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
            )[0]?.note ?? null;
        if (contract.tradeDirection === 'EXPORT') {
          nextAction = this.workflowService.computeNextActionForBooking(
            booking,
            bookingMilestones,
          );
        }
      }
    }

    return {
      contractId,
      status: contract.status,
      clearanceStatus: contract.clearanceStatus,
      cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber,
      includesCustoms,
      inputCode,
      outputCode,
      documents,
      allApproved,
      phase,
      milestones: milestones.map((m) => ({
        id: m.id,
        milestoneCode: m.milestoneCode,
        milestoneLabel: m.milestoneLabel,
        status: m.status,
        ownerRegion: m.ownerRegion,
        metadata: (m.metadata ?? null) as Record<string, unknown> | null,
        sortOrder: m.sortOrder,
      })),
      nextAction,
      dutyRequired: cycle?.dutyRequired ?? null,
      roHold: Boolean(cycle?.roHoldReason),
      roHoldReason: cycle?.roHoldReason ?? null,
      vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
      vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
      doCollectedDate: cycle?.doCollectedDate ?? null,
      roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
        ? cycle.roAmendmentRequestedAt.toISOString()
        : null,
      bookingReady: boundary,
      preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
      exportClearanceFinalized: Boolean(cycle?.completedAt),
      linkedBookingId: cycle?.bookingId ?? null,
      linkedBookingReference,
      linkedBookingStatus,
      linkedBookingReviewNote,
      linkedBookingScheduledDate,
      dutyAdvice,
      dutyDispute,
      transitAssignee,
      workflowFiles,
      t1,
      train,
      gatepassGranted: gatepass.granted,
      gatepassAt: gatepass.grantedAt,
      t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
      t1ClosedAt:
        t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
          ? t1ClosedMilestone.triggeredAt.toISOString()
          : null,
      offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
      finalInvoice,
      riskLevel:
        riskMilestone?.status === 'COMPLETED'
          ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
          : null,
      riskAssignedAt:
        riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
          ? riskMilestone.triggeredAt.toISOString()
          : null,
      // Every risk decision, oldest first — see booking-clearance.service.
      riskHistory:
        riskMilestone?.status === 'COMPLETED'
          ? (riskMilestone.metadata?.riskHistory ?? [])
          : [],
      secondDuty,
      importReleaseGranted:
        bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
    };
  }

  private buildDutyAdvice(
    files: Array<{ code?: string | null; id: string; name: string; url: string }>,
    milestones: ClearanceMilestone[],
  ): ContractClearanceView['dutyAdvice'] {
    const advised = milestones.find(
      (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
    );
    if (!advised?.metadata) return null;
    const amount = advised.metadata.dutyAmount;
    const currency = advised.metadata.dutyCurrency;
    if (typeof amount !== 'number' || typeof currency !== 'string') return null;
    const notice = files.find((f) => f.code === 'duty_tax_notice');
    return {
      amount,
      currency,
      declarationSerial:
        typeof advised.metadata.declarationSerial === 'string'
          ? advised.metadata.declarationSerial
          : null,
      noticeFile: notice
        ? { id: notice.id, name: notice.name, url: notice.url }
        : null,
    };
  }

  /**
   * The customer's duty objection, but only while it is still OPEN — i.e. the
   * advice milestone sits back at PENDING because nobody has re-advised yet.
   * Re-advising completes that milestone again, which closes the dispute here
   * without any extra state to keep in sync; the notes stay as the audit trail
   * and their count is the round number.
   */
  private async buildDutyDispute(
    contractId: string,
    milestones: ClearanceMilestone[],
  ): Promise<ContractClearanceView['dutyDispute']> {
    const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
    if (!advised || advised.status === 'COMPLETED') return null;
    const notes = await this.contractsRepository.findReviewNotes(
      contractId,
      'DUTY_DISPUTE',
    );
    const latest = notes[0];
    if (!latest) return null;
    return {
      note: latest.body,
      raisedAt: latest.createdAt.toISOString(),
      rounds: notes.length,
    };
  }

  /**
   * True when every REQUIRED customer-input field has an APPROVED review row in
   * the current cycle. The 100% gate before clearance can be finalized.
   */
  private async isClearanceFullyApproved(contract: Contract): Promise<boolean> {
    const { inputCode } = contractClearanceCodes(contract);
    if (!inputCode) return true;
    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 cycle = await this.contractsRepository.currentCycle(contract.id);
    const reviews = await this.contractsRepository.findDocumentReviews(
      contract.id,
      cycle?.id ?? null,
    );
    return required.every((field) =>
      reviews.some(
        (r) =>
          r.settingCode === inputCode &&
          r.fileKey === field.fileKey &&
          r.status === 'APPROVED',
      ),
    );
  }

  /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
  private assertClearanceReviewableStatus(contract: Contract): void {
    const allowed = [
      'CLEARANCE_UNDER_REVIEW',
      'AWAITING_CLEARANCE_DOCUMENTS',
      'CLEARANCE_READY_FOR_BOOKING',
    ];
    if (!allowed.includes(contract.status)) {
      throw new ConflictException(
        `Cannot review clearance documents on status "${contract.status}".`,
      );
    }
  }

  /** Finalize when docs are under review or all approved after a partial query cycle. */
  private assertClearanceFinalizableStatus(contract: Contract): void {
    const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
    if (!allowed.includes(contract.status)) {
      throw new ConflictException(
        `Cannot finalize document approval on status "${contract.status}".`,
      );
    }
  }

  private assertClearanceOutputUploadableStatus(contract: Contract): void {
    const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
    if (!allowed.includes(contract.status)) {
      throw new ConflictException(
        `Cannot upload output documents on status "${contract.status}".`,
      );
    }
  }

  private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
    const refreshed = await this.contractsService.findById(contractId);
    const allApproved = await this.isClearanceFullyApproved(refreshed);
    if (
      !allApproved ||
      (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
        refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
    ) {
      return;
    }
    await this.contractsRepository.update(contractId, {
      status: 'CLEARANCE_UNDER_REVIEW',
      clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
    } as never);
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (cycle) {
      await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
    }
  }

  /**
   * Customer uploads clearance documents on the contract. When every required
   * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
   */
  async uploadDocuments(
    contractId: string,
    files: Express.Multer.File[],
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    if (
      contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
      contract.status !== 'CLEARANCE_UNDER_REVIEW'
    ) {
      throw new ConflictException(
        `Cannot upload clearance documents on status "${contract.status}".`,
      );
    }
    const { inputCode } = contractClearanceCodes(contract);
    if (!inputCode) {
      throw new BadRequestException('This contract has no document-clearance step');
    }
    if (files.length === 0) {
      throw new BadRequestException('No documents uploaded');
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);

    // First submission: every required input field must be present.
    if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') {
      await this.assertRequiredInputsPresent(contractId, inputCode, files);
    }

    for (const file of files) {
      const record = await this.filesService.upsertByCode({
        resourceId: contractId,
        resource: 'contracts',
        code: file.fieldname,
        file,
      });
      const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode;
      await this.contractsRepository.upsertDocumentReviewPending({
        contractId,
        clearanceCycleId: cycle?.id ?? null,
        settingCode,
        fileKey: file.fieldname,
        fileRecordId: record.id,
        uploadedByRole: 'CUSTOMER',
      });
    }

    await this.contractsRepository.update(contractId, {
      status: 'CLEARANCE_UNDER_REVIEW',
      clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
    } as never);
    if (cycle) {
      await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
        currentPhase: ContractDocPhase.GlEtReview,
      });
    }

    if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
      await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
      await this.workflowService.onDocumentReviewReopened(contractId);
    }

    const updated = await this.contractsService.findById(contractId);
    this.notifier.clearanceDocsUploadedToStaff(updated);
    return updated;
  }

  private async assertRequiredInputsPresent(
    contractId: string,
    inputCode: string,
    files: Express.Multer.File[],
  ): Promise<void> {
    let setting;
    try {
      setting = await this.fileUploadSettingsService.getByCode(inputCode);
    } catch {
      return;
    }
    const required = (setting.fields ?? []).filter((f) => f.isRequired);
    if (required.length === 0) return;

    const existing = await this.filesService.findByResource(contractId, 'contracts');
    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}`,
      );
    }
  }

  /**
   * Path A (no customs) — the customer clears the cargo himself and uploads his
   * own clearance proof, reviewed by Operations rather than GL. True when a
   * clearance doc set resolves for a non-customs contract.
   */
  private isSelfClear(contract: Contract): boolean {
    if (contract.customsClearingEnabled) return false;
    return contractClearanceCodes(contract).inputCode != null;
  }

  /** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */
  async reviewDocument(
    contractId: string,
    fileKey: string,
    status: 'APPROVED' | 'QUERIED',
    staffId: string,
    note?: string,
  ): Promise<Contract> {
    return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note);
  }

  /**
   * Operations (Path A) reviews a customer self-clearance document. Identical
   * approve/query loop to {@link reviewDocument}; rejects customs (Path B)
   * contracts, which are GL-reviewed.
   */
  async opsReviewDocument(
    contractId: string,
    fileKey: string,
    status: 'APPROVED' | 'QUERIED',
    staffId: string,
    note?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    if (!this.isSelfClear(contract)) {
      throw new ConflictException(
        'Operations review applies only to self-clearance (non-customs) contracts.',
      );
    }
    return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
  }

  /**
   * GL corrects a clearance document in place instead of bouncing it back to
   * the customer. The customer's upload is NOT lost — it is retired into the
   * document's version history, stamped with who replaced it and why — and the
   * new version starts unreviewed, so GL still has to approve it (or query it)
   * before clearance can be finalized.
   *
   * Use this for the small fixes staff can make faster than the customer can
   * (a wrong page order, a missing stamp scan); a query is still the right tool
   * when only the customer can produce the correct document.
   */
  async replaceDocument(
    contractId: string,
    fileKey: string,
    file: Express.Multer.File,
    staffId: string,
    reason?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertClearanceReviewableStatus(contract);
    if (!file) throw new BadRequestException('No replacement file uploaded');
    if (!reason?.trim()) {
      throw new BadRequestException(
        'Say why the document is being replaced — it is kept on the file history.',
      );
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
      throw new BadRequestException(
        'Documents cannot be changed after pre-clearance is finalized.',
      );
    }

    const existing = await this.filesService.findByCode(
      contractId,
      'contracts',
      fileKey,
    );
    if (!existing) {
      throw new NotFoundException(
        `No document is stored under "${fileKey}" on this contract.`,
      );
    }

    await this.filesService.upsertByCode(
      { resourceId: contractId, resource: 'contracts', code: fileKey, file },
      { userId: staffId, reason: reason.trim() },
    );

    // A fresh version is unreviewed by definition: clear any earlier verdict so
    // the corrected file is signed off explicitly rather than inheriting a tick.
    const { inputCode, outputCode } = contractClearanceCodes(contract);
    const reviews = await this.contractsRepository.findDocumentReviews(
      contractId,
      cycle?.id ?? null,
    );
    const settingCode =
      reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
      (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
    await this.contractsRepository.setDocumentReviewStatus({
      contractId,
      clearanceCycleId: cycle?.id ?? null,
      settingCode,
      fileKey,
      status: 'PENDING',
      staffId,
      note: `Replaced by staff: ${reason.trim()}`,
    });

    await this.contractsRepository.createReviewNote(
      contractId,
      `Document "${fileKey}" replaced by staff: ${reason.trim()}`,
      'STAFF_NOTE',
      staffId,
      'GL_ET',
    );

    return this.contractsService.findById(contractId);
  }

  /** Every stored version of one clearance document, newest first. */
  async documentVersions(contractId: string, fileKey: string) {
    return this.filesService.versionHistory(contractId, 'contracts', fileKey);
  }

  private async applyReview(
    contractId: string,
    fileKey: string,
    status: 'APPROVED' | 'QUERIED',
    staffId: string,
    reviewerRole: 'GL_ET' | 'OPERATIONS',
    note?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertClearanceReviewableStatus(contract);
    if (status === 'QUERIED' && !note?.trim()) {
      throw new BadRequestException('A note is required when querying a document');
    }

    const { inputCode, outputCode } = contractClearanceCodes(contract);
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (
      status === 'QUERIED' &&
      this.isPhasedCustoms(contract) &&
      cycle?.preClearanceFinalizedAt
    ) {
      throw new BadRequestException(
        'Customer documents cannot be queried after pre-clearance is finalized.',
      );
    }
    const reviews = await this.contractsRepository.findDocumentReviews(
      contractId,
      cycle?.id ?? null,
    );
    const match = reviews.find((r) => r.fileKey === fileKey);
    const settingCode =
      match?.settingCode ??
      (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));

    await this.contractsRepository.setDocumentReviewStatus({
      contractId,
      clearanceCycleId: cycle?.id ?? null,
      settingCode,
      fileKey,
      status,
      staffId,
      note,
    });

    if (status === 'QUERIED') {
      await this.contractsRepository.createReviewNote(
        contractId,
        `Document "${fileKey}" queried: ${note}`,
        'CHANGES_REQUESTED',
        staffId,
        reviewerRole,
      );
      // Return the contract to the customer to re-upload the queried document.
      await this.contractsRepository.update(contractId, {
        status: 'AWAITING_CLEARANCE_DOCUMENTS',
        clearanceStatus: 'AWAITING_DOCUMENTS',
      } as never);
      this.notifier.clearanceDocumentQueried(contract, fileKey, note ?? '');
      if (cycle) {
        await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
      }
      if (this.isPhasedCustoms(contract)) {
        await this.workflowService.onDocumentReviewReopened(contractId);
        if (cycle) {
          await this.contractsRepository.updateCycle(cycle.id, {
            currentPhase: ContractDocPhase.GlEtReview,
          });
        }
      }
    } else if (status === 'APPROVED') {
      await this.bumpToUnderReviewWhenFullyApproved(contractId);
      const refreshed = await this.contractsService.findById(contractId);
      if (
        refreshed.customsClearingEnabled &&
        refreshed.contractKind === 'ONE_TIME' &&
        (await this.isClearanceFullyApproved(refreshed))
      ) {
        await this.workflowService.onAllDocsApproved(contractId);
        const c = await this.contractsRepository.currentCycle(contractId);
        if (c) {
          await this.contractsRepository.updateCycle(c.id, {
            currentPhase:
              refreshed.tradeDirection === 'EXPORT'
                ? ContractDocPhase.GlDjCollection
                : ContractDocPhase.GlEtOutput,
          });
        }
      }
    }

    return this.contractsService.findById(contractId);
  }

  /** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */
  async uploadOutputDocuments(
    contractId: string,
    files: Express.Multer.File[],
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertClearanceOutputUploadableStatus(contract);
    const { outputCode } = contractClearanceCodes(contract);
    if (!outputCode) {
      throw new BadRequestException('This contract 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: contractId,
        resource: 'contracts',
        code: file.fieldname,
        file,
      });
    }
    return this.contractsService.findById(contractId);
  }

  /**
   * GL ET finalizes Path B pre-booking clearance: requires every customer
   * document APPROVED. For phased customs (ONE_TIME), document review completes
   * here — booking readiness is set only after delivery order (import) or export
   * release via the milestone workflow. Non-phased customs still jump straight to
   * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
   */
  async finalize(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    if (this.isSelfClear(contract)) {
      throw new ConflictException(
        'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
      );
    }
    this.assertClearanceFinalizableStatus(contract);

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

    if (this.isPhasedCustoms(contract)) {
      await this.workflowService.onAllDocsApproved(contractId);
      const cycle = await this.contractsRepository.currentCycle(contractId);
      if (cycle) {
        await this.contractsRepository.updateCycle(cycle.id, {
          currentPhase:
            contract.tradeDirection === 'EXPORT'
              ? ContractDocPhase.GlDjCollection
              : ContractDocPhase.GlEtOutput,
        });
      }
      await this.contractsRepository.update(contractId, {
        status: 'CLEARANCE_UNDER_REVIEW',
        clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
      } as never);
      return this.contractsService.findById(contractId);
    }

    const { outputCode } = contractClearanceCodes(contract);
    if (outputCode) {
      const setting = await this.fileUploadSettingsService.getByCode(outputCode);
      const files = await this.filesService.findByResource(contractId, 'contracts');
      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(', ')}`,
        );
      }
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    await this.contractsRepository.update(contractId, {
      status: 'CLEARANCE_READY_FOR_BOOKING',
      clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
    } as never);
    if (cycle) {
      await this.contractsRepository.setCycleStatus(
        cycle.id,
        'CLEARANCE_READY_FOR_BOOKING',
        { clearanceReadyAt: new Date() },
      );
    }
    return this.contractsService.findById(contractId);
  }

  /**
   * Operations finalizes Path A self-clearance: requires every customer document
   * APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL
   * output phase on Path A, so the contract goes straight to FULLY_EXECUTED
   * (ONE_TIME) / CONTRACT_ACTIVE (GENERAL).
   */
  async opsFinalize(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    if (!this.isSelfClear(contract)) {
      throw new ConflictException(
        'Operations finalize applies only to self-clearance (non-customs) contracts.',
      );
    }
    this.assertClearanceFinalizableStatus(contract);

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

    const cycle = await this.contractsRepository.currentCycle(contractId);
    await this.contractsRepository.update(contractId, {
      status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED',
      clearanceStatus: 'SELF_CLEARED',
    } as never);
    if (cycle) {
      await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
        clearanceReadyAt: new Date(),
      });
    }
    return this.contractsService.findById(contractId);
  }

  /**
   * GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in
   * phased clearance that already carry at least one uploaded clearance
   * document — a contract still waiting for its first document has nothing to
   * review, and GENERAL contracts clear per booking, not at contract level.
   */
  async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
    return this.contractsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 100,
      statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
      customsClearingEnabled: true,
      contractKind: 'ONE_TIME',
      hasClearanceDocuments: true,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /**
   * Operations queue: self-clearance (Path A) contracts awaiting Operations
   * review of the customer's own clearance documents.
   */
  /**
   * Statuses a non-customs contract passes through around Operations
   * clearance review — the set a caller may narrow {@link opsQueue} to.
   */
  private static readonly OPS_CLEARANCE_STATUSES = [
    'AWAITING_CLEARANCE_DOCUMENTS',
    'CLEARANCE_UNDER_REVIEW',
    'CLEARANCE_READY_FOR_BOOKING',
    'FULLY_EXECUTED',
    'CONTRACT_ACTIVE',
    'ACTIVE_SHIPMENT_IN_PROGRESS',
    'CONTRACT_CLOSED',
    'CANCELLED',
  ];

  async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
    // Callers may narrow to any subset of the ops-clearance lifecycle (the
    // hub's status filter sends an explicit list); anything outside the
    // whitelist is dropped so this endpoint can't become a general contract
    // browser. No statuses given → the original under-review queue.
    const requested = (filter.statuses ?? filter.status ?? '')
      .split(',')
      .map((s) => s.trim())
      .filter((s) =>
        ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
      );
    return this.contractsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 100,
      statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
      customsClearingEnabled: false,
      search: filter.search,
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });
  }

  /** GL ET history: contracts that completed Path B clearance. */
  async history(filter: FilterContractDto): Promise<PaginatedContracts> {
    return this.contractsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 50,
      statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
      customsClearingEnabled: true,
      sortBy: filter.sortBy ?? 'createdAt',
      sortOrder: filter.sortOrder ?? 'DESC',
    });
  }

  /** Operations history: contracts that completed Path A self-clearance review. */
  async opsHistory(filter: FilterContractDto): Promise<PaginatedContracts> {
    return this.contractsRepository.findAllPaginated({
      page: filter.page ?? 1,
      pageSize: filter.pageSize ?? 50,
      statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
      customsClearingEnabled: false,
      search: filter.search,
      sortBy: filter.sortBy ?? 'createdAt',
      sortOrder: filter.sortOrder ?? 'DESC',
    });
  }

  // ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────

  /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
  /**
   * GL Ethiopia asks Djibouti to name the officer who will handle the shipment
   * in transit. Nothing else moves until Djibouti answers — the declaration is
   * gated on it — so this is the first thing ET does once the documents are
   * approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
   */
  async requestTransitAssignee(
    contractId: string,
    note: string | undefined,
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');

    await this.contractsRepository.updateCycle(cycle.id, {
      transitAssigneeRequestedAt: new Date(),
      transitAssigneeRequestedByUserId: userId ?? null,
      transitAssigneeRequestNote: note?.trim() || null,
    });

    const updated = await this.contractsService.findById(contractId);
    this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
    return updated;
  }

  /**
   * GL Djibouti names the transit officer — free text, because the person is
   * not a platform user. Answering unblocks the declaration for Ethiopia. A
   * later call overwrites the name (reassignment) and re-notifies.
   */
  async assignTransitAssignee(
    contractId: string,
    assignee: string,
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (!assignee?.trim()) {
      throw new BadRequestException('Name the officer who will handle the transit.');
    }
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');
    if (!cycle.transitAssigneeRequestedAt) {
      throw new BadRequestException(
        'GL Ethiopia has not requested a transit assignee for this clearance yet.',
      );
    }

    const previous = cycle.transitAssigneeName ?? null;
    await this.contractsRepository.updateCycle(cycle.id, {
      transitAssigneeName: assignee.trim(),
      transitAssigneeAssignedAt: new Date(),
      transitAssigneeAssignedByUserId: userId ?? null,
    });

    const updated = await this.contractsService.findById(contractId);
    this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
    return updated;
  }

  private async ensureDeclarationPrerequisites(
    contractId: string,
    contract: Contract,
  ): Promise<void> {
    const allApproved = await this.isClearanceFullyApproved(contract);
    if (!allApproved) {
      throw new BadRequestException(
        'All required customer documents must be approved before uploading a declaration.',
      );
    }
    // The transit officer must be named by Djibouti first — the declaration is
    // filed against whoever will physically handle the shipment there.
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle?.transitAssigneeName) {
      throw new BadRequestException(
        cycle?.transitAssigneeRequestedAt
          ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
          : 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
      );
    }
    const milestones = await this.workflowService.listMilestones(contractId);
    const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
    if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
      await this.workflowService.onAllDocsApproved(contractId);
    }
  }

  async uploadDeclaration(
    contractId: string,
    files: Express.Multer.File[],
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    await this.ensureDeclarationPrerequisites(contractId, contract);
    await this.workflowService.assertPriorComplete(
      contractId,
      contract.tradeDirection,
      'UNDER_CUSTOMS_CLEARANCE',
    );

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

    await persistDeclarationUploads(
      this.filesService,
      contractId,
      'contracts',
      files,
    );

    await this.workflowService.onDeclarationUploaded(contractId, userId);

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (cycle) {
      await this.contractsRepository.updateCycle(cycle.id, {
        currentPhase:
          contract.tradeDirection === 'EXPORT'
            ? ContractDocPhase.GlEtPostClearance
            : ContractDocPhase.CustomerDuty,
      });
    }

    // Export: the declaration is the last GL ET pre-booking action — release
    // immediately so booking creation unlocks without a separate confirm click.
    if (contract.tradeDirection === 'EXPORT') {
      await this.workflowService.onExportReleased(contractId, userId);
    }

    return this.contractsService.findById(contractId);
  }

  async adviseDuty(
    contractId: string,
    dto: AdviseContractDutyDto,
    userId?: string,
    attachment?: Express.Multer.File,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Duty advice applies only to import contracts.');
    }
    await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');

    await this.contractsRepository.updateCycle(cycle.id, {
      dutyRequired: dto.dutyRequired,
      currentPhase: dto.dutyRequired
        ? ContractDocPhase.CustomerDuty
        : ContractDocPhase.GlEtPostClearance,
    });

    if (!dto.dutyRequired) {
      await this.workflowService.onDutySkipped(contractId);
    } else {
      if (dto.amount == null || dto.amount < 0) {
        throw new BadRequestException('Duty amount is required when duty applies.');
      }
      if (!attachment) {
        throw new BadRequestException('Duty notice attachment is required when duty applies.');
      }
      await this.filesService.upsertByCode({
        resourceId: contractId,
        resource: 'contracts',
        code: 'duty_tax_notice',
        file: attachment,
      });
      await this.milestoneService.adviseDutyForContract(
        contractId,
        {
          amount: dto.amount,
          currency: dto.currency ?? 'ETB',
          declarationSerial: dto.declarationSerial,
        },
        userId,
      );
      this.notifier.dutyAdvised(contract, dto.amount, dto.currency ?? 'ETB');
    }

    return this.contractsService.findById(contractId);
  }

  /**
   * The customer disagrees with the advised duty & tax and asks GL Ethiopia to
   * correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
   * step becomes actionable again on the GL clearance page, with the customer's
   * message shown beside it. GL re-advises (same endpoint as the first time),
   * which closes the dispute — the loop may run as many rounds as it takes.
   */
  async disputeDuty(
    contractId: string,
    note: string,
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Duty applies only to import contracts.');
    }
    if (!note?.trim()) {
      throw new BadRequestException(
        'Say what is wrong with the advised amount so GL can correct it.',
      );
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle?.dutyRequired) {
      throw new BadRequestException('Duty/tax is not required for this clearance.');
    }
    const milestones = await this.workflowService.listMilestones(contractId);
    const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
    if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
      throw new BadRequestException(
        'There is no advised duty amount to dispute yet.',
      );
    }
    // Once the slip is in, the money is paid — a dispute then is a refund
    // conversation, not a re-advice.
    if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
      throw new BadRequestException(
        'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
      );
    }

    await this.contractsRepository.createReviewNote(
      contractId,
      note.trim(),
      'DUTY_DISPUTE',
      userId,
      'CUSTOMER',
    );
    // Back to GL: reopening the milestone is what re-arms the Duty & tax step
    // (the stepper picks its active step from milestone completion).
    await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
    await this.contractsRepository.updateCycle(cycle.id, {
      currentPhase: ContractDocPhase.GlEtOutput,
    });

    const updated = await this.contractsService.findById(contractId);
    this.notifier.dutyDisputed(updated, note.trim());
    return updated;
  }

  async uploadDutySlip(
    contractId: string,
    file: Express.Multer.File,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Duty slip upload applies only to import contracts.');
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle?.dutyRequired) {
      throw new BadRequestException('Duty/tax is not required for this clearance.');
    }

    if (!file) throw new BadRequestException('No payment slip uploaded');

    await this.filesService.upsertByCode({
      resourceId: contractId,
      resource: 'contracts',
      code: 'duty_tax_receipt',
      file,
    });

    await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
    if (cycle) {
      await this.contractsRepository.updateCycle(cycle.id, {
        currentPhase: ContractDocPhase.GlEtPostClearance,
      });
    }

    const updated = await this.contractsService.findById(contractId);
    this.notifier.dutySlipUploadedToStaff(updated);
    return updated;
  }

  async uploadTransitPermit(
    contractId: string,
    files: Express.Multer.File[],
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Transit permit applies only to import contracts.');
    }
    await this.workflowService.assertPriorComplete(
      contractId,
      'IMPORT',
      'TRANSIT_PERMIT_UPLOADED',
    );

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

    await persistTransitPermitUploads(
      this.filesService,
      contractId,
      'contracts',
      files,
    );

    await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (cycle) {
      await this.contractsRepository.updateCycle(cycle.id, {
        currentPhase: ContractDocPhase.GlEtPostClearance,
      });
    }

    return this.contractsService.findById(contractId);
  }

  async finalizePreClearance(contractId: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
    }

    await this.workflowService.assertPriorComplete(
      contractId,
      'IMPORT',
      'TRANSIT_PERMIT_UPLOADED',
    );

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');
    if (cycle.preClearanceFinalizedAt) {
      return this.contractsService.findById(contractId);
    }

    await this.contractsRepository.updateCycle(cycle.id, {
      preClearanceFinalizedAt: new Date(),
      currentPhase: ContractDocPhase.GlDjCollection,
    });

    // GL Djibouti may have uploaded the DO early (un-gated) — count it now.
    const files = await this.filesService.findByResource(contractId, 'contracts');
    if (files.some((f) => f.code === 'delivery_order')) {
      await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
      await this.workflowService.markReadyForBooking(contractId);
    }

    this.notifier.preClearanceFinalized(contract);
    return this.contractsService.findById(contractId);
  }

  async uploadDeliveryOrder(
    contractId: string,
    file: Express.Multer.File,
    userId?: string,
    dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'IMPORT') {
      throw new BadRequestException('Delivery Order applies only to import contracts.');
    }

    if (!file) throw new BadRequestException('No Delivery Order uploaded');

    const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);

    // DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
    // any file type. The DO_COLLECTED milestone (and booking readiness) still waits
    // for GL Ethiopia to finalize pre-clearance so the workflow order holds.
    await this.filesService.upsertByCode({
      resourceId: contractId,
      resource: 'contracts',
      code: 'delivery_order',
      file,
    });

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (cycle) {
      await this.contractsRepository.updateCycle(cycle.id, {
        vesselArrivalDate,
        doCollectedDate,
      });
    }
    if (cycle?.preClearanceFinalizedAt) {
      await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
      await this.workflowService.markReadyForBooking(contractId);
    }

    return this.contractsService.findById(contractId);
  }

  private async resolveRoMinDays(): Promise<number> {
    try {
      const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
      const first = setting.children?.[0];
      const n = Number(first?.value);
      return Number.isFinite(n) && n > 0 ? n : 2;
    } catch {
      return 2;
    }
  }

  private daysUntil(dateStr: string): number {
    const target = new Date(dateStr);
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    target.setHours(0, 0, 0, 0);
    return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
  }

  async uploadReleaseOrder(
    contractId: string,
    file: Express.Multer.File,
    vesselDepartureDate: string,
    userId?: string,
  ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'EXPORT') {
      throw new BadRequestException('Release Order applies only to export contracts.');
    }
    await this.workflowService.assertPriorComplete(
      contractId,
      'EXPORT',
      'RELEASE_ORDER_SECURED',
    );

    if (!file) throw new BadRequestException('No Release Order uploaded');
    if (!vesselDepartureDate?.trim()) {
      throw new BadRequestException('Vessel departure date is required');
    }

    const minDays = await this.resolveRoMinDays();
    const leadDays = this.daysUntil(vesselDepartureDate);
    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');

    await this.filesService.upsertByCode({
      resourceId: contractId,
      resource: 'contracts',
      code: 'release_order',
      file,
    });

    await this.contractsRepository.updateCycle(cycle.id, {
      vesselDepartureDate,
      roAmendmentRequestedAt: null,
    });

    if (leadDays < minDays) {
      const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
      await this.contractsRepository.updateCycle(cycle.id, {
        roHoldReason: reason,
        currentPhase: ContractDocPhase.GlDjCollection,
      });
      return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
    }

    await this.contractsRepository.updateCycle(cycle.id, {
      roHoldReason: null,
      currentPhase: ContractDocPhase.GlEtOutput,
    });
    await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);

    return { contract: await this.contractsService.findById(contractId), hold: false };
  }

  async requestRoAmendment(
    contractId: string,
    note?: string,
    userId?: string,
  ): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'EXPORT') {
      throw new BadRequestException('RO amendment applies only to export contracts.');
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle) throw new BadRequestException('No clearance cycle found');

    const reason =
      note?.trim() ||
      'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';

    await this.contractsRepository.updateCycle(cycle.id, {
      roAmendmentRequestedAt: new Date(),
      roHoldReason: reason,
      currentPhase: ContractDocPhase.GlDjCollection,
    });

    if (userId) {
      await this.contractsRepository.createReviewNote(
        contractId,
        reason,
        'CHANGES_REQUESTED',
        userId,
        'GL_DJ',
      );
    }

    return this.contractsService.findById(contractId);
  }

  async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'EXPORT') {
      throw new BadRequestException('Export release applies only to export contracts.');
    }
    await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');

    await this.workflowService.onExportReleased(contractId, userId);
    return this.contractsService.findById(contractId);
  }

  /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
  async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
    const contract = await this.contractsService.findById(contractId);
    this.assertPhasedCustoms(contract);
    if (contract.tradeDirection !== 'EXPORT') {
      throw new BadRequestException('Export clearance finalize applies only to export contracts.');
    }

    const cycle = await this.contractsRepository.currentCycle(contractId);
    if (!cycle?.bookingId) {
      throw new BadRequestException(
        'A shipment booking must exist before export clearance can be finalized.',
      );
    }
    if (cycle.completedAt) {
      return this.contractsService.findById(contractId);
    }

    const bookingMilestones = await this.workflowService.listMilestonesForBooking(
      cycle.bookingId,
    );
    const transportDone = bookingMilestones.some(
      (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
    );
    if (!transportDone) {
      throw new BadRequestException(
        'Upload the transit permit before finalizing export clearance.',
      );
    }

    await this.contractsRepository.updateCycle(cycle.id, {
      completedAt: new Date(),
      currentPhase: ContractDocPhase.GlEtPostClearance,
    });

    void userId;
    return this.contractsService.findById(contractId);
  }

  /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
  async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
    const base = await this.contractsRepository.findAllPaginated({
      page: 1,
      pageSize: 500,
      statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
      customsClearingEnabled: true,
      contractKind: 'ONE_TIME',
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });

    const filtered: typeof base.items = [];
    for (const c of base.items) {
      const milestones = await this.workflowService.listMilestones(c.id);
      if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
    }

    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 50;
    const start = (page - 1) * pageSize;
    const items = filtered.slice(start, start + pageSize);

    return {
      items,
      total: filtered.length,
      meta: {
        page,
        pageSize,
        total: filtered.length,
        totalPages: Math.ceil(filtered.length / pageSize) || 1,
        hasNextPage: start + pageSize < filtered.length,
        hasPreviousPage: page > 1,
      },
    };
  }

  /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
  async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
    const base = await this.contractsRepository.findAllPaginated({
      page: 1,
      pageSize: 500,
      statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
      customsClearingEnabled: true,
      contractKind: 'ONE_TIME',
      sortBy: filter.sortBy,
      sortOrder: filter.sortOrder,
    });

    const filtered: typeof base.items = [];
    for (const c of base.items) {
      const cycle = await this.contractsRepository.currentCycle(c.id);
      const milestones = await this.workflowService.listMilestones(c.id);
      if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
        filtered.push(c);
      }
    }

    const page = filter.page ?? 1;
    const pageSize = filter.pageSize ?? 50;
    const start = (page - 1) * pageSize;
    const items = filtered.slice(start, start + pageSize);

    return {
      items,
      total: filtered.length,
      meta: {
        page,
        pageSize,
        total: filtered.length,
        totalPages: Math.ceil(filtered.length / pageSize) || 1,
        hasNextPage: start + pageSize < filtered.length,
        hasPreviousPage: page > 1,
      },
    };
  }
}
