import {
  Injectable,
  NotFoundException,
  BadRequestException,
  forwardRef,
  Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull, type FindOptionsWhere, type ObjectLiteral } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import { QuoteRepository } from './repositories/quote.repository';
import { QueryRepository } from './repositories/query.repository';
import { UserReportingService } from '../user/user-reporting.service';
import { CreateQuoteDto } from './dto/create-quote.dto';
import { UpdateQuoteDto } from './dto/update-quote.dto';
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 { In } from 'typeorm';
import { QueryQuoteSpecialEntity } from '../../entities/query-quote-special.entity';
import { TransportPricingEntity } from '../../entities/transport-pricing.entity';
import { ActivityPricingEntity } from '../../entities/activity-pricing.entity';
import { SeasonEntity } from '../../entities/season.entity';
import { ExchangeRateEntity } from '../../entities/exchange-rate.entity';
import { AuditService } from '../audit/audit.service';
import { type AuditChange } from '../audit/audit-types';
import { diffItineraryItems, type ItineraryAuditContext } from '../audit/itinerary-audit';
import {
  findBelowFloorLines,
  formatBelowFloorError,
  type BelowFloorOffender,
} from '../transport-pricing/floor-price-guard';
import { findBelowFloorActivityLines } from '../activity-pricing/floor-price-guard';
import { ChangeRequestService } from '../change-request/change-request.service';
import { TaxSnapshotService } from '../tax-type/tax-snapshot.service';
import { TaxSlabResolverService } from '../../common/services/tax-slab-resolver.service';
import { extractBase } from '../../common/services/tax-bifurcation.helper';
import { splitFinalPrice } from '../../common/services/markup-tax.helper';
import { TaxSlabEntity } from '../../entities/tax-slab.entity';
import { CurrencyEntity } from '../../entities/currency.entity';
import { ChangeRequestKind } from '../../entities/booking-change-request.entity';
import { findMatchingSeasonId } from '../../common/utils/season-date';

