import { Inject, Injectable, BadRequestException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
import { Rate, RateTrigger } from './entities/rate.entity';
import { isBulkQuantityUnit } from './entities/rate-unit.util';
import {
  ICargoTypesRepository,
  CARGO_TYPES_REPOSITORY,
} from './interfaces/cargo-types.repository.interface';
import {
  IServiceTypesRepository,
  SERVICE_TYPES_REPOSITORY,
} from './interfaces/service-types.repository.interface';
import {
  IWeightLimitRulesRepository,
  WEIGHT_LIMIT_RULES_REPOSITORY,
} from './interfaces/weight-limit-rules.repository.interface';
import {
  IPriorityConfigsRepository,
  PRIORITY_CONFIGS_REPOSITORY,
} from './interfaces/priority-configs.repository.interface';
import {
  IRatesRepository,
  RATES_REPOSITORY,
} from './interfaces/rates.repository.interface';
import {
  IShippingLinesRepository,
  SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';

// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
// from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true';

export interface BookingContainerEvalInput {
  containerTypeId: string;
  quantity: number;
  vgmPerUnitTons: number;
  totalVgmTons: number;
  isReefer?: boolean;
  isOverweight?: boolean;
  overweightExcessTons?: number | null;
  /**
   * How many individual containers on this line opted into each handling
   * service. PER_CONTAINER surcharges bill these counts, not the line
   * quantity — 20 containers with 10 hazardous bill hazard on 10.
   */
  hazardousQuantity?: number;
  reeferQuantity?: number;
  returnQuantity?: number;
  /**
   * Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5).
   * Lets a PER_WAGON empty-return rate bill the wagons the returned empties
   * ride back on. Missing ⇒ one wagon per container.
   */
  wagonsPerUnit?: number;
}

export interface BookingEvaluationInput {
  cargoTypeId?: string | null;
  freightType?: 'CONTAINER' | 'BULK';
  serviceTypeId: string;
  paymentCurrency: string;
  tradeDirection: string;
  isHazardous: boolean;
  /** Booking-level reefer flag; ORed with per-container reefer. */
  isReefer?: boolean;
  /**
   * Booking ships with empty-container return (equipment_return = WITH_RETURN,
   * container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
   */
  withReturn?: boolean;
  isGovernment?: boolean;
  allowConsolidation?: boolean;
  shippingLineId?: string | null;
  /**
   * The booking's rail leg. Import overweight derives its per-ton price from
   * this route's own container freight rate, so the engine needs the yards.
   */
  originYardId?: string | null;
  destinationYardId?: string | null;
  /**
   * Booking's cargo type needs EDR-provided lashing/securing (cargoType
   * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
   * engine from cargoTypeId when omitted.
   */
  hasLashing?: boolean;
  totalWagons: number;
  /**
   * Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
   * PER_TON surcharges (e.g. the bulk reefer surcharge). 0/undefined for
   * container freight, which is scaled by container count instead.
   */
  bulkTons?: number;
  /**
   * Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by
   * the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing);
   * 0/undefined when unknown — those charges then bill nothing.
   */
  bulkWagons?: number;
  containers: BookingContainerEvalInput[];
}

export interface AppliedCargoModifier {
  /** The trigger-based rate that produced this surcharge line. */
  rateId: string;
  /** Stable display/audit code, derived from the rate's trigger + rateType. */
  surchargeCode: string;
  triggerValue: number | null;
  calculatedAmount: number;
  currency: string;
  /**
   * Effective per-unit USD price when it differs from the rate row's own value
   * — set by derived charges (import overweight: base freight ÷ 2×limit) so
   * the breakdown shows the real per-ton figure, not the base container price.
   * Any modifier carrying it also bypasses frozen contract snapshots.
   */
  unitPriceUsd?: number | null;
  /** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
  billingUnit?: string;
}

export interface ContainerWeightResult {
  containerTypeId: string;
  weightLimitRuleId: string | null;
  isOverweight: boolean;
  overweightExcessTons: number | null;
}

export interface RuleEvaluationResult {
  priorityScore: number;
  appliedModifiers: AppliedCargoModifier[];
  containerWeightResults: ContainerWeightResult[];
  warnings: string[];
  hardBlocked: string[];
  requiresDirectorApproval: boolean;
}

@Injectable()
export class RuleEngineService {
  constructor(
    @Inject(CARGO_TYPES_REPOSITORY)
    private readonly cargoTypesRepo: ICargoTypesRepository,
    @Inject(SERVICE_TYPES_REPOSITORY)
    private readonly serviceTypesRepo: IServiceTypesRepository,
    @Inject(WEIGHT_LIMIT_RULES_REPOSITORY)
    private readonly weightLimitRulesRepo: IWeightLimitRulesRepository,
    @Inject(PRIORITY_CONFIGS_REPOSITORY)
    private readonly priorityConfigsRepo: IPriorityConfigsRepository,
    @Inject(RATES_REPOSITORY)
    private readonly ratesRepo: IRatesRepository,
    @Inject(SHIPPING_LINES_REPOSITORY)
    private readonly shippingLinesRepo: IShippingLinesRepository,
    private readonly dataSource: DataSource,
  ) {}

  /**
   * Evaluate all rule engine rules against a booking snapshot.
   */
  async evaluate(input: BookingEvaluationInput): Promise<RuleEvaluationResult> {
    const warnings: string[] = [];
    const hardBlocked: string[] = [];
    const appliedModifiers: AppliedCargoModifier[] = [];
    const containerWeightResults: ContainerWeightResult[] = [];
    let priorityScore = 0;
    let requiresDirectorApproval = false;

    if (input.freightType === 'BULK') {
      requiresDirectorApproval = true;
    }

    // Lashing is a cargo-type property: a booking incurs the flat LASHING
    // surcharge when its cargo type has hasLashing = true. Resolve it here so
    // matchesTrigger can fire the LASHING rate. Falls back to an explicit
    // input flag when no cargo type is set (e.g. container bookings).
    let hasLashing = input.hasLashing === true;
    if (input.cargoTypeId) {
      const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
      if (!cargoType) {
        hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
      } else {
        if (cargoType.requiresDirectorApproval) {
          requiresDirectorApproval = true;
        }
        if (cargoType.hasLashing) {
          hasLashing = true;
        }
      }
    }

    hardBlocked.push(
      ...(await this.capacityViolations(input.containers, input.tradeDirection)),
    );

    // Per-container-line weight limit (maxVgmTons), index-aligned with
    // containerWeightResults — the derived import overweight divides by it.
    const lineMaxVgmTons: Array<number | null> = [];
    for (const container of input.containers) {
      const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
        container.containerTypeId,
        input.tradeDirection,
      );
      const rule = rules[0];
      lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
      let isOverweight = container.isOverweight ?? false;
      let excess = container.overweightExcessTons ?? null;

      if (rule) {
        const maxTotal = Number(rule.maxVgmTons) * container.quantity;
        const totalVgm = container.totalVgmTons;
        if (totalVgm > maxTotal) {
          isOverweight = true;
          excess = Math.max(0, totalVgm - maxTotal);
          warnings.push(
            `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
          );
        }
        containerWeightResults.push({
          containerTypeId: container.containerTypeId,
          weightLimitRuleId: rule.id,
          isOverweight,
          overweightExcessTons: excess,
        });
      } else {
        containerWeightResults.push({
          containerTypeId: container.containerTypeId,
          weightLimitRuleId: null,
          isOverweight,
          overweightExcessTons: excess,
        });
      }
    }

    const serviceType = await this.serviceTypesRepo.findById(input.serviceTypeId);
    const includesCustoms = serviceType?.includesCustoms ?? false;

    // Additive priority blocks, each keyed on the booking's total wagon count:
    //  - WAGON    rules apply regardless of currency.
    //  - CURRENCY rules apply only when the payment currency matches.
    //  - CUSTOMS  rules apply only when the service type includes customs.
    const priorityConfigs = await this.priorityConfigsRepo.findAllActive();
    const wagonsInRange = (cfg: { minWagonCount: number; maxWagonCount: number }) =>
      input.totalWagons >= cfg.minWagonCount &&
      input.totalWagons <= cfg.maxWagonCount;

    for (const cfg of priorityConfigs) {
      const applies =
        cfg.type === 'WAGON' ||
        (cfg.type === 'CURRENCY' && cfg.currency === input.paymentCurrency) ||
        (cfg.type === 'CUSTOMS' && includesCustoms);
      if (applies && wagonsInRange(cfg)) {
        priorityScore += cfg.scorePoints;
      }
    }

    if (input.isGovernment) {
      priorityScore += GOVERNMENT_PRIORITY_BONUS;
    }

    let shippingLineMapped = false;
    if (input.shippingLineId) {
      const line = await this.shippingLinesRepo.findById(input.shippingLineId);
      shippingLineMapped = Boolean(line?.mappedToCode);
    }

    const hasReefer =
      input.isReefer === true || input.containers.some((c) => c.isReefer);
    const hasOverweight = containerWeightResults.some((r) => r.isOverweight);

    // Surcharges are now self-describing rates: any LIVE rate whose `trigger`
    // is not ALWAYS. Each fires independently and stacks on top of base freight
    // — hazard + reefer + overweight all add together, each with its own unit.
    //
    // A given surcharge identity (same trigger + rateType + unit + value +
    // scope) must contribute exactly ONE line. Duplicate LIVE rate rows — e.g.
    // from a non-idempotent seeder — would otherwise repeat the same surcharge
    // many times and inflate the total, so we collapse them to one row each.
    const liveRates = await this.ratesRepo.findLiveRates();
    const surchargeRates = this.dedupeRatesBySignature(
      liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'),
    );

    // A handling service the booking asks for (booking-level flag OR any
    // per-container opt-in count) with no LIVE surcharge rate configured is a
    // hard block — pricing would otherwise ship the service for free. System-
    // derived charges (consolidation, overweight, shipping line, lashing) stay
    // exempt: the customer never opted into those, so they must not block.
    const requestedServices: Array<{
      trigger: RateTrigger;
      wanted: boolean;
      label: string;
    }> = [
      {
        trigger: 'HAZARDOUS',
        wanted:
          truthy(input.isHazardous) ||
          input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0),
        label: 'hazardous cargo',
      },
      {
        trigger: 'REEFER',
        wanted:
          hasReefer ||
          input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
        label: 'refrigerated (reefer) cargo',
      },
    ];
    for (const svc of requestedServices) {
      if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
        hardBlocked.push(
          `No ${svc.label} surcharge rate is configured — the booking cannot ` +
            `be priced with this service. Remove the ${svc.label} option or ` +
            'ask EDR to configure its rate.',
        );
      }
    }

    for (const rate of surchargeRates) {
      // Import overweight never bills the configured rate — its per-ton price
      // derives from the route's base container freight (see below).
      if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
        continue;
      }
      // Empty-container return is sold per route + container type — billed by
      // the route-matched block below, never by this route-agnostic loop.
      if (rate.trigger === 'WITH_RETURN') continue;
      // Lashing is sold per cargo kind + container type — billed by the
      // kind-aware block below, never by this generic loop.
      if (rate.trigger === 'LASHING') continue;
      const triggered = this.matchesTrigger(rate.trigger, {
        isHazardous: input.isHazardous,
        hasReefer,
        withReturn: input.withReturn ?? false,
        hasOverweight,
        shippingLineMapped,
        allowConsolidation: input.allowConsolidation ?? false,
        hasLashing,
      });
      if (!triggered) continue;

      // Surcharges scale by their own rateUnit, so the same trigger can bill the
      // right way per freight shape — e.g. a PER_TON reefer rate multiplies the
      // bulk tonnage, while a PER_CONTAINER reefer rate multiplies the container
      // count. triggerValue records the quantity billed (shown on the breakdown).
      const rateValue = Number(rate.rateValue);
      const containerCount = input.containers.reduce(
        (sum, c) => sum + Number(c.quantity || 0),
        0,
      );
      const overweightExcessTons = containerWeightResults.reduce(
        (sum, r) => sum + (r.overweightExcessTons ?? 0),
        0,
      );
      /**
       * Containers that opted into this trigger's handling service, summed
       * across lines. null when the trigger isn't per-container handling (or
       * no line carries a count) so the caller falls back to the full count.
       */
      const optedInCount = (trigger: string | null): number | null => {
        const field =
          trigger === 'HAZARDOUS'
            ? 'hazardousQuantity'
            : trigger === 'REEFER'
              ? 'reeferQuantity'
              : trigger === 'WITH_RETURN'
                ? 'returnQuantity'
                : null;
        if (!field) return null;
        const total = input.containers.reduce(
          (sum, c) => sum + Number(c[field] ?? 0),
          0,
        );
        return total > 0 ? total : null;
      };

      let triggerValue: number | null = null;
      let calculatedAmount: number;

      switch (rate.rateUnit) {
        // PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk
        // quantity is recorded in the commodity's own unit either way.
        case 'PER_ITEM':
        case 'PER_TON':
          // OVERWEIGHT bills the excess tons; every other PER_TON surcharge
          // (e.g. bulk reefer) bills the full bulk tonnage.
          triggerValue =
            rate.trigger === 'OVERWEIGHT'
              ? overweightExcessTons
              : Number(input.bulkTons ?? 0);
          calculatedAmount = triggerValue * rateValue;
          break;
        case 'PER_CONTAINER':
          // Handling surcharges bill only the containers that opted in, not the
          // whole line — 20 containers with 10 hazardous bill hazard on 10.
          // Legacy bookings carry no per-container counts (all 0) while their
          // booking-level flag is set, so fall back to the full count there.
          triggerValue = optedInCount(rate.trigger) ?? containerCount;
          calculatedAmount = triggerValue * rateValue;
          break;
        case 'PER_WAGON':
          triggerValue = input.totalWagons;
          calculatedAmount = triggerValue * rateValue;
          break;
        case 'FLAT':
        default:
          // FLAT (and any unknown unit) bills once.
          calculatedAmount = rateValue;
          break;
      }

      // Safety guard: never include a surcharge with a non-positive amount (a
      // zero-rate or zero-trigger line would otherwise show as a confusing
      // "free" surcharge on the breakdown).
      if (!(calculatedAmount > 0)) continue;

      appliedModifiers.push({
        rateId: rate.id,
        surchargeCode: this.surchargeCode(rate),
        triggerValue,
        calculatedAmount,
        currency: rate.currency,
      });
    }

    if (input.tradeDirection === 'IMPORT') {
      appliedModifiers.push(
        ...this.derivedImportOverweight(
          input,
          containerWeightResults,
          lineMaxVgmTons,
          liveRates,
        ),
      );
    }

    const withReturn = this.withReturnCharges(input, liveRates);
    appliedModifiers.push(...withReturn.modifiers);
    hardBlocked.push(...withReturn.blocked);

    if (hasLashing) {
      appliedModifiers.push(...this.lashingCharges(input, liveRates));
    }

    return {
      priorityScore,
      appliedModifiers,
      containerWeightResults,
      warnings,
      hardBlocked,
      requiresDirectorApproval,
    };
  }

  /**
   * Import overweight — derived, never configured. Each overweight container
   * line bills its excess tons at (its own base import freight on the booking's
   * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
   * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
   * Note: derives from the LIVE route rate even for frozen-rate contract
   * bookings — the frozen snapshot has no route-scoped container price to
   * divide.
   */
  private derivedImportOverweight(
    input: BookingEvaluationInput,
    weightResults: ContainerWeightResult[],
    lineMaxVgmTons: Array<number | null>,
    liveRates: Rate[],
  ): AppliedCargoModifier[] {
    const modifiers: AppliedCargoModifier[] = [];
    if (!input.originYardId || !input.destinationYardId) return modifiers;

    for (let i = 0; i < weightResults.length; i++) {
      const wr = weightResults[i];
      const excess = Number(wr?.overweightExcessTons ?? 0);
      const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
      if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;

      // Same precedence as base freight pricing: the rate scoped to this
      // container type wins over the route's catch-all rate.
      const onLeg = liveRates.filter(
        (r) =>
          r.rateType === 'CONTAINER_IMPORT' &&
          r.currency === 'USD' &&
          r.originYardId === input.originYardId &&
          r.destinationYardId === input.destinationYardId,
      );
      const base =
        onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
        onLeg.find((r) => !r.containerTypeId);
      // No base rate → the base-freight line hard-blocks this booking anyway.
      if (!base) continue;

      const perTon = Number(base.rateValue) / (2 * maxVgm);
      const amount = excess * perTon;
      if (!(amount > 0)) continue;

      modifiers.push({
        rateId: base.id,
        surchargeCode: 'OVERWEIGHT_PER_TON',
        triggerValue: excess,
        calculatedAmount: amount,
        currency: base.currency,
        unitPriceUsd: perTon,
        billingUnit: 'PER_TON',
      });
    }
    return modifiers;
  }

  /**
   * Empty-container return — sold per direction + route + container type, like
   * base freight. Each container line that opted in (returnQuantity, or every
   * container when only the legacy booking-level flag is set) bills the
   * route-matched WITH_RETURN rate for its own container type; a line with no
   * matching rate hard-blocks the booking instead of shipping the service for
   * free. Rates are import-only for now, so an export booking that asks for
   * return blocks too.
   * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
   * RETURN_SURCHARGE snapshot code can't hold per-size route prices.
   */
  private withReturnCharges(
    input: BookingEvaluationInput,
    liveRates: Rate[],
  ): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
    const modifiers: AppliedCargoModifier[] = [];
    const blocked: string[] = [];
    const bookingLevel = truthy(input.withReturn);
    const wanted =
      bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
    if (!wanted) return { modifiers, blocked };

    const onLeg = liveRates.filter(
      (r) =>
        r.trigger === 'WITH_RETURN' &&
        r.currency === 'USD' &&
        r.tradeDirection === input.tradeDirection &&
        r.originYardId === input.originYardId &&
        r.destinationYardId === input.destinationYardId,
    );

    for (const container of input.containers) {
      const qty =
        Number(container.returnQuantity ?? 0) > 0
          ? Number(container.returnQuantity)
          : bookingLevel
            ? Number(container.quantity || 0)
            : 0;
      if (!(qty > 0)) continue;

      const rate =
        onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
        onLeg.find((r) => !r.containerTypeId);
      if (!rate) {
        blocked.push(
          'No empty-container return rate is configured for this container ' +
            'type on this route (return is import-only) — remove the return ' +
            'option or ask EDR to configure its rate for this origin → destination.',
        );
        continue;
      }

      const rateValue = Number(rate.rateValue);
      // PER_WAGON bills the wagons the returned empties occupy (two 20ft share
      // one wagon), PER_CONTAINER the boxes themselves, FLAT once per line.
      const billed =
        rate.rateUnit === 'PER_WAGON'
          ? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
          : qty;
      const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue;
      if (!(amount > 0)) continue;
      modifiers.push({
        rateId: rate.id,
        surchargeCode: this.surchargeCode(rate),
        triggerValue: rate.rateUnit === 'FLAT' ? qty : billed,
        calculatedAmount: amount,
        currency: rate.currency,
        unitPriceUsd: rateValue,
        billingUnit: rate.rateUnit,
      });
    }

    // Same block deduplicated — several lines missing the rate is one problem.
    return { modifiers, blocked: [...new Set(blocked)] };
  }

  /**
   * Cargo securing / lashing — BULK only, sold per trade direction, optionally
   * narrowed to one leaf commodity (the commodity-scoped rate wins over the
   * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
   * wagons the bulk occupies. Container bookings never incur lashing, and an
   * unconfigured rate simply bills nothing — same leniency as hazard/reefer.
   */
  private lashingCharges(
    input: BookingEvaluationInput,
    liveRates: Rate[],
  ): AppliedCargoModifier[] {
    const modifiers: AppliedCargoModifier[] = [];
    if (input.containers.length > 0) return modifiers; // bulk-only service

    const onDirection = liveRates.filter(
      (r) =>
        r.trigger === 'LASHING' &&
        r.currency === 'USD' &&
        !r.containerTypeId &&
        r.tradeDirection === input.tradeDirection,
    );
    const rate =
      (input.cargoTypeId
        ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
        : undefined) ?? onDirection.find((r) => !r.cargoTypeId);
    if (!rate) return modifiers;

    const billedQty =
      isBulkQuantityUnit(rate.rateUnit)
        ? Math.max(0, Number(input.bulkTons ?? 0))
        : rate.rateUnit === 'PER_WAGON'
          ? Math.max(0, Number(input.bulkWagons ?? 0))
          : 1;
    const rateValue = Number(rate.rateValue);
    const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
    if (!(amount > 0)) return modifiers;
    modifiers.push({
      rateId: rate.id,
      surchargeCode: this.surchargeCode(rate),
      triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
      calculatedAmount: amount,
      currency: rate.currency,
      unitPriceUsd: rateValue,
      billingUnit: rate.rateUnit,
    });
    return modifiers;
  }

  /**
   * Messages for container lines whose total weight exceeds the hard capacity
   * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
   * must not be created at all. Overweight (above maxVgmTons but within
   * capacity) is NOT reported here — that is a surcharge, not a block.
   */
  async capacityViolations(
    containers: Array<{
      containerTypeId: string;
      quantity: number;
      totalVgmTons: number;
    }>,
    tradeDirection: string,
  ): Promise<string[]> {
    const violations: string[] = [];
    for (const container of containers) {
      const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
        container.containerTypeId,
        tradeDirection,
      );
      const rule = rules[0];
      if (!rule || rule.maxCapacityTons == null) continue;
      const perUnit = Number(rule.maxCapacityTons);
      const maxTotal = perUnit * container.quantity;
      if (container.totalVgmTons > maxTotal) {
        const label = rule.containerType?.code ?? container.containerTypeId;
        violations.push(
          `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
        );
      }
    }
    return violations;
  }

  /**
   * Snapshot only the rates used in a booking's final price.
   */
  async snapshotRates(
    bookingId: string,
    rates: Array<{
      id: string;
      rateType: string;
      rateValue: number;
      rateUnit: string;
      currency: string;
    }>,
  ): Promise<BookingRateSnapshot[]> {
    const snapshotRepo = this.dataSource.getRepository(BookingRateSnapshot);
    const now = new Date();
    const seen = new Set<string>();
    const snapshots: BookingRateSnapshot[] = [];

    for (const rate of rates) {
      if (seen.has(rate.id)) continue;
      seen.add(rate.id);

      const snapshot = snapshotRepo.create({
        bookingId,
        rateId: rate.id,
        rateType: rate.rateType,
        rateValue: rate.rateValue,
        rateUnit: rate.rateUnit,
        currency: rate.currency,
        snapshottedAt: now,
      });
      snapshots.push(await snapshotRepo.save(snapshot));
    }

    return snapshots;
  }

  /** Guard helper — throws BadRequestException if hardBlocked is non-empty. */
  assertNoHardBlocks(result: RuleEvaluationResult): void {
    if (result.hardBlocked.length > 0) {
      throw new BadRequestException(result.hardBlocked.join('; '));
    }
  }

  private matchesTrigger(
    trigger: RateTrigger,
    state: {
      isHazardous: boolean;
      hasReefer: boolean;
      withReturn: boolean;
      hasOverweight: boolean;
      shippingLineMapped: boolean;
      allowConsolidation: boolean;
      hasLashing: boolean;
    },
  ): boolean {
    switch (trigger) {
      case 'HAZARDOUS':
        return truthy(state.isHazardous);
      case 'REEFER':
        return truthy(state.hasReefer);
      case 'WITH_RETURN':
        return truthy(state.withReturn);
      case 'OVERWEIGHT':
        return truthy(state.hasOverweight);
      case 'SHIPPING_LINE':
        return truthy(state.shippingLineMapped);
      case 'CONSOLIDATION':
        return truthy(state.allowConsolidation);
      case 'LASHING':
        return truthy(state.hasLashing);
      // CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
      // explicitly elsewhere (not auto-triggered by a booking's cargo flags).
      default:
        return false;
    }
  }

  /** Stable surcharge code for display + audit, derived from the rate. */
  private surchargeCode(rate: Rate): string {
    return rate.rateType ?? rate.trigger;
  }

  /**
   * Collapse rates that describe the same charge to a single representative.
   *
   * Two rates are "the same" when they would produce an identical price line:
   * same trigger, rateType, unit, value, currency, and scoping (container /
   * cargo type). Duplicate rows (e.g. a seeder run more than once) therefore
   * stack into one line instead of repeating — keeping the breakdown clean and
   * the total correct. The first row of each signature is kept so an existing
   * rateId is preserved for snapshotting.
   */
  private dedupeRatesBySignature(rates: Rate[]): Rate[] {
    const seen = new Set<string>();
    const result: Rate[] = [];
    for (const rate of rates) {
      const signature = [
        rate.trigger,
        rate.rateType,
        rate.rateUnit,
        Number(rate.rateValue),
        rate.currency,
        rate.containerTypeId ?? '',
        rate.cargoTypeId ?? '',
      ].join('|');
      if (seen.has(signature)) continue;
      seen.add(signature);
      result.push(rate);
    }
    return result;
  }
}
