import {
  Injectable,
  Logger,
  NotFoundException,
  BadRequestException,
  ConflictException,
} from "@nestjs/common";
import { PrismaService } from "../../common/prisma.service";
import { SeatsService } from "../seats/seats.service";
import { TicketsService } from "../tickets/tickets.service";
import { EventEmitter2 } from "@nestjs/event-emitter";
import {
  Prisma,
  PaymentIntentStatus,
  PaymentMethodType,
  PaymentRegion,
} from "@prisma/client";
import {
  InitiatePaymentDto,
  RefundDto,
  AddPaymentMethodDto,
  InitiateResponseDto,
  IntentStatusDto,
  PaymentRegionEnum,
  ForceConfirmDto,
} from "./payments.dto";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto";
import {
  PaymentClientService,
  PaymentDiagnostic,
} from "./payment-client.service";
import { CurrencyService } from "../currency/currency.service";
import { AuditService } from "../../common/audit.service";
import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util";
import {
  PaymentService as PaymentServiceEnum,
  PaymentReferenceType,
  PaymentIntentSnapshot,
  ProviderMethod,
  ClientAction,
  ProviderPaymentStatus,
} from "@edr/types";

const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [
  PaymentIntentStatus.REQUIRES_ACTION,
  PaymentIntentStatus.PROCESSING,
  PaymentIntentStatus.SUCCEEDED,
];

// Methods whose return/failure URLs are browser-facing pages on the passenger
// portal, so they should follow whichever domain the user came in on. DMONEY is
// deliberately excluded — its return URL is a server-to-server webhook host, not
// a page the browser lands on.
const DOMAIN_AWARE_METHODS = new Set<PaymentMethodType>([
  PaymentMethodType.TELEBIRR,
  PaymentMethodType.WAAFI,
]);

@Injectable()
export class PaymentsService {
  private readonly logger = new Logger(PaymentsService.name);

  private readonly waafiDemoTrustReturn = true;

  constructor(
    private prisma: PrismaService,
    private seatsService: SeatsService,
    private ticketsService: TicketsService,
    private eventEmitter: EventEmitter2,
    private paymentClient: PaymentClientService,
    private currencyService: CurrencyService,
    private auditService: AuditService,
  ) {}

  async deletePayment(id: string) {
    const intent = await this.prisma.paymentIntent.findUnique({ where: { id } });
    if (!intent) throw new NotFoundException('Payment intent not found');
    await this.prisma.paymentIntent.delete({ where: { id } });
    return { deleted: true, id };
  }

  async getAll(filters: {
    search?: string;
    status?: string;
    method?: string;
    page?: number;
    pageSize?: number;
  }) {
    const { search, status, method, page = 1, pageSize = 10 } = filters;
    const skip = (page - 1) * pageSize;

    const where: any = {};
    if (search) {
      where.OR = [
        { id: { contains: search, mode: "insensitive" } },
        { booking: { bookingRef: { contains: search, mode: "insensitive" } } },
      ];
    }
    if (status) {
      where.status = status;
    }
    if (method) {
      where.method = method;
    }

    const [items, total] = await Promise.all([
      this.prisma.paymentIntent.findMany({
        where,
        include: {
          booking: {
            select: {
              bookingRef: true,
              bookingType: true,
              packageId: true,
              priceTierId: true,
              adultCount: true,
              childCount: true,
              totalMinor: true,
              currency: true,
              priceTier: { select: { priceMinor: true } },
            },
          },
        },
        skip,
        take: pageSize,
        orderBy: { createdAt: "desc" },
      }),
      this.prisma.paymentIntent.count({ where }),
    ]);

    return {
      items: items.map((item) => {
        const b = item.booking as any;
        // For package round-trip bookings the stored amountMinor may be the single-leg
        // amount. Recompute from the tier price when applicable.
        let amountMinor = item.amountMinor;
        if (b?.packageId && b?.bookingType === 'ROUND_TRIP' && b?.priceTier?.priceMinor) {
          const adultFare = b.priceTier.priceMinor * 2;
          const childFare = Math.round(adultFare * 0.1);
          const correctMinor = (b.adultCount || 1) * adultFare + (b.childCount || 0) * childFare;
          // Convert to the charge currency ratio: stored amountMinor is in charge currency
          // (may be DJF/USD), but correctMinor is in ETB minor. Only override when the
          // currency is ETB (most common case); for foreign currencies keep stored value.
          if (item.currency === 'ETB') amountMinor = correctMinor;
        }
        return {
          id: item.id,
          reference: item.id.substring(0, 8),
          bookingId: item.bookingId,
          booking: { bookingRef: b?.bookingRef, totalMinor: b?.totalMinor, currency: b?.currency },
          amountMinor,
          currency: item.currency,
          method: item.method,
          status: item.status,
          createdAt: item.createdAt,
          paidAt: item.paidAt,
        };
      }),
      total,
      page,
      pageSize,
    };
  }

  /**
   * Returns the correct totalMinor (in ETB) for a booking, accounting for package round-trip
   * bookings where totalMinor may have been stored as a single-leg amount before the server fix.
   */
  private async resolveBookingTotal(booking: {
    id: string;
    totalMinor: number;
    bookingType: string;
    packageId?: string | null;
    priceTierId?: string | null;
    displayTotalMinor?: number | null;
  }): Promise<number> {
    if (!booking.packageId || !booking.priceTierId || booking.bookingType !== 'ROUND_TRIP') {
      return booking.totalMinor;
    }
    // New bookings store displayTotalMinor from the frontend's reviewedTotalMinor; their
    // totalMinor was already computed in ETB at creation time — no recomputation needed.
    if (booking.displayTotalMinor != null && booking.displayTotalMinor > 0) {
      return booking.totalMinor;
    }
    // Legacy path: old bookings may have stored a single-leg totalMinor — recompute from tier.
    const tier = await this.prisma.packagePriceTier.findUnique({ where: { id: booking.priceTierId } });
    if (!tier) return booking.totalMinor;
    const seats = await this.prisma.bookingSeat.findMany({ where: { bookingId: booking.id, leg: 1 }, select: { passengerCategory: true } });
    const adultCount = seats.filter(s => s.passengerCategory === 'ADULT').length || 1;
    const childCount = seats.filter(s => s.passengerCategory === 'CHILD').length;
    // tier.priceMinor may be in a non-ETB currency — convert to ETB so the result is
    // always in the same units as totalMinor (which is always the ETB canonical).
    const rawFare = tier.priceMinor * 2;
    const adultFareMinor = tier.currency && (tier.currency as string) !== 'ETB'
      ? await this.currencyService.convertAmount(rawFare, tier.currency as any, 'ETB' as any)
      : rawFare;
    const childFareMinor = Math.round(adultFareMinor * 0.1);
    return adultCount * adultFareMinor + childCount * childFareMinor;
  }

