import {
  Injectable,
  ForbiddenException,
  BadRequestException,
  NotFoundException,
  forwardRef,
  Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, ObjectLiteral } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { BookingRepository } from '../repositories/booking.repository';
import { UpdateBookingDto } from '../dto';
import { BookingEntity, BookingStatus } from '../../../entities/booking.entity';
import { BookingHotelItemEntity } from '../../../entities/booking-hotel-item.entity';
import { BookingHotelPeriodEntity } from '../../../entities/booking-hotel-period.entity';
import { BookingHotelExtraEntity } from '../../../entities/booking-hotel-extra.entity';
import { BookingTransportItemEntity } from '../../../entities/booking-transport-item.entity';
import { BookingActivityItemEntity } from '../../../entities/booking-activity-item.entity';
import { BookingSpecialItemEntity } from '../../../entities/booking-special-item.entity';
import { BookingFlightDetailEntity } from '../../../entities/booking-flight-detail.entity';
import {
  computeHotelStayTotal,
  type HotelPeriodInput,
  type HotelExtraInput,
} from '../../../common/hotel/hotel-totals';
import { assertNightsInRange } from '../../../common/hotel/hotel-validation';
import { QueryEntity } from '../../../entities/query.entity';
import { QueryQuoteEntity } from '../../../entities/query-quote.entity';
import { ActivityEntity } from '../../../entities/activity.entity';
import { AuditService } from '../../audit/audit.service';
import { AuditEntityType, type AuditChange } from '../../audit/audit-types';
import { diffItineraryItems, type ItineraryAuditContext } from '../../audit/itinerary-audit';
import { TaxSnapshotService } from '../../tax-type/tax-snapshot.service';
import { TaxSlabResolverService } from '../../../common/services/tax-slab-resolver.service';
import { type TaxSlabSnapshotInput } from '../../../common/services/tax-bifurcation.helper';
import {
  splitFinalPrice,
  distributeSellingComponents,
} from '../../../common/services/markup-tax.helper';
import { TaxSlabEntity } from '../../../entities/tax-slab.entity';
import {
  findBelowFloorLines,
  formatBelowFloorError,
} from '../../transport-pricing/floor-price-guard';
import { resolveFloorSnapshot } from '../../transport-pricing/floor-snapshot.helper';
import { findBelowFloorActivityLines } from '../../activity-pricing/floor-price-guard';
import { DriverService } from '../../driver/driver.service';
import { PermissionCheckService } from '../../../common/permissions/permission-check.service';
import { ModuleName, AccessLevel } from '../../../entities/role-permission.entity';
import { PaymentSettlementService } from '../../payment-settlement/payment-settlement.service';
import {
  bookingVisibilitySql,
  type VisibleUserIds,
} from '../../../common/auth/row-visibility';
import { UserReportingService } from '../../user/user-reporting.service';
import { serviceMessages } from '../../../common/constants/messages';
import { OP_STATUS_TERMINAL } from '../../../common/constants/op-status';
import { ChangeRequestService } from '../../change-request/change-request.service';
import { detectBookingApprovalTriggers } from '../../change-request/booking-approval-triggers';
import { assertRowPaxWithinParty, partyFromQuery } from '../../../common/itinerary/row-pax';

const MSG = serviceMessages('Booking');
const ENTITY_TYPE = AuditEntityType.BOOKING;

/**
 * Subset of dto fields routed to the linked query (not the booking
 * row itself). Mirrors the constant in BookingService — kept in lockstep.
 */
const QUERY_ROUTED_FIELDS = [
  'guest_name',
  'guest_email',
  'guest_mobile',
  'guest_alt_phone',
  'start_date',
  'end_date',
  'adults',
  'children_ages',
  'infants',
  'sales_team',
  'source_id',
  'source_tags',
  'remark_tags',
] as const;

/** Booking-owned markup config + remarks — persisted on the booking row
 *  so a reload reproduces the booking's own price (not the quote's). */
const BOOKING_PRICING_FIELDS = [
  'pricing_strategy',
  'markup_type',
  'rounding',
  'markup_amount',
  'hotel_markup',
  'transport_markup',
  'activity_markup',
  'special_markup',
  'transport_excluded_pax',
  'internal_comments',
  'customer_remarks',
] as const;

/** Header fields audited as numbers (decimal columns come back as strings). */
const BOOKING_NUMERIC_AUDIT_FIELDS = new Set<string>([
  'selling_total',
  'exchange_rate',
  'rounding',
  'markup_amount',
  'hotel_markup',
  'transport_markup',
  'activity_markup',
  'special_markup',
  'transport_excluded_pax',
]);

/** Loose shape covering the four itinerary item-array containers on
 *  the booking update DTO. The actual DTO classes are strictly typed
 *  but these helpers operate on the raw payload (which may include
 *  transient fields like refresh_tax + link_key) so we accept records. */
type ItineraryRow = Record<string, unknown>;
type ItineraryDtoLike = {
  hotel_items?: ItineraryRow[];
  transport_items?: ItineraryRow[];
  activity_items?: ItineraryRow[];
  special_items?: ItineraryRow[];
};

/** Zero-value detector — returns true when the incoming update payload
 *  has any service line with a 0 (or missing) price. Covers all four
 *  item types so a free complimentary anything triggers the approval
 *  flow. An empty placeholder row (no ref id) is not considered —
 *  it'll be stripped on save anyway. */
function zeroValueOffenders(dto: ItineraryDtoLike): string[] {
  const out: string[] = [];
  const zeroPriced = (arr: ItineraryRow[] | undefined, idKey: string, priceKeys: string[]) => {
    if (!Array.isArray(arr) || arr.length === 0) return false;
    return arr.some((row) => {
      if (!row || !row[idKey]) return false; // skip empty placeholders
      return priceKeys.every((k) => !row[k] || Number(row[k]) === 0);
    });
  };
  if (zeroPriced(dto.hotel_items, 'hotel_id', ['subtotal'])) out.push('Hotel');
  if (zeroPriced(dto.transport_items, 'transport_service_id', ['unit_price', 'subtotal']))
    out.push('Transport');
  if (zeroPriced(dto.activity_items, 'activity_id', ['unit_price', 'subtotal']))
    out.push('Activity');
  if (zeroPriced(dto.special_items, 'service_name', ['total_price'])) out.push('Special');
  return out;
}

/** Returns the list of unique dates on which the payload contains an
 *  activity whose activity_id falls in `transferActivityIds`. Used to
 *  cross-check that a transport item exists on the same dates. */
function datesWithTransferActivity(
  dto: ItineraryDtoLike,
  transferActivityIds: Set<string>,
): string[] {
  const arr = dto.activity_items || [];
  if (!Array.isArray(arr)) return [];
  const dates = new Set<string>();
  for (const ai of arr) {
    const activityId = ai?.activity_id as string | undefined;
    const date = ai?.date as string | undefined;
    if (activityId && date && transferActivityIds.has(activityId)) {
      dates.add(String(date));
    }
  }
  return Array.from(dates);
}

/** Returns a Set of dates that have at least one transport item. */
function datesWithTransport(dto: ItineraryDtoLike): Set<string> {
  const arr = dto.transport_items || [];
  const out = new Set<string>();
  if (!Array.isArray(arr)) return out;
  for (const ti of arr) {
    const date = ti?.date as string | undefined;
    if (date) out.add(String(date));
  }
  return out;
}

/**
 * BookingItineraryService — itinerary edit + approved change-request
 * application.
 *
 * Owns the largest method in the codebase: BookingService.update
 * (~340 LOC of approval guards, query-routed field handling, full
 * delete-and-replace of all 5 item categories with tax-snapshot
 * preservation, driver sync-to-masters, audit composition).
 *
 * Extracted from BookingService (2026-05-12 refactor — final stage).
 * BookingService keeps thin delegators (update + applyApprovedChangeRequest)
 * so external callers (BookingController, ChangeRequestService) see
 * the same API and don't need to change.
 *
 * Small helpers (getTenantId, getViewerForScope, isBookingVisibleTo,
 * resolveLineTaxFields, recalculateTotals)
 * are duplicated here from BookingService rather than injected. This
 * avoids a circular dependency (BookingService → BookingItineraryService
 * for update; BookingItineraryService → BookingService for helpers)
 * which would require forwardRef. ~100 LOC of duplication for a
 * clean acyclic DI graph is a fair trade.
 */
