import {
  BadRequestException,
  Injectable,
  NotFoundException,
  forwardRef,
  Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, type FindOptionsWhere } from 'typeorm';
import { ClsService } from 'nestjs-cls';
import {
  BookingChangeRequestEntity,
  ChangeRequestKind,
  ChangeRequestStatus,
} from '../../entities/booking-change-request.entity';
import { BookingEntity } from '../../entities/booking.entity';
import { UserEntity } from '../../entities/user.entity';
import { CLS_TENANT_ID, CLS_USER_ID } from '../../common/cls/cls.constants';
import { CreateChangeRequestDto } from './dto/create-change-request.dto';
import { DecideChangeRequestDto } from './dto/decide-change-request.dto';
import { BookingService } from '../booking/booking.service';
import { NotificationService } from '../notification/notification.service';
import { AuditService } from '../audit/audit.service';
import { AuditEntityType, AuditAction } from '../audit/audit-types';
import { NotificationKind } from '../../entities/notification.entity';
import { ModuleName, AccessLevel } from '../../entities/role-permission.entity';
import { detectBookingApprovalTriggers } from './booking-approval-triggers';

/**
 * ChangeRequestService — append-only approval log for booking edits.
 *
 * Flow:
 *   1. Sales POSTs a change request (kind = itinerary or zero_value)
 *      while the booking is ONGOING or when their save contains
 *      zero-priced services. Payload = the full UpdateBookingDto the
 *      editor would have sent directly.
 *   2. Manager (booking_approval:edit) decides — approve / reject /
 *      on_hold — via PATCH /change-requests/:id/decide.
 *   3. On approval, BookingService.applyApprovedChangeRequest writes
 *      the snapshot to the booking, bypassing the normal on-trip +
 *      zero-value guards (we're in manager-approved context).
 */
@Injectable()
export class ChangeRequestService {
  constructor(
    @InjectRepository(BookingChangeRequestEntity)
    private readonly repo: Repository<BookingChangeRequestEntity>,
    @InjectRepository(BookingEntity)
    private readonly bookingRepo: Repository<BookingEntity>,
    @InjectRepository(UserEntity)
    private readonly userRepo: Repository<UserEntity>,
    private readonly cls: ClsService,
    // BookingService forward-ref — applied snapshots go back through
    // the booking update path (with guards bypassed).
    @Inject(forwardRef(() => BookingService))
    private readonly bookingService: BookingService,
    private readonly notifications: NotificationService,
    private readonly auditService: AuditService,
  ) {}

  private tenantId(): string {
    return this.cls.get<string>(CLS_TENANT_ID);
  }