  async initiatePayment(
    dto: InitiatePaymentDto,
    requestOrigin?: string | null,
  ): Promise<InitiateResponseDto> {
    const booking = await this.prisma.booking.findUnique({
      where: { id: dto.bookingId },
      include: { seats: true },
    });
    if (!booking) throw new NotFoundException("Booking not found");
    if (booking.status !== "PENDING_PAYMENT") {
      throw new BadRequestException("Booking not payable");
    }

    const method = dto.method as PaymentMethodType;

    // CAC Bank is an OTP debit — the bank SMSes the OTP to this number, so it's required.
    if (method === PaymentMethodType.CAC_BANK && !dto.payerAccount?.trim()) {
      throw new BadRequestException(
        "payerAccount (mobile number) is required for CAC Bank",
      );
    }

    const correctTotalMinor = await this.resolveBookingTotal(booking as any);

    // Patch the DB if the stored total is wrong (single-leg for a round-trip package booking)
    if (correctTotalMinor !== booking.totalMinor) {
      await this.prisma.booking.update({
        where: { id: booking.id },
        data: { totalMinor: correctTotalMinor },
      });
      (booking as any).totalMinor = correctTotalMinor;
    }

    // WALLET is an internal balance debit — it never leaves this app.
    if (method === PaymentMethodType.WALLET) {
      const existing = await this.prisma.paymentIntent.findUnique({
        where: { bookingId: dto.bookingId },
      });
      if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
        return this.formatIntentResponse(existing);
      }
      return this.initiateWalletPayment(booking);
    }

    // Double-charge guard for payment-method switches. Before opening a fresh charge over
    // this booking, reconcile any still-open intent against the authoritative provider
    // status — the booking-status check above only blocks once the booking is CONFIRMED,
    // which leaves a window where the first attempt actually paid but the mark-paid
    // webhook/poll hasn't landed yet.
    const existingIntent = await this.prisma.paymentIntent.findUnique({
      where: { bookingId: booking.id },
    });
    if (existingIntent && NON_TERMINAL_STATUSES.includes(existingIntent.status)) {
      let snapshot: PaymentIntentSnapshot | null = null;
      try {
        snapshot = await this.paymentClient.getIntentByReference(
          PaymentReferenceType.BOOKING,
          booking.id,
        );
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        this.logger.warn(
          `payment reconcile before initiate failed for booking ${booking.id}: ${message}; treating existing intent as still open`,
        );
      }

      // The previous attempt actually paid (provider SUCCEEDED, event just late):
      // converge the booking now and return it — never charge a second time.
      if (snapshot?.status === ProviderPaymentStatus.SUCCEEDED) {
        let intent = await this.syncIntentProjection(booking.id, snapshot);
        await this.finalizePaymentSuccess({
          intentId: intent.id,
          providerTxnId: snapshot.providerTxnId,
          paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
        });
        intent = await this.prisma.paymentIntent.findUniqueOrThrow({
          where: { id: intent.id },
        });
        return this.formatIntentResponse(intent);
      }

      // Still pending at the provider (REQUIRES_ACTION/PROCESSING) — or the payment
      // service was unreachable and the local status is non-terminal. Block the switch:
      // return the existing intent so the payer completes or waits out the open attempt
      // rather than opening a second concurrent charge.
      if (
        !snapshot ||
        snapshot.status === ProviderPaymentStatus.REQUIRES_ACTION ||
        snapshot.status === ProviderPaymentStatus.PROCESSING
      ) {
        const intent = snapshot
          ? await this.syncIntentProjection(booking.id, snapshot)
          : existingIntent;
        return this.formatIntentResponse(intent);
      }
      // Otherwise the provider reports FAILED/CANCELLED — fall through and initiate
      // the newly selected method below.
    }

    const { returnUrl, failureUrl } = this.resolveReturnUrls(
      method,
      requestOrigin,
    );

    // The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY
    // settle in DJF, CARD in USD, Ethiopian wallets in ETB). When the booking's displayCurrency
    // already matches the charge currency, use displayTotalMinor directly — the rate is already
    // baked in at booking creation time. Only fall back to ETB→target conversion when they differ.
    const paymentMethod = await this.prisma.paymentMethod.findUnique({
      where: { type: method },
    });
    const chargeCurrency = (
      paymentMethod?.currency ?? booking.currency
    ).toUpperCase();

    const bookingDisplayCurrency = ((booking as any).displayCurrency ?? 'ETB').toUpperCase();
    const bookingDisplayTotalMinor = (booking as any).displayTotalMinor as number | null;

    let chargeAmount: number;
    if (
      chargeCurrency === bookingDisplayCurrency &&
      chargeCurrency !== 'ETB' &&
      bookingDisplayTotalMinor != null
    ) {
      // Display currency matches charge currency — use the pre-converted amount directly.
      chargeAmount = this.currencyService.displayMinorToChargeMajor(bookingDisplayTotalMinor, chargeCurrency);
    } else if (chargeCurrency === 'ETB') {
      chargeAmount = this.currencyService.displayMinorToChargeMajor(booking.totalMinor, 'ETB');
    } else {
      // Booking is in ETB — convert to the provider's settlement currency.
      chargeAmount = await this.currencyService.convertMinorToChargeMajor(
        booking.totalMinor,
          booking.currency,
        chargeCurrency,
      );
    }