@Injectable()
export class BookingItineraryService {
  constructor(
    private readonly repo: BookingRepository,
    private readonly cls: ClsService,
    private readonly auditService: AuditService,
    @InjectRepository(BookingHotelItemEntity)
    private readonly hotelItemRepo: Repository<BookingHotelItemEntity>,
    @InjectRepository(BookingHotelPeriodEntity)
    private readonly hotelPeriodRepo: Repository<BookingHotelPeriodEntity>,
    @InjectRepository(BookingHotelExtraEntity)
    private readonly hotelExtraRepo: Repository<BookingHotelExtraEntity>,
    @InjectRepository(BookingTransportItemEntity)
    private readonly transportItemRepo: Repository<BookingTransportItemEntity>,
    @InjectRepository(BookingActivityItemEntity)
    private readonly activityItemRepo: Repository<BookingActivityItemEntity>,
    @InjectRepository(BookingSpecialItemEntity)
    private readonly specialItemRepo: Repository<BookingSpecialItemEntity>,
    @InjectRepository(BookingFlightDetailEntity)
    private readonly flightDetailRepo: Repository<BookingFlightDetailEntity>,
    @InjectRepository(QueryEntity) private readonly queryRepo: Repository<QueryEntity>,
    @InjectRepository(QueryQuoteEntity) private readonly quoteRepo: Repository<QueryQuoteEntity>,
    @InjectRepository(ActivityEntity) private readonly activityRepo: Repository<ActivityEntity>,
    private readonly driverService: DriverService,
    private readonly permissions: PermissionCheckService,
    private readonly taxSnapshot: TaxSnapshotService,
    // Per-line slab resolver — writes the `tax_snapshot` JSONB the
    // accounting tax-ledger reads. Edited lines are delete+reinserted
    // and the FE does not round-trip tax_snapshot, so we re-resolve it
    // fresh from the destination or the ledger reads the line as zero-tax.
    private readonly taxResolver: TaxSlabResolverService,
    // §12 — delta-driven settlement on every itinerary update path.
    private readonly settlements: PaymentSettlementService,
    // Auto-CR on approver saves that hit same-day / next-day /
    // post-confirm-edit triggers. Circular via ChangeRequestService →
    // BookingService, hence forwardRef.
    @Inject(forwardRef(() => ChangeRequestService))
    private readonly changeRequestService: ChangeRequestService,
    private readonly userReporting: UserReportingService,
  ) {}

  /**
   * Due date for a settlement raised by an itinerary edit on this
   * booking. Reads the linked query's `start_date` — the booking
   * itself doesn't carry the travel date (it lives on the query per
   * the createFromQuery contract). Falls back to today when the
   * query / start_date can't be resolved so the row still saves.
   */
  private async getBookingDueDate(bookingId: string): Promise<string> {
    const b = await this.repo.findById(bookingId);
    if (!b?.query_id) return new Date().toISOString().slice(0, 10);
    const q = await this.queryRepo.findOne({ where: { id: b.query_id } });
    return q?.start_date || new Date().toISOString().slice(0, 10);
  }

  // ── Helpers (duplicated from BookingService — see class doc) ─────

  private getTenantId(): string {
    return this.cls.get('tenant_id');
  }

  private async isBookingVisibleTo(
    bookingId: string,
    visibleUserIds: VisibleUserIds,
  ): Promise<boolean> {
    if (visibleUserIds === null) return true;
    if (visibleUserIds.length === 0) return false;
    const tenantId = this.getTenantId();
    const visSql = bookingVisibilitySql('b', 'q');
    const row = await this.repo.ormRepo.manager.query(
      `SELECT 1 FROM bookings b
       LEFT JOIN queries q ON q.id = b.query_id
       WHERE b.id = $1 AND b.tenant_id = $2 AND b.is_deleted = false
         AND ${visSql}
       LIMIT 1`,
      [bookingId, tenantId, visibleUserIds],
    );
    return row.length > 0;
  }

  private resolveLineTaxFields(
    line:
      | {
          tax_breakdown?: Record<string, Record<string, number>> | null;
          refresh_tax?: boolean;
        }
      | null
      | undefined,
    subtotal: number,
    destinationSnapshot: {
      tax_breakdown: Record<string, Record<string, number>>;
      total_rate: number;
    } | null,
    opts?: { forceRefresh?: boolean },
  ): { tax_breakdown: Record<string, Record<string, number>> | null; tax_amount: number | null } {
    const refresh = opts?.forceRefresh === true || line?.refresh_tax === true;
    const hasExisting = !!line?.tax_breakdown;
    const breakdown: Record<string, Record<string, number>> | null =
      hasExisting && !refresh
        ? (line?.tax_breakdown ?? null)
        : (destinationSnapshot?.tax_breakdown ?? null);
    const amount = this.taxSnapshot.computeAmount(subtotal, breakdown);
    return { tax_breakdown: breakdown, tax_amount: amount };
  }

  /**
   * Resolve the per-line `tax_snapshot` (frozen slab + components) the
   * accounting tax-ledger bifurcates. Mirrors BookingService — see its
   * `resolveLineTaxSnapshot`. Re-resolved fresh on edit because the FE
   * does not round-trip this field and edits delete+reinsert lines.
   */
  private async resolveLineTaxSnapshot(
    kind: 'hotel' | 'transport' | 'activity' | 'special',
    destinationId: string | null | undefined,
    ids?: { transport_service_id?: string | null; activity_id?: string | null },
  ): Promise<Record<string, unknown> | null> {
    if (!destinationId) return null;
    let slab: TaxSlabSnapshotInput | null;
    if (kind === 'transport') {
      slab = await this.taxResolver.resolveForTransport(
        ids?.transport_service_id ?? null,
        destinationId,
      );
    } else if (kind === 'activity') {
      slab = await this.taxResolver.resolveForActivity(ids?.activity_id ?? null, destinationId);
    } else {
      slab = await this.taxResolver.resolveForHotelOrSpecial(destinationId);
    }
    return slab ? (slab as unknown as Record<string, unknown>) : null;
  }

  /**
   * Back-solve markup + markup tax from the booking's final
   * `selling_total` (see markup-tax.helper) and persist
   * `markup_tax_amount`, then re-split the selling components. The slab
   * rate is read server-side — the FE figure is never trusted.
   */
  async refreshMarkupTax(bookingId: string, tenantId: string): Promise<void> {
    const booking = await this.repo.findById(bookingId);
    if (!booking) return;
    let rate = 0;
    if (booking.tax_slab_id) {
      const slab = await this.repo.manager.findOne(TaxSlabEntity, {
        where: { id: booking.tax_slab_id, tenant_id: tenantId },
      });
      rate = Number(slab?.total_rate) || 0;
    }
    const costInSelling = (Number(booking.grand_total) || 0) * (Number(booking.exchange_rate) || 1);
    const { markupTax } = splitFinalPrice(Number(booking.selling_total) || 0, costInSelling, rate);
    if (Math.abs(markupTax - (Number(booking.markup_tax_amount) || 0)) > 0.004) {
      await this.repo.update(bookingId, { markup_tax_amount: markupTax });
    }
    await this.recalculateTotals(bookingId, tenantId);
  }