/** Categories that contain at least one zero-priced row. Returned as
 *  user-facing labels so the API error can name which item type is
 *  offending. Mirror of booking.service.zeroValueOffenders — duplicated
 *  here to avoid a cross-module import for a small helper. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- helper accepts both Create- and Update-QuoteDto, plus item arrays that mix entity instances with DTO objects.
function quoteZeroValueOffenders(dto: any): string[] {
  const out: string[] = [];
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- rows may be DTO or entity instances; we only read indexed properties.
  const zeroPriced = (arr: any[] | undefined, idKey: string, priceKeys: string[]) => {
    if (!Array.isArray(arr) || arr.length === 0) return false;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- row shape is heterogeneous (DTO/entity); index access only.
    return arr.some((row: any) => {
      if (!row || !row[idKey]) return false;
      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;
}
import { QueryEntity, QueryStage } from '../../entities/query.entity';
import { assertRowPaxWithinParty, partyFromQuery } from '../../common/itinerary/row-pax';
// PermissionCheckService is still needed for the BOOKING_APPROVAL gate
// on quote-save (zero-priced / below-floor lines auto-trigger a CR
// for non-approvers). The markup-specific permission helpers were
// retired 2026-05-13 with the MARKUP module.
import { PermissionCheckService } from '../../common/permissions/permission-check.service';
import { ModuleName, AccessLevel } from '../../entities/role-permission.entity';

const ENTITY_TYPE = 'query';

@Injectable()
export class QuoteService {
  constructor(
    private readonly repo: QuoteRepository,
    private readonly cls: ClsService,
    private readonly auditService: AuditService,
    @InjectRepository(QueryQuoteHotelEntity)
    private readonly hotelItemRepo: Repository<QueryQuoteHotelEntity>,
    @InjectRepository(QueryQuoteHotelPeriodEntity)
    private readonly hotelPeriodRepo: Repository<QueryQuoteHotelPeriodEntity>,
    @InjectRepository(QueryQuoteHotelExtraEntity)
    private readonly hotelExtraRepo: Repository<QueryQuoteHotelExtraEntity>,
    @InjectRepository(QueryQuoteTransportEntity)
    private readonly transportItemRepo: Repository<QueryQuoteTransportEntity>,
    @InjectRepository(QueryQuoteActivityEntity)
    private readonly activityItemRepo: Repository<QueryQuoteActivityEntity>,
    @InjectRepository(ActivityEntity)
    private readonly activityMasterRepo: Repository<ActivityEntity>,
    @InjectRepository(QueryQuoteSpecialEntity)
    private readonly specialItemRepo: Repository<QueryQuoteSpecialEntity>,
    @InjectRepository(TransportPricingEntity)
    private readonly transportPricingRepo: Repository<TransportPricingEntity>,
    @InjectRepository(ActivityPricingEntity)
    private readonly activityPricingRepo: Repository<ActivityPricingEntity>,
    @InjectRepository(SeasonEntity) private readonly seasonRepo: Repository<SeasonEntity>,
    @InjectRepository(ExchangeRateEntity)
    private readonly exchangeRateRepo: Repository<ExchangeRateEntity>,
    @InjectRepository(QueryEntity) private readonly queryRepo: Repository<QueryEntity>,
    private readonly permissions: PermissionCheckService,
    // forwardRef avoids the booking → change-request → quote cycle.
    // ChangeRequestService is only used here for the auto-CR path.
    @Inject(forwardRef(() => ChangeRequestService))
    private readonly changeRequestService: ChangeRequestService,
    // Tax snapshot helper — Q1=C/Q2=B/Q3=A policy lives there. Used
    // by saveChildItems to freeze rates per line on first save and
    // refresh them on explicit refresh_tax: true requests.
    private readonly taxSnapshot: TaxSnapshotService,
    // Tax bifurcation Step 3 resolver — produces the per-line slab
    // snapshot the new bifurcation flow consumes. Used by the price
    // lookups to derive `base` and the `tax_snapshot` on the response.
    private readonly taxResolver: TaxSlabResolverService,
    @InjectRepository(CurrencyEntity)
    private readonly currencyRepo: Repository<CurrencyEntity>,
    private readonly queryVisibilityRepo: QueryRepository,
    private readonly userReporting: UserReportingService,
  ) {}

  /** Same row-level gate as QueryService.findById — quotes must not leak out-of-scope queries. */
  private async assertQueryVisible(queryId: string): Promise<void> {
    const visibleUserIds = await this.userReporting.resolveVisibleUserIds('query');
    if (visibleUserIds !== null && !(await this.queryVisibilityRepo.isVisibleTo(queryId, visibleUserIds))) {
      throw new NotFoundException('Query not found');
    }
  }

  /** Block a quote save when an activity with has_transfer=true is on
   *  a day that has no transport item. Same rule as the booking side —
   *  ops asked for an error ("transport needed for this activity")
   *  rather than an auto-insert. */
  private async validateTransferActivities(dto: {
    activity_items?: ReadonlyArray<{ activity_id?: string; date?: string }>;
    transport_items?: ReadonlyArray<{ date?: string }>;
  }): Promise<void> {
    if (!dto.activity_items) return;
    const tenantId = this.getTenantId();
    const activityIds = Array.from(
      new Set((dto.activity_items || []).map((a) => a?.activity_id).filter(Boolean) as string[]),
    );
    if (activityIds.length === 0) return;
    const rows = await this.activityMasterRepo.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) return;
    // Collect transport dates + activity dates keyed by date.
    const transportDates = new Set<string>();
    for (const ti of dto.transport_items || []) {
      if (ti?.date) transportDates.add(String(ti.date));
    }
    const missing = new Map<string, Set<string>>();
    for (const ai of dto.activity_items || []) {
      if (!ai?.activity_id || !ai?.date || !transferIds.has(ai.activity_id)) continue;
      if (transportDates.has(String(ai.date))) continue;
      if (!missing.has(String(ai.date))) missing.set(String(ai.date), new Set());
      const act = rows.find((r) => r.id === ai.activity_id);
      missing.get(String(ai.date))!.add(act?.name || 'activity');
    }
    if (missing.size === 0) return;
    const parts: string[] = [];
    for (const [date, names] of missing) {
      parts.push(`${date}: ${Array.from(names).join(', ')}`);
    }
    throw new BadRequestException(
      `Transport needed for these transfer-inclusive activities — ${parts.join(' | ')}`,
    );
  }

  /** Per-row pax cap — no transport / activity row may exceed the
   *  query's adults / children. Runs before any writes. */
  private async validateRowPax(
    queryId: string,
    dto: {
      transport_items?: ReadonlyArray<{ date?: string; adults?: number | null; children?: number | null }>;
      activity_items?: ReadonlyArray<{
        activity_id?: string;
        ticket_id?: string;
        date?: string;
        age_group?: string;
        quantity?: number;
      }>;
    },
  ): Promise<void> {
    if (!dto.transport_items && !dto.activity_items) return;
    const query = await this.queryRepo.findOne({
      where: { id: queryId, tenant_id: this.getTenantId() },
      select: { id: true, adults: true, children_ages: true },
    });
    if (!query) return;
    assertRowPaxWithinParty(
      partyFromQuery(query),
      dto.transport_items || [],
      dto.activity_items || [],
    );
  }

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

  async findByQueryId(queryId: string) {
    await this.assertQueryVisible(queryId);
    return this.repo.findByQueryId(queryId);
  }

  async findSummaries(queryId: string) {
    await this.assertQueryVisible(queryId);
    return this.repo.findSummariesByQueryId(queryId);
  }

  async findQuoteDetail(queryId: string, quoteId: string) {
    await this.assertQueryVisible(queryId);
    const quote = await this.repo.findByIdWithRelations(quoteId);
    if (!quote || quote.query_id !== queryId || quote.is_deleted)
      throw new NotFoundException('Quote not found');

    // Filter soft-deleted items + project nested entities to trim verbose fields
    if (quote.hotel_items) {
      quote.hotel_items = quote.hotel_items.filter((i) => !i.is_deleted);
      for (const hi of quote.hotel_items) {
        if (hi.hotel) {
          hi.hotel = {
            id: hi.hotel.id,
            name: hi.hotel.name,
            destination_id: hi.hotel.destination_id,
            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally trimmed projection, not a full HotelEntity
          } as any;
        }
      }
    }
    if (quote.transport_items) {
      quote.transport_items = quote.transport_items.filter((i) => !i.is_deleted);
      for (const ti of quote.transport_items) {
        if (ti.transport_service) {
          ti.transport_service = {
            id: ti.transport_service.id,
            from_city: ti.transport_service.from_city,
            to_city: ti.transport_service.to_city,
            category: ti.transport_service.category,
            short_code: ti.transport_service.short_code,
            destination_id: ti.transport_service.destination_id,
            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally trimmed projection, not a full TransportServiceEntity
          } as any;
        }
      }
    }
    if (quote.activity_items) {
      quote.activity_items = quote.activity_items.filter((i) => !i.is_deleted);
      for (const ai of quote.activity_items) {
        if (ai.activity) {
          ai.activity = {
            id: ai.activity.id,
            name: ai.activity.name,
            destination_id: ai.activity.destination_id,
            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally trimmed projection, not a full ActivityEntity
          } as any;
        }
        if (ai.ticket) {
          ai.ticket = {
            id: ai.ticket.id,
            name: ai.ticket.name,
            duration_mins: ai.ticket.duration_mins,
            // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentionally trimmed projection, not a full ActivityTicketEntity
          } as any;
        }
      }
    }
    if (quote.special_items) quote.special_items = quote.special_items.filter((i) => !i.is_deleted);

    return quote;
  }

  async createQuote(queryId: string, dto: CreateQuoteDto) {
    await this.assertQueryVisible(queryId);
    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;
    // Run the transfer-activity + row-pax checks BEFORE any writes so a
    // failure doesn't leave a half-saved quote behind.
    await this.validateTransferActivities(dto);
    await this.validateRowPax(queryId, dto);
    const quoteNumber = await this.repo.getNextQuoteNumber(queryId);

    const quote = await this.repo.save({
      query_id: queryId,
      quote_number: quoteNumber,
      label: dto.label || `Quote ${quoteNumber}`,
      is_selected: dto.is_selected ?? false,
      notes: dto.notes || null,
      pricing_strategy: dto.pricing_strategy || 'overall',
      selling_currency_id: dto.selling_currency_id || null,
      exchange_rate: dto.exchange_rate ?? 1,
      markup_type: dto.markup_type || 'amount',
      rounding: dto.rounding ?? 1,
      markup_amount: dto.markup_amount ?? 0,
      hotel_markup: dto.hotel_markup ?? 0,
      transport_markup: dto.transport_markup ?? 0,
      activity_markup: dto.activity_markup ?? 0,
      special_markup: dto.special_markup ?? 0,
      transport_excluded_pax: dto.transport_excluded_pax ?? 0,
      selling_total: dto.selling_total ?? 0,
      internal_comments: dto.internal_comments || null,
      customer_remarks: dto.customer_remarks || null,
      // Quote-level tax config (independent of per-line snapshots).
      tax_slab_id: dto.tax_slab_id ?? null,
      tax_inclusive: dto.tax_inclusive ?? false,
    } as Partial<QueryQuoteEntity>);

    await this.saveChildItems(quote.id, tenantId, userId, dto, queryId);
    await this.recalculateTotals(quote.id, tenantId);

    // Auto-transition query from New → In Progress
    const query = await this.queryRepo.findOne({
      where: { id: queryId, tenant_id: tenantId },
    });
    if (query && query.stage === QueryStage.NEW) {
      await this.queryRepo.update(queryId, { stage: QueryStage.IN_PROGRESS });
      await this.auditService.log(ENTITY_TYPE, queryId, 'stage_changed', [
        { field: 'stage', old_value: 'New', new_value: 'In Progress' },
      ]);
    }

    await this.auditService.log(ENTITY_TYPE, queryId, 'updated', [
      { field: 'quotes', old_value: null, new_value: `Added Quote ${quoteNumber}` },
    ]);

    return { id: quote.id, quote_number: quoteNumber, message: 'Quote created successfully' };
  }

  async updateQuote(queryId: string, quoteId: string, dto: UpdateQuoteDto) {
    await this.assertQueryVisible(queryId);
    const quote = await this.repo.findById(quoteId);
    if (!quote || quote.query_id !== queryId) throw new NotFoundException('Quote not found');
    // Transfer-activity + row-pax guards before any writes.
    await this.validateTransferActivities(dto);
    await this.validateRowPax(queryId, dto);

    const tenantId = this.getTenantId();
    const userId = this.cls.get('user_id') || null;

    // Zero-price guard — same rule as booking. Block any save that
    // Quote-side approval policy (per stakeholder spec):
    //   - Admin / approver:           save without restriction
    //   - Sales (no approval rights): save anyway, AND auto-raise a
    //                                 Change Request so a manager can
    //                                 review the override
    //
    // Different from the booking flow which BLOCKS the save and asks
    // the user to submit a CR manually. Quotes are pre-sale drafts —
    // blocking would slow sales down; the CR captures the exception
    // for manager visibility without holding up the work.
    const canApproveQuote = await this.permissions.hasAtLeast(
      ModuleName.BOOKING_APPROVAL,
      AccessLevel.EDIT,
    );
    const zeroCats = quoteZeroValueOffenders(dto);
    // Below-floor guards run for both transport and activity. Mixed
    // offenders merge into a single list so non-approvers raise ONE
    // change request per save regardless of how many categories
    // tripped the guard. Stakeholder rule: approvers want a single
    // approval ask per save, not N CRs split by category or kind.
    const transportFloorOffenders: BelowFloorOffender[] = dto.transport_items
      ? await findBelowFloorLines(this.transportPricingRepo.manager, tenantId, dto.transport_items)
      : [];
    const activityFloorOffenders: BelowFloorOffender[] = dto.activity_items
      ? await findBelowFloorActivityLines(
          this.activityPricingRepo.manager,
          tenantId,
          dto.activity_items,
        )
      : [];
    const floorOffenders = [...transportFloorOffenders, ...activityFloorOffenders];

    // Build at most ONE CR per save. Pick BELOW_FLOOR_PRICE as the
    // kind whenever any below-floor line exists (more specific signal
    // for approver triage); otherwise fall back to ZERO_VALUE. The
    // payload carries both shapes so the dialog can render whichever
    // sections are populated. Approvers still see the full violation
    // set in one request — single decision, single audit trail.
    const quoteCRsToCreate: Array<{ kind: ChangeRequestKind; payload: Record<string, unknown> }> =
      [];
    if (!canApproveQuote && (zeroCats.length > 0 || floorOffenders.length > 0)) {
      const summaryParts: string[] = [];
      if (floorOffenders.length > 0) summaryParts.push(formatBelowFloorError(floorOffenders));
      if (zeroCats.length > 0) summaryParts.push(`${zeroCats.join(' / ')} item(s) priced at 0`);
      quoteCRsToCreate.push({
        kind:
          floorOffenders.length > 0
            ? ChangeRequestKind.BELOW_FLOOR_PRICE
            : ChangeRequestKind.ZERO_VALUE,
        payload: {
          // Below-floor section
          offenders: floorOffenders.length > 0 ? floorOffenders : undefined,
          // Zero-price section
          categories: zeroCats.length > 0 ? zeroCats : undefined,
          // Combined human-readable summary
          summary: summaryParts.join(' · '),
          message: summaryParts.join(' · '),
        },
      });
    }

    // Markup/selling fields are no longer permission-gated — the MARKUP
    // module was retired 2026-05-13. Every authed user with query:edit
    // can write markup directly.

    // Update quote fields
    const updateData: Record<string, unknown> = {};
    const fields = [
      'label',
      'is_selected',
      'notes',
      'pricing_strategy',
      'selling_currency_id',
      'exchange_rate',
      'markup_type',
      'rounding',
      'markup_amount',
      'hotel_markup',
      'transport_markup',
      'activity_markup',
      'special_markup',
      'transport_excluded_pax',
      'selling_total',
      'internal_comments',
      'customer_remarks',
      'tax_slab_id',
      'tax_inclusive',
    ];
    const dtoRec = dto as unknown as Record<string, unknown>;
    for (const field of fields) {
      if (dtoRec[field] !== undefined) updateData[field] = dtoRec[field];
    }
    if (Object.keys(updateData).length > 0) await this.repo.update(quoteId, updateData);

    // Replace child items if provided
    const hasItems =
      dto.hotel_items || dto.transport_items || dto.activity_items || dto.special_items;
    // Pricing-only save — still re-derive markup tax from the new final price.
    if (!hasItems && Object.keys(updateData).length > 0) {
      await this.recalculateTotals(quoteId, tenantId);
    }
    const changes: AuditChange[] = [];
    if (hasItems) {
      // Snapshot BEFORE rows so the audit can show concrete adds /
      // removes per category — same pattern as BookingService.update.
      const beforeHotels = dto.hotel_items
        ? await this.hotelItemRepo.find({
            where: { quote_id: quoteId, tenant_id: tenantId },
          })
        : null;
      const beforeTransports = dto.transport_items
        ? await this.transportItemRepo.find({
            where: { quote_id: quoteId, tenant_id: tenantId },
          })
        : null;
      const beforeActivities = dto.activity_items
        ? await this.activityItemRepo.find({
            where: { quote_id: quoteId, tenant_id: tenantId },
          })
        : null;
      const beforeSpecials = dto.special_items
        ? await this.specialItemRepo.find({
            where: { quote_id: quoteId, tenant_id: tenantId },
          })
        : null;

      await this.hardDeleteChildItems(quoteId, tenantId);
      await this.saveChildItems(quoteId, tenantId, userId, dto, queryId);
      await this.recalculateTotals(quoteId, tenantId);

      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 —
      // meaning the user clicked "🔄 Refresh Tax" and re-snapshotted
      // from the destination's current slab. 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) {
        // Resolve the destination's current snapshot once for the
        // audit message — same lookup the save flow already did
        // inline; cached on the saveChildItems closure but not
        // available here, so do a small extra fetch.
        const queryRow = await this.queryRepo.findOne({
          where: { id: queryId, tenant_id: tenantId },
        });
        const snap = queryRow
          ? await this.taxSnapshot.resolveForDestination(queryRow.destination_id)
          : null;
        const newRate = snap ? this.taxSnapshot.sumRates(snap.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}%`,
        });
      }
    }

    if (changes.length === 0) {
      // Non-itinerary update (e.g. label or markup only) — keep a single
      // marker so the audit timeline still shows the touch.
      changes.push({ field: 'quote', old_value: null, new_value: 'Quote updated' });
    }
    await this.auditService.log(ENTITY_TYPE, queryId, 'updated', changes);

    // Raise auto-CRs for any policy violations the non-approver
    // captured before the save. Save already committed, so this is
    // pure visibility for managers — they can review in Approvals.
    // Non-blocking: a CR creation failure shouldn't fail the save.
    const crIds: string[] = [];
    if (quoteCRsToCreate.length > 0) {
      const remarks = dto.change_request_remarks;
      try {
        for (const c of quoteCRsToCreate) {
          const cr = await this.changeRequestService.createForQuote(
            quoteId,
            c.kind,
            c.payload,
            remarks,
          );
          crIds.push(cr.id);
        }
      } catch (err) {
        // Log but don't fail. Audit captures the override regardless.
        console.error('[quote.updateQuote] failed to raise CR(s):', err);
      }
    }

    return {
      id: quoteId,
      message:
        quoteCRsToCreate.length > 0
          ? 'Quote saved — change request(s) raised for manager review'
          : 'Quote updated successfully',
      change_request_ids: crIds,
    };
  }

  /** Same id-to-name lookup helper as BookingService — kept inline here
   *  to avoid a cross-module helper module just for two callers. */
  private async buildItineraryAuditContext(snapshots: {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- snapshots mix loaded entities (QueryQuote*Entity) and incoming DTOs; helper only reads indexed properties.
    before: { hotels?: any[] | null; transports?: any[] | null; activities?: any[] | null };
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see above
    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 are heterogeneous entity/DTO items
      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;
  }

  async deleteQuote(queryId: string, quoteId: string) {
    await this.assertQueryVisible(queryId);
    const quote = await this.repo.findById(quoteId);
    if (!quote || quote.query_id !== queryId) throw new NotFoundException('Quote not found');

    // Ensure at least one quote remains
    const count = await this.repo.count({
      where: { query_id: queryId },
    });
    if (count <= 1) throw new BadRequestException('Cannot delete the last quote');

    const tenantId = this.getTenantId();
    await this.hardDeleteChildItems(quoteId, tenantId);
    await this.repo.softDelete(quoteId);

    await this.auditService.log(ENTITY_TYPE, queryId, 'updated', [
      { field: 'quotes', old_value: quote.label, new_value: 'Deleted' },
    ]);

    return { message: 'Quote deleted' };
  }

  async selectQuote(queryId: string, quoteId: string) {
    await this.assertQueryVisible(queryId);
    // Unselect all quotes for this query, then select the specified one
    await this.repo.unselectAllForQuery(queryId);
    await this.repo.markSelected(quoteId);

    await this.auditService.log(ENTITY_TYPE, queryId, 'updated', [
      { field: 'selected_quote', old_value: null, new_value: 'Quote selected' },
    ]);

    return { id: queryId, quote_id: quoteId, message: 'Quote selected' };
  }

  /**
   * Duplicate a quote. By default copies within the same query.
   * When `targetQueryId` is provided (e.g. cloning an entire query with
   * all its quotes), the new quote is created under that target query
   * and the source-query ownership check is relaxed.
   */
  /**
   * @param opts.skipHotelItems  Don't copy booking_hotel_items from the
   *   source quote. Used by query-level duplicate where the new inquiry
   *   is typically used for a different traveller / different dates,
   *   so the original hotel selection (with stale check-in/out) would
   *   otherwise carry through to the booking + Movement Dashboard.
   *   UAT 2026-06-30 ask.
   */
  async duplicateQuote(
    queryId: string,
    quoteId: string,
    targetQueryId?: string,
    opts: { skipHotelItems?: boolean } = {},
  ) {
    await this.assertQueryVisible(queryId);
    if (targetQueryId && targetQueryId !== queryId) {
      await this.assertQueryVisible(targetQueryId);
    }
    const tenantId = this.getTenantId();
    const original = await this.repo.findByIdWithRelations(quoteId);
    if (!original) throw new NotFoundException('Quote not found');
    // Ownership check only when not cloning cross-query
    if (!targetQueryId && original.query_id !== queryId) {
      throw new NotFoundException('Quote not found');
    }
    const destinationQueryId = targetQueryId || queryId;

    const nextNumber = await this.repo.getNextQuoteNumber(destinationQueryId);
    const userId = this.cls.get('user_id') || null;

    const newQuote = await this.repo.save({
      query_id: destinationQueryId,
      quote_number: nextNumber,
      label: `${original.label || 'Quote'} (Copy)`,
      is_selected: false,
      notes: original.notes,
      pricing_strategy: original.pricing_strategy,
      selling_currency_id: original.selling_currency_id,
      exchange_rate: original.exchange_rate,
      markup_type: original.markup_type,
      rounding: original.rounding,
      markup_amount: original.markup_amount,
      hotel_markup: original.hotel_markup,
      transport_markup: original.transport_markup,
      activity_markup: original.activity_markup,
      special_markup: original.special_markup,
      transport_excluded_pax: original.transport_excluded_pax,
      selling_total: original.selling_total,
      internal_comments: original.internal_comments,
      customer_remarks: original.customer_remarks,
      // Carry forward the tax config so the duplicated quote starts
      // from the same tax choice — user can change it on the new copy.
      tax_slab_id: original.tax_slab_id ?? null,
      tax_inclusive: original.tax_inclusive ?? false,
    });

    // Copy child items
    // Hotel items are skipped when the caller is the query-duplicate
    // path (opts.skipHotelItems) — the new inquiry will typically use
    // different dates/guest so any stale check-in/out from the source
    // would surface on the eventual booking + Movement Dashboard.
    if (!opts.skipHotelItems) {
      for (const hi of (original.hotel_items || []).filter((i) => !i.is_deleted)) {
        // Load fresh children off the original so the duplicate stay
        // gets its own copy under a new UUID. The parent's `periods`
        // + `extras` relations may or may not be eager-loaded on the
        // passed-in `original`, so we fetch them explicitly.
        const [origPeriods, origExtras] = await Promise.all([
          this.hotelPeriodRepo.find({
            where: { hotel_item_id: hi.id, tenant_id: tenantId, is_deleted: false },
            order: { sort_order: 'ASC' },
          }),
          this.hotelExtraRepo.find({
            where: { hotel_item_id: hi.id, tenant_id: tenantId, is_deleted: false },
            order: { sort_order: 'ASC' },
          }),
        ]);
        const savedHotel = await this.hotelItemRepo.save({
          ...this.stripMeta(hi),
          quote_id: newQuote.id,
          tenant_id: tenantId,
          created_by: userId,
          updated_by: userId,
        });
        if (origPeriods.length > 0) {
          await this.hotelPeriodRepo.save(
            origPeriods.map((p) => ({
              ...this.stripMeta(p),
              hotel_item_id: savedHotel.id,
              tenant_id: tenantId,
              created_by: userId,
              updated_by: userId,
            })),
          );
        }
        if (origExtras.length > 0) {
          await this.hotelExtraRepo.save(
            origExtras.map((e) => ({
              ...this.stripMeta(e),
              hotel_item_id: savedHotel.id,
              tenant_id: tenantId,
              created_by: userId,
              updated_by: userId,
            })),
          );
        }
      }
    }
    for (const ti of (original.transport_items || []).filter((i) => !i.is_deleted)) {
      await this.transportItemRepo.save({
        ...this.stripMeta(ti),
        quote_id: newQuote.id,
        tenant_id: tenantId,
        created_by: userId,
        updated_by: userId,
      });
    }
    for (const ai of (original.activity_items || []).filter((i) => !i.is_deleted)) {
      await this.activityItemRepo.save({
        ...this.stripMeta(ai),
        quote_id: newQuote.id,
        tenant_id: tenantId,
        created_by: userId,
        updated_by: userId,
      });
    }
    for (const si of (original.special_items || []).filter((i) => !i.is_deleted)) {
      await this.specialItemRepo.save({
        ...this.stripMeta(si),
        quote_id: newQuote.id,
        tenant_id: tenantId,
        created_by: userId,
        updated_by: userId,
      });
    }

    await this.recalculateTotals(newQuote.id, tenantId);

    return {
      id: destinationQueryId,
      quote_id: newQuote.id,
      quote_number: nextNumber,
      message: 'Quote duplicated',
    };
  }

  // --- Price Lookups ---
  //
  // Currency rule: every price lookup is filtered by the
  // *destination's* currency_id. Pricing rows are stored per-
  // destination in that destination's currency (see
  // transport_pricing / activity_pricing entities — currency_id is
  // part of the unique key). Tenant default currency is for package
  // totals only and is intentionally NOT used here.
  //
  // Fallback ladder (same shape on both lookups):
  //   1. seasonal_price[N] for the season that matches the date
  //   2. default_price (when ops keeps a non-seasonal flat rate)
  //   3. seasonal_fallback — first non-null seasonal price on the row
  //      (covers the "ops only filled seasons + the trip date sits in
  //      a calendar gap between seasons" case — which is real today
  //      since Dubai has Nov–Mar and May–Sep but Apr/Oct are unmapped).
  //   4. null + source=not_found (truly unconfigured)
  //
  // The soft fallback is non-destructive: the FE keeps the existing
  // unit_price on edit, and the response carries source='seasonal_fallback'
  // + season_name so a future UI can flag the soft match to ops.
  /**
   * Augment a price-lookup response with the base + tax_snapshot the
   * new bifurcation flow exposes (Step 3e). Stays additive:
   *
   *   - `base`        — what FE should drop into the line's unit_price.
   *                     Reads from `*_base_price` when it's set
   *                     (post-3b saves), else derives on the fly via
   *                     extractBase(inclusive, slab, rounding).
   *   - `floor_base`  — base counterpart for floor warnings, so a
   *                     base-vs-base comparison is possible on the FE.
   *   - `tax_snapshot` — frozen slab + components, persisted on the
   *                     line at save time so the bifurcation rolls up
   *                     reliably for totals/invoice.
   *
   * Inclusive `price` + `floor` keep their old semantics so existing
   * FE renderers keep working until the bifurcation UI takes over.
   */
  private async augmentTransportPriceResponse(
    response: {
      price: number | null;
      source: string;
      season_name: string | null;
      min_cost: number | null;
      floor: number | null;
    },
    pricing: TransportPricingEntity | null,
    transportServiceId: string,
    destinationId: string | null,
    destCurrencyId: string | undefined,
    seasonIdx: number | null, // 1..4 when seasonal, null for default fallback
  ) {
    let slab = null;
    let rounding = 1;
    if (destinationId) {
      slab = await this.taxResolver.resolveForTransport(transportServiceId, destinationId);
    }
    if (destCurrencyId) {
      const cur = await this.currencyRepo.findOne({ where: { id: destCurrencyId } });
      rounding = Number(cur?.rounding) || 1;
    }
    const baseCol = (col: string): number | null => {
      if (!pricing) return null;
      const v = (pricing as unknown as Record<string, unknown>)[col];
      return v == null ? null : Number(v);
    };
    const sellCol = seasonIdx ? `season_${seasonIdx}_price` : 'default_price';
    const sellBaseCol = seasonIdx ? `season_${seasonIdx}_base_price` : 'default_base_price';
    const floorCol = seasonIdx ? `floor_season_${seasonIdx}_price` : 'floor_default_price';
    const floorBaseCol = seasonIdx
      ? `floor_season_${seasonIdx}_base_price`
      : 'floor_default_base_price';

    const baseStored = baseCol(sellBaseCol);
    const base =
      baseStored != null
        ? baseStored
        : response.price != null
          ? extractBase(response.price, slab, rounding)
          : null;
    const floorBaseStored = baseCol(floorBaseCol);
    const floorBase =
      floorBaseStored != null
        ? floorBaseStored
        : response.floor != null
          ? extractBase(response.floor, slab, rounding)
          : null;
    // Capture the floor column we read from so the FE can label which
    // tier the comparison is using; this is purely informational.
    void floorCol;
    void sellCol;
    return {
      ...response,
      base,
      floor_base: floorBase,
      tax_snapshot: slab,
    };
  }

  async lookupTransportPrice(
    transportServiceId: string,
    vehicleTypeId: string,
    date?: string,
    isShared?: boolean,
  ) {
    const tenantId = this.getTenantId();
    const ts = await this.transportPricingRepo.manager.findOne('transport_services', {
      where: { id: transportServiceId },
    });
    const dest = ts
      ? await this.transportPricingRepo.manager.findOne('destinations', {
          where: { id: (ts as unknown as { destination_id: string }).destination_id },
        })
      : null;
    const destCurrencyId = (dest as { currency_id?: string } | null)?.currency_id;
    const destId = (dest as { id?: string } | null)?.id ?? null;

    // Mode is derived from the line item's is_shared flag:
    //   is_shared = true  → SIC (per-person rate, vehicle_type_id is NULL on the row)
    //   is_shared = false → PVT (per-vehicle rate, broken down by vehicle_type_id)
    // Default to PVT when the caller hasn't specified — preserves
    // pre-mode behaviour for any pre-existing FE that only sends
    // vehicleTypeId.
    const mode = isShared ? 'SIC' : 'PVT';
    const where: Record<string, unknown> = {
      transport_service_id: transportServiceId,
      mode,
      tenant_id: tenantId,
      is_deleted: false,
    };
    if (mode === 'PVT') {
      where.vehicle_type_id = vehicleTypeId;
    } else {
      // SIC rows have vehicle_type_id IS NULL — we filter via
      // TypeORM's IsNull operator to express that correctly.
      where.vehicle_type_id = IsNull();
    }
    if (destCurrencyId) where.currency_id = destCurrencyId;

    const pricings = await this.transportPricingRepo.find({
      where: where as unknown as FindOptionsWhere<TransportPricingEntity>,
    });
    const hasAnyPrice = (p: TransportPricingEntity) =>
      p.default_price != null ||
      p.season_1_price != null ||
      p.season_2_price != null ||
      p.season_3_price != null ||
      p.season_4_price != null ||
      p.season_5_price != null;
    const pricing = pricings.find(hasAnyPrice) || pricings[0] || null;

    // Selling-too-high reference: cheapest configured supplier cost
    // for this (route × vehicle × mode). Prefer the season that
    // contains `date`, then the all-season row, per supplier — then
    // MIN across suppliers. Seasonal cells are what the Supplier
    // Pricing matrix actually writes; all-season-only missed them.
    const minCost = await this.lookupMinSupplierCost({
      tenantId,
      transportServiceId,
      mode,
      vehicleTypeId,
      destinationId: destId,
      date,
    });

    if (!pricing)
      return this.augmentTransportPriceResponse(
        {
          price: null,
          source: 'not_found',
          season_name: null,
          min_cost: minCost,
          floor: null,
        },
        null,
        transportServiceId,
        destId,
        destCurrencyId,
        null,
      );

    // Floor lookup mirrors the selling lookup: same season, just the
    // floor_* matrix. Returned alongside so the FE can show a single
    // "Target X · Floor Y (Mid season)" helper and warn inline when
    // the user types a unit_price below the floor.
    let floor: number | null = null;
    let resolvedSeasonIdx: number | null = null;
    if (date) {
      const seasonMatch = await this.findSeasonForDate(date, tenantId, pricing.destination_id);
      if (seasonMatch) resolvedSeasonIdx = seasonMatch.index;
      const sellResult = await this.getSeasonalPrice(
        pricing as unknown as Record<string, unknown>,
        date,
        tenantId,
        pricing.destination_id,
      );
      const floorResult = await this.getSeasonalFloor(
        pricing as unknown as Record<string, unknown>,
        date,
        tenantId,
        pricing.destination_id,
      );
      floor =
        floorResult?.price ??
        (pricing.floor_default_price != null ? Number(pricing.floor_default_price) : null);
      if (sellResult !== null)
        return this.augmentTransportPriceResponse(
          {
            price: sellResult.price,
            source: 'seasonal',
            season_name: sellResult.season_name,
            min_cost: minCost,
            floor,
          },
          pricing,
          transportServiceId,
          pricing.destination_id,
          destCurrencyId,
          resolvedSeasonIdx,
        );
    }
    floor =
      floor ?? (pricing.floor_default_price != null ? Number(pricing.floor_default_price) : null);
    if (pricing.default_price != null)
      return this.augmentTransportPriceResponse(
        {
          price: Number(pricing.default_price),
          source: 'default',
          season_name: null,
          min_cost: minCost,
          floor,
        },
        pricing,
        transportServiceId,
        pricing.destination_id,
        destCurrencyId,
        null,
      );
    const fallback = this.firstNonNullSeasonalPrice(
      pricing as unknown as Record<string, unknown>,
      tenantId,
    );
    if (fallback)
      return this.augmentTransportPriceResponse(
        {
          price: fallback.price,
          source: 'seasonal_fallback',
          season_name: await this.seasonNameForIndex(
            fallback.index,
            tenantId,
            pricing.destination_id,
          ),
          min_cost: minCost,
          floor,
        },
        pricing,
        transportServiceId,
        pricing.destination_id,
        destCurrencyId,
        fallback.index,
      );
    return this.augmentTransportPriceResponse(
      { price: null, source: 'not_found', season_name: null, min_cost: minCost, floor },
      pricing,
      transportServiceId,
      pricing.destination_id,
      destCurrencyId,
      null,
    );
  }

  /** Activity counterpart of augmentTransportPriceResponse — see that
   *  method's docstring for the policy. Differs in the resolver path
   *  (resolveForActivity instead of resolveForTransport). */
  private async augmentActivityPriceResponse(
    response: {
      price: number | null;
      source: string;
      season_name: string | null;
      floor: number | null;
    },
    pricing: ActivityPricingEntity | null,
    activityId: string,
    destinationId: string | null,
    destCurrencyId: string | undefined,
    seasonIdx: number | null,
  ) {
    let slab = null;
    let rounding = 1;
    if (destinationId) {
      slab = await this.taxResolver.resolveForActivity(activityId, destinationId);
    }
    if (destCurrencyId) {
      const cur = await this.currencyRepo.findOne({ where: { id: destCurrencyId } });
      rounding = Number(cur?.rounding) || 1;
    }
    const baseCol = (col: string): number | null => {
      if (!pricing) return null;
      const v = (pricing as unknown as Record<string, unknown>)[col];
      return v == null ? null : Number(v);
    };
    const sellBaseCol = seasonIdx ? `season_${seasonIdx}_base_price` : 'default_base_price';
    const floorBaseCol = seasonIdx
      ? `floor_season_${seasonIdx}_base_price`
      : 'floor_default_base_price';
    const baseStored = baseCol(sellBaseCol);
    const base =
      baseStored != null
        ? baseStored
        : response.price != null
          ? extractBase(response.price, slab, rounding)
          : null;
    const floorBaseStored = baseCol(floorBaseCol);
    const floorBase =
      floorBaseStored != null
        ? floorBaseStored
        : response.floor != null
          ? extractBase(response.floor, slab, rounding)
          : null;
    return {
      ...response,
      base,
      floor_base: floorBase,
      tax_snapshot: slab,
    };
  }

  async lookupActivityPrice(
    activityId: string,
    ticketId: string | null,
    ageGroup: string,
    date?: string,
  ) {
    const tenantId = this.getTenantId();
    const act = await this.activityPricingRepo.manager.findOne('activities', {
      where: { id: activityId },
    });
    const dest = act
      ? await this.activityPricingRepo.manager.findOne('destinations', {
          where: { id: (act as unknown as { destination_id: string }).destination_id },
        })
      : null;
    const destCurrencyId = (dest as { currency_id?: string } | null)?.currency_id;
    const destId = (dest as { id?: string } | null)?.id ?? null;

    const where: Record<string, unknown> = {
      activity_id: activityId,
      age_group: ageGroup,
      tenant_id: tenantId,
      is_deleted: false,
    };
    if (ticketId) where.ticket_id = ticketId;
    if (destCurrencyId) where.currency_id = destCurrencyId;

    const pricings = await this.activityPricingRepo.find({
      where: where as unknown as FindOptionsWhere<ActivityPricingEntity>,
    });
    const hasAnyPrice = (p: ActivityPricingEntity) =>
      p.default_price != null ||
      p.season_1_price != null ||
      p.season_2_price != null ||
      p.season_3_price != null ||
      p.season_4_price != null ||
      p.season_5_price != null;
    const pricing = pricings.find(hasAnyPrice) || pricings[0] || null;
    if (!pricing)
      return this.augmentActivityPriceResponse(
        { price: null, source: 'not_found', season_name: null, floor: null },
        null,
        activityId,
        destId,
        destCurrencyId,
        null,
      );

    // Floor lookup mirrors the selling lookup: same season, just the
    // floor_* matrix. Returned alongside so the FE can show a single
    // "Selling X · Floor Y (Mid season)" helper and warn inline when
    // the user types a unit_price below the floor. Same shape as
    // lookupTransportPrice for symmetric FE consumption.
    let floor: number | null = null;
    let resolvedSeasonIdx: number | null = null;
    if (date) {
      const seasonMatch = await this.findSeasonForDate(date, tenantId, pricing.destination_id);
      if (seasonMatch) resolvedSeasonIdx = seasonMatch.index;
      const sellResult = await this.getSeasonalPrice(
        pricing as unknown as Record<string, unknown>,
        date,
        tenantId,
        pricing.destination_id,
      );
      const floorResult = await this.getSeasonalFloor(
        pricing as unknown as Record<string, unknown>,
        date,
        tenantId,
        pricing.destination_id,
      );
      floor =
        floorResult?.price ??
        (pricing.floor_default_price != null ? Number(pricing.floor_default_price) : null);
      if (sellResult !== null)
        return this.augmentActivityPriceResponse(
          {
            price: sellResult.price,
            source: 'seasonal',
            season_name: sellResult.season_name,
            floor,
          },
          pricing,
          activityId,
          pricing.destination_id,
          destCurrencyId,
          resolvedSeasonIdx,
        );
    }
    floor =
      floor ?? (pricing.floor_default_price != null ? Number(pricing.floor_default_price) : null);
    if (pricing.default_price != null)
      return this.augmentActivityPriceResponse(
        { price: Number(pricing.default_price), source: 'default', season_name: null, floor },
        pricing,
        activityId,
        pricing.destination_id,
        destCurrencyId,
        null,
      );
    const fallback = this.firstNonNullSeasonalPrice(
      pricing as unknown as Record<string, unknown>,
      tenantId,
    );
    if (fallback)
      return this.augmentActivityPriceResponse(
        {
          price: fallback.price,
          source: 'seasonal_fallback',
          season_name: await this.seasonNameForIndex(
            fallback.index,
            tenantId,
            pricing.destination_id,
          ),
          floor,
        },
        pricing,
        activityId,
        pricing.destination_id,
        destCurrencyId,
        fallback.index,
      );
    return this.augmentActivityPriceResponse(
      { price: null, source: 'not_found', season_name: null, floor },
      pricing,
      activityId,
      pricing.destination_id,
      destCurrencyId,
      null,
    );
  }

  /**
   * Helper — find the first season_N_price that's set on the row
   * (1..5). Returns the price plus its 1-based season index so the
   * caller can resolve the season name for UX messaging.
   */
  private firstNonNullSeasonalPrice(
    pricing: Record<string, unknown>,
    _tenantId: string,
  ): { price: number; index: number } | null {
    for (let i = 1; i <= 5; i++) {
      const v = pricing[`season_${i}_price`];
      if (v != null) return { price: Number(v), index: i };
    }
    return null;
  }

  /**
   * Helper — resolve a season's display name from its 1-based index
   * (matches getSeasonalPrice ordering: seasons sorted ASC, index 1
   * → first, etc.). Best-effort — returns null if seasons can't be
   * loaded so the lookup still returns a price.
   */
  private async seasonNameForIndex(
    index: number,
    tenantId: string,
    destinationId: string,
  ): Promise<string | null> {
    try {
      const seasons = await this.seasonRepo.find({
        where: {
          destination_id: destinationId,
          tenant_id: tenantId,
          is_deleted: false,
          is_active: true,
        },
        order: { sort_order: 'ASC' },
      });
      const s = seasons[index - 1];
      return s?.name ?? null;
    } catch {
      return null;
    }
  }

  async lookupExchangeRate(fromCurrencyId: string, toCurrencyId: string) {
    if (fromCurrencyId === toCurrencyId) return { rate: 1 };
    const tenantId = this.getTenantId();
    // 1) Direct match — "from → to" stored at the rate ops typed.
    const direct = await this.exchangeRateRepo.findOne({
      where: {
        from_currency_id: fromCurrencyId,
        to_currency_id: toCurrencyId,
        tenant_id: tenantId,
        is_deleted: false,
        is_active: true,
      },
    });
    if (direct && Number(direct.rate) > 0) return { rate: Number(direct.rate) };
    // 2) Inverse fallback — ops typed the natural USD-anchored direction
    //    "1 USD = 3.67 AED" but the quote needs the AED → USD multiplier
    //    (so cost in AED × rate = selling in USD). Invert it for the
    //    caller: 1 / 3.67 ≈ 0.272. Spares ops from maintaining two rows
    //    per pair and matches their mental model of typing one rate per
    //    currency pair.
    const inverse = await this.exchangeRateRepo.findOne({
      where: {
        from_currency_id: toCurrencyId,
        to_currency_id: fromCurrencyId,
        tenant_id: tenantId,
        is_deleted: false,
        is_active: true,
      },
    });
    if (inverse && Number(inverse.rate) > 0) {
      return { rate: 1 / Number(inverse.rate) };
    }
    return { rate: null };
  }

  // --- Private Helpers ---
  private async saveChildItems(
    quoteId: string,
    tenantId: string,
    userId: string | null,
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dto carries FE-only transient fields (link_key, refresh_tax) AND typed line DTOs; constraining to a single shape breaks the TypeORM .save() inference for downstream entity saves.
    dto: any,
    queryId?: string,
  ) {
    // Resolve destination once per save — used by the tax-snapshot
    // policy. Q1=C / Q2=B / Q3=A:
    //   - If the line carries an existing tax_breakdown AND no
    //     refresh_tax flag, persist as-is (immutable on edit, Q2=B).
    //   - Else resolve fresh from the destination's active slabs.
    // tax_amount is always recomputed by extracting the tax embedded
    // in the line's inclusive `subtotal` (computeAmount) so qty/price
    // edits stay consistent.
    let cachedSnapshot: {
      tax_breakdown: Record<string, Record<string, number>>;
      total_rate: number;
    } | null = null;
    let snapshotResolved = false;
    const ensureSnapshot = async () => {
      if (snapshotResolved) return cachedSnapshot;
      snapshotResolved = true;
      if (!queryId) return null;
      const query = await this.queryRepo.findOne({
        where: { id: queryId, tenant_id: tenantId },
      });
      cachedSnapshot = await this.taxSnapshot.resolveForDestination(query?.destination_id);
      return cachedSnapshot;
    };
    const applyTaxSnapshot = async (line: Record<string, unknown>, subtotal: number) => {
      const refresh = line.refresh_tax === true;
      const hasExisting = !!line.tax_breakdown;
      let breakdown: Record<string, Record<string, number>> | null;
      if (hasExisting && !refresh) {
        // Q2=B: preserve frozen rates; only the amount recomputes.
        breakdown = line.tax_breakdown as Record<string, Record<string, number>>;
      } else {
        const fresh = await ensureSnapshot();
        breakdown = fresh ? fresh.tax_breakdown : null;
      }
      const amount = this.taxSnapshot.computeAmount(subtotal, breakdown);
      return { tax_breakdown: breakdown, tax_amount: amount };
    };

    // Step 3f — per-line `tax_snapshot` JSONB (new bifurcation model,
    // sits alongside the legacy `tax_breakdown`). Stores ONLY the slab
    // structure (slab_id + slab_name + total_rate + components+rates).
    // Per-line amounts are derived at totals-display time from the
    // line's base — keeps this commit non-opinionated about whether
    // unit_price is base or inclusive during the rollout window.
    let cachedQueryDest: string | null = null;
    let queryDestResolved = false;
    const queryDestinationId = async (): Promise<string | null> => {
      if (queryDestResolved) return cachedQueryDest;
      queryDestResolved = true;
      if (!queryId) return null;
      const query = await this.queryRepo.findOne({
        where: { id: queryId, tenant_id: tenantId },
      });
      cachedQueryDest = query?.destination_id ?? null;
      return cachedQueryDest;
    };
    const resolveLineSnapshot = async (
      kind: 'hotel' | 'transport' | 'activity' | 'special',
      line: Record<string, unknown>,
    ) => {
      const destId = await queryDestinationId();
      if (!destId) return null;
      if (kind === 'hotel' || kind === 'special') {
        return this.taxResolver.resolveForHotelOrSpecial(destId);
      }
      if (kind === 'transport') {
        const svcId = (line.transport_service_id as string | null | undefined) ?? null;
        return this.taxResolver.resolveForTransport(svcId, destId);
      }
      // activity
      const actId = (line.activity_id as string | null | undefined) ?? null;
      return this.taxResolver.resolveForActivity(actId, destId);
    };

    if (dto.hotel_items?.length) {
      for (const hi of dto.hotel_items) {
        // Split children off the DTO — persisted as separate rows
        // under the new hotel_item UUID.
        const { periods, extras, refresh_tax, ...rest } = hi;
        void refresh_tax;
        // App-layer bound check before we hit the DB CHECK.
        assertNightsInRange(periods, extras, hi.nights);
        // Recompute stay subtotal from children so a stale FE payload
        // can't set a headline number that disagrees with the rows.
        const subtotal = computeHotelStayTotal(periods, extras);
        const tax = await applyTaxSnapshot(hi, subtotal);
        const tax_snapshot = await resolveLineSnapshot('hotel', hi);
        const saved = await this.hotelItemRepo.save({
          ...rest,
          quote_id: quoteId,
          tenant_id: tenantId,
          subtotal,
          ...tax,
          tax_snapshot,
          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 — activities are persisted BEFORE transports so
    // any transport carrying linked_activity_link_key can resolve to a
    // freshly-minted activity UUID. Map link_key → saved id here.
    const linkKeyToActivityId = new Map<string, string>();
    if (dto.activity_items?.length) {
      for (const ai of dto.activity_items) {
        const qty = ai.quantity ?? 1;
        const price = ai.unit_price ?? 0;
        const subtotal = Number(ai.subtotal ?? qty * price);
        const tax = await applyTaxSnapshot(ai, subtotal);
        const tax_snapshot = await resolveLineSnapshot('activity', ai);
        // link_key is transient — strip before persisting so the
        // entity insert doesn't choke on an unknown column.
        const { link_key, refresh_tax, ...rest } = ai;
        const saved = await this.activityItemRepo.save({
          ...rest,
          quote_id: quoteId,
          tenant_id: tenantId,
          quantity: qty,
          unit_price: price,
          subtotal,
          ticket_id: rest.ticket_id || null,
          ...tax,
          tax_snapshot,
          created_by: userId,
          updated_by: userId,
        });
        if (link_key) linkKeyToActivityId.set(String(link_key), saved.id);
      }
    }
    if (dto.transport_items?.length) {
      for (const ti of dto.transport_items) {
        const qty = ti.quantity ?? 1;
        const price = ti.unit_price ?? 0;
        const subtotal = Number(ti.subtotal ?? qty * price);
        const tax = await applyTaxSnapshot(ti, subtotal);
        const tax_snapshot = await resolveLineSnapshot('transport', ti);
        const { linked_activity_link_key, refresh_tax, ...rest } = ti;
        // PREFER the in-payload link_key over the persisted
        // linked_activity_id. saveChildItems uses delete-and-replace on
        // activities, so any persisted activity_id the FE remembers is
        // pointing at a row that was just deleted in this same call.
        // The link_key resolves to the freshly-inserted activity's UUID
        // and therefore won't FK-violate. Persisted id is kept only as
        // a defensive fallback for partial-update flows that don't
        // touch activity_items.
        const linkedFromKey = linked_activity_link_key
          ? linkKeyToActivityId.get(String(linked_activity_link_key)) || null
          : null;
        const linked_activity_id =
          linkedFromKey || (rest.linked_activity_id as string | undefined) || null;
        await this.transportItemRepo.save({
          ...rest,
          quote_id: quoteId,
          tenant_id: tenantId,
          quantity: qty,
          unit_price: price,
          subtotal,
          linked_activity_id,
          ...tax,
          tax_snapshot,
          created_by: userId,
          updated_by: userId,
        });
      }
    }
    if (dto.special_items?.length) {
      for (const si of dto.special_items) {
        const subtotal = Number(si.total_price ?? 0);
        const tax = await applyTaxSnapshot(si, subtotal);
        const tax_snapshot = await resolveLineSnapshot('special', si);
        const { refresh_tax, ...rest } = si;
        await this.specialItemRepo.save({
          ...rest,
          quote_id: quoteId,
          tenant_id: tenantId,
          ...tax,
          tax_snapshot,
          created_by: userId,
          updated_by: userId,
        });
      }
    }
  }

  /**
   * Soft-delete all child items belonging to a quote. Replaces the
   * prior hard-delete pattern — accounting ledger + booking cost
   * snapshots may still reference these rows historically, and dropping
   * them physically breaks referential integrity of the audit trail.
   * Every read path already filters WHERE is_deleted = false, so
   * soft-deleted rows are invisible to the app but preserved in the DB.
   */
  private async hardDeleteChildItems(quoteId: string, tenantId: string) {
    // Soft-delete every hotel_item first + capture their ids so the
    // per-night children can be soft-deleted in the same pass. CASCADE
    // FKs only fire on hard delete, so soft-delete needs an explicit
    // sweep.
    const hotelIds = (
      await this.hotelItemRepo.find({
        where: { quote_id: quoteId, tenant_id: tenantId, is_deleted: false },
        select: { id: true },
      })
    ).map((r) => r.id);
    for (const repo of [
      this.hotelItemRepo,
      this.transportItemRepo,
      this.activityItemRepo,
      this.specialItemRepo,
    ]) {
      await repo.update(
        { quote_id: quoteId, tenant_id: tenantId, is_deleted: false },
        { is_deleted: true, updated_at: new Date() },
      );
    }
    if (hotelIds.length > 0) {
      for (const childRepo of [this.hotelPeriodRepo, this.hotelExtraRepo]) {
        await childRepo
          .createQueryBuilder()
          .update()
          .set({ is_deleted: true, updated_at: new Date() })
          .where('hotel_item_id IN (:...ids)', { ids: hotelIds })
          .andWhere('tenant_id = :t', { t: tenantId })
          .andWhere('is_deleted = false')
          .execute();
      }
    }
  }

  private async recalculateTotals(quoteId: string, tenantId: string) {
    const hotelTotal = await this.sumField(
      this.hotelItemRepo,
      'quote_id',
      quoteId,
      tenantId,
      'subtotal',
    );
    const transportTotal = await this.sumField(
      this.transportItemRepo,
      'quote_id',
      quoteId,
      tenantId,
      'subtotal',
    );
    const activityTotal = await this.sumField(
      this.activityItemRepo,
      'quote_id',
      quoteId,
      tenantId,
      'subtotal',
    );
    const specialTotal = await this.sumField(
      this.specialItemRepo,
      'quote_id',
      quoteId,
      tenantId,
      'total_price',
    );

    const grandTotal = hotelTotal + transportTotal + activityTotal + specialTotal;

    // Markup tax — back-solved from the final selling_total with the
    // slab rate read server-side (see markup-tax.helper).
    const quote = await this.repo.findOne({ where: { id: quoteId, tenant_id: tenantId } });
    let markupTax = 0;
    if (quote) {
      let rate = 0;
      if (quote.tax_slab_id) {
        const slab = await this.repo.manager.findOne(TaxSlabEntity, {
          where: { id: quote.tax_slab_id, tenant_id: tenantId },
        });
        rate = Number(slab?.total_rate) || 0;
      }
      markupTax = splitFinalPrice(
        Number(quote.selling_total) || 0,
        grandTotal * (Number(quote.exchange_rate) || 1),
        rate,
      ).markupTax;
    }

    await this.repo.update(quoteId, {
      hotel_total: hotelTotal,
      transport_total: transportTotal,
      activity_total: activityTotal,
      special_total: specialTotal,
      grand_total: grandTotal,
      markup_tax_amount: markupTax,
    });
  }

  private async sumField(
    repo: Repository<ObjectLiteral>,
    fkColumn: string,
    fkValue: string,
    tenantId: string,
    field: string,
  ): Promise<number> {
    const result = await repo
      .createQueryBuilder('item')
      .select(`SUM(item.${field})`, 'total')
      .where(`item.${fkColumn} = :fkValue`, { fkValue })
      .andWhere('item.tenant_id = :tenantId', { tenantId })
      .andWhere('item.is_deleted = false')
      .getRawOne();
    return Number(result?.total) || 0;
  }

  /**
   * Cheapest supplier cost for a quote transport lookup. Prefers the
   * seasonal `supplier_cost_prices` row that matches `date` (what the
   * Supplier Pricing matrix writes), then the all-season fallback,
   * then MIN across suppliers. Missing config → null (no FE flag).
   */
  private async lookupMinSupplierCost(args: {
    tenantId: string;
    transportServiceId: string;
    mode: string;
    vehicleTypeId: string;
    destinationId: string | null;
    date?: string;
  }): Promise<number | null> {
    let seasonId: string | null = null;
    if (args.date && args.destinationId) {
      const seasons = await this.seasonRepo.find({
        where: {
          destination_id: args.destinationId,
          tenant_id: args.tenantId,
          is_deleted: false,
          is_active: true,
        },
      });
      seasonId = findMatchingSeasonId(seasons, args.date);
    }

    const isSic = args.mode === 'SIC';
    const vehicleSql = isSic ? 'vehicle_type_id IS NULL' : 'vehicle_type_id = $4';
    const seasonParamIdx = isSic ? 4 : 5;
    const seasonSql = seasonId
      ? `(season_id IS NULL OR season_id = $${seasonParamIdx})`
      : 'season_id IS NULL';
    const sql = `
      SELECT MIN(picked.cost_price)::float AS min_cost
      FROM (
        SELECT DISTINCT ON (supplier_id)
          cost_price
        FROM supplier_cost_prices
        WHERE tenant_id = $1
          AND transport_service_id = $2
          AND mode = $3
          AND is_deleted = false
          AND is_active = true
          AND ${seasonSql}
          AND ${vehicleSql}
        ORDER BY supplier_id, CASE WHEN season_id IS NOT NULL THEN 0 ELSE 1 END
      ) picked
    `;
    const params: Array<string> = [args.tenantId, args.transportServiceId, args.mode];
    if (!isSic) params.push(args.vehicleTypeId);
    if (seasonId) params.push(seasonId);
    const costRow = await this.transportPricingRepo.manager.query(sql, params);
    return costRow?.[0]?.min_cost != null ? Number(costRow[0].min_cost) : null;
  }

  /**
   * Returns the 1-based season index + the season name, or null when
   * no configured season matches the date. Pure logic — caller picks
   * which price column to read (selling vs floor).
   */
  private async findSeasonForDate(
    date: string,
    tenantId: string,
    destinationId: string,
  ): Promise<{ index: number; season_name: string } | null> {
    const seasons = await this.seasonRepo.find({
      where: {
        destination_id: destinationId,
        tenant_id: tenantId,
        is_deleted: false,
        is_active: true,
      },
      order: { sort_order: 'ASC' },
    });
    const travelMMDD = date.substring(5); // YYYY-MM-DD → MM-DD
    for (let i = 0; i < seasons.length && i < 5; i++) {
      const season = seasons[i];
      if (!season.start_date || !season.end_date) continue;
      const start = season.start_date;
      const end = season.end_date;
      const match =
        start <= end
          ? travelMMDD >= start && travelMMDD <= end
          : travelMMDD >= start || travelMMDD <= end;
      if (match) return { index: i + 1, season_name: season.name };
    }
    return null;
  }

  private async getSeasonalPrice(
    pricing: Record<string, unknown>,
    date: string,
    tenantId: string,
    destinationId: string,
  ): Promise<{ price: number; season_name: string } | null> {
    const season = await this.findSeasonForDate(date, tenantId, destinationId);
    if (!season) return null;
    const price = pricing[`season_${season.index}_price`];
    if (price === null || price === undefined) return null;
    return { price: Number(price), season_name: season.season_name };
  }

  /** Same shape as getSeasonalPrice but reads the floor matrix. */
  private async getSeasonalFloor(
    pricing: Record<string, unknown>,
    date: string,
    tenantId: string,
    destinationId: string,
  ): Promise<{ price: number; season_name: string } | null> {
    const season = await this.findSeasonForDate(date, tenantId, destinationId);
    if (!season) return null;
    const price = pricing[`floor_season_${season.index}_price`];
    if (price === null || price === undefined) return null;
    return { price: Number(price), season_name: season.season_name };
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any of QueryQuote{Hotel,Transport,Activity,Special}Entity for cloning during duplicate; helper just strips known meta columns.
  private stripMeta(item: any) {
    const {
      id,
      created_at,
      updated_at,
      created_by,
      updated_by,
      is_deleted,
      quote_id,
      quote,
      hotel,
      transport_service,
      activity,
      ticket,
      ...rest
    } = item;
    return rest;
  }
}