  async create(bookingId: string, dto: CreateChangeRequestDto) {
    const tenantId = this.tenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;
    const booking = await this.bookingRepo.findOne({
      where: { id: bookingId, tenant_id: tenantId, is_deleted: false },
      relations: { query: { destination: true } as any } as any,
    });
    if (!booking) throw new NotFoundException('Booking not found');

    // Upgrade the CR kind from the default `itinerary` (FE's catch-all
    // when sales submits) to the most-specific trigger that applies —
    // post_confirm_edit / same_day_booking / next_day_booking. If the
    // payload/booking state hits multiple, the highest-precedence kind
    // wins (see KIND_PRIORITY below). Kinds the FE set explicitly
    // (e.g. below_floor_price) are left alone.
    if (dto.kind === ChangeRequestKind.ITINERARY) {
      const q: any = (booking as any).query;
      const triggers = detectBookingApprovalTriggers({
        startDate: q?.start_date,
        destTz: q?.destination?.timezone ?? null,
        currentStatus: booking.status,
        dto: dto.payload as any,
        isUpdate: true,
      });
      const KIND_ORDER = ['post_confirm_edit', 'same_day_booking', 'next_day_booking'] as const;
      const best = triggers
        .slice()
        .sort(
          (a, b) =>
            KIND_ORDER.indexOf(a.kind as any) - KIND_ORDER.indexOf(b.kind as any),
        )[0];
      if (best) dto.kind = best.kind;
    }

    // Meaningful contexts for a change request:
    //   - itinerary kind: any status — UAT 2026-06-30 broadened from
    //     ONGOING-only so sales-role users (no booking_approval:edit)
    //     route every booking edit through manager approval, not just
    //     on-trip edits. Managers (canApprove=true) still write
    //     directly; the FE only opens this endpoint when canApprove is
    //     false, so a stray itinerary CR from a manager is already
    //     prevented at the source.
    //   - zero_value / below_floor / selling_price: any status.
    //
    // No status guard fires here; the booking's status is captured in
    // before_snapshot for the manager's diff view.

    // Single-open-CR contract per stakeholder spec: a booking can have
    // at most ONE pending CR at a time. If a pending CR already exists,
    // refresh its payload + remarks in place rather than stack a second
    // row. Closed CRs (approved/rejected/withdrawn) are history and
    // don't block — a fresh submission creates a brand-new CR. Mirrors
    // the same rule on the quote-side createForQuote().
    const existingPending = await this.repo.findOne({
      where: {
        booking_id: bookingId,
        tenant_id: tenantId,
        status: ChangeRequestStatus.PENDING,
        is_deleted: false,
      },
    });

    if (existingPending) {
      // Kind preference order matches the quote-side rule: itinerary
      // (most specific manual ask) > below_floor_price > zero_value.
      // Refresh payload + remarks; keep before_snapshot as-is so the
      // diff still shows changes vs the booking state at FIRST
      // submission, not the latest refresh.
      const KIND_PRIORITY: Record<string, number> = {
        [ChangeRequestKind.ITINERARY]: 4,
        [ChangeRequestKind.BELOW_FLOOR_PRICE]: 3,
        [ChangeRequestKind.SELLING_PRICE]: 3,
        [ChangeRequestKind.POST_CONFIRM_EDIT]: 2,
        [ChangeRequestKind.SAME_DAY_BOOKING]: 2,
        [ChangeRequestKind.NEXT_DAY_BOOKING]: 1,
        [ChangeRequestKind.ZERO_VALUE]: 1,
      };
      const nextKind =
        (KIND_PRIORITY[dto.kind] || 0) > (KIND_PRIORITY[existingPending.kind] || 0)
          ? dto.kind
          : existingPending.kind;

      // save() rather than update() because the latter's
      // QueryDeepPartialEntity<T> typing rejects Record<string, unknown>
      // jsonb columns. save() accepts DeepPartial<T> which handles them
      // natively. The extra SELECT is negligible — change-request
      // updates are rare and not in a hot path.
      await this.repo.save({
        id: existingPending.id,
        kind: nextKind,
        payload: dto.payload,
        requester_remarks: dto.requester_remarks?.trim() || existingPending.requester_remarks,
        updated_by: userId,
      });

      await this.auditService.log(
        AuditEntityType.CHANGE_REQUEST,
        existingPending.id,
        AuditAction.UPDATED,
        [
          { field: 'refresh', old_value: existingPending.kind, new_value: nextKind },
          { field: 'payload_refreshed', old_value: null, new_value: 'true' },
        ],
      );

      // Skip the booking-timeline twin entry on refresh — the original
      // submission already logged it. Approver notification also stays
      // silent so we don't spam the bell on every revision.
      return { id: existingPending.id, message: 'Existing pending change request refreshed' };
    }

    // Capture a snapshot of the booking's CURRENT state at submission
    // time — same shape as the proposed payload — so the diff panel
    // can still show "what actually changed" after the request is
    // approved (at which point the live booking equals the payload
    // and a payload-vs-current diff would yield nothing).
    //
    // Failure to load the snapshot is non-fatal: the column is
    // nullable, and the FE falls back to live-booking-diff. We log
    // and proceed so a transient hiccup never blocks the request.
    let beforeSnapshot: Record<string, unknown> | null;
    try {
      const detail = await this.bookingService.findById(bookingId);
      beforeSnapshot = JSON.parse(JSON.stringify(detail));
    } catch {
      beforeSnapshot = null;
    }

    const row = await this.repo.save({
      tenant_id: tenantId,
      booking_id: bookingId,
      kind: dto.kind,
      status: ChangeRequestStatus.PENDING,
      requester_id: userId,
      requester_remarks: dto.requester_remarks,
      payload: dto.payload,
      before_snapshot: beforeSnapshot,
      created_by: userId,
      updated_by: userId,
    });

    // Notify every manager (booking_approval:edit) in this tenant.
    // Excluding the requester is defensive — they shouldn't have edit
    // access in most configs but a tenant could grant it. Swallow any
    // notification failure so it never blocks the actual save.
    try {
      const bookingNum = booking.booking_number || 'Booking';
      const kindLabel =
        dto.kind === ChangeRequestKind.ITINERARY
          ? 'On-trip itinerary change'
          : 'Zero-value services';
      await this.notifications.createForRoleWithAccess(
        ModuleName.BOOKING_APPROVAL,
        AccessLevel.EDIT,
        {
          kind: NotificationKind.CHANGE_REQUEST_PENDING,
          title: `Change request — ${bookingNum}`,
          message: `${kindLabel}: ${dto.requester_remarks.substring(0, 140)}${dto.requester_remarks.length > 140 ? '…' : ''}`,
          link: `/bookings/approvals`,
          related_entity_type: 'booking_change_request',
          related_entity_id: row.id,
        },
        userId ? [userId] : [],
      );
    } catch {
      /* non-blocking */
    }

    await this.auditService.log(AuditEntityType.CHANGE_REQUEST, row.id, AuditAction.CREATED, [
      { field: 'kind', old_value: null, new_value: dto.kind },
      { field: 'booking_id', old_value: null, new_value: bookingId },
    ]);
    // Twin entry on the booking timeline so the change request shows up
    // when reviewing the booking history (without leaving the page).
    await this.auditService.log(AuditEntityType.BOOKING, bookingId, AuditAction.UPDATED, [
      { field: 'change_request', old_value: null, new_value: `submitted (${dto.kind})` },
    ]);

    return { id: row.id, message: 'Change request submitted for approval' };
  }

