import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ObjectLiteral, Repository } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { BookingRepository } from './repositories/booking.repository';
import { QuickBookingService } from './services/quick-booking.service';
import { BookingItineraryService } from './services/booking-itinerary.service';
import { PaymentSettlementService } from '../payment-settlement/payment-settlement.service';
import { DriverService } from '../driver/driver.service';
import {
  CLS_USER_ID,
  CLS_USER_ROLE_NAME,
  CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES,
} from '../../common/cls/cls.constants';
import {
  applyBookingRowVisibility,
  bookingVisibilitySql,
  type VisibleUserIds,
} from '../../common/auth/row-visibility';
import { UserReportingService } from '../user/user-reporting.service';

// BOOKING_MARKUP_FIELDS + the markup-stripping logic were removed
// 2026-05-13: the MARKUP module was retired (client decision — all
// users with query/booking access now see markup + selling totals).
import { UpdateBookingDto, CreateQuickBookingDto } 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 { QueryEntity, QueryStage } from '../../entities/query.entity';
import { QueryQuoteEntity } from '../../entities/query-quote.entity';
import { QueryQuoteHotelEntity } from '../../entities/query-quote-hotel.entity';
import { QueryQuoteHotelPeriodEntity } from '../../entities/query-quote-hotel-period.entity';
import { QueryQuoteHotelExtraEntity } from '../../entities/query-quote-hotel-extra.entity';
import { computeHotelStayTotal } from '../../common/hotel/hotel-totals';
import { assertNightsInRange } from '../../common/hotel/hotel-validation';
import { QueryQuoteTransportEntity } from '../../entities/query-quote-transport.entity';
import { QueryQuoteActivityEntity } from '../../entities/query-quote-activity.entity';
import { ActivityEntity } from '../../entities/activity.entity';
import { QueryQuoteSpecialEntity } from '../../entities/query-quote-special.entity';
import { AuditService } from '../audit/audit.service';
import { AuditEntityType } from '../audit/audit-types';
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 { distributeSellingComponents } from '../../common/services/markup-tax.helper';
import { serviceMessages } from '../../common/constants/messages';
import { resolveFloorSnapshot } from '../transport-pricing/floor-snapshot.helper';
import { resolveActivityFloorSnapshot } from '../activity-pricing/activity-floor-snapshot.helper';

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

// TRANSPORT_ITEM_AUDIT_FIELDS, ACTIVITY_ITEM_AUDIT_FIELDS, and pickFields()
// moved to services/driver-assignment.service.ts (2026-05-12 split
// refactor) — they were used exclusively by the assign/patch methods
// that now live there.

