import { Injectable, NotFoundException } from '@nestjs/common';

import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ExchangeService } from '@edr/api-common';
import {
  AppliedCargoModifier,
  BookingEvaluationInput,
  RuleEngineService,
} from '../rule-engine/rule-engine.service';
import {
  containersPerWagonForSize,
  wagonsPerUnitForSize,
} from '../rule-engine/container-type.util';
import { BookingsRepository } from './bookings.repository';
import { wagonRemainder } from './consolidation.service';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service';

export interface OverweightLine {
  containerTypeCode: string;
  totalVgmTons: number;
  maxAllowedTons: number;
  excessTons: number;
}

export interface ComputedPriceResult {
  lineItems: PriceLineItemDto[];
  totalAmount: number;
  currency: string;
  usedRates: Rate[];
  appliedModifiers: AppliedCargoModifier[];
  priorityScore: number;
  warnings: string[];
  hardBlocked: string[];
  overweightLines: OverweightLine[];
}

type StoredPricingBreakdown = {
  lineItems?: PriceLineItemDto[];
  totalAmount?: number;
  currency?: string;
  generatedAt?: string;
} | null;

/** Friendly labels for the per-unit rate card shown at the confirm step. */
const SURCHARGE_LABELS: Record<string, string> = {
  HAZARD_SURCHARGE: 'Hazardous cargo',
  HAZARDOUS_CARGO: 'Hazardous cargo',
  REEFER_SURCHARGE: 'Refrigerated (reefer)',
  REEFER_CARGO: 'Refrigerated (reefer)',
  OVERWEIGHT_PER_TON: 'Overweight excess',
  DOUBLE_HANDLING: 'Double handling',
  LASHING: 'Lashing',
  PIL_EXTRA_FEE: 'Shipping line fee',
};

function surchargeLabel(code: string): string {
  return (
    SURCHARGE_LABELS[code] ??
    code
      .toLowerCase()
      .split('_')
      .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
      .join(' ')
  );
}

@Injectable()
export class BookingPricingService {
  constructor(
    private readonly bookingsRepository: BookingsRepository,
    private readonly ruleEngineService: RuleEngineService,
    private readonly containerTypesService: ContainerTypesService,
    private readonly ratesService: RatesService,
    private readonly exchangeService: ExchangeService,
    private readonly containerValidationService: ContainerValidationService,
    private readonly cargoTypesService: CargoTypesService,
  ) {}