  /**
   * Auto-create a quote-scoped Change Request when a non-approver saves
   * a quote with a zero-priced service or a below-floor transport line.
   * Unlike booking CRs (which BLOCK the save and need manual submission),
   * the save has already happened — the CR is informational, surfacing
   * the override to managers via the Approvals queue.
   *
   * No requester_remarks (auto-generated → nothing for the user to type).
   * payload captures the offending bits so a manager can scan without
   * re-fetching the quote.
   */
  async createForQuote(
    quoteId: string,
    kind: ChangeRequestKind,
    payload: Record<string, unknown>,
    requesterRemarks?: string | null,
  ) {
    const tenantId = this.tenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;

    // Single-open-CR contract per stakeholder spec: one pending CR per
    // quote at a time. If there's already a pending CR for this quote
    // (zero-value, below-floor, or both), REFRESH it in place rather
    // than stack a second row. Approver still sees one approval ask
    // covering the current save's full violation set. Closed CRs
    // (approved/rejected/withdrawn) don't block — those are history,
    // and a fresh save with violations creates a brand-new CR.
    const existing = await this.repo.findOne({
      where: {
        quote_id: quoteId,
        tenant_id: tenantId,
        status: ChangeRequestStatus.PENDING,
        is_deleted: false,
      },
    });

    if (existing) {
      // Kind upgrades to BELOW_FLOOR_PRICE if any below-floor line is
      // now present (more specific signal than ZERO_VALUE). Otherwise
      // keep existing kind. Remarks: replace if user provided a fresh
      // string; else keep what's there so we don't blank out a prior
      // justification on a subsequent save with empty remarks.
      const nextKind =
        kind === ChangeRequestKind.BELOW_FLOOR_PRICE
          ? ChangeRequestKind.BELOW_FLOOR_PRICE
          : existing.kind;
      const nextRemarks = requesterRemarks?.trim() || existing.requester_remarks;

      // See note above on save() vs update() for jsonb payloads.
      await this.repo.save({
        id: existing.id,
        kind: nextKind,
        payload,
        requester_remarks: nextRemarks,
        updated_by: userId,
      });

      await this.auditService.log(
        AuditEntityType.CHANGE_REQUEST,
        existing.id,
        AuditAction.UPDATED,
        [
          { field: 'auto_refresh', old_value: existing.kind, new_value: nextKind },
          { field: 'payload_refreshed', old_value: null, new_value: 'true' },
        ],
      );

      // Don't re-notify the approver pool on every refresh — the CR
      // is already in their queue. Spamming notifications on every
      // save would train them to ignore the bell.
      return { id: existing.id, refreshed: true };
    }

    const row = await this.repo.save({
      tenant_id: tenantId,
      booking_id: null,
      quote_id: quoteId,
      kind,
      status: ChangeRequestStatus.PENDING,
      requester_id: userId,
      requester_remarks: requesterRemarks?.trim() || null,
      payload,
      before_snapshot: null,
      created_by: userId,
      updated_by: userId,
    });

    await this.auditService.log(AuditEntityType.CHANGE_REQUEST, row.id, AuditAction.CREATED, [
      { field: 'kind', old_value: null, new_value: kind },
      { field: 'quote_id', old_value: null, new_value: quoteId },
      { field: 'auto_generated', old_value: null, new_value: 'true' },
    ]);

    // Notify the same approver pool used for booking CRs.
    try {
      await this.notifications.createForRoleWithAccess(
        ModuleName.BOOKING_APPROVAL,
        AccessLevel.EDIT,
        {
          kind: NotificationKind.CHANGE_REQUEST_PENDING,
          title: `Quote needs review — ${kind}`,
          message: `A quote was saved with ${kind === ChangeRequestKind.ZERO_VALUE ? 'a zero-priced service' : 'a below-floor price'}. Review in Approvals.`,
          link: `/bookings/approvals`,
          related_entity_type: 'booking_change_request',
          related_entity_id: row.id,
        },
        userId ? [userId] : [],
      );
    } catch {
      /* non-blocking */
    }

    return { id: row.id, refreshed: false };
  }