  /**
   * Recompute booking-level rollups (hotel_total / transport_total /
   * activity_total / special_total / grand_total + their selling_*
   * mirrors) from the current child rows. Public so the Movement
   * Dashboard's per-row edits (DriverAssignmentService.assignTransportItem
   * / patchActivityItem) can keep the Booking Detail summary in sync
   * after a quantity / unit_price / subtotal change. Idempotent — safe
   * to call multiple times per save.
   */
  async recalculateTotals(bookingId: string, tenantId: string) {
    const sum = async (repo: Repository<ObjectLiteral>, field: string) => {
      const result = await repo
        .createQueryBuilder('item')
        .select(`SUM(item.${field})`, 'total')
        .where('item.booking_id = :bookingId', { bookingId })
        .andWhere('item.tenant_id = :tenantId', { tenantId })
        .andWhere('item.is_deleted = false')
        .getRawOne();
      return Number(result?.total) || 0;
    };

    const hotelTotal = await sum(this.hotelItemRepo, 'subtotal');
    const transportTotal = await sum(this.transportItemRepo, 'subtotal');
    const activityTotal = await sum(this.activityItemRepo, 'subtotal');
    const specialTotal = await sum(this.specialItemRepo, 'total_price');

    const grandTotal = hotelTotal + transportTotal + activityTotal + specialTotal;

    const booking = await this.repo.findById(bookingId);
    const updatePayload: Partial<BookingEntity> = {
      hotel_total: hotelTotal,
      transport_total: transportTotal,
      activity_total: activityTotal,
      special_total: specialTotal,
      grand_total: grandTotal,
    };

    if (booking) {
      // Split the booking's own pre-tax selling price (final − markup
      // tax) across components using the booking's markup config. Falls
      // back to cost × FX when no selling price is set yet.
      const costs = {
        hotel: hotelTotal,
        transport: transportTotal,
        activity: activityTotal,
        special: specialTotal,
      };
      const er = Number(booking.exchange_rate) || 1;
      const sellingTotal = Number(booking.selling_total) || 0;
      const preTax = sellingTotal - (Number(booking.markup_tax_amount) || 0);
      const split =
        sellingTotal > 0
          ? distributeSellingComponents(costs, booking, preTax)
          : distributeSellingComponents(costs, {}, grandTotal * er);
      updatePayload.selling_hotel_total = split.hotel;
      updatePayload.selling_transport_total = split.transport;
      updatePayload.selling_activity_total = split.activity;
      updatePayload.selling_special_total = split.special;
    }

    await this.repo.update(bookingId, updatePayload);
  }

  /**
   * Build name lookups (id -> human label) for the audit diff renderer.
   * Collects only the IDs actually referenced in either the BEFORE
   * snapshot or the proposed AFTER list, so a 30-row booking doesn't
   * fetch every hotel / transport / activity / vehicle in the tenant.
   */
  private async buildItineraryAuditContext(snapshots: {
    // Accept both persisted entity arrays (BEFORE snapshot) and DTO arrays
    // (AFTER payload). Both share the FK-bearing properties we read here
    // (hotel_id, transport_service_id, activity_id, vehicle_type_id, ticket_id).
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts entity OR DTO arrays; only common FK fields are read
    before: { hotels?: any[] | null; transports?: any[] | null; activities?: any[] | null };
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts entity OR DTO arrays; only common FK fields are read
    after: { hotels?: any[] | null; transports?: any[] | null; activities?: any[] | null };
  }): Promise<ItineraryAuditContext> {
    const ctx: ItineraryAuditContext = {
      hotelNames: new Map(),
      transportLabels: new Map(),
      activityNames: new Map(),
      ticketNames: new Map(),
      vehicleTypeNames: new Map(),
    };

    const collect = (
      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- rows may be entity or DTO arrays (see method signature)
      rows: any[] | null | undefined,
      key: string,
    ): string[] =>
      Array.from(new Set((rows || []).map((r) => r?.[key]).filter(Boolean))) as string[];

    const hotelIds = [
      ...collect(snapshots.before.hotels, 'hotel_id'),
      ...collect(snapshots.after.hotels, 'hotel_id'),
    ];
    const transportIds = [
      ...collect(snapshots.before.transports, 'transport_service_id'),
      ...collect(snapshots.after.transports, 'transport_service_id'),
    ];
    const vehicleTypeIds = [
      ...collect(snapshots.before.transports, 'vehicle_type_id'),
      ...collect(snapshots.after.transports, 'vehicle_type_id'),
    ];
    const activityIds = [
      ...collect(snapshots.before.activities, 'activity_id'),
      ...collect(snapshots.after.activities, 'activity_id'),
    ];
    const ticketIds = [
      ...collect(snapshots.before.activities, 'ticket_id'),
      ...collect(snapshots.after.activities, 'ticket_id'),
    ];

    const mgr = this.hotelItemRepo.manager;
    if (hotelIds.length > 0) {
      const rows = await mgr.query<{ id: string; name: string }[]>(
        `SELECT id, name FROM hotels WHERE id = ANY($1::uuid[])`,
        [hotelIds],
      );
      for (const r of rows) ctx.hotelNames!.set(r.id, r.name);
    }
    if (transportIds.length > 0) {
      const rows = await mgr.query<
        { id: string; from_city: string; to_city: string; short_code: string }[]
      >(
        `SELECT id, from_city, to_city, short_code FROM transport_services WHERE id = ANY($1::uuid[])`,
        [transportIds],
      );
      for (const r of rows) {
        ctx.transportLabels!.set(
          r.id,
          `${r.from_city || ''}→${r.to_city || ''}`.trim() || r.short_code,
        );
      }
    }
    if (vehicleTypeIds.length > 0) {
      const rows = await mgr.query<{ id: string; name: string }[]>(
        `SELECT id, name FROM vehicle_types WHERE id = ANY($1::uuid[])`,
        [vehicleTypeIds],
      );
      for (const r of rows) ctx.vehicleTypeNames!.set(r.id, r.name);
    }
    if (activityIds.length > 0) {
      const rows = await mgr.query<{ id: string; name: string }[]>(
        `SELECT id, name FROM activities WHERE id = ANY($1::uuid[])`,
        [activityIds],
      );
      for (const r of rows) ctx.activityNames!.set(r.id, r.name);
    }
    if (ticketIds.length > 0) {
      const rows = await mgr.query<{ id: string; name: string }[]>(
        `SELECT id, name FROM activity_tickets WHERE id = ANY($1::uuid[])`,
        [ticketIds],
      );
      for (const r of rows) ctx.ticketNames!.set(r.id, r.name);
    }

    return ctx;
  }

  /**
   * Selling-below-cost detector — scans the incoming transport_items
   * against the supplier_cost_prices master and returns any lines
   * where the incoming `unit_price` is strictly less than the
   * cheapest cost configured for that route × mode × vehicle. Non-
   * transport lines (activity / hotel / special) are skipped for now.
   *
   * Mode-aware (Phase 3c): each line's `is_shared` flag derives the
   * mode (true → SIC per-person, false → PVT per-vehicle). PVT lines
   * compare against PVT cost rows for the same vehicle_type; SIC lines
   * compare against SIC cost rows (vehicle_type_id NULL on the cost
   * row, no vehicle axis on the line).
   *
   * Cost = MIN across all suppliers, NOT the line's assigned supplier.
   * The returned `cheapest_supplier_id` lets the Movement Chart cost
   * badge surface "cheapest = X" alongside the assigned supplier's
   * actual cost.
   *
   * Missing cost config → line is skipped (not an offender). Rationale:
   * tenants mid-rollout haven't populated every cost combo yet;
   * treating missing as "no guard" keeps saves unblocked while the
   * cost master fills in.
   *
   * Keyed via a single SQL query so N transport lines = 1 round-trip.
   * Joins vehicle_types by name since booking_transport_items stores
   * the type as a text column (not an FK).
   */
  private async findSellingBelowCostLines(
    dto: ItineraryDtoLike,
    tenantId: string,
  ): Promise<Array<{ label: string; selling: number; cost: number; supplier_id: string }>> {
    const lines = dto?.transport_items || [];
    if (lines.length === 0) return [];
    // Per stakeholder spec the cost comparison is meaningful only when
    // a supplier is actually picked on the line — an unassigned row
    // has no cost anchor. So we filter by `supplier_id` and look up
    // THAT supplier's cost_price (not MIN across all suppliers).
    const relevant = lines.filter((l) => {
      if (!l?.transport_service_id) return false;
      if (!l?.supplier_id) return false; // no supplier → no comparison
      if (!(Number(l?.unit_price) > 0)) return false;
      const isShared = l?.is_shared === true;
      if (!isShared && !l?.vehicle_type_id) return false; // PVT needs vehicle_type_id
      return true;
    });
    if (relevant.length === 0) return [];

    // One query for ALL relevant (supplier, service, vehicle, mode)
    // combos — keys are unique per line.
    const costRows: Array<{
      supplier_id: string;
      transport_service_id: string;
      vehicle_type_id: string | null;
      mode: 'PVT' | 'SIC';
      cost_price: string;
    }> = await this.repo.ormRepo.manager.query(
      `SELECT cp.supplier_id, cp.transport_service_id, cp.vehicle_type_id, cp.mode, cp.cost_price
       FROM supplier_cost_prices cp
       JOIN transport_services ts
         ON ts.id = cp.transport_service_id AND ts.tenant_id = cp.tenant_id
       JOIN destinations d
         ON d.id = ts.destination_id AND d.tenant_id = cp.tenant_id
       WHERE cp.tenant_id = $1
         AND cp.is_deleted = false
         AND cp.is_active  = true
         AND cp.season_id  IS NULL
         AND cp.currency_id = d.currency_id
         AND cp.supplier_id = ANY($2::uuid[])
         AND cp.transport_service_id = ANY($3::uuid[])`,
      [
        tenantId,
        [...new Set(relevant.map((l) => l.supplier_id as string))],
        [...new Set(relevant.map((l) => l.transport_service_id as string))],
      ],
    );

    // Key by (supplier, service, vehicle_type_id_or_empty, mode).
    const costByKey = new Map<string, { cost: number; supplier_id: string }>();
    for (const r of costRows) {
      const key = `${r.supplier_id}|${r.transport_service_id}|${r.vehicle_type_id ?? ''}|${r.mode}`;
      costByKey.set(key, { cost: Number(r.cost_price), supplier_id: r.supplier_id });
    }

    const offenders: Array<{ label: string; selling: number; cost: number; supplier_id: string }> =
      [];
    for (const l of relevant) {
      const isShared = l.is_shared === true;
      const mode: 'PVT' | 'SIC' = isShared ? 'SIC' : 'PVT';
      const vehicleKeyPart = isShared ? '' : (l.vehicle_type_id as string);
      const key = `${l.supplier_id}|${l.transport_service_id}|${vehicleKeyPart}|${mode}`;
      const match = costByKey.get(key);
      if (!match) continue; // supplier has no cost configured → skip
      const selling = Number(l.unit_price);
      if (selling < match.cost) {
        const labelType = isShared ? 'SIC' : String(l.vehicle_type_id || '').slice(0, 6) + '…';
        offenders.push({
          label: `${labelType} on ${String(l.transport_service_id).slice(0, 6)}… (supplier ${String(l.supplier_id).slice(0, 6)}…)`,
          selling,
          cost: match.cost,
          supplier_id: match.supplier_id,
        });
      }
    }
    return offenders;
  }