@Injectable()
export class BookingService {
  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(QueryQuoteHotelEntity)
    private readonly quoteHotelRepo: Repository<QueryQuoteHotelEntity>,
    @InjectRepository(QueryQuoteHotelPeriodEntity)
    private readonly quoteHotelPeriodRepo: Repository<QueryQuoteHotelPeriodEntity>,
    @InjectRepository(QueryQuoteHotelExtraEntity)
    private readonly quoteHotelExtraRepo: Repository<QueryQuoteHotelExtraEntity>,
    @InjectRepository(QueryQuoteTransportEntity)
    private readonly quoteTransportRepo: Repository<QueryQuoteTransportEntity>,
    @InjectRepository(QueryQuoteActivityEntity)
    private readonly quoteActivityRepo: Repository<QueryQuoteActivityEntity>,
    @InjectRepository(ActivityEntity) private readonly activityRepo: Repository<ActivityEntity>,
    @InjectRepository(QueryQuoteSpecialEntity)
    private readonly quoteSpecialRepo: Repository<QueryQuoteSpecialEntity>,
    private readonly driverService: DriverService,
    // Tax-snapshot helper (Q1=C / Q2=B / Q3=A) — used by both
    // createFromQuery (always re-snapshot per Q1=C) and update
    // (preserve unless refresh_tax: true per Q2=B).
    private readonly taxSnapshot: TaxSnapshotService,
    // Per-line slab resolver — produces the `tax_snapshot` JSONB the
    // accounting tax-ledger reads (base + tax bifurcation per line).
    // Booking lines MUST carry this or the ledger reports zero tax.
    private readonly taxResolver: TaxSlabResolverService,
    // Sub-service for the ad-hoc walk-in flow (createQuick). The
    // public createQuick() on this class is now a thin delegator to
    // preserve the existing external API.
    private readonly quickBooking: QuickBookingService,
    // Sub-service for itinerary edit (update + applyApprovedChangeRequest).
    // BookingService.update + BookingService.applyApprovedChangeRequest
    // are thin delegators to keep BookingController + ChangeRequestService
    // call sites unchanged.
    private readonly itinerary: BookingItineraryService,
    // §12 — initial COLLECTION is raised here on booking confirmation.
    private readonly settlements: PaymentSettlementService,
    private readonly userReporting: UserReportingService,
  ) {}

  /**
   * Apply the per-line tax snapshot policy (Q1=C / Q2=B / Q3=A) to a
   * line item before saving. Caches the destination's resolved
   * snapshot per call via the closure pattern in the caller — pass
   * the cached snapshot in if the booking has multiple lines.
   *
   *   - If the line has an existing tax_breakdown AND no refresh_tax
   *     flag, preserve as-is (Q2=B). tax_amount recomputes.
   *   - Else use the destination snapshot (or `forceRefreshSnapshot`
   *     when called from createFromQuery — Q1=C requires a fresh
   *     re-snapshot at booking-create regardless of what the source
   *     quote line carried).
   */
  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) for
   * a booking line. This is the field the accounting tax-ledger
   * bifurcates to compute tax collected — without it the ledger reads
   * the line as zero-tax. Mirrors the quote-side `resolveLineSnapshot`
   * so a converted booking carries the same slab the quote showed.
   * Transport / activity honour per-service / per-activity overrides;
   * hotel / special fall back to the destination slab.
   */
  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);
    }
    // Column type is the looser `Record<string, unknown>`; the resolver
    // returns the concrete TaxSlabSnapshotInput shape (no index sig).
    return slab ? (slab as unknown as Record<string, unknown>) : null;
  }

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

  /**
   * Resolve the viewer-scope user id for booking visibility:
   *   - 'all' visibility (Admin / Ops Mgr / Trip Coord / Sales Head)
   *     → null (no row filter)
   *   - 'own' visibility (Sales Team / custom roles)
   *     → current user_id (filter to bookings I own or whose parent
   *       query is owned by me / has me in sales_team)
   *
   * Booking ownership = booking.created_by = me OR
   *                     query.created_by = me OR
   *                     me ∈ query.sales_team[]
   * The query path is what matters for sales reps — they create the
   * query, the booking is auto-created by the conversion flow with
   * the same created_by, but querying via the join keeps us robust
   * to that flow changing.
   */
  private async assertBookingVisible(bookingId: string): Promise<void> {
    const visibleUserIds = await this.userReporting.resolveVisibleUserIds('booking');
    if (visibleUserIds !== null && !(await this.isBookingVisibleTo(bookingId, visibleUserIds))) {
      throw new NotFoundException(MSG.NOT_FOUND);
    }
  }

  /**
   * Past-trip lock (client policy 2026-10-09): once a booking's travel
   * END date is before today, the booking is fully read-only for every
   * role except Admin. Admin may still edit completed / past bookings
   * (itinerary, details, status, guarantee) so ops mistakes can be
   * corrected after the trip. Delete stays locked even for Admin.
   *
   * The travel end date lives on the parent query (`query.end_date`, a
   * plain 'YYYY-MM-DD' date). "Today" is resolved in Asia/Kolkata so
   * the day boundary matches the office, not the server's UTC clock.
   *
   * Note: Movement Dashboard operational edits (driver assignment,
   * op_status) go through DriverAssignmentService and are intentionally
   * NOT locked here — ops still need to mark yesterday's trips picked
   * up / no-show. This guard covers only the booking-edit endpoints.
   */
  private isAdminRole(): boolean {
    return (this.cls.get<string>(CLS_USER_ROLE_NAME) || '').trim().toLowerCase() === 'admin';
  }

  private async assertBookingNotPast(bookingId: string, allowAdmin = true): Promise<void> {
    if (allowAdmin && this.isAdminRole()) return;
    const booking = await this.repo.findById(bookingId);
    if (!booking) return; // existence handled by the caller's findById
    const query = await this.queryRepo.findOne({
      where: { id: booking.query_id },
      select: { id: true, end_date: true },
    });
    const endDate = query?.end_date;
    if (!endDate) return; // no travel end date on record → nothing to lock

    const parts = new Intl.DateTimeFormat('en-CA', {
      timeZone: 'Asia/Kolkata',
      year: 'numeric',
      month: '2-digit',
      day: '2-digit',
    }).formatToParts(new Date());
    const y = parts.find((p) => p.type === 'year')?.value ?? '1970';
    const m = parts.find((p) => p.type === 'month')?.value ?? '01';
    const d = parts.find((p) => p.type === 'day')?.value ?? '01';
    const todayIst = `${y}-${m}-${d}`;

    if (endDate < todayIst) {
      throw new BadRequestException(
        'This trip has ended — the booking is locked and can no longer be edited.',
      );
    }
  }

  /**
   * Visibility check for a single booking — used to gate detail /
   * mutation endpoints. Returns true for full-visibility roles (no
   * filter applied) or when the booking is owned by the viewer.
   */
  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;
  }

  /**
   * 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.
   */

  /**
   * Flatten query basic details onto the booking so API responses keep their
   * historical shape (frontend reads `booking.guest_name` etc. as before).
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BookingEntity enriched at runtime with flattened query fields (guest_*, dates, pax) for legacy API shape
  private flattenQuery(booking: any): any {
    if (!booking) return booking;
    if (booking.query) {
      const q = booking.query;
      booking.guest_name = q.guest_name;
      booking.guest_email = q.guest_email;
      booking.guest_mobile = q.guest_mobile;
      booking.guest_alt_phone = q.guest_alt_phone;
      booking.start_date = q.start_date;
      booking.end_date = q.end_date;
      booking.adults = q.adults;
      booking.children_ages = q.children_ages;
      booking.infants = q.infants;
      booking.sales_team = q.sales_team;
      booking.source_tags = q.source_tags;
      booking.remark_tags = q.remark_tags;
      booking.destination_id = q.destination_id;
      booking.destination = q.destination;
      booking.currency_id = q.currency_id;
      booking.currency = q.currency;
    }
    this.exposePanFlag(booking);
    return booking;
  }

  /** Public API never returns the on-disk filename. Idempotent if called twice. */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- runtime-enriched booking row
  private exposePanFlag(booking: any): void {
    if (!booking) return;
    booking.has_pan_card = Boolean(booking.has_pan_card || booking.pan_card_filename);
    delete booking.pan_card_filename;
  }

  private computeSellingComponent(
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts QueryQuoteEntity or a fakeQuote literal merged with override totals
    quote: any,
    component: 'hotel' | 'transport' | 'activity' | 'special',
  ): number {
    const costMap = {
      hotel: quote.hotel_total,
      transport: quote.transport_total,
      activity: quote.activity_total,
      special: quote.special_total,
    };
    const markupMap = {
      hotel: quote.hotel_markup,
      transport: quote.transport_markup,
      activity: quote.activity_markup,
      special: quote.special_markup,
    };
    const cost = Number(costMap[component]) || 0;
    const rate = Number(quote.exchange_rate) || 1;
    const isPerComponent = quote.pricing_strategy?.includes('component');

    let total: number;
    if (isPerComponent) {
      const mkp = Number(markupMap[component]) || 0;
      total = quote.markup_type === 'percentage' ? cost + (cost * mkp) / 100 : cost + mkp;
    } else {
      const grandTotal =
        Number(quote.hotel_total) +
        Number(quote.transport_total) +
        Number(quote.activity_total) +
        Number(quote.special_total);
      const mkp = Number(quote.markup_amount) || 0;
      const markedUpTotal =
        quote.markup_type === 'percentage'
          ? grandTotal + (grandTotal * mkp) / 100
          : grandTotal + mkp;
      const ratio = grandTotal > 0 ? markedUpTotal / grandTotal : 1;
      total = cost * ratio;
    }
    return Math.round(total * rate * 100) / 100;
  }

  async findAll(
    query?: { page?: number; limit?: number; search?: string },
    filters?: {
      status?: string;
      destinationId?: string;
      sourceId?: string;
      dateFrom?: string;
      dateTo?: string;
    },
  ) {
    const tenantId = this.getTenantId();
    const page = query?.page || 1;
    const limit = Math.min(query?.limit || 10, 100);
    const offset = (page - 1) * limit;

    const qb = this.repo.ormRepo
      .createQueryBuilder('b')
      .leftJoinAndSelect('b.query', 'q')
      .leftJoinAndSelect('q.destination', 'qd')
      .leftJoinAndSelect('qd.currency', 'qdc')
      .leftJoinAndSelect('q.currency', 'qc')
      .leftJoinAndSelect('b.selling_currency', 'sc')
      .leftJoinAndSelect('b.quote', 'qt')
      .leftJoinAndSelect('qt.selling_currency', 'qtsc')
      .where('b.tenant_id = :tenantId', { tenantId })
      .andWhere('b.is_deleted = false');

    if (filters?.status) qb.andWhere('b.status = :status', { status: filters.status });
    if (filters?.destinationId)
      qb.andWhere('q.destination_id = :destinationId', { destinationId: filters.destinationId });
    if (filters?.sourceId)
      qb.andWhere('q.source_id = :sourceId', { sourceId: filters.sourceId });
    if (filters?.dateFrom && filters?.dateTo) {
      qb.andWhere('q.start_date BETWEEN :dateFrom AND :dateTo', {
        dateFrom: filters.dateFrom,
        dateTo: filters.dateTo,
      });
    } else if (filters?.dateFrom) {
      qb.andWhere('q.start_date >= :dateFrom', { dateFrom: filters.dateFrom });
    } else if (filters?.dateTo) {
      qb.andWhere('q.start_date <= :dateTo', { dateTo: filters.dateTo });
    }

    // Row-level visibility — see common/auth/visibility.ts. Null
    // viewer = full-visibility role; no filter added. Wrapped in
    // Brackets so the OR-group composes correctly with the search
    // OR-group below.
    applyBookingRowVisibility(
      qb,
      'b',
      'q',
      await this.userReporting.resolveVisibleUserIds('booking'),
    );

    if (query?.search) {
      const raw = query.search.trim();
      // When the search looks like a booking number (`{prefix}-\d+`,
      // any tenant prefix — BK, MT, …), match it EXACTLY on
      // booking_number. A previous `%BK-001%` substring match returned
      // BK-0010, BK-0011, etc. once the series wrapped past 999 — ops
      // reported that searching "BK-001" and later "BK-002" could
      // surface each other because the prefix is common and the
      // wildcard lets longer numbers match shorter queries.
      // Guest-name / mobile searches still use the wildcard match.
      if (/^[A-Za-z]{1,10}-\d+$/.test(raw)) {
        qb.andWhere('b.booking_number ILIKE :exact', { exact: raw });
      } else {
        qb.andWhere(
          '(b.booking_number ILIKE :s OR q.guest_name ILIKE :s OR q.guest_mobile ILIKE :s)',
          { s: `%${raw}%` },
        );
      }
    }

    qb.orderBy('b.created_at', 'DESC').skip(offset).take(limit);

    const [items, total] = await qb.getManyAndCount();
    items.forEach((b) => this.flattenQuery(b));
    // Backfill selling fields from quote for legacy bookings where selling_total
    // wasn't copied on creation. Same logic as findById.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- BookingEntity enriched at runtime with selling_* fields backfilled from quote
    items.forEach((b: any) => {
      if (Number(b.selling_total) === 0 && b.quote) {
        const q = b.quote;
        b.selling_currency_id = q.selling_currency_id || b.selling_currency_id;
        b.selling_currency = q.selling_currency || b.selling_currency;
        b.exchange_rate = Number(q.exchange_rate) || 1;
        b.selling_total = Number(q.selling_total) || 0;
      }
    });
    await this.resolveUserNames(items);

    // Agent-roster privacy (client policy 2026-10-10): scoped (non
    // "view all") roles must not learn which agent a booking came from —
    // reading/counting source names across their visible bookings reveals
    // the roster. The list UI hides the SOURCE column for them; strip the
    // field from the payload too so it can't be read via devtools. Only
    // affects the list — booking detail is unchanged.
    const viewAll = this.cls.get<boolean>(CLS_ROLE_VIEW_ALL_BOOKINGS_QUERIES) === true;
    if (!viewAll) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- runtime-enriched rows
      items.forEach((b: any) => {
        if (b.query) {
          delete b.query.source;
          delete b.query.source_id;
        }
      });
    }

    return {
      items,
      meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
    };
  }

  /**
   * Resolve user names for sales_team UUID arrays (attaches sales_team_users to each booking).
   * Reads from booking.query.sales_team (flattened onto booking.sales_team by flattenQuery).
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bookings are runtime-enriched (sales_team_users added)
  private async resolveUserNames(bookings: any[]) {
    const allIds = new Set<string>();
    for (const b of bookings) (b.sales_team || []).forEach((id: string) => allIds.add(id));
    if (allIds.size === 0) {
      for (const b of bookings) b.sales_team_users = [];
      return;
    }
    // tenant_id filter is mandatory on every raw query — without it
    // a sales_team UUID forged from another tenant would resolve to
    // that tenant's user name. backend.md / tenancy.md require this
    // explicitly since CLS-based scoping doesn't auto-apply to
    // manager.query().
    const tenantId = this.getTenantId();
    const userRows: Array<{ id: string; name: string }> = await this.repo.ormRepo.manager.query(
      `SELECT id, name FROM users WHERE id = ANY($1) AND tenant_id = $2 AND is_deleted = false`,
      [Array.from(allIds), tenantId],
    );
    const userMap = new Map(userRows.map((u) => [u.id, u.name]));
    for (const b of bookings) {
      b.sales_team_users = (b.sales_team || [])
        .filter((id: string) => userMap.has(id))
        .map((id: string) => ({ id, name: userMap.get(id) }));
    }
  }

  async findById(id: string) {
    // Visibility gate first — return 404 (not 403) for out-of-scope
    // rows. Same don't-leak-existence pattern used on the query side.
    await this.assertBookingVisible(id);
    const item = await this.repo.findByIdWithRelations(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);
    this.flattenQuery(item);
    await this.resolveUserNames([item]);

    // Backfill selling data from quote if missing (legacy bookings)
    if (Number(item.selling_total) === 0 && item.quote) {
      const quote = item.quote;
      item.selling_currency_id = quote.selling_currency_id || null;
      // selling_currency is typed non-null on BookingEntity but the DB column is
      // nullable; this assignment preserves the historical runtime shape.
      item.selling_currency =
        quote.selling_currency || (null as unknown as typeof item.selling_currency);
      item.exchange_rate = Number(quote.exchange_rate) || 1;
      item.selling_hotel_total = this.computeSellingComponent(quote, 'hotel');
      item.selling_transport_total = this.computeSellingComponent(quote, 'transport');
      item.selling_activity_total = this.computeSellingComponent(quote, 'activity');
      item.selling_special_total = this.computeSellingComponent(quote, 'special');
      item.selling_total = Number(quote.selling_total) || 0;
    }

    // Markup/selling fields used to be stripped for roles without
    // markup:view here. The MARKUP module was retired 2026-05-13;
    // every authed user with booking:view now sees the full breakdown.
    return item;
  }

  /**
   * Booking lookup for ticket-batch booking selector.
   * Returns all bookings whose source query stage = CONVERTED.
   */
  async findAllForLookup() {
    const tenantId = this.getTenantId();
    const rows = await this.repo.ormRepo
      .createQueryBuilder('b')
      .leftJoin('b.query', 'q')
      .select('b.id', 'id')
      .addSelect('b.booking_number', 'booking_number')
      .addSelect('q.guest_name', 'guest_name')
      .addSelect('q.guest_mobile', 'guest_mobile')
      .addSelect('q.source_id', 'source_id')
      .addSelect('q.start_date', 'start_date')
      .where('b.tenant_id = :tenantId', { tenantId })
      .andWhere('b.is_deleted = false')
      .andWhere('q.stage = :stage', { stage: 'converted' })
      .orderBy('q.start_date', 'ASC')
      .getRawMany<{
        id: string;
        booking_number: string;
        guest_name: string | null;
        guest_mobile: string | null;
        source_id: string | null;
        start_date: string | null;
      }>();
    return rows.map((r) => ({
      id: r.id,
      booking_number: r.booking_number,
      guest_name: r.guest_name || null,
      guest_mobile: r.guest_mobile || null,
      source_id: r.source_id || null,
      start_date: r.start_date || null,
    }));
  }

  /**
   * Guest lookup by mobile — returns the most recent query matching this
   * mobile number so the Quick Booking form can prefill name/email.
   * Used by the walk-in flow; any authed tenant user may call.
   */
  async lookupGuestByMobile(mobile: string) {
    const tenantId = this.getTenantId();
    const trimmed = (mobile || '').trim();
    if (trimmed.length < 4) return null;
    const q = await this.queryRepo
      .createQueryBuilder('q')
      .where('q.tenant_id = :t', { t: tenantId })
      .andWhere('q.is_deleted = false')
      .andWhere('q.guest_mobile = :m', { m: trimmed })
      .orderBy('q.created_at', 'DESC')
      .limit(1)
      .getOne();
    if (!q) return null;
    return {
      guest_name: q.guest_name,
      guest_mobile: q.guest_mobile,
      guest_email: q.guest_email,
      guest_alt_phone: q.guest_alt_phone,
      // Trip defaults — each booking has new dates but destination / pax
      // often repeat for returning walk-ins.
      destination_id: q.destination_id,
      source_id: q.source_id,
      adults: q.adults,
      children_ages: q.children_ages,
      infants: q.infants,
    };
  }

  /**
   * Quick Booking — moved to QuickBookingService (2026-05-12 refactor).
   * Kept as a thin delegator so existing callers (BookingController,
   * external modules) don't need to change. New code should inject
   * QuickBookingService directly instead.
   *
   * @deprecated inject QuickBookingService directly for new callers.
   */
  async createQuick(dto: CreateQuickBookingDto) {
    return this.quickBooking.createQuick(dto);
  }

  async createFromQuery(queryId: string) {
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;

    // 1. Load query
    const query = await this.queryRepo.findOne({
      where: { id: queryId, tenant_id: tenantId, is_deleted: false },
    });
    if (!query) throw new NotFoundException('Query not found');

    // Guest name is required on conversion — the booking record, every
    // downstream voucher, the Movement Dashboard rows and supplier /
    // customer WhatsApp content all carry it. Queries can sit in the
    // pipeline with a placeholder name during early triage; the
    // convert step is where it has to be real. Trim trims accidental
    // whitespace-only entries.
    if (!query.guest_name || !query.guest_name.trim()) {
      throw new BadRequestException(
        'Guest name is required. Add a guest name on the query before converting to a booking.',
      );
    }

    // 2. Find selected quote
    const selectedQuote = await this.quoteRepo.findOne({
      where: {
        query_id: queryId,
        tenant_id: tenantId,
        is_selected: true,
        is_deleted: false,
      },
    });
    if (!selectedQuote)
      throw new BadRequestException(
        'No quote selected. Please select a quote before converting to booking.',
      );

    // 3. Check if booking already exists for this query
    const existingBooking = await this.repo.findOne({
      where: { query_id: queryId },
    });
    if (existingBooking) throw new BadRequestException('A booking already exists for this query.');

    // 4. Load quote child items
    const [quoteHotels, quoteTransports, quoteActivities, quoteSpecials] = await Promise.all([
      this.quoteHotelRepo.find({
        where: { quote_id: selectedQuote.id, tenant_id: tenantId, is_deleted: false },
        relations: { periods: true, extras: true },
      }),
      this.quoteTransportRepo.find({
        where: { quote_id: selectedQuote.id, tenant_id: tenantId, is_deleted: false },
      }),
      this.quoteActivityRepo.find({
        where: { quote_id: selectedQuote.id, tenant_id: tenantId, is_deleted: false },
      }),
      this.quoteSpecialRepo.find({
        where: { quote_id: selectedQuote.id, tenant_id: tenantId, is_deleted: false },
      }),
    ]);

    // 5. Generate booking number — reuse the query's 6 digits with the
    //    tenant prefix (1:1), so QRY-093254 → {prefix}-093254.
    const bookingNumber = await this.repo.getBookingNumberForQuery(query.query_number);

    // 6. Create booking — NO basic-detail copy; they live on the query.
    const quoteSelling = Number(selectedQuote.selling_total) || 0;
    const quoteSplit = distributeSellingComponents(
      {
        hotel: Number(selectedQuote.hotel_total) || 0,
        transport: Number(selectedQuote.transport_total) || 0,
        activity: Number(selectedQuote.activity_total) || 0,
        special: Number(selectedQuote.special_total) || 0,
      },
      selectedQuote,
      quoteSelling > 0
        ? quoteSelling - (Number(selectedQuote.markup_tax_amount) || 0)
        : (Number(selectedQuote.grand_total) || 0) * (Number(selectedQuote.exchange_rate) || 1),
    );
    const booking = await this.repo.save({
      booking_number: bookingNumber,
      query_id: queryId,
      quote_id: selectedQuote.id,
      hotel_total: selectedQuote.hotel_total,
      transport_total: selectedQuote.transport_total,
      activity_total: selectedQuote.activity_total,
      special_total: selectedQuote.special_total,
      grand_total: selectedQuote.grand_total,
      selling_currency_id: selectedQuote.selling_currency_id || null,
      exchange_rate: selectedQuote.exchange_rate || 1,
      selling_hotel_total: quoteSplit.hotel,
      selling_transport_total: quoteSplit.transport,
      selling_activity_total: quoteSplit.activity,
      selling_special_total: quoteSplit.special,
      // Final customer price (incl. markup tax) — billed as-is.
      selling_total: selectedQuote.selling_total || 0,
      markup_tax_amount: selectedQuote.markup_tax_amount || 0,
      // Markup config + remarks become booking-owned from here on.
      pricing_strategy: selectedQuote.pricing_strategy,
      markup_type: selectedQuote.markup_type,
      rounding: selectedQuote.rounding,
      markup_amount: selectedQuote.markup_amount,
      hotel_markup: selectedQuote.hotel_markup,
      transport_markup: selectedQuote.transport_markup,
      activity_markup: selectedQuote.activity_markup,
      special_markup: selectedQuote.special_markup,
      transport_excluded_pax: selectedQuote.transport_excluded_pax,
      internal_comments: selectedQuote.internal_comments,
      customer_remarks: selectedQuote.customer_remarks,
      notes: null,
      status: BookingStatus.UPCOMING,
      // Carry quote-level tax config forward to the booking. Per-line
      // tax_breakdown is independently re-snapshotted below (Q1=C);
      // these fields are the user's display preference, copied as-is.
      tax_slab_id: selectedQuote.tax_slab_id ?? null,
      tax_inclusive: selectedQuote.tax_inclusive ?? false,
    });

    // 7. Copy child items — Q1=C policy: RE-snapshot tax from the
    // destination's current slab at booking-create rather than
    // carrying the quote's frozen snapshot forward. This is the one
    // place forceRefresh=true on the resolver — every hotel /
    // transport / activity / special line gets a fresh breakdown.
    const convertTaxSnap = await this.taxSnapshot.resolveForDestination(query.destination_id);
    for (const hi of quoteHotels) {
      // Re-run app-layer bound check even though these rows already
      // passed on the quote side — cheap and defends against any
      // out-of-band writes since the last quote save.
      assertNightsInRange(hi.periods, hi.extras, hi.nights);
      // Recompute stay subtotal from children — the quote's stored
      // subtotal is the source of truth on the quote side but we
      // re-derive here so a divergent quote row can't leak forward.
      const subtotal = computeHotelStayTotal(hi.periods, hi.extras);
      const tax = {
        ...this.resolveLineTaxFields(hi, subtotal, convertTaxSnap, { forceRefresh: true }),
        tax_snapshot: await this.resolveLineTaxSnapshot('hotel', query.destination_id),
      };
      const savedItem = await this.hotelItemRepo.save({
        booking_id: booking.id,
        tenant_id: tenantId,
        hotel_id: hi.hotel_id,
        check_in_date: hi.check_in_date,
        check_out_date: hi.check_out_date,
        nights: hi.nights,
        // Per-stay remarks — sales-pitch text inherits onto the
        // booking record so ops never re-types the early-check-in
        // / infant-cot / accessibility notes.
        notes: hi.notes ?? null,
        subtotal,
        ...tax,
        created_by: userId,
        updated_by: userId,
      });
      // Mirror periods + extras onto the booking side. Each child
      // gets a fresh UUID; the quote children remain untouched so
      // the quote stays reprintable.
      if (Array.isArray(hi.periods) && hi.periods.length > 0) {
        await this.hotelPeriodRepo.save(
          hi.periods.map((p) => ({
            hotel_item_id: savedItem.id,
            tenant_id: tenantId,
            sort_order: p.sort_order,
            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(hi.extras) && hi.extras.length > 0) {
        await this.hotelExtraRepo.save(
          hi.extras.map((e) => ({
            hotel_item_id: savedItem.id,
            tenant_id: tenantId,
            sort_order: e.sort_order,
            kind: e.kind,
            description: e.description,
            quantity: e.quantity,
            rate: e.rate,
            nights: e.nights,
            scale_by_rooms: e.scale_by_rooms,
            created_by: userId,
            updated_by: userId,
          })),
        );
      }
    }
    for (const ti of quoteTransports) {
      const subtotal = Number(ti.subtotal ?? 0);
      const tax = {
        ...this.resolveLineTaxFields(ti, subtotal, convertTaxSnap, { forceRefresh: true }),
        tax_snapshot: await this.resolveLineTaxSnapshot('transport', query.destination_id, { transport_service_id: ti.transport_service_id }),
      };
      // Snapshot the Floor Price as the default actual_supplier_cost.
      // Movement Chart's Cost cell starts at this value; ops can
      // override when the day's reality differs (Ola/Uber sub etc.).
      // Snapshot frozen at booking-create — later changes to the
      // Transport Pricing master don't retro-apply.
      const floorPerUnit = await resolveFloorSnapshot(this.transportItemRepo.manager, {
        tenant_id: tenantId,
        destination_id: query.destination_id,
        transport_service_id: ti.transport_service_id,
        vehicle_type_id: ti.vehicle_type_id,
        is_shared: ti.is_shared ?? false,
        date: ti.date,
      });
      // actual_supplier_cost stores the TRIP TOTAL (per-trip × qty
      // baked in) — matches the Movement Chart Cost cell's "what we
      // actually paid for this leg" semantics. Multiply per-unit
      // floor by qty.
      const floorSnapshot =
        floorPerUnit != null ? +(floorPerUnit * Number(ti.quantity ?? 1)).toFixed(2) : null;
      await this.transportItemRepo.save({
        booking_id: booking.id,
        tenant_id: tenantId,
        transport_service_id: ti.transport_service_id,
        vehicle_type_id: ti.vehicle_type_id,
        quantity: ti.quantity,
        unit_price: ti.unit_price,
        subtotal,
        date: ti.date,
        sort_order: ti.sort_order,
        notes: ti.notes,
        // Carry per-row pickup + drop-off times and addresses forward
        // — quote captures all four at sales-pitch time (times typed
        // by ops, addresses auto-filled from the Transport Master +
        // hotel master); the operational booking record inherits so
        // ops doesn't have to re-type on the booking screen and the
        // Movement Dashboard reads the same values for dispatch.
        pickup_time: (ti as any).pickup_time || null,
        dropoff_time: (ti as any).dropoff_time || null,
        pickup_address: ti.pickup_address || null,
        dropoff_address: ti.dropoff_address || null,
        // Preserve shared-pool flag from the quote so Fleet Planner can
        // group guests into combined vehicles on conversion.
        is_shared: ti.is_shared ?? false,
        // Per-row pax (null = full party) → the booking row's override
        // columns, which the Movement Dashboard shows.
        adults_override: ti.adults ?? null,
        children_override: ti.children ?? null,
        // Per-trip actual cost — defaults to the floor snapshot; null
        // when no floor configured for the (route × cab × mode × date)
        // combo.
        actual_supplier_cost: floorSnapshot,
        ...tax,
        created_by: userId,
        updated_by: userId,
      });
    }
    for (const ai of quoteActivities) {
      const subtotal = Number(ai.subtotal ?? 0);
      const tax = {
        ...this.resolveLineTaxFields(ai, subtotal, convertTaxSnap, { forceRefresh: true }),
        tax_snapshot: await this.resolveLineTaxSnapshot('activity', query.destination_id, { activity_id: ai.activity_id }),
      };
      // Activity Floor snapshot — same per-trip TOTAL semantic as
      // transport: per-unit floor × quantity. Surfaces as the row's
      // expected cost basis until ops picks a real supplier.
      const activityFloorPerUnit = await resolveActivityFloorSnapshot(
        this.activityItemRepo.manager,
        {
          tenant_id: tenantId,
          destination_id: query.destination_id,
          activity_id: ai.activity_id,
          ticket_id: ai.ticket_id ?? null,
          age_group: ai.age_group,
          date: ai.date,
        },
      );
      const activityFloorSnapshot =
        activityFloorPerUnit != null
          ? +(activityFloorPerUnit * Number(ai.quantity ?? 1)).toFixed(2)
          : null;
      await this.activityItemRepo.save({
        booking_id: booking.id,
        tenant_id: tenantId,
        activity_id: ai.activity_id,
        ticket_id: ai.ticket_id,
        age_group: ai.age_group,
        quantity: ai.quantity,
        unit_price: ai.unit_price,
        subtotal,
        slot: ai.slot,
        date: ai.date,
        sort_order: ai.sort_order,
        notes: ai.notes,
        actual_supplier_cost: activityFloorSnapshot,
        ...tax,
        created_by: userId,
        updated_by: userId,
      });
    }
    for (const si of quoteSpecials) {
      const subtotal = Number(si.total_price ?? 0);
      const tax = {
        ...this.resolveLineTaxFields(si, subtotal, convertTaxSnap, { forceRefresh: true }),
        tax_snapshot: await this.resolveLineTaxSnapshot('special', query.destination_id),
      };
      await this.specialItemRepo.save({
        booking_id: booking.id,
        tenant_id: tenantId,
        service_name: si.service_name,
        total_price: si.total_price,
        date: si.date,
        sort_order: si.sort_order,
        comments: si.comments,
        ...tax,
        created_by: userId,
        updated_by: userId,
      });
    }

    // 8. Soft-lock the quote
    await this.quoteRepo.update(selectedQuote.id, { is_selected: true });

    // 9. Transition query stage to CONVERTED
    if (query.stage !== QueryStage.CONVERTED) {
      await this.queryRepo.update(queryId, { stage: QueryStage.CONVERTED });
      await this.auditService.log('query', queryId, 'stage_changed', [
        { field: 'stage', old_value: query.stage, new_value: 'Converted' },
      ]);
    }

    await this.auditService.log(ENTITY_TYPE, booking.id, 'created');

    // ── Initial COLLECTION (§12 Phase B) ────────────────────────────
    // Auto-creates a PENDING collection for the full selling_total,
    // due on travel start. Idempotent — no-op if the booking already
    // has a settlement row (e.g. retried client call). Wrapped in
    // try/catch so a money-side hiccup never blocks the booking
    // confirmation itself; finance can recover via the admin
    // edit-amount action.
    try {
      if (booking.selling_currency_id && Number(booking.selling_total) > 0) {
        await this.settlements.recordFromBooking({
          booking_id: booking.id,
          amount: Number(booking.selling_total),
          currency_id: booking.selling_currency_id,
          due_date: query.start_date,
        });
      }
    } catch (err) {
      // Log but do not fail the booking-create — initial-collection
      // recovery is an admin task; the audit-trail entry below makes
      // the gap visible in the timeline.
      const e = err as { message?: string };
      await this.auditService.log(ENTITY_TYPE, booking.id, 'updated', [
        {
          field: 'payment_settlement_init_error',
          old_value: null,
          new_value: e?.message ?? 'unknown',
        },
      ]);
    }

    return {
      id: booking.id,
      booking_number: bookingNumber,
      message: 'Booking created successfully',
    };
  }

  /**
   * Itinerary update — moved to BookingItineraryService in the
   * 2026-05-12 split refactor. BookingService keeps this method as a
   * thin delegator so external callers (BookingController PATCH
   * endpoint, ChangeRequestService.applyApprovedChangeRequest path)
   * see the same API.
   *
   * @deprecated for new code — inject BookingItineraryService directly.
   */
  async update(id: string, dto: UpdateBookingDto, opts?: { bypassApprovalGuards?: boolean }) {
    await this.assertBookingNotPast(id);
    return this.itinerary.update(id, dto, opts);
  }

  /** Called by the approval flow to write an approved change-request
   *  snapshot to the booking. Delegates to BookingItineraryService.
   *  changeRequestId is threaded through so any settlement delta the
   *  apply triggers carries `triggered_by_change_request_id` for audit. */
  async applyApprovedChangeRequest(
    id: string,
    payload: Record<string, unknown>,
    changeRequestId?: string,
  ) {
    return this.itinerary.applyApprovedChangeRequest(id, payload, changeRequestId);
  }

  async changeStatus(id: string, status: BookingStatus, holdReason?: string | null) {
    await this.assertBookingVisible(id);
    await this.assertBookingNotPast(id);
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    // Base transition graph. On-hold-specific exits are refined below —
    // e.g. from on_hold the ONLY valid resume target is the pre-hold
    // status (so an upcoming-then-held booking can't skip forward to
    // completed on resume).
    const validTransitions: Record<string, BookingStatus[]> = {
      [BookingStatus.UPCOMING]: [
        BookingStatus.ONGOING,
        BookingStatus.CANCELLED,
        BookingStatus.ON_HOLD,
      ],
      [BookingStatus.ONGOING]: [
        BookingStatus.COMPLETED,
        BookingStatus.CANCELLED,
        BookingStatus.ON_HOLD,
      ],
      [BookingStatus.COMPLETED]: [],
      [BookingStatus.CANCELLED]: [BookingStatus.UPCOMING],
      // On-hold → anywhere in the exit set below. Runtime check narrows
      // this based on pre_hold_status.
      [BookingStatus.ON_HOLD]: [
        BookingStatus.UPCOMING,
        BookingStatus.ONGOING,
        BookingStatus.COMPLETED,
        BookingStatus.CANCELLED,
      ],
    };

    const allowed = validTransitions[item.status] || [];
    if (!allowed.includes(status)) {
      throw new BadRequestException(`Cannot change status from "${item.status}" to "${status}"`);
    }

    // ── On-hold entry/exit rules ───────────────────────────────────
    const update: Partial<BookingEntity> = { status };

    if (status === BookingStatus.ON_HOLD) {
      // Entry — reason required (min 5 chars, matches cancellation
      // convention). Snapshot the current status so Resume knows the
      // target state.
      const reason = (holdReason || '').trim();
      if (reason.length < 5) {
        throw new BadRequestException(
          'Hold reason is required (at least 5 characters) — describe why the booking is on hold.',
        );
      }
      update.hold_reason = reason;
      update.pre_hold_status = item.status;
    } else if (item.status === BookingStatus.ON_HOLD) {
      // Exit — narrow the target based on where the booking came from.
      const priorRaw = item.pre_hold_status as BookingStatus | null;
      const resumeTargets: BookingStatus[] = [BookingStatus.CANCELLED];
      if (priorRaw) resumeTargets.push(priorRaw);
      // Only ongoing-then-held bookings can jump straight to completed
      // ("finish in between" per stakeholder spec).
      if (priorRaw === BookingStatus.ONGOING) {
        resumeTargets.push(BookingStatus.COMPLETED);
      }
      if (!resumeTargets.includes(status)) {
        throw new BadRequestException(
          `Cannot resume from on_hold to "${status}" — allowed: ${resumeTargets.join(', ')}.`,
        );
      }
      // Clear the hold metadata so a re-hold captures fresh state.
      update.hold_reason = null;
      update.pre_hold_status = null;
    }

    await this.repo.update(id, update);
    await this.auditService.log(ENTITY_TYPE, id, 'stage_changed', [
      { field: 'status', old_value: item.status, new_value: status },
      ...(status === BookingStatus.ON_HOLD
        ? [{ field: 'hold_reason', old_value: null, new_value: update.hold_reason }]
        : []),
    ]);

    return { id, status, message: `Status changed to ${status}` };
  }

  async setGuarantee(id: string, isGuaranteed: boolean, description?: string) {
    await this.assertBookingVisible(id);
    await this.assertBookingNotPast(id);
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    const guaranteeDescription = isGuaranteed ? description?.trim() || null : null;
    const message = isGuaranteed ? 'Booking marked as guaranteed' : 'Booking guarantee removed';
    const result = {
      id,
      is_guaranteed: isGuaranteed,
      guarantee_description: guaranteeDescription,
      message,
    };
    if (
      item.is_guaranteed === isGuaranteed &&
      (item.guarantee_description ?? null) === guaranteeDescription
    ) {
      return result;
    }

    await this.repo.update(id, {
      is_guaranteed: isGuaranteed,
      guarantee_description: guaranteeDescription,
    });
    await this.auditService.log(ENTITY_TYPE, id, 'updated', [
      ...(item.is_guaranteed !== isGuaranteed
        ? [{ field: 'is_guaranteed', old_value: item.is_guaranteed, new_value: isGuaranteed }]
        : []),
      ...((item.guarantee_description ?? null) !== guaranteeDescription
        ? [
            {
              field: 'guarantee_description',
              old_value: item.guarantee_description ?? null,
              new_value: guaranteeDescription,
            },
          ]
        : []),
    ]);

    return result;
  }

  async remove(id: string) {
    await this.assertBookingVisible(id);
    await this.assertBookingNotPast(id, false);
    const item = await this.repo.findById(id);
    if (!item) throw new NotFoundException(MSG.NOT_FOUND);

    const tenantId = this.getTenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) ?? null;

    // Soft-delete the booking AND everything it owns, atomically.
    // Without this cascade the child rows survive with is_deleted = false,
    // and any report reading a child table WITHOUT re-joining bookings
    // would leak a deleted booking's data (tax ledger + supplier payout
    // are safe today only because they join bookings.is_deleted = false).
    //
    // Money-safe: settlement rows that already carry a recorded payment
    // (amount_settled > 0) are PRESERVED — silently voiding real money
    // movement would corrupt the payable/receivable ledger. They remain
    // and are excluded from reports via the booking join instead.
    //
    // Raw parameterised SQL: CLS auto-scoping does not apply to these
    // bulk UPDATEs, so tenant_id = $1 is added to every statement by hand
    // (see rules/tenancy.md — raw SQL must scope tenant manually). Table
    // names are a hardcoded whitelist, never request input.
    // NOT cascaded (deliberately): customer_invoices, booking_cash_collections,
    // service_payment_collections (real received-money records) and
    // booking_change_requests (workflow audit) — kept for audit, and the
    // money reports already exclude them via the booking join.
    const stamp = 'is_deleted = true, updated_by = $2, updated_at = now()';
    const P = [tenantId, userId, id]; // $1 tenant · $2 user · $3 booking
    await this.hotelItemRepo.manager.transaction(async (em) => {
      // Hotel grandchildren (keyed by hotel_item_id, not booking_id).
      await em.query(
        `UPDATE booking_hotel_periods SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND hotel_item_id IN
             (SELECT id FROM booking_hotel_items WHERE tenant_id = $1 AND booking_id = $3)`,
        P,
      );
      await em.query(
        `UPDATE booking_hotel_extras SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND hotel_item_id IN
             (SELECT id FROM booking_hotel_items WHERE tenant_id = $1 AND booking_id = $3)`,
        P,
      );
      // Supplier-assignment rows (keyed by parent line-item id).
      await em.query(
        `UPDATE booking_transport_item_suppliers SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND transport_item_id IN
             (SELECT id FROM booking_transport_items WHERE tenant_id = $1 AND booking_id = $3)`,
        P,
      );
      await em.query(
        `UPDATE booking_activity_item_suppliers SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND activity_item_id IN
             (SELECT id FROM booking_activity_items WHERE tenant_id = $1 AND booking_id = $3)`,
        P,
      );
      // Itinerary line items + flight details (keyed by booking_id).
      for (const table of [
        'booking_hotel_items',
        'booking_transport_items',
        'booking_activity_items',
        'booking_special_items',
        'booking_flight_details',
      ]) {
        await em.query(
          `UPDATE ${table} SET ${stamp}
             WHERE tenant_id = $1 AND is_deleted = false AND booking_id = $3`,
          P,
        );
      }
      // Settlement ledger — ONLY unpaid rows. Paid / partially-paid rows
      // (amount_settled > 0) survive so recorded money is never hidden.
      await em.query(
        `UPDATE payment_settlements SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND booking_id = $3
             AND amount_settled = 0`,
        P,
      );
      // Master rollup — drop only when no paid settlement child survives.
      await em.query(
        `UPDATE booking_settlement_masters SET ${stamp}
           WHERE tenant_id = $1 AND is_deleted = false AND booking_id = $3
             AND NOT EXISTS (
               SELECT 1 FROM payment_settlements ps
                WHERE ps.tenant_id = $1 AND ps.booking_id = $3
                  AND ps.is_deleted = false AND ps.amount_settled > 0)`,
        P,
      );
      // Finally the booking row itself.
      await em.query(
        `UPDATE bookings SET ${stamp} WHERE tenant_id = $1 AND id = $3`,
        P,
      );
    });

    await this.auditService.log(ENTITY_TYPE, id, 'deleted');
    return { message: MSG.DELETED };
  }

  private 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) {
      // Booking-owned split — mirrors BookingItineraryService.recalculateTotals.
      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);
  }

  // ── Activity Cancel / Refund ────────────────────────────────────────
  async cancelActivityItem(id: string, count: number, notes?: string) {
    const item = await this.activityItemRepo.findOne({ where: { id } });
    if (!item) throw new NotFoundException('Activity item not found');
    if (count < 0 || count > item.quantity) {
      throw new BadRequestException(`Cancel count must be between 0 and ${item.quantity}`);
    }
    await this.activityItemRepo.update(id, {
      cancelled_count: count,
      cancellation_notes: notes ?? null,
    });
    return { message: `${count} pax cancelled`, id };
  }

  async refundActivityItem(id: string, notes?: string) {
    const item = await this.activityItemRepo.findOne({ where: { id } });
    if (!item) throw new NotFoundException('Activity item not found');
    await this.activityItemRepo.update(id, {
      refund_status: 'REFUNDED',
      cancellation_notes: notes ?? null,
    });
    return { message: 'Activity marked as refunded', id };
  }
}