  /**
   * Auto-emit an informational CR when a user with BOOKING_APPROVAL:edit
   * saves a booking that hit one of the new trigger kinds
   * (same_day / next_day / post_confirm_edit). Unlike Sales CRs, the
   * save has already happened — the CR is a review notice.
   *
   * Idempotent: reuses an existing pending CR on the same booking
   * (upgrading kind by KIND_PRIORITY). Skips silently when no
   * userId in CLS (system saves) — no requester → no CR.
   */
  async createAutoForBooking(
    bookingId: string,
    kind: ChangeRequestKind,
    payload: Record<string, unknown>,
    detail: string,
  ): Promise<{ id: string; refreshed: boolean } | null> {
    const tenantId = this.tenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;
    if (!userId) return null;

    const KIND_PRIORITY: Record<string, number> = {
      [ChangeRequestKind.ITINERARY]: 4,
      [ChangeRequestKind.BELOW_FLOOR_PRICE]: 3,
      [ChangeRequestKind.SELLING_PRICE]: 3,
      [ChangeRequestKind.POST_CONFIRM_EDIT]: 2,
      [ChangeRequestKind.SAME_DAY_BOOKING]: 2,
      [ChangeRequestKind.NEXT_DAY_BOOKING]: 1,
      [ChangeRequestKind.ZERO_VALUE]: 1,
    };

    const existing = await this.repo.findOne({
      where: {
        booking_id: bookingId,
        tenant_id: tenantId,
        status: ChangeRequestStatus.PENDING,
        is_deleted: false,
      },
    });

    if (existing) {
      const nextKind =
        (KIND_PRIORITY[kind] || 0) > (KIND_PRIORITY[existing.kind] || 0) ? kind : existing.kind;
      await this.repo.save({
        id: existing.id,
        kind: nextKind,
        payload,
        // Keep existing requester_remarks — auto-CRs don't overwrite
        // a real user's justification.
        updated_by: userId,
      });
      await this.auditService.log(AuditEntityType.CHANGE_REQUEST, existing.id, AuditAction.UPDATED, [
        { field: 'auto_refresh', old_value: existing.kind, new_value: nextKind },
      ]);
      return { id: existing.id, refreshed: true };
    }

    const row = await this.repo.save({
      tenant_id: tenantId,
      booking_id: bookingId,
      kind,
      status: ChangeRequestStatus.PENDING,
      requester_id: userId,
      requester_remarks: detail,
      payload,
      before_snapshot: null,
      created_by: userId,
      updated_by: userId,
    });

    await this.auditService.log(AuditEntityType.CHANGE_REQUEST, row.id, AuditAction.CREATED, [
      { field: 'kind', old_value: null, new_value: kind },
      { field: 'booking_id', old_value: null, new_value: bookingId },
      { field: 'auto_generated', old_value: null, new_value: 'true' },
    ]);
    await this.auditService.log(AuditEntityType.BOOKING, bookingId, AuditAction.UPDATED, [
      { field: 'change_request', old_value: null, new_value: `auto (${kind})` },
    ]);

    // Notify the approver pool — Admin (bypass) sees these; approvers
    // who don't have bypass may have created it themselves, but the
    // pool notification is still useful for the tenant's other admins.
    try {
      const booking = await this.bookingRepo.findOne({ where: { id: bookingId } });
      const bookingNum = booking?.booking_number || 'Booking';
      await this.notifications.createForRoleWithAccess(
        ModuleName.BOOKING_APPROVAL,
        AccessLevel.EDIT,
        {
          kind: NotificationKind.CHANGE_REQUEST_PENDING,
          title: `Auto-review — ${bookingNum}`,
          message: detail.substring(0, 200),
          link: `/bookings/approvals`,
          related_entity_type: 'booking_change_request',
          related_entity_id: row.id,
        },
        [userId],
      );
    } catch {
      /* non-blocking */
    }

    return { id: row.id, refreshed: false };
  }