  async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
    const booking = await this.requireBooking(bookingId);
    assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);

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

    await this.bookingsRepository.update(bookingId, {
      totalAmount: computed.totalAmount,
      priorityScore: computed.priorityScore,
      pricingBreakdown: {
        lineItems: computed.lineItems,
        totalAmount: computed.totalAmount,
        currency: computed.currency,
        generatedAt: new Date().toISOString(),
      },
    } as never);

    // 20ft weight-pairing preview: surfaced now so the customer sees the problem
    // (and the overweight warning + surcharge) at the confirm step, before submit.
    // Submit re-runs this and HARD-BLOCKS on a non-empty result.
    const pairing = await this.containerValidationService.validate20ftPairing(booking);

    return {
      bookingId,
      totalAmount: computed.totalAmount,
      currency: computed.currency,
      lineItems: computed.lineItems,
      warnings: computed.warnings,
      overweightLines: computed.overweightLines,
      pairingErrors: pairing.map((p) => p.message),
    };
  }

  async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
    const evalInput = await this.buildEvalInputForBooking(booking);
    const ruleResult = await this.ruleEngineService.evaluate(evalInput);

    const paymentCurrency = booking.paymentCurrency;
    const isEtbBooking = paymentCurrency === 'ETB';
    const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;

    // H15: a booking created under a contract prices from that contract's FROZEN
    // rate snapshots (the agreed rates), not the live rate of the day. Loaded
    // once and threaded through the line builders; each rate code that has a
    // snapshot uses it, and any code without one falls back to the live rate.
    // Non-contract bookings resolve to null and keep the live-rate path.
    const frozenRates = await this.loadFrozenContractRates(booking);

    const lineItems: PriceLineItemDto[] = [];
    let total = 0;

    const {
      lineItems: baseLines,
      usedRates: baseRates,
      warnings: baseWarnings,
      blocked: baseBlocked,
    } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates);
    for (const line of baseLines) {
      lineItems.push(line);
      total += line.amount;
    }

    // First / last mile trucking — billed per the rate's unit (km / container /
    // ton / flat), only for legs the booking actually carries.
    const { lineItems: mileLines, usedRates: mileRates } =
      await this.computeFirstLastMileLines(booking, evalInput, frozenRates);
    for (const line of mileLines) {
      lineItems.push(line);
      total += line.amount;
    }

    const liveRates = await this.ratesService.findLiveRates();
    const rateById = new Map(liveRates.map((r) => [r.id, r]));
    const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r]));

    for (const mod of ruleResult.appliedModifiers) {
      const usdAmount = mod.calculatedAmount;

      const rate = rateById.get(mod.rateId);
      // Derived/route-matched charges (import overweight, empty-container
      // return) carry their own unit price + billing unit — bill and display
      // those, not whatever the referenced rate row says.
      const isDerived = mod.unitPriceUsd != null;
      const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
      const unitUsd = isDerived
        ? Number(mod.unitPriceUsd)
        : rate
          ? Number(rate.rateValue)
          : usdAmount;
      // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
      // explicit trigger (e.g. overweight tons) wins when present; otherwise
      // derive from total ÷ unit price (the live unit price — a count, not a
      // currency amount, so it is snapshot-independent).
      const quantity =
        unit === 'FLAT' || unit === 'PER_INVOICE'
          ? 1
          : mod.triggerValue != null && mod.triggerValue > 0
            ? mod.triggerValue
            : unitUsd > 0
              ? Math.max(1, Math.round(usdAmount / unitUsd))
              : 1;

      // H15: bill the frozen contract surcharge rate (already in the booking
      // currency) when this code has a snapshot; else keep the live amount.
      // Derived charges skip the snapshot — import overweight prices off the
      // route's container freight, never a frozen OVERWEIGHT_PER_TON value.
      const frozen = isDerived
        ? null
        : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
      const unitAmount = frozen
        ? Number(frozen.unitPrice)
        : isEtbBooking
          ? Math.round(unitUsd * usdToEtb)
          : unitUsd;
      const convertedAmount = frozen
        ? isEtbBooking
          ? Math.round(unitAmount * quantity)
          : unitAmount * quantity
        : isEtbBooking
          ? Math.round(usdAmount * usdToEtb)
          : usdAmount;

      const item: PriceLineItemDto = {
        code: mod.surchargeCode,
        description: surchargeLabel(mod.surchargeCode),
        amount: convertedAmount,
        unitAmount,
        unit,
        quantity,
        currency: paymentCurrency,
      };
      lineItems.push(item);
      total += convertedAmount;

      if (rate) usedRatesMap.set(rate.id, rate);
    }

    // Customs clearance service fee (Path B) — billed HERE, on the booking
    // invoice with the freight; no separate prepaid clearance invoice. Sold per
    // cargo kind: container bookings bill each container type's own fee (per
    // box or per wagon), bulk bookings the route's bulk fee (per ton or per
    // wagon). Frozen contract snapshots win over live rates; a customs booking
    // with nothing configured hard-blocks — clearance never ships for free.
    const clearanceBlocked: string[] = [];
    if (booking.customsClearingEnabled) {
      const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
      for (const line of clearance.lineItems) {
        lineItems.push(line);
        total += line.amount;
      }
      for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate);
      clearanceBlocked.push(...clearance.blocked);
    }

    // Overweight detail for the customer: map the engine's per-line results back
    // to the booking's container lines (same order) for code + weights. maxAllowed
    // is derived from the line total minus the excess the engine computed.
    const overweightLines: OverweightLine[] = [];
    const containerLines = (booking.bookingContainers ?? []).filter(
      (bc) => bc.containerTypeId != null,
    );
    for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
      const wr = ruleResult.containerWeightResults[i];
      if (!wr?.isOverweight) continue;
      const line = containerLines[i];
      const totalVgmTons = Number(line?.totalVgmTons ?? 0);
      const excessTons = Number(wr.overweightExcessTons ?? 0);
      let code = line?.containerSize ?? '';
      if (line?.containerTypeId) {
        try {
          code = (await this.containerTypesService.findById(line.containerTypeId)).code;
        } catch {
          // fall back to the container size label
        }
      }
      overweightLines.push({
        containerTypeCode: code,
        totalVgmTons,
        maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
        excessTons,
      });
    }

    return {
      lineItems,
      totalAmount: total,
      currency: booking.paymentCurrency,
      usedRates: [...usedRatesMap.values()],
      appliedModifiers: ruleResult.appliedModifiers,
      priorityScore: ruleResult.priorityScore,
      warnings: [...ruleResult.warnings, ...baseWarnings],
      hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked],
      overweightLines,
    };
  }

  pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
    if (!stored?.lineItems?.length) return false;
    if (Number(stored.totalAmount) !== computed.totalAmount) return false;
    return (
      this.lineItemsSignature(stored.lineItems) ===
      this.lineItemsSignature(computed.lineItems)
    );
  }

  async createPricingSnapshots(
    bookingId: string,
    usedRates: Rate[],
    appliedModifiers: AppliedCargoModifier[],
  ): Promise<void> {
    await this.bookingsRepository.clearPricingArtifacts(bookingId);
    const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);

    const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
    const rows = appliedModifiers
      .map((m) => {
        const snapshotId = snapshotByRateId.get(m.rateId);
        if (!snapshotId) return null;
        return {
          bookingId,
          rateId: m.rateId,
          triggerValue: m.triggerValue,
          calculatedAmount: m.calculatedAmount,
          rateSnapshotId: snapshotId,
        };
      })
      .filter((r): r is NonNullable<typeof r> => r !== null);

    if (rows.length > 0) {
      await this.bookingsRepository.createCargoModifiers(rows);
    }
  }

  async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
    const lines = await Promise.all(
      (booking.bookingContainers ?? [])
        .filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
        .map(async (bc) => {
          const ct = await this.containerTypesService.findById(bc.containerTypeId);
          const vgm = Number(bc.vgmPerUnitTons);
          const qty = bc.quantity;
          return {
            container: {
              containerTypeId: bc.containerTypeId,
              quantity: qty,
              vgmPerUnitTons: vgm,
              totalVgmTons: qty * vgm,
              isReefer: ct.isReefer,
              // Per-container opt-ins — PER_CONTAINER surcharges bill these.
              hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
              reeferQuantity: Number(bc.reeferQuantity ?? 0),
              returnQuantity: Number(bc.returnQuantity ?? 0),
              // Wagon share per box — a PER_WAGON empty-return rate bills on it.
              wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt),
            },
            perWagon: containersPerWagonForSize(ct.sizeFt),
            quantity: qty,
          };
        }),
    );
    const containers = lines.map((l) => l.container);
    // Wagon count is persisted per container line at booking creation; sum it.
    const totalWagons =
      booking.freightType === 'CONTAINER'
        ? Math.ceil(
            (booking.bookingContainers ?? []).reduce(
              (sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
              0,
            ),
          )
        : 0;
    // Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing).
    // Deliberately NOT totalWagons — that would shift wagon-count priority
    // scoring for bulk bookings.
    const bulkWagons =
      booking.freightType === 'BULK'
        ? ((await this.bulkWagonCount(booking)) ?? 0)
        : 0;

    // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
    // a container type leaves a wagon partially filled. Aggregate by type first —
    // two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
    // NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
    const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
    for (const l of lines) {
      const prev = remainderByType.get(l.container.containerTypeId);
      remainderByType.set(l.container.containerTypeId, {
        quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
        perWagon: l.perWagon,
      });
    }
    const allowConsolidation =
      booking.freightType === 'CONTAINER' &&
      [...remainderByType.values()].some(
        (t) => wagonRemainder(t.quantity, t.perWagon) > 0,
      );

    return {
      freightType: booking.freightType as 'CONTAINER' | 'BULK',
      cargoTypeId: booking.cargoTypeId ?? null,
      serviceTypeId: booking.serviceTypeId,
      paymentCurrency: booking.paymentCurrency,
      tradeDirection: booking.tradeDirection,
      // Coerce defensively in case the stored flag is a string ("true"/"false").
      isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
      // Booking-level reefer flag (set by contract drawdown orders that carry a
      // reefer quantity) applies the REEFER surcharge even for non-reefer
      // container types. ORed with per-container reefer in the engine.
      isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
      // Empty-container return service (container freight only) — bills the
      // WITH_RETURN surcharge per container, like hazard/reefer.
      withReturn:
        booking.freightType === 'CONTAINER' &&
        booking.equipmentReturn === 'WITH_RETURN',
      isGovernment: booking.isGovernment,
      allowConsolidation,
      shippingLineId: booking.shippingLineId,
      originYardId: booking.originYardId,
      destinationYardId: booking.destinationYardId,
      totalWagons,
      // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
      // Container freight carries 0 here — its surcharges scale by container count.
      bulkTons:
        booking.freightType === 'BULK'
          ? Number(booking.cargoTotalWeightVgm ?? 0)
          : 0,
      bulkWagons,
      containers,
    };
  }

  private async requireBooking(id: string): Promise<Booking> {
    const booking = await this.bookingsRepository.findByIdWithFiles(id);
    if (!booking) throw new NotFoundException(`Booking ${id} not found`);
    return booking;
  }

  /** Line items for contract schedule (uses stored breakdown or recomputes). */
  async computeContractLineItems(booking: Booking): Promise<{
    lineItems: PriceLineItemDto[];
    totalAmount: number;
    currency: string;
  }> {
    const stored = booking.pricingBreakdown as StoredPricingBreakdown;

    if (stored?.lineItems?.length) {
      return {
        lineItems: stored.lineItems,
        totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
        currency: stored.currency ?? booking.paymentCurrency,
      };
    }

    const computed = await this.computePriceForBooking(booking);

    if (computed.lineItems.length === 0) {
      const total = Number(booking.totalAmount);
      return {
        lineItems: [
          {
            code: 'TOTAL',
            description: 'Contract total',
            amount: total,
            unitAmount: total,
            unit: 'FLAT',
            quantity: 1,
            currency: booking.paymentCurrency,
          },
        ],
        totalAmount: total,
        currency: booking.paymentCurrency,
      };
    }

    return {
      lineItems: computed.lineItems,
      totalAmount: computed.totalAmount || Number(booking.totalAmount),
      currency: computed.currency,
    };
  }

  /**
   * Recompute priority on submit.
   *
   * The full priority model is additive and capped at 100:
   *   service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35).
   * All three components are produced by RuleEngineService.evaluate, so submit
   * simply re-runs the engine — there is no extra submit-time inflation.
   */
  async computeSubmitPriorityScore(
    booking: Booking,
    totalWagonsOverride?: number,
  ): Promise<number> {
    const evalInput = await this.buildEvalInputForBooking(booking);
    // BULK bookings have no container lines, so buildEvalInputForBooking yields
    // totalWagons = 0 and every wagon-range priority config misses. The batch
    // engine derives a bulk booking's wagon footprint from tonnage vs. live
    // wagon capacity and passes it here to score the booking properly.
    if (totalWagonsOverride != null && totalWagonsOverride > 0) {
      evalInput.totalWagons = totalWagonsOverride;
    }
    const ruleResult = await this.ruleEngineService.evaluate(evalInput);
    return ruleResult.priorityScore;
  }

  private async computeBaseRailLinesWithRates(
    booking: Booking,
    evalInput: BookingEvaluationInput,
    frozenRates: Map<string, ContractRateSnapshot> | null = null,
  ): Promise<{
    lineItems: PriceLineItemDto[];
    usedRates: Rate[];
    warnings: string[];
    blocked: string[];
  }> {
    const liveRates = await this.ratesService.findLiveRates();
    const paymentCurrency = booking.paymentCurrency;
    const isEtbBooking = paymentCurrency === 'ETB';
    const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
    const isBulk = booking.freightType === 'BULK';

    const rateType =
      booking.tradeDirection === 'IMPORT'
        ? isBulk
          ? 'BULK_IMPORT'
          : 'CONTAINER_IMPORT'
        : booking.tradeDirection === 'EXPORT'
          ? isBulk
            ? 'BULK_EXPORT'
            : 'CONTAINER_EXPORT'
          : isBulk
            ? 'INTERCITY_BULK'
            : 'INTERCITY_CONTAINER';

    const lines: PriceLineItemDto[] = [];
    const usedRatesMap = new Map<string, Rate>();
    const warnings: string[] = [];
    const blocked: string[] = [];
    for (const container of evalInput.containers) {
      const rate = this.pickRate(
        liveRates,
        rateType,
        container.containerTypeId,
        'USD',
        booking.originYardId,
        booking.destinationYardId,
      );
      // H15: frozen contract rate for this container size, when present —
      // converted into the booking currency by frozenRateForContainer. It also
      // stands on its own: a contract line prices off the agreed rate even when
      // nobody configured a live rate for this leg + type yet.
      const frozen = await this.frozenRateForContainer(
        frozenRates,
        container.containerTypeId,
        paymentCurrency,
        usdToEtb,
      );
      const label = await this.containerTypeLabel(container.containerTypeId);
      if (!rate && !frozen) {
        // Never price this line off another container type's (or another
        // route's) rate, and never let an unpriced line through: a booking
        // that ships a container type nobody configured a rate for would be
        // carried for free. Hard-block instead — the customer drops the line
        // or EDR configures the rate.
        blocked.push(
          `No ${rateType} rate is configured for ${label} on this route — ` +
            `the booking cannot be priced. Remove the ${label} line or ask EDR ` +
            'to configure its rate for this origin → destination.',
        );
        continue;
      }

      const rateUnit = rate?.rateUnit ?? 'PER_CONTAINER';
      // A PER_WAGON line bills the wagons THIS line occupies (two 20ft share
      // one), never the booking-wide count — otherwise a booking with a 20ft
      // and a 40ft line charges each line for the other's wagons too.
      const lineWagons = await this.lineWagonCount(container);
      let amount: number;
      let unitAmount: number;
      if (frozen) {
        unitAmount = Number(frozen.unitPrice);
        amount = this.amountForUnit(
          rateUnit,
          unitAmount,
          container.quantity,
          lineWagons,
        );
      } else {
        const unitUsd = Number(rate!.rateValue);
        const usdAmount = this.amountForRate(rate!, container.quantity, lineWagons);
        amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
        unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
      }
      if (rate) usedRatesMap.set(rate.id, rate);
      lines.push({
        code: rateType,
        description: `${label} rail freight`,
        amount,
        unitAmount,
        unit: rateUnit,
        quantity: this.effectiveUnitQuantity(rateUnit, container.quantity, lineWagons),
        currency: paymentCurrency,
      });
    }

    if (lines.length === 0 && evalInput.containers.length === 0) {
      // Bulk (and any booking with no container lines) still has to price off a
      // rate configured for this leg — never one belonging to another route.
      // Container bookings never reach this fallback: their lines price per
      // container type above or stay unpriced with a warning — falling back to
      // a corridor rate of a DIFFERENT container type billed once (qty 1) is
      // how a 38-container booking was invoiced 40 USD instead of 1900.
      // Within the leg, the rate scoped to the booking's own commodity wins over
      // the commodity-wide catch-all — a per-item machinery rate must never
      // price a per-ton wheat booking (or the reverse).
      const onLeg = liveRates.filter(
        (r) =>
          r.rateType === rateType &&
          r.currency === 'USD' &&
          r.status === 'LIVE' &&
          r.originYardId === booking.originYardId &&
          r.destinationYardId === booking.destinationYardId,
      );
      const fallback =
        (booking.cargoTypeId
          ? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
          : undefined) ?? onLeg.find((r) => !r.cargoTypeId);
      if (fallback) {
        usedRatesMap.set(fallback.id, fallback);
        // Bulk has no container lines to count wagons from, so a PER_WAGON bulk
        // rate bills the tonnage-derived estimate for the WHOLE booking (there
        // is only ever this one line).
        const wagonCount = isBulk
          ? Number(evalInput.bulkWagons ?? 0) ||
            (await this.bulkWagonCount(booking)) ||
            0
          : await this.resolveWagonCount(booking);
        // Bulk quantity is stored in the commodity's own unit — tonnes for a
        // PER_TON commodity, item count for a PER_ITEM one.
        const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
        const quantity =
          isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
        const unitUsd = Number(fallback.rateValue);
        // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
        const frozen = isBulk
          ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
          : null;
        let amount: number;
        let unitAmount: number;
        if (frozen) {
          unitAmount = Number(frozen.unitPrice);
          amount = this.amountForUnit(
            fallback.rateUnit,
            unitAmount,
            quantity,
            wagonCount,
          );
        } else {
          const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
          amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
          unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
        }
        lines.push({
          code: rateType,
          description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
          amount,
          unitAmount,
          unit: fallback.rateUnit,
          quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
          currency: paymentCurrency,
        });
      } else if (isBulk) {
        // Same rule as container lines: bulk freight with no rate on this leg
        // must not proceed unpriced.
        blocked.push(
          `No ${rateType} rate is configured for this route — the booking ` +
            'cannot be priced. Ask EDR to configure the rate for this ' +
            'origin → destination.',
        );
      }
    }

    return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked };
  }

  /**
   * First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is
   * billed only when the booking carries that leg (an address is set) and a LIVE
   * rate exists, scaled by the rate's own unit:
   *   PER_KM        → contract-route road distance (km)
   *   PER_CONTAINER → total container count
   *   PER_TON       → total bulk tonnage
   *   FLAT          → once
   * A leg whose rate value (or computed amount) is 0 contributes nothing.
   */
  private async computeFirstLastMileLines(
    booking: Booking,
    evalInput: BookingEvaluationInput,
    frozenRates: Map<string, ContractRateSnapshot> | null = null,
  ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
    const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [
      {
        rateType: 'FIRST_MILE',
        label: 'First mile (pick-up)',
        active: Boolean(booking.firstMilePickupAddress),
      },
      {
        rateType: 'LAST_MILE',
        label: 'Last mile (delivery)',
        active: Boolean(booking.lastMileDeliveryAddress),
      },
    ];
    if (!legs.some((l) => l.active)) {
      return { lineItems: [], usedRates: [] };
    }

    const liveRates = await this.ratesService.findLiveRates();
    const paymentCurrency = booking.paymentCurrency;
    const isEtbBooking = paymentCurrency === 'ETB';
    const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;

    const containerCount = evalInput.containers.reduce(
      (sum, c) => sum + Number(c.quantity || 0),
      0,
    );
    const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
    const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId);

    const lines: PriceLineItemDto[] = [];
    const usedRatesMap = new Map<string, Rate>();

    for (const leg of legs) {
      if (!leg.active) continue;
      const rate = liveRates.find(
        (r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
      );
      if (!rate) continue;

      const value = Number(rate.rateValue);
      let quantity: number;
      switch (rate.rateUnit) {
        case 'PER_KM':
          quantity = routeKm;
          break;
        case 'PER_CONTAINER':
          quantity = containerCount;
          break;
        case 'PER_TON':
        case 'PER_ITEM':
          quantity = bulkTons;
          break;
        case 'FLAT':
        default:
          quantity = 1;
          break;
      }

      // H15: frozen mile rate (converted into the booking currency) when the
      // contract has one; else the live USD rate converted as before.
      const frozen = this.frozenRateByCode(
        frozenRates,
        leg.rateType,
        paymentCurrency,
        usdToEtb,
      );
      let amount: number;
      let unitAmount: number;
      if (frozen) {
        unitAmount = Number(frozen.unitPrice);
        amount = isEtbBooking
          ? Math.round(unitAmount * quantity)
          : unitAmount * quantity;
      } else {
        const usdAmount = value * quantity;
        amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
        unitAmount = isEtbBooking ? Math.round(value * usdToEtb) : value;
      }
      // Skip legs that resolve to nothing (zero rate, or zero km / count / tons).
      if (!(amount > 0)) continue;

      usedRatesMap.set(rate.id, rate);
      lines.push({
        code: leg.rateType,
        description: leg.label,
        amount,
        unitAmount,
        unit: rate.rateUnit,
        quantity,
        currency: paymentCurrency,
      });
    }

    return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
  }

  /**
   * Wagons ONE container line occupies: two 20ft share a wagon, a 40ft takes a
   * whole one. This — not the booking-wide total — is what a PER_WAGON base
   * freight line bills, so a booking of 4×20ft + 1×40ft charges the 20ft line
   * for 2 wagons and the 40ft line for its own 1, instead of billing each line
   * for all 3.
   */
  private async lineWagonCount(container: {
    containerTypeId: string;
    quantity: number;
    wagonsPerUnit?: number;
  }): Promise<number> {
    let perUnit = container.wagonsPerUnit;
    if (perUnit == null) {
      // Preview bookings build their eval input without the fraction — read it
      // off the container type instead of assuming one wagon per box.
      try {
        const ct = await this.containerTypesService.findById(
          container.containerTypeId,
        );
        perUnit = wagonsPerUnitForSize(Number(ct.sizeFt));
      } catch {
        perUnit = 1; // unknown type: never under-bill
      }
    }
    return Math.max(1, Math.ceil(container.quantity * perUnit));
  }

  /**
   * Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
   * an unsaved preview booking (no id) sums the wagonsRequired already computed
   * on its in-memory container lines — same math, no DB row needed.
   */
  private async resolveWagonCount(booking: Booking): Promise<number> {
    if (!booking.id) {
      return Math.ceil(
        (booking.bookingContainers ?? []).reduce(
          (sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
          0,
        ),
      );
    }
    return this.bookingsRepository.calculateWagonCount(booking.id);
  }

  /** Friendly container-type label for the per-unit card; degrades to "Container". */
  private async containerTypeLabel(containerTypeId: string): Promise<string> {
    try {
      const ct = await this.containerTypesService?.findById?.(containerTypeId);
      return ct?.label ?? 'Container';
    } catch {
      return 'Container';
    }
  }

  /** How many units a rate's total is divided into, by rate unit (for the per-unit card). */
  private effectiveUnitQuantity(
    rateUnit: string,
    quantity: number,
    wagonCount: number,
  ): number {
    switch (rateUnit) {
      case 'PER_WAGON':
        return wagonCount;
      case 'FLAT':
        return 1;
      case 'PER_CONTAINER':
      case 'PER_TON':
      case 'PER_ITEM':
      default:
        return quantity;
    }
  }

  /**
   * Base freight is quoted per leg, so a rate only applies to a booking running
   * the exact origin → destination it was configured for. There is deliberately
   * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment
   * because nobody configured Mojo yet is worse than surfacing no line at all.
   * Within the leg, a rate scoped to the container type wins over one that
   * covers every type.
   */
  private pickRate(
    rates: Rate[],
    rateType: string,
    containerTypeId: string,
    currency: string,
    originYardId: string,
    destinationYardId: string,
  ): Rate | undefined {
    const onLeg = rates.filter(
      (r) =>
        r.rateType === rateType &&
        r.currency === currency &&
        r.originYardId === originYardId &&
        r.destinationYardId === destinationYardId,
    );
    return (
      onLeg.find((r) => r.containerTypeId === containerTypeId) ??
      onLeg.find((r) => !r.containerTypeId)
    );
  }

  private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
    return this.amountForUnit(
      rate.rateUnit,
      Number(rate.rateValue),
      quantity,
      wagonCount,
    );
  }

  /** Apply a unit value by rate unit — shared by live and frozen-snapshot lines. */
  private amountForUnit(
    rateUnit: string,
    unitValue: number,
    quantity: number,
    wagonCount: number,
  ): number {
    switch (rateUnit) {
      case 'PER_CONTAINER':
        return unitValue * quantity;
      case 'PER_WAGON':
        return unitValue * wagonCount;
      case 'PER_TON':
      case 'PER_ITEM':
        return unitValue * quantity;
      case 'FLAT':
        return unitValue;
      default:
        return unitValue * quantity;
    }
  }

  // ── H15: frozen contract rate snapshots ────────────────────────────────────

  /**
   * Load a contract's frozen rate snapshots into a by-rate-code lookup, or null
   * for a non-contract booking (or a contract with no snapshots). The pricing
   * line builders prefer a matching snapshot's unit price over the live rate.
   */
  private async loadFrozenContractRates(
    booking: Booking,
  ): Promise<Map<string, ContractRateSnapshot> | null> {
    if (!booking.contractId) return null;
    const snapshots = await this.bookingsRepository.findContractRateSnapshots(
      booking.contractId,
    );
    if (!snapshots.length) return null;
    const byCode = new Map<string, ContractRateSnapshot>();
    for (const snap of snapshots) byCode.set(snap.rateCode, snap);
    return byCode;
  }

  /**
   * The frozen snapshot for a rate code, expressed in the BOOKING's currency.
   *
   * A contract quotes in USD and freezes USD unit prices; the customer chooses
   * the billing currency per booking. So a currency mismatch is the normal case
   * now, not an error — the snapshot is converted rather than discarded. (It
   * previously returned null on mismatch, which silently dropped the agreed
   * contract price and re-priced the booking at whatever the live rate had
   * drifted to.) Grandfathered ETB contracts convert the other way for the same
   * reason.
   *
   * Returns null only when there is no snapshot or its price is unusable.
   */
  private frozenRateByCode(
    frozenRates: Map<string, ContractRateSnapshot> | null,
    code: string,
    bookingCurrency: string,
    usdToEtb: number,
  ): ContractRateSnapshot | null {
    const snap = frozenRates?.get(code);
    if (!snap) return null;
    const unitPrice = Number(snap.unitPrice);
    if (!(unitPrice >= 0)) return null;
    if (snap.currency === bookingCurrency) return snap;

    // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
    if (!(usdToEtb > 0)) return null;
    const converted =
      snap.currency === 'USD' && bookingCurrency === 'ETB'
        ? Math.round(unitPrice * usdToEtb)
        : snap.currency === 'ETB' && bookingCurrency === 'USD'
          ? unitPrice / usdToEtb
          : null;
    if (converted == null) return null;

    // A copy — the snapshot rows are shared across the pricing pass.
    return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
      unitPrice: converted,
      currency: bookingCurrency,
    }) as ContractRateSnapshot;
  }

  /**
   * The frozen base-rail snapshot for a container line, matched by the
   * container's size (CONTAINER_20FT / CONTAINER_40FT — the codes
   * ContractPricingService freezes). Null when there is no snapshot.
   */
  private async frozenRateForContainer(
    frozenRates: Map<string, ContractRateSnapshot> | null,
    containerTypeId: string,
    bookingCurrency: string,
    usdToEtb: number,
  ): Promise<ContractRateSnapshot | null> {
    if (!frozenRates) return null;
    let sizeFt: number | null = null;
    try {
      sizeFt = Number((await this.containerTypesService.findById(containerTypeId)).sizeFt) || null;
    } catch {
      return null;
    }
    if (!sizeFt) return null;
    return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
  }

  /**
   * Customs clearance service fee lines for a customs booking (Path B), billed
   * with the freight. Container bookings bill each container line at its own
   * container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the
   * line occupies (two 20ft share one). Bulk bookings bill the route's type-less
   * fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen
   * contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE)
   * win over live rates; contracts frozen before the per-kind model carry one
   * FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking.
   */
  private async customsClearanceLines(
    booking: Booking,
    frozenRates: Map<string, ContractRateSnapshot> | null,
    liveRates: Rate[],
  ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> {
    const lineItems: PriceLineItemDto[] = [];
    const usedRates: Rate[] = [];
    const blocked: string[] = [];
    const currency = booking.paymentCurrency;
    const isEtb = currency === 'ETB';
    const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
    const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd);

    const onLeg = liveRates.filter(
      (r) =>
        r.rateType === 'CUSTOMS_CLEARANCE' &&
        r.currency === 'USD' &&
        r.tradeDirection === booking.tradeDirection &&
        r.originYardId === booking.originYardId &&
        r.destinationYardId === booking.destinationYardId,
    );
    const missingRateMessage = (scope: string): string =>
      `No customs clearance service fee is configured for ${scope} on this ` +
      'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.';

    if (booking.freightType === 'CONTAINER') {
      // Legacy short-circuit: an old contract froze one flat fee — bill it once.
      const hasPerSizeSnapshot =
        frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
        frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
      const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
      if (legacyFlat && !hasPerSizeSnapshot) {
        const amount = Number(legacyFlat.unitPrice);
        if (amount > 0) {
          lineItems.push({
            code: 'CUSTOMS_CLEARANCE',
            description: 'Customs clearance service',
            amount,
            unitAmount: amount,
            unit: 'FLAT',
            quantity: 1,
            currency,
          });
        }
        return { lineItems, usedRates, blocked };
      }

      for (const bc of booking.bookingContainers ?? []) {
        if (!bc.containerTypeId) continue;
        const qty = Number(bc.quantity || 0);
        if (!(qty > 0)) continue;
        let sizeFt = 0;
        try {
          sizeFt =
            Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0;
        } catch {
          // unknown type — falls through to the live per-type lookup below
        }
        const frozen = sizeFt
          ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
          : null;
        const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
        if (!frozen && !live) {
          blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`));
          continue;
        }
        const unit = frozen
          ? this.rateUnitFromSnapshot(frozen.unitOfMeasure)
          : live!.rateUnit;
        const unitAmount = frozen
          ? Number(frozen.unitPrice)
          : convert(Number(live!.rateValue));
        const billedQty =
          unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty;
        const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
        if (!(amount > 0)) continue;
        lineItems.push({
          code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE',
          description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`,
          amount,
          unitAmount,
          unit,
          quantity: unit === 'FLAT' ? 1 : billedQty,
          currency,
        });
        if (live && !frozen) usedRates.push(live);
      }
      return { lineItems, usedRates, blocked };
    }

    // Bulk — one fee for the whole booking. The bulk snapshot and the legacy
    // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
    // Live lookup: the rate scoped to the booking's commodity wins; a
    // commodity-less rate (legacy) is the catch-all fallback.
    const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
    const live =
      (booking.cargoTypeId
        ? onLeg.find(
            (r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
          )
        : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
    if (!frozen && !live) {
      blocked.push(missingRateMessage('bulk cargo'));
      return { lineItems, usedRates, blocked };
    }
    const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
    const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
    let billedQty = 1;
    if (isBulkQuantityUnit(unit)) {
      billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
    } else if (unit === 'PER_WAGON') {
      const wagons = await this.bulkWagonCount(booking);
      if (wagons == null) {
        blocked.push(
          'The bulk customs clearance fee is per wagon, but this cargo type has ' +
            'no wagon type with a capacity configured — the wagon count cannot ' +
            'be derived. Ask EDR to configure the cargo type’s wagon types.',
        );
        return { lineItems, usedRates, blocked };
      }
      billedQty = wagons;
    }
    const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty;
    if (amount > 0) {
      lineItems.push({
        code: 'CUSTOMS_CLEARANCE',
        description: 'Customs clearance service (bulk)',
        amount,
        unitAmount,
        unit,
        quantity: unit === 'FLAT' ? 1 : billedQty,
        currency,
      });
      if (live && !frozen) usedRates.push(live);
    }
    return { lineItems, usedRates, blocked };
  }

  /** Snapshot unit-of-measure → the rate unit the billing math applies. */
  private rateUnitFromSnapshot(unitOfMeasure: string): string {
    switch (unitOfMeasure) {
      case 'per_wagon':
        return 'PER_WAGON';
      case 'per_ton':
        return 'PER_TON';
      case 'per_item':
        return 'PER_ITEM';
      case 'per_container':
        return 'PER_CONTAINER';
      default:
        return 'FLAT';
    }
  }

  /**
   * Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the
   * largest-capacity wagon type its cargo type allows. Null when the chain is
   * unconfigured (no cargo type, no wagon types, no capacity).
   * ponytail: pricing-time estimate off the biggest allowed wagon; scheduling
   * may stock a smaller type and use more wagons.
   */
  private async bulkWagonCount(booking: Booking): Promise<number | null> {
    const tons = Number(booking.cargoTotalWeightVgm ?? 0);
    if (!(tons > 0) || !booking.cargoTypeId) return null;
    try {
      const cargo = await this.cargoTypesService.findById(booking.cargoTypeId);
      const capacity = Math.max(
        0,
        ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0),
      );
      if (!(capacity > 0)) return null;
      return Math.max(1, Math.ceil(tons / capacity));
    } catch {
      return null;
    }
  }

  private lineItemsSignature(items: PriceLineItemDto[]): string {
    return JSON.stringify(
      [...items]
        .map((item) => ({
          code: item.code,
          amount: item.amount,
          currency: item.currency,
        }))
        .sort((a, b) => a.code.localeCompare(b.code)),
    );
  }
}