  // ── Public surface (BookingService delegates here) ────────────────

  /** Called by the approval flow to write an approved change-request
   *  snapshot to the booking. Bypasses the ONGOING + zero-value guards
   *  since a manager has already signed off. The CR id is threaded
   *  through so any resulting settlement delta can be tagged with
   *  `triggered_by_change_request_id` for audit traceability. */
  async applyApprovedChangeRequest(
    id: string,
    payload: Record<string, unknown>,
    changeRequestId?: string,
  ) {
    return this.update(id, payload as UpdateBookingDto, {
      bypassApprovalGuards: true,
      triggeredByChangeRequestId: changeRequestId ?? null,
    });
  }

  async update(
    id: string,
    dto: UpdateBookingDto,
    opts?: { bypassApprovalGuards?: boolean; triggeredByChangeRequestId?: string | null },
  ) {
    // Visibility gate — same 404 semantic as findById. Sales Team
    // (the only role with BOOKING:view but not :edit) is already
    // blocked by the controller's @Permissions decorator, but a
    // future custom role with BOOKING:edit + 'own' scope must also
    // not be able to mutate someone else's booking via URL snooping.
    const visibleUserIds = await this.userReporting.resolveVisibleUserIds('booking');
    if (visibleUserIds !== null && !(await this.isBookingVisibleTo(id, visibleUserIds))) {
      throw new NotFoundException(MSG.NOT_FOUND);
    }
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    const tenantId = this.getTenantId();

    // Per-row pax cap — no transport / activity row above the party on
    // the query (party edits in this same save win).
    if (dto.transport_items || dto.activity_items) {
      const party = await this.queryRepo.findOne({
        where: { id: item.query_id, tenant_id: tenantId },
        select: { id: true, adults: true, children_ages: true },
      });
      if (party) {
        assertRowPaxWithinParty(
          partyFromQuery({
            adults: dto.adults ?? party.adults,
            children_ages: dto.children_ages ?? party.children_ages,
          }),
          (dto.transport_items || []).map((t) => ({
            date: t.date,
            adults: t.adults_override,
            children: t.children_override,
          })),
          dto.activity_items || [],
        );
      }
    }
    const userId = this.cls.get('user_id') || null;
    const changes: { field: string; old_value: unknown; new_value: unknown }[] = [];

    // ── Approval guards ────────────────────────────────────────────────
    // Tiered permission model (2026-07-02):
    //   - booking_approval_bypass:view (Admin default) → save freely,
    //     no CR ever generated. Full trust tier.
    //   - booking_approval:edit (Ops Mgr, Sales Head, plus anyone with
    //     bypass) → save proceeds directly, but same-day / next-day /
    //     post-confirm-edit triggers auto-create a review CR at the
    //     end of update(). Ops team stays productive while approvers
    //     get a heads-up on flagged saves.
    //   - Below :edit (Sales Team, etc.) → 403 as before; must submit
    //     a manual CR through /change-requests.
    //
    // The change-request re-entry path (opts.bypassApprovalGuards=true)
    // skips the whole block since the manager has already signed off.
    let auditCanApproveNoBypass = false;
    if (!opts?.bypassApprovalGuards) {
      const hasBypass = await this.permissions.hasAtLeast(
        ModuleName.BOOKING_APPROVAL_BYPASS,
        AccessLevel.VIEW,
      );
      const canApprove = await this.permissions.hasAtLeast(
        ModuleName.BOOKING_APPROVAL,
        AccessLevel.EDIT,
      );
      auditCanApproveNoBypass = canApprove && !hasBypass;

      if (!canApprove && !hasBypass) {
        const reason =
          item.status === BookingStatus.ONGOING
            ? 'Booking is on trip — submit a change request for manager approval instead.'
            : 'Sales role — submit a change request for manager approval instead.';
        throw new ForbiddenException(reason);
      }

      if (!canApprove) {
        const zeroCats = zeroValueOffenders(dto as unknown as ItineraryDtoLike);
        if (zeroCats.length > 0) {
          throw new ForbiddenException(
            `${zeroCats.join(' / ')} item(s) priced at 0 — needs manager approval. Submit a change request instead.`,
          );
        }
      }

      // Selling-below-cost guard — fire when any transport line's
      // selling unit_price dips below the configured supplier cost
      // for its (supplier × service × vehicle_type) combo. Same
      // approval bar as zero-priced: Sales must go through the
      // change-request flow; Ops/Admin can save directly.
      if (!canApprove) {
        const offenders = await this.findSellingBelowCostLines(
          dto as unknown as ItineraryDtoLike,
          tenantId,
        );
        if (offenders.length > 0) {
          const sample = offenders
            .slice(0, 2)
            .map((o) => `${o.label} (selling ${o.selling} < cost ${o.cost})`)
            .join('; ');
          const more = offenders.length > 2 ? ` + ${offenders.length - 2} more` : '';
          throw new ForbiddenException(
            `Selling price below cost on ${offenders.length} line${offenders.length === 1 ? '' : 's'} — needs manager approval. Submit a change request instead. (${sample}${more})`,
          );
        }

        // Below-floor guard — selling unit_price below the configured
        // floor for the active season. Mirrors the cost guard: blocks
        // the save and points the user to submit a Change Request
        // (BELOW_FLOOR_PRICE kind). Floor is per (route × vehicle ×
        // mode × season) for transport, per (activity × ticket × age ×
        // season) for activities; NULL floor = no guard for that
        // combination. Mixed offenders merge into a single error so
        // the user submits ONE change request covering both categories.
        const transportFloorOffenders = await findBelowFloorLines(
          this.repo.ormRepo.manager,
          tenantId,
          dto.transport_items,
        );
        const activityFloorOffenders = dto.activity_items
          ? await findBelowFloorActivityLines(
              this.repo.ormRepo.manager,
              tenantId,
              dto.activity_items,
            )
          : [];
        const floorOffenders = [...transportFloorOffenders, ...activityFloorOffenders];
        if (floorOffenders.length > 0) {
          throw new ForbiddenException(formatBelowFloorError(floorOffenders));
        }
      }
    }

    // Transfer-activity guard — runs regardless of approval (a manager
    // also shouldn't save an "includes transfer" activity without a
    // transport on the same day). Only activates when the payload
    // actually touches activities or transports; skip otherwise to
    // keep non-itinerary PATCHes cheap.
    if (dto.activity_items || dto.transport_items) {
      const activityIds = Array.from(
        new Set((dto.activity_items || []).map((a) => a?.activity_id).filter(Boolean)),
      ) as string[];
      if (activityIds.length > 0) {
        const rows = await this.activityRepo.find({
          where: { id: In(activityIds), tenant_id: tenantId },
          select: {
            id: true,
            name: true,
            has_transfer: true,
          },
        });
        const transferIds = new Set(rows.filter((r) => r.has_transfer).map((r) => r.id));
        if (transferIds.size > 0) {
          const transportDates = datesWithTransport(dto as unknown as ItineraryDtoLike);
          const transferDates = datesWithTransferActivity(
            dto as unknown as ItineraryDtoLike,
            transferIds,
          );
          const missing = transferDates.filter((d) => !transportDates.has(d));
          if (missing.length > 0) {
            // Build a human-readable message naming the offending
            // activity(ies) + date(s).
            const byDate: string[] = [];
            for (const date of missing) {
              const names = (dto.activity_items || [])
                .filter((ai) => ai?.date === date && transferIds.has(ai?.activity_id))
                .map((ai) => {
                  const act = rows.find((r) => r.id === ai.activity_id);
                  return act?.name || 'activity';
                });
              byDate.push(`${date}: ${Array.from(new Set(names)).join(', ')}`);
            }
            throw new BadRequestException(
              `Transport needed for these transfer-inclusive activities — ${byDate.join(' | ')}`,
            );
          }
        }
      }
    }

    // ── A. Route basic-detail fields to the linked query ──────────────────
    const queryUpdate: Partial<QueryEntity> = {};
    const dtoRecord = dto as unknown as Record<string, unknown>;
    for (const field of QUERY_ROUTED_FIELDS) {
      if (dtoRecord[field] !== undefined)
        (queryUpdate as Record<string, unknown>)[field] = dtoRecord[field];
    }
    if (Object.keys(queryUpdate).length > 0) {
      // Snapshot BEFORE values so the audit only records fields that
      // actually changed, with their real old value.
      const beforeQuery = (await this.queryRepo.findOne({
        where: { id: item.query_id, tenant_id: tenantId },
      })) as unknown as Record<string, unknown> | null;
      // Update directly — bypass QueryService.update's full audit flow to keep
      // the change log on the booking entity. The booking audit below captures the diff.
      await this.queryRepo.update(
        { id: item.query_id, tenant_id: tenantId, is_deleted: false },
        // Cast through unknown — `Partial<QueryEntity>` includes the
        // relation `quotes` typed as `QueryQuoteEntity[]`, which can't
        // be inferred as a TypeORM `QueryDeepPartialEntity` once the
        // tax_snapshot column was added downstream. The runtime value
        // only carries scalar columns; the cast is safe.
        queryUpdate as unknown as Record<string, unknown>,
      );
      // Collect diffs for audit
      const freshQuery = await this.queryRepo.findOne({ where: { id: item.query_id } });
      if (freshQuery) {
        const freshRecord = freshQuery as unknown as Record<string, unknown>;
        for (const field of Object.keys(queryUpdate)) {
          const oldValue = beforeQuery?.[field] ?? null;
          const newValue = freshRecord[field] ?? null;
          if (JSON.stringify(oldValue) === JSON.stringify(newValue)) continue;
          changes.push({ field, old_value: oldValue, new_value: newValue });
        }
      }
    }

    // ── B. Booking-only fields ────────────────────────────────────────────
    const updateData: Partial<BookingEntity> = {};
    if (dto.notes !== undefined) updateData.notes = dto.notes;
    if (dto.selling_currency_id !== undefined)
      updateData.selling_currency_id = dto.selling_currency_id || null;
    if (dto.exchange_rate !== undefined) updateData.exchange_rate = dto.exchange_rate;
    if (dto.selling_total !== undefined) updateData.selling_total = dto.selling_total;
    if (dto.tax_slab_id !== undefined) updateData.tax_slab_id = dto.tax_slab_id || null;
    if (dto.tax_inclusive !== undefined) updateData.tax_inclusive = !!dto.tax_inclusive;
    for (const field of BOOKING_PRICING_FIELDS) {
      if (dtoRecord[field] !== undefined)
        (updateData as Record<string, unknown>)[field] = dtoRecord[field];
    }
    const pricingTouched =
      dto.selling_total !== undefined ||
      dto.exchange_rate !== undefined ||
      dto.tax_slab_id !== undefined;
    if (Object.keys(updateData).length > 0) {
      await this.repo.update(id, updateData);
      // Audit pricing-header changes (markup config, selling_total, FX,
      // tax slab, remarks).
      const beforeRecord = item as unknown as Record<string, unknown>;
      for (const [field, newRaw] of Object.entries(updateData)) {
        const oldRaw = beforeRecord[field] ?? null;
        const same = BOOKING_NUMERIC_AUDIT_FIELDS.has(field)
          ? Number(oldRaw ?? 0) === Number(newRaw ?? 0)
          : (oldRaw === '' ? null : oldRaw) === (newRaw === '' || newRaw == null ? null : newRaw);
        if (same) continue;
        changes.push({ field, old_value: oldRaw, new_value: newRaw ?? null });
      }
    }

    // ── C. Itinerary child items ──────────────────────────────────────────
    const hasItems =
      dto.hotel_items ||
      dto.transport_items ||
      dto.activity_items ||
      dto.special_items ||
      dto.flight_details;
    if (hasItems) {
      // Snapshot BEFORE rows so we can diff them against the new dto
      // payload after the delete-reinsert. Each category is conditional
      // — only fetch what the dto is touching to avoid wasted queries.
      // Periods carry room type / meal plan — without them the audit
      // label reads "(4n, —, —)" and every save looks like a hotel swap.
      const beforeHotels = dto.hotel_items
        ? await this.hotelItemRepo.find({
            where: { booking_id: id, tenant_id: tenantId },
            relations: { periods: true },
          })
        : null;
      const beforeTransports = dto.transport_items
        ? await this.transportItemRepo.find({
            where: { booking_id: id, tenant_id: tenantId },
          })
        : null;
      const beforeActivities = dto.activity_items
        ? await this.activityItemRepo.find({
            where: { booking_id: id, tenant_id: tenantId },
          })
        : null;
      const beforeSpecials = dto.special_items
        ? await this.specialItemRepo.find({ where: { booking_id: id, tenant_id: tenantId } })
        : null;

      // Resolve the booking's destination + cache its current tax
      // snapshot once for this save — used to fill tax_breakdown on
      // brand-new lines and on lines explicitly flagged refresh_tax.
      // Q2=B: existing snapshots are preserved unless refresh_tax.
      const updateBookingRow = await this.repo.findById(id);
      const updateQueryRow = updateBookingRow?.query_id
        ? await this.queryRepo.findOne({
            where: { id: updateBookingRow.query_id, tenant_id: tenantId },
          })
        : null;
      const updateDestId: string | null = updateQueryRow?.destination_id || null;
      const updateTaxSnap = updateDestId
        ? await this.taxSnapshot.resolveForDestination(updateDestId)
        : null;

      if (dto.hotel_items) {
        // Cascade FK wipes booking_hotel_periods + booking_hotel_extras
        // when we delete parents, so no separate child-delete needed.
        await this.hotelItemRepo.delete({ booking_id: id, tenant_id: tenantId });
        for (const hi of dto.hotel_items) {
          // Split children off the parent DTO. Periods + extras are
          // persisted separately after we know the parent's UUID.
          const { periods, extras, refresh_tax, ...rest } = hi;
          void refresh_tax;
          // App-layer bound check — rejects stale FE payloads pointing
          // at nights outside the stay window before we hit the DB.
          assertNightsInRange(periods, extras, hi.nights);
          // Recompute subtotal server-side from children so an out-of-
          // date FE payload can't set a stale headline total.
          const subtotal = computeHotelStayTotal(
            periods as HotelPeriodInput[] | undefined,
            extras as HotelExtraInput[] | undefined,
          );
          const tax = {
            ...this.resolveLineTaxFields(hi, subtotal, updateTaxSnap),
            tax_snapshot: await this.resolveLineTaxSnapshot('hotel', updateDestId),
          };
          const saved = await this.hotelItemRepo.save({
            ...rest,
            ...tax,
            subtotal,
            booking_id: id,
            tenant_id: tenantId,
            created_by: userId,
            updated_by: userId,
          });
          if (Array.isArray(periods) && periods.length > 0) {
            await this.hotelPeriodRepo.save(
              periods.map((p, i) => ({
                hotel_item_id: saved.id,
                tenant_id: tenantId,
                sort_order: p.sort_order ?? i,
                nights: p.nights || [],
                room_type: p.room_type || '',
                meal_plan: p.meal_plan || '',
                num_rooms: p.num_rooms,
                pax_per_room: p.pax_per_room,
                rate_per_night: p.rate_per_night,
                created_by: userId,
                updated_by: userId,
              })),
            );
          }
          if (Array.isArray(extras) && extras.length > 0) {
            await this.hotelExtraRepo.save(
              extras.map((e, i) => ({
                hotel_item_id: saved.id,
                tenant_id: tenantId,
                sort_order: e.sort_order ?? i,
                kind: e.kind,
                description: e.description ?? null,
                quantity: e.quantity,
                rate: e.rate,
                nights: e.nights || [],
                scale_by_rooms: e.scale_by_rooms ?? false,
                created_by: userId,
                updated_by: userId,
              })),
            );
          }
        }
      }
      // ORDER MATTERS — same rule as the create path: persist
      // activities first, capture their fresh ids in a link_key map,
      // then resolve linked_activity_link_key on each transport row.
      // Both lists fully wipe & re-insert, so the link must be
      // re-established on every save.
      const linkKeyToActivityId = new Map<string, string>();
      if (dto.activity_items) {
        // Terminal-status rows (Completed / No-show / Cancelled set on
        // the Movement Dashboard) are LOCKED — they must survive this
        // delete-and-replace untouched so their op_status, supplier,
        // cost and pricing stay frozen. We do two things:
        //   1. Skip them in the delete (filter the WHERE clause), so
        //      the existing row + UUID stay intact.
        //   2. Filter the incoming DTO to drop any rows that match a
        //      locked row by composite key (activity_id, ticket_id,
        //      date, age_group), so we don't re-insert a duplicate
        //      planned-state copy on top of the locked one.
        const lockedActivities = await this.activityItemRepo.find({
          where: {
            booking_id: id,
            tenant_id: tenantId,
            is_deleted: false,
            op_status: In(OP_STATUS_TERMINAL),
          },
        });
        const lockedActivityKeys = new Set(
          lockedActivities.map(
            (a) => `${a.activity_id}|${a.ticket_id || ''}|${a.date || ''}|${a.age_group || ''}`,
          ),
        );
        await this.activityItemRepo
          .createQueryBuilder()
          .delete()
          .where('booking_id = :bookingId', { bookingId: id })
          .andWhere('tenant_id = :tenantId', { tenantId })
          .andWhere('(op_status IS NULL OR op_status NOT IN (:...terminal))', {
            terminal: OP_STATUS_TERMINAL,
          })
          .execute();
        // Preserve the link_key → existing-uuid map for locked rows so
        // a transport sent in the SAME payload with
        // linked_activity_link_key targeting a now-locked activity
        // still resolves to a valid FK.
        for (const locked of lockedActivities) {
          // ai.link_key is FE-only; we don't have it for an already-
          // persisted row, but the persisted id IS available — the
          // transport-side resolver below also falls back to
          // rest.linked_activity_id, which the FE keeps pointing at
          // the same UUID across edits.
          void locked;
        }
        const filteredActivityItems = dto.activity_items.filter((ai) => {
          const key = `${ai.activity_id}|${ai.ticket_id || ''}|${ai.date || ''}|${ai.age_group || ''}`;
          return !lockedActivityKeys.has(key);
        });
        for (const ai of filteredActivityItems) {
          const subtotal = Number(ai.subtotal ?? (ai.quantity ?? 1) * (ai.unit_price ?? 0));
          const tax = {
            ...this.resolveLineTaxFields(ai, subtotal, updateTaxSnap),
            tax_snapshot: await this.resolveLineTaxSnapshot('activity', updateDestId, {
              activity_id: ai.activity_id,
            }),
          };
          const { link_key, refresh_tax, ...rest } = ai;
          void refresh_tax;
          const saved = await this.activityItemRepo.save({
            ...rest,
            ...tax,
            booking_id: id,
            tenant_id: tenantId,
            ticket_id: rest.ticket_id || null,
            created_by: userId,
            updated_by: userId,
          });
          if (link_key) linkKeyToActivityId.set(String(link_key), saved.id);
        }
      }
      if (dto.transport_items) {
        // Same lock semantics as activity_items: terminal-status rows
        // (Dropped/No-show/Cancelled from MD) survive untouched.
        const lockedTransports = await this.transportItemRepo.find({
          where: {
            booking_id: id,
            tenant_id: tenantId,
            is_deleted: false,
            op_status: In(OP_STATUS_TERMINAL),
          },
        });
        const lockedTransportKeys = new Set(
          lockedTransports.map(
            (t) =>
              `${t.transport_service_id || ''}|${t.date || ''}|${t.sort_order ?? ''}`,
          ),
        );
        await this.transportItemRepo
          .createQueryBuilder()
          .delete()
          .where('booking_id = :bookingId', { bookingId: id })
          .andWhere('tenant_id = :tenantId', { tenantId })
          .andWhere('(op_status IS NULL OR op_status NOT IN (:...terminal))', {
            terminal: OP_STATUS_TERMINAL,
          })
          .execute();
        // Reuse the destination lookup we already did at the top of
        // this block (used by the tax-snapshot cache). The driver
        // sync-to-masters path below also wants a destination_id.
        const destinationId: string | null = updateDestId;
        const filteredTransportItems = dto.transport_items.filter((ti) => {
          const key = `${ti.transport_service_id || ''}|${ti.date || ''}|${ti.sort_order ?? ''}`;
          return !lockedTransportKeys.has(key);
        });
        for (const ti of filteredTransportItems) {
          const subtotal = Number(ti.subtotal ?? (ti.quantity ?? 1) * (ti.unit_price ?? 0));
          const tax = {
            ...this.resolveLineTaxFields(ti, subtotal, updateTaxSnap),
            tax_snapshot: await this.resolveLineTaxSnapshot('transport', updateDestId, {
              transport_service_id: ti.transport_service_id,
            }),
          };
          const { linked_activity_link_key, refresh_tax, ...rest } = ti;
          void refresh_tax;
          // toSave merges DTO fields, tax snapshot, sync_driver_to_masters
          // (transient, deleted before save), and a resolved linked_activity_id.
          // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic merge of DTO + transient flags + resolved FKs before save
          const toSave: any = { ...rest, ...tax };
          // Resolve "dedicated transport for activity" link. PREFER
          // link_key — activities are delete-and-replaced on update, so
          // any persisted linked_activity_id the FE remembers is stale
          // and would FK-violate. link_key resolves to the freshly-
          // inserted activity's UUID. Persisted id kept only as a
          // defensive fallback for paths that skip activity replacement.
          const linkedFromKey = linked_activity_link_key
            ? linkKeyToActivityId.get(String(linked_activity_link_key)) || null
            : null;
          toSave.linked_activity_id =
            linkedFromKey || (rest.linked_activity_id as string | undefined) || null;
          // Promote free-text driver to a real master row when ops ticked
          // "Sync to masters". Requires name + phone + supplier_id (either
          // a picked id or one of the text suppliers — the latter can't
          // anchor a driver so we skip if missing).
          if (
            ti.sync_driver_to_masters &&
            ti.driver_name_text &&
            ti.driver_phone_text &&
            ti.supplier_id &&
            destinationId
          ) {
            try {
              const r = await this.driverService.findOrCreateQuick({
                name: ti.driver_name_text,
                phone: ti.driver_phone_text,
                supplier_id: ti.supplier_id,
                destination_id: destinationId,
              });
              // Link the (possibly newly-created) driver and drop the
              // text placeholders — the master row is now the source.
              toSave.driver_id = r.id;
              toSave.driver_name_text = null;
              toSave.driver_phone_text = null;
            } catch {
              // Don't fail the booking save if sync hiccups — leave the
              // text values intact so nothing is lost.
            }
          }
          // sync_driver_to_masters is NOT a column on the transport entity;
          // strip before save.
          delete toSave.sync_driver_to_masters;
          await this.transportItemRepo.save({
            ...toSave,
            booking_id: id,
            tenant_id: tenantId,
            created_by: userId,
            updated_by: userId,
          });
        }
      }
      if (dto.special_items) {
        await this.specialItemRepo.delete({ booking_id: id, tenant_id: tenantId });
        for (const si of dto.special_items) {
          const subtotal = Number(si.total_price ?? 0);
          const tax = {
            ...this.resolveLineTaxFields(si, subtotal, updateTaxSnap),
            tax_snapshot: await this.resolveLineTaxSnapshot('special', updateDestId),
          };
          const { refresh_tax, ...rest } = si;
          void refresh_tax;
          await this.specialItemRepo.save({
            ...rest,
            ...tax,
            booking_id: id,
            tenant_id: tenantId,
            created_by: userId,
            updated_by: userId,
          });
        }
      }
      // Flight details — full-replace mirror of the other item lists.
      // transport_item_id is either a real FK (when ops linked the
      // flight to a specific transport row) or null (flight recorded
      // standalone — typical at quote phase before transport is
      // finalised).
      if (dto.flight_details) {
        const rows = dto.flight_details;
        // Old → new transport_item_id translation for flight links.
        // transport_items above is delete-and-replaced, so any
        // transport_item_id the FE submitted is a STALE uuid pointing
        // to a transport row that no longer exists. Saving the flight
        // row with that stale FK fires a foreign-key violation (500).
        // Identify the matching new row by the stable
        // (transport_service_id, date, sort_order) composite — those
        // three together are unique per booking — and substitute.
        // Skip the map when transport_items wasn't part of the save
        // (FE only re-sending flight_details): the old ids are still
        // valid and pass through unchanged.
        const transportIdMap = new Map<string, string>();
        if (dto.transport_items && beforeTransports) {
          const oldKeyById = new Map<string, string>();
          for (const t of beforeTransports) {
            oldKeyById.set(
              t.id,
              `${t.transport_service_id || ''}|${t.date || ''}|${t.sort_order ?? ''}`,
            );
          }
          const newTransports = await this.transportItemRepo.find({
            where: { booking_id: id, tenant_id: tenantId },
          });
          const newIdByKey = new Map<string, string>();
          for (const t of newTransports) {
            newIdByKey.set(
              `${t.transport_service_id || ''}|${t.date || ''}|${t.sort_order ?? ''}`,
              t.id,
            );
          }
          for (const [oldId, key] of oldKeyById) {
            const newId = newIdByKey.get(key);
            if (newId) transportIdMap.set(oldId, newId);
          }
        }
        // Atomic delete-then-reinsert so a partial failure can't
        // leave the booking with half its old flight rows wiped
        // and the new ones unsaved. Wraps both halves in one
        // transaction; rollback restores the prior state.
        await this.flightDetailRepo.manager.transaction(async (manager) => {
          await manager.delete(BookingFlightDetailEntity, {
            booking_id: id,
            tenant_id: tenantId,
          });
          for (const fd of rows) {
            const remappedTransportItemId = fd.transport_item_id
              ? (transportIdMap.get(fd.transport_item_id) ?? null)
              : null;
            await manager.save(BookingFlightDetailEntity, {
              ...fd,
              transport_item_id: remappedTransportItemId,
              booking_id: id,
              tenant_id: tenantId,
              created_by: userId,
              updated_by: userId,
            });
          }
        });
      }

      await this.recalculateTotals(id, tenantId);

      // Compose detailed audit entries — one row per category that
      // actually moved. Replaces the old single "Updated itinerary
      // details" placeholder so the audit timeline shows concrete
      // adds / removes (with hotel name, route, ticket, vehicle, mode).
      const ctx = await this.buildItineraryAuditContext({
        before: {
          hotels: beforeHotels,
          transports: beforeTransports,
          activities: beforeActivities,
        },
        after: {
          hotels: dto.hotel_items,
          transports: dto.transport_items,
          activities: dto.activity_items,
        },
      });
      const diffs: Array<AuditChange | null> = [
        dto.hotel_items
          ? diffItineraryItems('hotel_items', 'hotel', beforeHotels, dto.hotel_items, ctx)
          : null,
        dto.transport_items
          ? diffItineraryItems(
              'transport_items',
              'transport',
              beforeTransports,
              dto.transport_items,
              ctx,
            )
          : null,
        dto.activity_items
          ? diffItineraryItems(
              'activity_items',
              'activity',
              beforeActivities,
              dto.activity_items,
              ctx,
            )
          : null,
        dto.special_items
          ? diffItineraryItems('special_items', 'special', beforeSpecials, dto.special_items, ctx)
          : null,
      ];
      for (const d of diffs) if (d) changes.push(d);

      // Tax-refresh audit (Q2=B audit obligation). Count lines across
      // every category that explicitly carried refresh_tax: true on
      // this save — meaning the user clicked the "🔄 Refresh Tax"
      // button and re-snapshotted from the destination's current slab.
      // Captures the user's intentional override; routine edits don't
      // touch the snapshot so they never appear here.
      const refreshedCount = [
        ...(dto.hotel_items || []),
        ...(dto.transport_items || []),
        ...(dto.activity_items || []),
        ...(dto.special_items || []),
      ].filter((l) => l?.refresh_tax === true).length;
      if (refreshedCount > 0) {
        const newRate = updateTaxSnap ? this.taxSnapshot.sumRates(updateTaxSnap.tax_breakdown) : 0;
        changes.push({
          field: 'tax_refresh',
          old_value: null,
          new_value: `Tax snapshots refreshed for ${refreshedCount} line${refreshedCount === 1 ? '' : 's'} — new rate ${newRate}%`,
        });
      }
    }

    // Derive markup tax from the (possibly new) final price and re-split
    // the selling components. Runs after the item recalc so grand_total
    // reflects this save's lines.
    if (pricingTouched || hasItems) {
      await this.refreshMarkupTax(id, tenantId);
    }

    if (changes.length > 0) {
      await this.auditService.log(ENTITY_TYPE, id, 'updated', changes);
    }

    // ── Settlement delta (§12 Phase B) ───────────────────────────────
    // If the customer-facing selling_total moved, raise a delta-driven
    // settlement row (Collection on increase, Refund on decrease). Uses
    // the BEFORE snapshot captured in `item` and the AFTER value via
    // a single refresh — only one extra read in the common path.
    // Wrapped in try/catch so a settlement bug never blocks the
    // itinerary edit itself.
    try {
      const after = await this.repo.findById(id);
      const oldSelling = Number(item.selling_total ?? 0);
      const newSelling = Number(after?.selling_total ?? 0);
      const delta = newSelling - oldSelling;
      const currencyId = after?.selling_currency_id || item.selling_currency_id;
      if (delta !== 0 && currencyId) {
        const dueDate = await this.getBookingDueDate(id);
        await this.settlements.recordItineraryDelta({
          booking_id: id,
          delta,
          currency_id: currencyId,
          due_date: dueDate,
          source: opts?.bypassApprovalGuards ? 'CHANGE_REQUEST' : 'ITINERARY_EDIT',
          // CR id threaded by the approval flow — null for direct
          // admin saves. Lets finance click from the settlement back to
          // the original change-request payload + reason.
          triggered_by_change_request_id: opts?.triggeredByChangeRequestId ?? null,
        });
      }
    } catch (err) {
      // Log on the booking timeline; do not throw — finance recovers
      // via the admin edit-amount action.
      const e = err as { message?: string };
      await this.auditService.log(ENTITY_TYPE, id, 'updated', [
        {
          field: 'payment_settlement_delta_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    // ── Auto-CR on approver saves (2026-07-02) ─────────────────────────
    // Approvers WITHOUT booking_approval_bypass get a review CR when
    // their save hits same-day / next-day / post-confirm-edit. Admin
    // (has bypass) and Sales (blocked by 403 above) both skip.
    // Wrapped in try/catch so a CR failure never blocks the actual
    // save — this is a review notice, not a blocker.
    if (auditCanApproveNoBypass && !opts?.bypassApprovalGuards) {
      try {
        await this.emitAutoApprovalCR(id, dto);
      } catch (err) {
        const e = err as { message?: string };
        await this.auditService.log(ENTITY_TYPE, id, 'updated', [
          {
            field: 'auto_change_request_error',
            old_value: null,
            new_value: e?.message ?? 'unknown',
          },
        ]);
      }
    }

    return { id, message: MSG.UPDATED };
  }

  /**
   * Run the three booking-approval trigger detectors against the
   * saved booking + payload and, if any fire, emit a single
   * informational CR via ChangeRequestService.createAutoForBooking.
   * Kind precedence: post_confirm_edit > same_day_booking >
   * next_day_booking (matches KIND_PRIORITY in change-request.service).
   */
  private async emitAutoApprovalCR(
    bookingId: string,
    dto: UpdateBookingDto,
  ): Promise<void> {
    const booking = await this.repo.findByIdWithRelations(bookingId);
    if (!booking) return;
    const q = (booking as any).query;
    const destTz = q?.destination?.timezone ?? null;
    const startDate = q?.start_date ?? null;

    const triggers = detectBookingApprovalTriggers({
      startDate,
      destTz,
      currentStatus: booking.status,
      dto,
      isUpdate: true,
    });
    if (triggers.length === 0) return;

    // Precedence — highest wins for the CR's kind field.
    const KIND_ORDER = ['post_confirm_edit', 'same_day_booking', 'next_day_booking'] as const;
    const best = triggers
      .slice()
      .sort(
        (a, b) =>
          KIND_ORDER.indexOf(a.kind as any) - KIND_ORDER.indexOf(b.kind as any),
      )[0];

    await this.changeRequestService.createAutoForBooking(
      bookingId,
      best.kind,
      // Payload = the dto that triggered the save. Manager sees "what
      // was saved" alongside the booking's before_snapshot (null on
      // auto-CRs — no manual submission to snapshot against).
      dto as unknown as Record<string, unknown>,
      triggers.map((t) => t.detail).join(' | '),
    );
  }

  // ────────────────────────────────────────────────────────────────────
  // §12 Phase C — Movement Chart amendment endpoints
  // ────────────────────────────────────────────────────────────────────

  /**
   * Update `actual_supplier_cost` on an existing transport item.
   * Internal margin only — does NOT alter the customer-facing total,
   * does NOT raise a settlement, does NOT touch any selling_* field.
   *
   * Tenant scope is enforced via the repo's `find` + `update`.
   * Gated by TRANSPORT_ASSIGNMENT:edit at the controller (Ops Manager +
   * Admin per the seeded RBAC matrix). Pass `null` to clear the
   * override and fall back to the master rate.
   */
  async updateActualSupplierCost(
    transportItemId: string,
    actual_supplier_cost: number | null | undefined,
  ): Promise<{ id: string; message: string }> {
    const tenantId = this.getTenantId();
    const item = await this.transportItemRepo.findOne({
      where: { id: transportItemId, tenant_id: tenantId, is_deleted: false },
    });
    if (!item) throw new NotFoundException('Transport item not found');

    const next = actual_supplier_cost === undefined ? null : actual_supplier_cost;
    await this.transportItemRepo.update(transportItemId, {
      actual_supplier_cost: next,
      updated_by: this.cls.get<string>('user_id') || null,
    });

    await this.auditService.log(ENTITY_TYPE, item.booking_id, 'updated', [
      {
        field: `transport_items[${transportItemId}].actual_supplier_cost`,
        old_value: item.actual_supplier_cost,
        new_value: next,
      },
    ]);

    return { id: transportItemId, message: 'Actual supplier cost updated' };
  }

  /**
   * Create a new transport row tagged `is_amended = true` for a
   * post-booking customer request, and raise one COLLECTION settlement
   * for `quantity × unit_price` against the customer's outstanding
   * balance. The settlement row carries `source = MOVEMENT_CHART` so
   * the list page renders the "Amended" badge.
   *
   * Tenant scope via the repo. Booking-totals are NOT recomputed here
   * — amended rows are billed via the settlement, not by mutating
   * `selling_total` (which would double-bill on the next markup save).
   * Original itinerary totals stay frozen.
   */
  async createAmendedTransport(input: {
    booking_id: string;
    transport_service_id: string;
    vehicle_type_id?: string | null;
    quantity: number;
    unit_price: number;
    date: string;
    pickup_time?: string;
    dropoff_time?: string;
    pickup_address?: string;
    dropoff_address?: string;
    notes?: string;
  }): Promise<{ id: string; message: string }> {
    const tenantId = this.getTenantId();
    const userId = this.cls.get<string>('user_id') || null;

    const booking = await this.repo.findById(input.booking_id);
    if (!booking) throw new NotFoundException('Booking not found');
    if (!booking.selling_currency_id) {
      throw new BadRequestException(
        'Booking has no selling currency; amended row cannot be billed',
      );
    }

    const quantity = Number(input.quantity);
    const unitPrice = Number(input.unit_price);
    const subtotal = +(quantity * unitPrice).toFixed(2);

    // Snapshot Floor as actual_supplier_cost — same contract as
    // BookingService.createFromQuery. Amended rows skipped this
    // before, leaving the Movement Chart Cost cell as `—`; ops then
    // had no default and the supplier-payable fallback over-stated
    // the payable using the customer-facing unit_price × qty.
    // destination_id lives on the linked query, not the booking row.
    const linkedQuery = await this.queryRepo.findOne({
      where: { id: booking.query_id, tenant_id: tenantId },
    });
    const floorPerUnit = linkedQuery
      ? await resolveFloorSnapshot(this.transportItemRepo.manager, {
          tenant_id: tenantId,
          destination_id: linkedQuery.destination_id,
          transport_service_id: input.transport_service_id,
          vehicle_type_id: input.vehicle_type_id ?? null,
          is_shared: false, // amended rows are PVT by default
          date: input.date,
        })
      : null;
    // Same per-trip total semantic as createFromQuery.
    const floorSnapshot = floorPerUnit != null ? +(floorPerUnit * quantity).toFixed(2) : null;

    // Freeze the destination tax slab so this amended row is counted by
    // the accounting tax-ledger (which bifurcates tax_snapshot per line).
    const amendedTaxSnapshot = await this.resolveLineTaxSnapshot(
      'transport',
      linkedQuery?.destination_id ?? null,
      { transport_service_id: input.transport_service_id },
    );

    const saved = await this.transportItemRepo.save({
      booking_id: input.booking_id,
      tenant_id: tenantId,
      transport_service_id: input.transport_service_id,
      vehicle_type_id: input.vehicle_type_id ?? null,
      quantity,
      unit_price: unitPrice,
      subtotal,
      date: input.date,
      pickup_time: input.pickup_time ?? null,
      dropoff_time: input.dropoff_time ?? null,
      pickup_address: input.pickup_address ?? null,
      dropoff_address: input.dropoff_address ?? null,
      notes: input.notes ?? null,
      tax_snapshot: amendedTaxSnapshot,
      is_amended: true,
      actual_supplier_cost: floorSnapshot,
      created_by: userId,
      updated_by: userId,
    });

    try {
      const dueDate = await this.getBookingDueDate(input.booking_id);
      await this.settlements.recordItineraryDelta({
        booking_id: input.booking_id,
        delta: subtotal,
        currency_id: booking.selling_currency_id,
        due_date: dueDate,
        breakdown: {
          added: [
            {
              kind: 'transport',
              transport_service_id: input.transport_service_id,
              vehicle_type_id: input.vehicle_type_id ?? null,
              date: input.date,
              quantity,
              unit_price: unitPrice,
              subtotal,
            },
          ],
        },
        source: 'MOVEMENT_CHART',
      });
    } catch (err) {
      const e = err as { message?: string };
      await this.auditService.log(ENTITY_TYPE, input.booking_id, 'updated', [
        {
          field: 'payment_settlement_amended_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    await this.auditService.log(ENTITY_TYPE, input.booking_id, 'updated', [
      {
        field: 'transport_items.amended_added',
        old_value: null,
        new_value: { id: saved.id, subtotal, date: input.date },
      },
    ]);

    return { id: saved.id, message: 'Amended transport added' };
  }
}