  /** All requests for a booking — used by the booking detail page to
   *  show "your request is pending / approved / rejected" banners. */
  async findByBooking(bookingId: string) {
    const tenantId = this.tenantId();
    return this.repo.find({
      where: { booking_id: bookingId, tenant_id: tenantId, is_deleted: false },
      relations: {
        quote: {
          query: true,
        },

        requester: true,
        decided_by_user: true,
      },
      order: { created_at: 'DESC' },
    });
  }

  /** Pending queue across all bookings — feeds the Approvals page. */
  async findPending() {
    const tenantId = this.tenantId();
    return this.repo.find({
      where: { status: ChangeRequestStatus.PENDING, tenant_id: tenantId, is_deleted: false },
      relations: {
        booking: true,

        quote: {
          query: true,
        },

        requester: true,
      },
      order: { created_at: 'ASC' },
    });
  }

  /**
   * All change requests across the tenant, optionally filtered by
   * status. Feeds the Approvals page's status-tab view (Pending /
   * Approved / Rejected / On Hold / Withdrawn). When status is
   * omitted, returns every status (used by the "All" tab).
   *
   * Sort: newest first for decided lists (audit-trail readability),
   * oldest first when status is explicitly Pending so urgent items
   * surface at the top.
   */
  async findAll(status?: ChangeRequestStatus, pagination?: { page?: number; limit?: number }) {
    const tenantId = this.tenantId();
    const where: FindOptionsWhere<BookingChangeRequestEntity> = {
      tenant_id: tenantId,
      is_deleted: false,
    };
    if (status) where.status = status;

    // Bounded pagination — tenants accumulate hundreds of decided
    // requests over time; an unbounded list would balloon. Default
    // limit lines up with the rest of the platform's list endpoints.
    const page = Math.max(1, Math.floor(pagination?.page ?? 1));
    const limit = Math.min(100, Math.max(1, Math.floor(pagination?.limit ?? 50)));
    const skip = (page - 1) * limit;

    const [items, total] = await this.repo.findAndCount({
      where,
      relations: {
        booking: true,

        quote: {
          query: true,
        },

        requester: true,
        decided_by_user: true,
      },
      order: { created_at: status === ChangeRequestStatus.PENDING ? 'ASC' : 'DESC' },
      skip,
      take: limit,
    });
    return {
      items,
      meta: {
        total,
        page,
        limit,
        totalPages: Math.max(1, Math.ceil(total / limit)),
      },
    };
  }

  async findById(id: string) {
    const tenantId = this.tenantId();
    const row = await this.repo.findOne({
      where: { id, tenant_id: tenantId, is_deleted: false },
      relations: {
        booking: true,

        quote: {
          query: true,
        },

        requester: true,
        decided_by_user: true,
      },
    });
    if (!row) throw new NotFoundException('Change request not found');
    return row;
  }