    const snapshot = await this.paymentClient.initiate({
      service: PaymentServiceEnum.PASSENGER,
      referenceType: PaymentReferenceType.BOOKING,
      referenceId: booking.id,
      orderRef: booking.bookingRef,
      amountMinor: chargeAmount,
      currency: chargeCurrency,
      provider: method as unknown as ProviderMethod,
      platform: dto.platform,
      payerAccount: dto.payerAccount,
      returnUrl,
      failureUrl,
    });

    let intent = await this.syncIntentProjection(booking.id, snapshot);
    if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
      // Already-paid order re-initiated: converge the booking now (idempotent).
      await this.finalizePaymentSuccess({
        intentId: intent.id,
        providerTxnId: snapshot.providerTxnId,
        paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
      });
      intent = await this.prisma.paymentIntent.findUniqueOrThrow({
        where: { id: intent.id },
      });
    }
    return this.formatIntentResponse(intent);
  }

  /**
   * Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by bookingId: the active
   * remote intent is looked up by reference, the OTP is forwarded to the payment service,
   * and the projection is refreshed. On success the booking is converged immediately
   * (idempotent — the outbox → mark-paid path also converges it). A wrong/expired OTP
   * bubbles up as a 400 so the payer can retry; the intent stays REQUIRES_ACTION.
   */
  async confirmOtpPayment(
    bookingId: string,
    otp: string,
  ): Promise<IntentStatusDto> {
    const snapshot = await this.paymentClient.getIntentByReference(
      PaymentReferenceType.BOOKING,
      bookingId,
    );
    if (!snapshot) {
      throw new NotFoundException("No active payment to confirm for this booking");
    }

    const confirmed = await this.paymentClient.confirmOtp(snapshot.intentId, otp);
    let intent = await this.syncIntentProjection(bookingId, confirmed);

    if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
      await this.finalizePaymentSuccess({
        intentId: intent.id,
        providerTxnId: confirmed.providerTxnId,
        paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
      });
      intent = await this.prisma.paymentIntent.findUniqueOrThrow({
        where: { id: intent.id },
      });
    }

    return this.formatIntentStatus(intent);
  }

  private resolveReturnUrls(
    method: PaymentMethodType,
    requestOrigin?: string | null,
  ): {
    returnUrl?: string;
    failureUrl?: string;
  } {
    const perMethod: Partial<
      Record<PaymentMethodType, { returnUrl?: string; failureUrl?: string }>
    > = {
      [PaymentMethodType.TELEBIRR]: {
        returnUrl: process.env.TELEBIRR_RETURN_URL,
      },
      [PaymentMethodType.WAAFI]: {
        returnUrl: process.env.WAAFI_SUCCESS_REDIRECT,
        failureUrl: process.env.WAAFI_FAIL_REDIRECT,
      },
      [PaymentMethodType.DMONEY]: {
        returnUrl: process.env.DMONEY_RETURN_URL,
      },
      [PaymentMethodType.CBE_BIRR]: {
        returnUrl: process.env.CBE_RETURN_URL,
      },
      [PaymentMethodType.EBIRR]: {
        returnUrl: process.env.EBIRR_RETURN_URL,
      },
      [PaymentMethodType.CARD]: {
        returnUrl: process.env.CARD_RETURN_URL,
      },
    };

    const m = perMethod[method] ?? {};
    let returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined;
    let failureUrl =
      m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl;

    // For browser-facing methods, swap the configured URL's host for whichever
    // allowlisted domain the user is currently on (bookingedr.et vs
    // passenger.edrsc.com). `requestOrigin` is already validated against the
    // allowlist by the controller; when it's null the configured URL is kept.
    if (DOMAIN_AWARE_METHODS.has(method) && requestOrigin) {
      returnUrl = rebaseUrlOrigin(returnUrl, requestOrigin);
      failureUrl = rebaseUrlOrigin(failureUrl, requestOrigin);
    }
    return { returnUrl, failureUrl };
  }

  async confirmWaafiReturnDemo(params: {
    referenceId?: string;
    state?: string;
    transactionId?: string;
  }): Promise<{ confirmed: boolean; bookingId?: string; reason?: string }> {
    if (!this.waafiDemoTrustReturn) {
      return { confirmed: false, reason: "demo-disabled" };
    }
    if ((params.state ?? "").toUpperCase() !== "APPROVED") {
      return { confirmed: false, reason: `not-approved (${params.state})` };
    }
    if (!params.referenceId) {
      return { confirmed: false, reason: "missing-referenceId" };
    }

    const intent = await this.prisma.paymentIntent.findFirst({
      where: { merchantOrderId: params.referenceId },
    });
    if (!intent) {
      this.logger.warn(
        `waafi demo return: no local intent for referenceId ${params.referenceId}`,
      );
      return { confirmed: false, reason: "intent-not-found" };
    }

    this.logger.warn(
      `WAAFI_DEMO_TRUST_RETURN enabled — confirming booking ${intent.bookingId} from browser return (INSECURE, demo only)`,
    );
    await this.finalizePaymentSuccess({
      intentId: intent.id,
      providerTxnId: params.transactionId,
    });
    return { confirmed: true, bookingId: intent.bookingId };
  }

  private async syncIntentProjection(
    bookingId: string,
    snapshot: PaymentIntentSnapshot,
  ) {
    const status =
      snapshot.status === ProviderPaymentStatus.SUCCEEDED
        ? PaymentIntentStatus.PROCESSING
        : (snapshot.status as unknown as PaymentIntentStatus);
    const data = {
      status,
      method: snapshot.provider as unknown as PaymentMethodType,
      merchantOrderId: snapshot.merchantOrderId,
      clientAction: snapshot.clientAction
        ? (snapshot.clientAction as unknown as Prisma.InputJsonValue)
        : Prisma.DbNull,
      providerTxnId: snapshot.providerTxnId ?? null,
      expiresAt: snapshot.expiresAt ? new Date(snapshot.expiresAt) : null,
      failureCode: snapshot.failureCode ?? null,
      failureMessage: snapshot.failureMessage ?? null,
      rawInitiation: (snapshot as any).providerResponse
        ? ((snapshot as any).providerResponse as unknown as Prisma.InputJsonValue)
        : Prisma.DbNull,
    };
    return this.prisma.paymentIntent.upsert({
      where: { bookingId },
      // amountMinor/currency are refreshed on update too: a cross-currency method switch
      // (e.g. Waafi/USD → Telebirr/ETB) re-initiates over the same row, and the projection
      // must reflect the currency the new provider actually charges — not the first one's.
      update: {
        ...data,
        amountMinor: snapshot.amountMinor,
        currency: snapshot.currency,
      },
      create: {
        bookingId,
        amountMinor: snapshot.amountMinor,
        currency: snapshot.currency,
        ...data,
      },
    });
  }

  private async initiateWalletPayment(
    booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
  ): Promise<InitiateResponseDto> {
    const debitResult = await this.prisma.$transaction(async (tx) => {
      const wallet = await tx.walletAccount.findUnique({
        where: { passengerId: booking.passengerId },
      });
      if (!wallet || wallet.balanceMinor < booking.totalMinor) {
        return { success: false };
      }
      const newBalance = wallet.balanceMinor - booking.totalMinor;
      await tx.walletAccount.update({
        where: { passengerId: booking.passengerId },
        data: { balanceMinor: newBalance },
      });
      await tx.walletLedgerEntry.create({
        data: {
          walletId: wallet.id,
          type: "DEBIT",
          amountMinor: booking.totalMinor,
          balanceAfterMinor: newBalance,
          description: `Train Ticket - ${booking.bookingRef}`,
          relatedBookingId: booking.id,
        },
      });
      return { success: true };
    });

    if (!debitResult.success) {
      const failed = await this.prisma.paymentIntent.upsert({
        where: { bookingId: booking.id },
        update: {
          status: PaymentIntentStatus.FAILED,
          failureCode: "INSUFFICIENT_BALANCE",
        },
        create: {
          bookingId: booking.id,
          amountMinor: booking.totalMinor,
          method: PaymentMethodType.WALLET,
          status: PaymentIntentStatus.FAILED,
          failureCode: "INSUFFICIENT_BALANCE",
        },
      });
      return this.formatIntentResponse(failed);
    }

    const intent = await this.prisma.paymentIntent.upsert({
      where: { bookingId: booking.id },
      update: { status: PaymentIntentStatus.PROCESSING },
      create: {
        bookingId: booking.id,
        amountMinor: booking.totalMinor,
        method: PaymentMethodType.WALLET,
        status: PaymentIntentStatus.PROCESSING,
        providerRef: `WALLET-${Date.now()}`,
      },
    });
    await this.finalizePaymentSuccess({ intentId: intent.id });
    const refreshed = await this.prisma.paymentIntent.findUniqueOrThrow({
      where: { id: intent.id },
    });
    return this.formatIntentResponse(refreshed);
  }

  private formatIntentResponse(
    intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
  ): InitiateResponseDto {
    const clientAction =
      intent.clientAction && typeof intent.clientAction === "object"
        ? (intent.clientAction as unknown as ClientAction)
        : undefined;
    return {
      intentId: intent.id,
      status: intent.status,
      clientAction,
      merchantOrderId: intent.merchantOrderId ?? undefined,
    };
  }

  /**
   * Payment status by booking id (UUID) OR booking reference / PNR (e.g. EDR-20240001).
   * Resolves the PNR to its booking id, then pulls the authoritative status from the payment
   * microservice (via {@link getIntentByBookingId}).
   */
  async getIntentByBookingRefOrId(
    bookingRefOrId: string,
  ): Promise<IntentStatusDto> {
    const bookingId = await this.resolveBookingId(bookingRefOrId);
    return this.getIntentByBookingId(bookingId);
  }

  /**
   * Diagnostic view by booking id (UUID) OR booking reference / PNR: the payment service's
   * stored intent row and a live provider status query, side by side ({ db, provider }).
   * Pure read — does not reconcile or confirm the booking.
   */
  async getPaymentDiagnosticByBookingRefOrId(
    bookingRefOrId: string,
  ): Promise<PaymentDiagnostic> {
    const bookingId = await this.resolveBookingId(bookingRefOrId);
    return this.paymentClient.getDiagnosticByReference(
      PaymentReferenceType.BOOKING,
      bookingId,
    );
  }

  /** Accept a booking UUID as-is; otherwise look the id up from its bookingRef/PNR. */
  private async resolveBookingId(bookingRefOrId: string): Promise<string> {
    const isUuid =
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
        bookingRefOrId,
      );
    if (isUuid) return bookingRefOrId;

    const booking = await this.prisma.booking.findUnique({
      where: { bookingRef: bookingRefOrId },
      select: { id: true },
    });
    if (!booking) {
      throw new NotFoundException(`Booking not found: ${bookingRefOrId}`);
    }
    return booking.id;
  }

  async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
    const local = await this.prisma.paymentIntent.findUnique({
      where: { bookingId },
    });


    if (local?.status === PaymentIntentStatus.SUCCEEDED) {
      const booking = await this.prisma.booking.findUnique({
        where: { id: bookingId },
        select: { status: true },
      });
      if (booking?.status === "CONFIRMED") {
        return this.formatIntentStatus(local);
      }
    }

    // WALLET payments never leave this app — no remote intent exists for them.
    if (local?.method === PaymentMethodType.WALLET) {
      return this.formatIntentStatus(local);
    }

    // Pull/reconcile through the payment microservice (it refreshes stale intents from the
    // provider itself). Falls back to the legacy local path when the service is unreachable
    // or only a pre-cutover local intent exists.
    let snapshot: PaymentIntentSnapshot | null = null;
    try {
      snapshot = await this.paymentClient.getIntentByReference(
        PaymentReferenceType.BOOKING,
        bookingId,
      );
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      this.logger.warn(
        `payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
      );
    }

    if (!snapshot) {
      // Pre-cutover/local-only intent (or service briefly unreachable): serve the cached
      // status. The payment service owns provider refresh for everything initiated after
      // the cutover; webhooks/mark-paid converge the rest.
      if (!local) throw new NotFoundException("PaymentIntent not found");
      return this.formatIntentStatus(local);
    }

    let intent = await this.syncIntentProjection(bookingId, snapshot);
    if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
      // Poll observed success before (or instead of) the mark-paid event — converge now.
      await this.finalizePaymentSuccess({
        intentId: intent.id,
        providerTxnId: snapshot.providerTxnId,
        paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
      });
      intent = await this.prisma.paymentIntent.findUniqueOrThrow({
        where: { id: intent.id },
      });
    }
    return this.formatIntentStatus(intent);
  }

  private formatIntentStatus(
    intent: Prisma.PaymentIntentGetPayload<Record<string, never>>,
  ): IntentStatusDto {
    const base = this.formatIntentResponse(intent);
    return {
      ...base,
      paidAt: intent.paidAt?.toISOString(),
      failureCode: intent.failureCode ?? undefined,
      failureMessage: intent.failureMessage ?? undefined,
      providerResponse:
        intent.rawInitiation && typeof intent.rawInitiation === "object"
          ? (intent.rawInitiation as Record<string, unknown>)
          : undefined,
    };
  }

  async refund(dto: RefundDto) {
    const intent = await this.prisma.paymentIntent.findUnique({
      where: { bookingId: dto.bookingId },
    });
    if (!intent || intent.status !== "SUCCEEDED")
      throw new BadRequestException("No successful payment to refund");
    await this.prisma.paymentIntent.update({
      where: { bookingId: dto.bookingId },
      data: { status: "CANCELLED" },
    });
    const booking = await this.prisma.booking.findUnique({
      where: { id: dto.bookingId },
      include: { seats: true },
    });
    if (booking) {
      await this.seatsService.releaseSeats(booking.id);
      await this.prisma.booking.update({
        where: { id: dto.bookingId },
        data: { status: "CANCELLED" },
      });
    }
    await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'REFUNDED', bookingId: dto.bookingId } });
    return { refunded: true, bookingRef: booking?.bookingRef };
  }

  addPaymentMethod(dto: AddPaymentMethodDto) {
    const data = {
      type: dto.type as unknown as PaymentMethodType,
      displayName: dto.displayName,
      region: dto.region as unknown as PaymentRegion,
      currency: dto.currency ?? "ETB",
      providerId: dto.providerId,
      enabled: dto.enabled ?? true,
      sortOrder: dto.sortOrder ?? 0,
    };
    return this.prisma.paymentMethod.upsert({
      where: { type: data.type },
      update: data,
      create: data,
    });
  }

  async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
    const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
    if (!existing) throw new NotFoundException('Payment method not found');
    
    const updateData: any = {};
    if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
    if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
    if (dto.currency !== undefined) updateData.currency = dto.currency;
    if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
    if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
    if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
    
    return this.prisma.paymentMethod.update({
      where: { id },
      data: updateData,
    });
  }

  getSupportedPaymentMethods(region?: PaymentRegionEnum) {
    return this.prisma.paymentMethod.findMany({
      where: {
        ...(region
          ? {
              region: {
                in: [
                  region,
                  PaymentRegionEnum.GLOBAL,
                ] as unknown as PaymentRegion[],
              },
            }
          : {}),
      },
      orderBy: [{ sortOrder: "asc" }, { displayName: "asc" }],
    });
  }

  async getBookingAmountByCurrency(
    bookingId: string,
    currency: string,
  ): Promise<{ booking_id: string; currency: string; amount: number }> {
    const booking = await this.prisma.booking.findUnique({
      where: { id: bookingId },
      select: {
        id: true,
        totalMinor: true,
        bookingType: true,
        packageId: true,
        priceTierId: true,
        currency: true,
        displayCurrency: true,
        displayTotalMinor: true,
      },
    });
    if (!booking) throw new NotFoundException('Booking not found');

    const correctTotalMinor = await this.resolveBookingTotal(booking as any);
    const requestedCurrency = currency.toUpperCase();

    // Source of truth: displayTotalMinor in displayCurrency when available,
    // otherwise totalMinor in ETB (bookings with no display currency override).
    const sourceCurrency = (booking.displayCurrency ?? 'ETB').toUpperCase();
    const sourceMinor = booking.displayTotalMinor ?? correctTotalMinor;

    // Same currency — return directly, no conversion needed.
    if (requestedCurrency === sourceCurrency) {
      return { booking_id: bookingId, currency: requestedCurrency, amount: sourceMinor / 100 };
    }

    const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
      where: { fromCurrency: sourceCurrency as any, toCurrency: requestedCurrency as any },
      orderBy: { effectiveDate: 'desc' },
    });

    let rate: number;
    if (exchangeRate) {
      rate = Number(exchangeRate.rate);
    } else {
      // Try inverse rate
      const inverseRate = await this.prisma.currencyExchangeRate.findFirst({
        where: { fromCurrency: requestedCurrency as any, toCurrency: sourceCurrency as any },
        orderBy: { effectiveDate: 'desc' },
      });
      if (inverseRate) {
        rate = 1 / Number(inverseRate.rate);
      } else {
        // Bridge via ETB (e.g. DJF→USD = (DJF→ETB) × (ETB→USD))
        rate = await this.currencyService.getRateOrThrow(sourceCurrency as any, requestedCurrency as any);
      }
    }
    const converted = (sourceMinor / 100) * rate;
    return { booking_id: bookingId, currency: requestedCurrency, amount: converted };
  }

  /**
   * Guard against an implausible paidAt from a provider event (e.g. a Telebirr epoch parsed as
   * ms×1000 → year 58429), which Prisma/Postgres rejects and would otherwise dead-letter the
   * whole confirmation. Falls back to "now" for missing/invalid/far-future/ancient values so the
   * booking still confirms.
   */
  private sanitizePaidAt(value?: Date): Date {
    const now = new Date();
    if (!value) return now;
    const t = value.getTime();
    const oneDayMs = 86_400_000;
    if (
      Number.isNaN(t) ||
      t > now.getTime() + oneDayMs ||
      t < Date.UTC(2000, 0, 1)
    ) {
      this.logger.warn(
        `finalizePaymentSuccess: implausible paidAt (epoch=${t}); using current time instead`,
      );
      return now;
    }
    return value;
  }

  async finalizePaymentSuccess(input: {
    intentId: string;
    providerTxnId?: string;
    paidAt?: Date;
  }): Promise<{ alreadyFinalized: boolean }> {
    const intent = await this.prisma.paymentIntent.findUnique({
      where: { id: input.intentId },
    });
    if (!intent) throw new NotFoundException("PaymentIntent not found");
    if (intent.status === PaymentIntentStatus.SUCCEEDED) {
      // Idempotency guard — but still repair missing tickets. They can be absent
      // when the first finalization threw from generate() after the transaction
      // committed: the caller got a 500, retried, and now hits this early-return.
      const ticketCount = await this.prisma.ticket.count({ where: { bookingId: intent.bookingId } });
      if (ticketCount === 0) {
        try {
          await this.ticketsService.generate(intent.bookingId);
        } catch (err) {
          const msg = err instanceof Error ? err.message : String(err);
          this.logger.warn(
            `Ticket generation failed on idempotency retry for booking ${intent.bookingId}: ${msg}. Attempting smart seat reassignment.`,
          );
          try {
            await this.ticketsService.smartAssignAndGenerate(intent.bookingId);
          } catch (retryErr) {
            this.logger.error(
              `Error generating ticket on idempotency retry for booking ${intent.bookingId}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
            );
          }
        }
      }
      return { alreadyFinalized: true };
    }
    if (intent.status === PaymentIntentStatus.CANCELLED) {
      throw new BadRequestException(
        "PaymentIntent is cancelled; cannot finalize",
      );
    }

    const booking = await this.prisma.booking.findUnique({
      where: { id: intent.bookingId },
      include: { seats: true },
    });
    if (!booking) throw new NotFoundException("Booking not found");

    const paidAt = this.sanitizePaidAt(input.paidAt);
    await this.prisma.$transaction(async (tx) => {
      await tx.paymentIntent.update({
        where: { id: intent.id },
        data: {
          status: PaymentIntentStatus.SUCCEEDED,
          providerTxnId:
            input.providerTxnId ?? intent.providerTxnId ?? undefined,
          paidAt,
        },
      });
      await tx.booking.update({
        where: { id: booking.id },
        data: { status: "CONFIRMED" },
      });
    });

    try {
      await this.seatsService.confirmSeats(booking.seats.map((s) => s.seatId));
    } catch (err) {
      this.logger.error(
        `Error confirming seats: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    try {
      await this.createJourneySegments(booking);
    } catch (err) {
      this.logger.error(
        `Error creating journey segments: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    try {
      await this.ticketsService.generate(booking.id);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      // Only reassign seats when a *different* booking genuinely holds the seat
      // (ConflictException). Any other error (transient DB issue, etc.) is logged
      // and swallowed — the passenger keeps their original seat and the ticket can
      // be retried via "Generate Missing" in the backoffice.
      if (err instanceof ConflictException) {
        this.logger.warn(
          `Seat conflict for booking ${booking.id}: ${msg}. Attempting smart seat reassignment.`,
        );
        try {
          await this.ticketsService.smartAssignAndGenerate(booking.id);
        } catch (retryErr) {
          this.logger.error(
            `Smart assign also failed for booking ${booking.id}: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`,
          );
        }
      } else {
        this.logger.error(`Error generating ticket for booking ${booking.id}: ${msg}`);
      }
    }

    try {
      await this.awardLoyaltyPoints(
        booking.passengerId,
        booking.totalMinor,
        booking.id,
      );
    } catch (err) {
      this.logger.warn(
        `Error awarding loyalty points: ${err instanceof Error ? err.message : String(err)}`,
      );
    }

    this.eventEmitter.emit("payment.succeeded", { booking });
    return { alreadyFinalized: false };
  }

  private async handleSupplementaryChargeEvent(event: PaymentEventDto): Promise<MarkPaidResponseDto> {
    if (event.eventType === 'payment.failed') {
      this.logger.warn(`supplementary charge ${event.referenceId} payment failed`);
      return { processed: true };
    }
    const charge = await this.prisma.supplementaryCharge.findUnique({ where: { id: event.referenceId } });
    if (!charge) {
      this.logger.error(`mark-paid: no supplementary charge for reference ${event.referenceId}`);
      return { processed: false, reason: 'charge-not-found' };
    }
    if (charge.status === 'PAID') return { processed: true, alreadyFinalized: true };
    await this.prisma.supplementaryCharge.update({
      where: { id: charge.id },
      data: { status: 'PAID', paidAt: new Date(), providerTxnId: event.providerTxnId ?? null },
    });
    await this.auditService.log({ action: 'UPDATE', entityType: 'SupplementaryCharge', entityId: charge.id, newData: { status: 'PAID', providerTxnId: event.providerTxnId } });
    return { processed: true };
  }

  async handlePaymentEvent(
    event: PaymentEventDto,
  ): Promise<MarkPaidResponseDto> {
    if (event.service !== PaymentServiceEnum.PASSENGER) {
      this.logger.warn(
        `mark-paid: ignoring foreign reference ${event.service}/${event.referenceType}/${event.referenceId}`,
      );
      return { processed: false, reason: "foreign-reference" };
    }

    if (event.referenceType === PaymentReferenceType.SUPPLEMENTARY_CHARGE) {
      return this.handleSupplementaryChargeEvent(event);
    }

    if (event.referenceType !== PaymentReferenceType.BOOKING) {
      this.logger.warn(
        `mark-paid: ignoring unknown referenceType ${event.referenceType}`,
      );
      return { processed: false, reason: "foreign-reference" };
    }

    if (event.eventType === "payment.failed") {
      const intent = await this.prisma.paymentIntent.findUnique({
        where: { bookingId: event.referenceId },
      });
      if (intent) {
        await this.markPaymentFailed({
          intentId: intent.id,
          failureCode: event.failureCode,
          failureMessage: event.failureMessage,
        });
      }
      const failedBooking = await this.prisma.booking.findUnique({
        where: { id: event.referenceId },
      });
      if (failedBooking) {
        this.eventEmitter.emit("payment.failed", { booking: failedBooking });
      }
      return { processed: true };
    }

    const booking = await this.prisma.booking.findUnique({
      where: { id: event.referenceId },
    });
    if (!booking) {
      // Ack (200) — a missing booking will not appear on redelivery; needs investigation.
      this.logger.error(
        `mark-paid: no booking for reference ${event.referenceId}`,
      );
      return { processed: false, reason: "booking-not-found" };
    }

    // C-4 guard: a settlement must cover what the passenger was quoted. Compare the provider-settled
    // amount against the booking's display-currency total (the amount the customer agreed to pay);
    // a short payment must NOT confirm the booking. Amount-only — the display↔charge currency
    // divergence is tracked separately under the USD/DJF findings. The 1% tolerance absorbs rounding.
    const expectedMinor = booking.displayTotalMinor ?? booking.totalMinor;
    const shortPayTolerance = Math.max(1, Math.round(expectedMinor * 0.01));
    if (event.amountMinor < expectedMinor - shortPayTolerance) {
      this.logger.error(
        `mark-paid: short payment for booking ${booking.id} — settled ${event.amountMinor} ${event.currency} < expected ${expectedMinor} ${booking.displayCurrency}; not confirming`,
      );
      return { processed: false, reason: "amount-mismatch" };
    }

    // Local intent row is a projection during the strangler migration: reuse it when the
    // legacy initiate path created one, otherwise materialize it from the event.
    let intent = await this.prisma.paymentIntent.findUnique({
      where: { bookingId: event.referenceId },
    });
    if (!intent) {
      intent = await this.prisma.paymentIntent.create({
        data: {
          bookingId: event.referenceId,
          amountMinor: event.amountMinor,
          currency: event.currency,
          method: event.provider as unknown as PaymentMethodType,
          status: PaymentIntentStatus.PROCESSING,
          merchantOrderId: event.merchantOrderId,
          providerTxnId: event.providerTxnId,
        },
      });
    }

    const { alreadyFinalized } = await this.finalizePaymentSuccess({
      intentId: intent.id,
      providerTxnId: event.providerTxnId,
      paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
    }).catch((err) => {
      this.logger.error(
        `finalizePaymentSuccess failed for booking ${event.referenceId}: ${err instanceof Error ? err.message : String(err)}`,
      );
      return { alreadyFinalized: false };
    });
    return { processed: true, alreadyFinalized };
  }

  async forceConfirmPayment(bookingId: string, dto: ForceConfirmDto = {}): Promise<{ alreadyFinalized: boolean }> {
    const booking = await this.prisma.booking.findUnique({ where: { id: bookingId } });
    if (!booking) throw new NotFoundException('Booking not found');

    const resolvedMethod = dto.paymentMethod
      ? (dto.paymentMethod as unknown as PaymentMethodType)
      : PaymentMethodType.TELEBIRR;

    let intent = await this.prisma.paymentIntent.findUnique({ where: { bookingId } });
    if (!intent) {
      intent = await this.prisma.paymentIntent.create({
        data: {
          bookingId,
          amountMinor: booking.totalMinor,
          currency: booking.currency,
          method: resolvedMethod,
          status: PaymentIntentStatus.PROCESSING,
          providerRef: `FORCE-${Date.now()}`,
          providerTxnId: dto.paymentReference ?? null,
          failureMessage: dto.notes ?? null,
        },
      });
    } else {
      // Update method/reference/notes regardless of current status
      const updateData: any = {};
      if (dto.paymentReference) updateData.providerTxnId = dto.paymentReference;
      if (dto.paymentMethod) updateData.method = resolvedMethod;
      if (dto.notes) updateData.failureMessage = dto.notes;
      if (intent.status === PaymentIntentStatus.CANCELLED || intent.status === PaymentIntentStatus.FAILED) {
        updateData.status = PaymentIntentStatus.PROCESSING;
      }
      if (Object.keys(updateData).length) {
        intent = await this.prisma.paymentIntent.update({
          where: { id: intent.id },
          data: updateData,
        });
      }
    }

    return this.finalizePaymentSuccess({
      intentId: intent.id,
      providerTxnId: dto.paymentReference ?? intent.providerTxnId ?? undefined,
    }).then(async (result) => {
      await this.auditService.log({ action: 'UPDATE', entityType: 'Payment', entityId: intent.id, newData: { status: 'FORCE_CONFIRMED', bookingId, paymentMethod: dto.paymentMethod, paymentReference: dto.paymentReference } });
      return result;
    });
  }

  async markPaymentFailed(input: {
    intentId: string;
    failureCode?: string;
    failureMessage?: string;
  }): Promise<void> {
    const intent = await this.prisma.paymentIntent.findUnique({
      where: { id: input.intentId },
    });
    if (!intent) throw new NotFoundException("PaymentIntent not found");
    if (
      intent.status === PaymentIntentStatus.SUCCEEDED ||
      intent.status === PaymentIntentStatus.CANCELLED
    ) {
      return;
    }
    await this.prisma.paymentIntent.update({
      where: { id: intent.id },
      data: {
        status: PaymentIntentStatus.FAILED,
        failureCode: input.failureCode,
        failureMessage: input.failureMessage,
      },
    });
  }

  private async awardLoyaltyPoints(
    passengerId: string,
    amountMinor: number,
    bookingId: string,
  ) {
    const points = Math.floor(amountMinor / 100);
    const account = await this.prisma.loyaltyAccount.findUnique({
      where: { passengerId },
    });
    if (!account) return;
    const newBalance = account.pointsBalance + points;
    const tier =
      newBalance >= 10000
        ? "PLATINUM"
        : newBalance >= 5000
          ? "GOLD"
          : newBalance >= 2000
            ? "SILVER"
            : "BRONZE";
    await this.prisma.loyaltyAccount.update({
      where: { passengerId },
      data: { pointsBalance: { increment: points }, tier: tier as any },
    });
    await this.prisma.loyaltyLedgerEntry.create({
      data: {
        accountId: account.id,
        delta: points,
        reason: "TRIP_COMPLETED",
        bookingId,
        balanceAfter: newBalance,
      },
    });
  }

  private async createJourneySegments(
    booking: Prisma.BookingGetPayload<{ include: { seats: true } }>,
  ) {
    const b = booking as any;

    // Build per-leg definitions: { scheduleId, originStationId, destinationStationId, seatIds[] }
    // BookingSeat.leg:  1=outbound/leg-1, 2=return/leg-2, 3=return leg-1 (transit), 4=return leg-2
    type LegDef = { scheduleId: string; originStationId: string; destinationStationId: string; seatIds: string[] };
    const legDefs: LegDef[] = [];

    const seatsForLeg = (legNum: number) =>
      booking.seats.filter((s: any) => s.leg === legNum).map((s: any) => s.seatId);

    if (booking.bookingType === 'ONE_WAY') {
      legDefs.push({
        scheduleId:          booking.scheduleId,
        originStationId:     b.originStationId,
        destinationStationId: b.destinationStationId,
        seatIds:             booking.seats.map((s: any) => s.seatId),
      });
    } else if (booking.bookingType === 'ROUND_TRIP') {
      legDefs.push({
        scheduleId:          booking.scheduleId,
        originStationId:     b.originStationId,
        destinationStationId: b.destinationStationId,
        seatIds:             seatsForLeg(1),
      });
      if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
        legDefs.push({
          scheduleId:          b.returnScheduleId,
          originStationId:     b.returnOriginStationId,
          destinationStationId: b.returnDestinationStationId,
          seatIds:             seatsForLeg(2),
        });
      }
    } else if (booking.bookingType === 'TRANSIT') {
      legDefs.push({
        scheduleId:          booking.scheduleId,
        originStationId:     b.originStationId,
        destinationStationId: b.leg2OriginStationId, // transit station
        seatIds:             seatsForLeg(1),
      });
      if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
        legDefs.push({
          scheduleId:          b.leg2ScheduleId,
          originStationId:     b.leg2OriginStationId,
          destinationStationId: b.leg2DestinationStationId,
          seatIds:             seatsForLeg(2),
        });
      }
    } else if (booking.bookingType === 'ROUND_TRIP_TRANSIT') {
      legDefs.push({
        scheduleId:          booking.scheduleId,
        originStationId:     b.originStationId,
        destinationStationId: b.leg2OriginStationId,
        seatIds:             seatsForLeg(1),
      });
      if (b.leg2ScheduleId && b.leg2OriginStationId && b.leg2DestinationStationId) {
        legDefs.push({
          scheduleId:          b.leg2ScheduleId,
          originStationId:     b.leg2OriginStationId,
          destinationStationId: b.leg2DestinationStationId,
          seatIds:             seatsForLeg(2),
        });
      }
      if (b.returnScheduleId && b.returnOriginStationId && b.returnDestinationStationId) {
        legDefs.push({
          scheduleId:          b.returnScheduleId,
          originStationId:     b.returnOriginStationId,
          destinationStationId: b.returnLeg2OriginStationId ?? b.returnDestinationStationId,
          seatIds:             seatsForLeg(3),
        });
      }
      if (b.returnLeg2ScheduleId && b.returnLeg2OriginStationId && b.returnLeg2DestStationId) {
        legDefs.push({
          scheduleId:          b.returnLeg2ScheduleId,
          originStationId:     b.returnLeg2OriginStationId,
          destinationStationId: b.returnLeg2DestStationId,
          seatIds:             seatsForLeg(4),
        });
      }
    }

    if (legDefs.length === 0) return;

    const journey = await this.prisma.journey.create({
      data: {
        passengerId: booking.passengerId,
        bookingId:   booking.id,
        status:      'CONFIRMED',
        totalMinor:  booking.totalMinor,
        currency:    booking.currency,
      } as any,
    });

    const journeySegments: any[] = [];
    let segmentOrder = 0;

    for (const leg of legDefs) {
      if (leg.seatIds.length === 0) continue;

      const stopTimes = await this.prisma.tripStopTime.findMany({
        where: { scheduleId: leg.scheduleId },
        orderBy: { sequence: 'asc' },
        select: { stationId: true, sequence: true },
      });

      const originIdx = stopTimes.findIndex(st => st.stationId === leg.originStationId);
      const destIdx   = stopTimes.findIndex(st => st.stationId === leg.destinationStationId);
      if (originIdx < 0 || destIdx < 0 || originIdx >= destIdx) continue;

      for (const seatId of leg.seatIds) {
        for (let i = originIdx; i < destIdx; i++) {
          journeySegments.push({
            journeyId:          journey.id,
            scheduleId:         leg.scheduleId,
            segmentOrder:       segmentOrder++,
            seatId,
            departureStationId: stopTimes[i].stationId,
            arrivalStationId:   stopTimes[i + 1].stationId,
          });
        }
      }
    }

    if (journeySegments.length > 0) {
      await this.prisma.journeySegment.createMany({ data: journeySegments });
    }
  }
}