  /** Requester cancels their own pending request. No remarks needed
   *  — it's simply a "never mind". Anyone else trying to withdraw
   *  (including managers) gets a 403. */
  async withdraw(id: string) {
    const tenantId = this.tenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;
    const row = await this.repo.findOne({
      where: { id, tenant_id: tenantId, is_deleted: false },
    });
    if (!row) throw new NotFoundException('Change request not found');
    if (row.status !== ChangeRequestStatus.PENDING) {
      throw new BadRequestException(`Already ${row.status} — cannot withdraw.`);
    }
    if (row.requester_id !== userId) {
      throw new BadRequestException('Only the original requester can withdraw this request.');
    }
    await this.repo.update(id, {
      status: ChangeRequestStatus.WITHDRAWN,
      updated_by: userId,
    });
    await this.auditService.log(AuditEntityType.CHANGE_REQUEST, id, AuditAction.STATUS_CHANGED, [
      { field: 'status', old_value: row.status, new_value: ChangeRequestStatus.WITHDRAWN },
    ]);
    return { id, status: ChangeRequestStatus.WITHDRAWN, message: 'Request withdrawn.' };
  }

  async decide(id: string, dto: DecideChangeRequestDto) {
    const tenantId = this.tenantId();
    const userId = this.cls.get<string>(CLS_USER_ID) || null;
    const row = await this.repo.findOne({
      where: { id, tenant_id: tenantId, is_deleted: false },
    });
    if (!row) throw new NotFoundException('Change request not found');
    if (row.status !== ChangeRequestStatus.PENDING) {
      throw new BadRequestException(`Already ${row.status} — decisions are final.`);
    }

    const nextStatus = dto.decision as unknown as ChangeRequestStatus;
    const now = new Date();

    // On approve — apply the snapshot through BookingService with the
    // guard-bypass flag. We do this BEFORE flipping status so a failed
    // apply leaves the request pending (caller gets a 500 and retries).
    // Quote-scoped CRs (booking_id NULL) skip the apply step — the
    // change has already been saved at the moment the CR was raised;
    // approve/reject is purely informational for those.
    if (nextStatus === ChangeRequestStatus.APPROVED && row.booking_id) {
      // Pass the CR id so any resulting settlement delta gets tagged
      // with `triggered_by_change_request_id` — lets finance click back
      // to the originating approval payload + reason.
      await this.bookingService.applyApprovedChangeRequest(
        row.booking_id,
        row.payload,
        row.id,
      );
    }

    await this.repo.update(id, {
      status: nextStatus,
      decided_by: userId,
      decision_remarks: dto.decision_remarks,
      decided_at: now,
      updated_by: userId,
    });

    await this.auditService.log(AuditEntityType.CHANGE_REQUEST, id, AuditAction.STATUS_CHANGED, [
      { field: 'status', old_value: row.status, new_value: nextStatus },
      { field: 'decision_remarks', old_value: null, new_value: dto.decision_remarks || null },
    ]);
    if (row.booking_id)
      await this.auditService.log(AuditEntityType.BOOKING, row.booking_id, AuditAction.UPDATED, [
        {
          field: 'change_request',
          old_value: 'pending',
          new_value:
            nextStatus === ChangeRequestStatus.APPROVED
              ? 'approved + applied'
              : nextStatus === ChangeRequestStatus.REJECTED
                ? 'rejected'
                : 'on_hold',
        },
      ]);

    // Notify the requester that a decision landed.
    if (row.requester_id) {
      try {
        // TODO(email): when SMTP is configured, also email the
        // requester. Template would lean on the same message copy.
        const booking = row.booking_id
          ? await this.bookingRepo.findOne({ where: { id: row.booking_id } })
          : null;
        const bookingNum = booking?.booking_number || 'your booking';
        const label =
          nextStatus === ChangeRequestStatus.APPROVED
            ? 'approved'
            : nextStatus === ChangeRequestStatus.REJECTED
              ? 'rejected'
              : 'put on hold';
        await this.notifications.create({
          user_id: row.requester_id,
          kind: NotificationKind.CHANGE_REQUEST_DECIDED,
          title: `Change request ${label} — ${bookingNum}`,
          message: dto.decision_remarks.substring(0, 200),
          link: `/bookings/${row.booking_id}`,
          related_entity_type: 'booking_change_request',
          related_entity_id: row.id,
        });
      } catch {
        /* non-blocking */
      }
    }

    return {
      id,
      status: nextStatus,
      message:
        nextStatus === ChangeRequestStatus.APPROVED
          ? 'Approved — changes applied to the booking.'
          : nextStatus === ChangeRequestStatus.REJECTED
            ? 'Rejected — booking left unchanged.'
            : 'Put on hold — review later.',
    };
  }
}
