/**
 * Reserve seat → release | issue booking — coverage for GuestBookingService.
 * issueBookingFromReservation(), BookingsService.getByPayToken(), and the
 * SeatsService.unblockSeat() reuse that releases the reservation.
 *
 * STAFF path: fee-waived, finalized immediately via PaymentsService.finalizePaymentSuccess()
 * (the same all-in-one finalizer every real payment webhook uses) — proves a Ticket is
 * issued, the booking is CONFIRMED, and the reservation's own SeatBlock is released (not
 * left stale) before TicketsService.generate() marks the now-ticketed seat BOOKED with its
 * own fresh block — the correct end state once a real ticket exists, distinct from an
 * operational reservation that never converts into a booking.
 *
 * PASSENGER path: booking stays PENDING_PAYMENT with a payToken texted to the traveler —
 * proves no PaymentIntent is created yet (the passenger creates one themselves later via the
 * already-public /payments/initiate, same as a normal guest checkout) and that
 * BookingsService.getByPayToken() resolves it for the portal's pay-by-link page.
 *
 * Uses the slim harness (real Nest DI) for SchedulesService/CurrencyService/FareEngineService,
 * same as stop-based-booking-segment.e2e-spec.ts. SeatsService/TicketsService/PaymentsService/
 * GuestBookingService/BookingsService are instantiated directly with a real Prisma +
 * stubbed collaborators (Tier-2 pattern, money-integrity.e2e-spec.ts) — PaymentsModule/
 * BookingsModule pull in NotificationsModule → RabbitMQ, which the slim harness avoids.
 */
import { IdDocumentType, PaymentIntentStatus, PaymentMethodType } from "@prisma/client";
import { validateSync } from "class-validator";
import { plainToInstance } from "class-transformer";
import { SchedulesService } from "../src/modules/schedules/schedules.service";
import { SeatsService } from "../src/modules/seats/seats.service";
import { TicketsService } from "../src/modules/tickets/tickets.service";
import { PaymentsService } from "../src/modules/payments/payments.service";
import { CurrencyService } from "../src/modules/currency/currency.service";
import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service";
import { SystemConfigService } from "../src/modules/system-config/system-config.service";
import { BookingsService } from "../src/modules/bookings/bookings.service";
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";

/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */
function asyncStub(): any {
  return new Proxy({}, { get: () => async () => undefined });
}

describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
  let harness: ServiceHarness;
  let schedulesService: SchedulesService;
  let seatsService: SeatsService;
  let guestBookingService: GuestBookingService;
  let bookingsService: BookingsService;
  let smsClient: { sendSms: jest.Mock };

  beforeAll(async () => {
    harness = await createServiceHarness();
    schedulesService = await harness.moduleRef.resolve(SchedulesService);
    const currencyService = harness.moduleRef.get(CurrencyService);
    const fareEngine = harness.moduleRef.get(FareEngineService);
    const systemConfig = new SystemConfigService(harness.prisma as any);

    seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
    const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
    const paymentsService = new PaymentsService(
      harness.prisma as any,
      seatsService,
      ticketsService,
      { emit: () => true } as any, // eventEmitter
      asyncStub(), // paymentClient — never reached: finalizePaymentSuccess doesn't call it
      currencyService,
      asyncStub(), // auditService
    );
    smsClient = { sendSms: jest.fn().mockResolvedValue({ queued: true }) };
    guestBookingService = new GuestBookingService(
      harness.prisma as any,
      seatsService,
      asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID
      currencyService,
      asyncStub(), // passengerAuthService — never reached: no createAccount in this flow
      fareEngine,
      { emit: () => true } as any, // eventEmitter
      paymentsService,
      asyncStub(), // auditService
      smsClient,
    );
    bookingsService = new BookingsService(
      harness.prisma as any,
      asyncStub(), // dataSource
      seatsService,
      { emit: () => true } as any,
      asyncStub(), // verifaydaService
      currencyService,
      fareEngine,
      asyncStub(), // auditService
    );
  });

  afterAll(async () => {
    await harness?.close();
  });

  beforeEach(() => {
    smsClient.sendSms.mockClear();
  });

  /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
  async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) {
    const train = await harness.prisma.train.create({
      data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` },
    });
    const coach = await harness.prisma.coach.create({
      data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" },
    });
    const seats = await Promise.all(
      ["1A", "1B"].map((seatNumber, i) =>
        harness.prisma.seat.create({
          data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 },
        }),
      ),
    );
    const schedule = await schedulesService.createSchedule({
      trainId: train.id,
      routeId: IDS.route,
      departureAt: opts.departureAt.toISOString(),
      arrivalAt: opts.arrivalAt.toISOString(),
      coachIds: [coach.id],
    } as any);
    return { schedule, seats };
  }

  function baseDto(overrides: Partial<Record<string, any>> = {}) {
    return {
      scheduleId: "", // filled per-test
      originStationId: IDS.stationA,
      destinationStationId: IDS.stationB,
      // No seatClassId — the seat's class is resolved server-side from its coach type +
      // nationality tier (Djiboutian -> LOCAL, matching seed-core's seatClassLocal).
      bookingKind: ReservationBookingKind.STAFF,
      passengerName: "Test Traveler",
      dateOfBirth: "1990-01-01",
      idDocumentType: IdDocumentType.PASSPORT,
      passportNumber: "X123456",
      nationality: "Djiboutian",
      ...overrides,
    };
  }

  it("404s when the seat has no active reservation", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await expect(
      guestBookingService.issueBookingFromReservation(
        seats[0].id,
        baseDto({ scheduleId: schedule.id }) as any,
        null,
      ),
    ).rejects.toThrow(/not reserved/i);
  });

  it("resolves the seat class (and therefore fare) from nationality — LOCAL for Ethiopian/Djiboutian, INTERNATIONAL for Other", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);

    // A->B is 100km (seed-core DISTANCE). LOCAL seatClass is 3.00 ETB/km, INTERNATIONAL is
    // 5.00 ETB/km, USD_TO_ETB=100 — same formula pricing-fare-engine.e2e-spec.ts verifies.
    // Both use PASSENGER kind so the computed fare survives unwaived (STAFF always zeroes
    // totalMinor/fareMinor regardless of the resolved seat class).
    const localSchedule = await createTestSchedule({ trainNumber: `RES-FARE-LOCAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
    await seatsService.blockSeat(localSchedule.seats[0].id, "Reserved", localSchedule.schedule.id);
    const localBooking: any = await guestBookingService.issueBookingFromReservation(
      localSchedule.seats[0].id,
      baseDto({ scheduleId: localSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+251911234567", nationality: "Ethiopian" }) as any,
      null,
    );
    const localSeat = await harness.prisma.bookingSeat.findFirst({ where: { bookingId: localBooking.booking.id } });
    expect(localSeat?.fareMinor).toBe(30_000);

    const intlSchedule = await createTestSchedule({ trainNumber: `RES-FARE-INTL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
    await seatsService.blockSeat(intlSchedule.seats[0].id, "Reserved", intlSchedule.schedule.id);
    const intlBooking: any = await guestBookingService.issueBookingFromReservation(
      intlSchedule.seats[0].id,
      baseDto({ scheduleId: intlSchedule.schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567", nationality: "Other" }) as any,
      null,
    );
    expect(intlBooking.booking.totalMinor).toBe(50_000);
  });

  it("rejects a nationality outside Ethiopian/Djiboutian/Other at the DTO level", () => {
    const dto = plainToInstance(IssueReservationBookingDto, baseDto({ scheduleId: "irrelevant", nationality: "French" }));
    const errors = validateSync(dto);
    expect(errors.some((e) => e.property === "nationality")).toBe(true);
  });

  it("STAFF + a GLOBAL block: booking is CONFIRMED, fee-waived, ticket issued, and the old global block is replaced (not left stale)", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-GLOBAL-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "VIP hold — mayor's office");

    const result: any = await guestBookingService.issueBookingFromReservation(
      seats[0].id,
      baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
      "staff-user-1",
    );

    expect(result.booking.status).toBe("CONFIRMED");
    expect(result.payUrl).toBeUndefined();

    const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
    expect(intent?.amountMinor).toBe(0);
    expect(intent?.status).toBe(PaymentIntentStatus.SUCCEEDED);
    expect(intent?.method).toBe(PaymentMethodType.WALLET);

    const ticketCount = await harness.prisma.ticket.count({ where: { bookingId: result.booking.id } });
    expect(ticketCount).toBeGreaterThan(0);

    // TicketsService.generate() itself creates a fresh global SeatBlock ("Booked in
    // tickets ...") once a ticket is actually issued — a ticketed seat SHOULD show as
    // unavailable. The meaningful assertion is that the ORIGINAL reservation's block is
    // gone (proving unblockSeat ran), replaced by exactly this one new booked-marker block —
    // not that the seat is left globally BLOCKED under the old reservation's reason forever.
    const blocks = await harness.prisma.seatBlock.findMany({ where: { seatId: seats[0].id } });
    expect(blocks).toHaveLength(1);
    expect(blocks[0].reason).not.toMatch(/VIP hold/);
    expect(blocks[0].reason).toMatch(/Booked in tickets/);

    const seat = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
    expect(seat?.status).toBe("BOOKED");
  });

  it("STAFF + a SCHEDULE-SCOPED block: same release path, no leftover reservation block", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAFF-SCOPED-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved for inspection", schedule.id);
    const seatBefore = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
    expect(seatBefore?.status).not.toBe("BLOCKED"); // schedule-scoped block never touches Seat.status

    const result: any = await guestBookingService.issueBookingFromReservation(
      seats[0].id,
      baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
      "staff-user-2",
    );

    expect(result.booking.status).toBe("CONFIRMED");
    // The original schedule-scoped reservation block is gone; only generate()'s own
    // booked-marker block remains (see the global-block test above for why).
    const originalScopedBlock = await harness.prisma.seatBlock.count({
      where: { seatId: seats[0].id, scheduleId: schedule.id, reason: "Reserved for inspection" },
    });
    expect(originalScopedBlock).toBe(0);

    // Same as the global-block test — generate() marks the now-ticketed seat BOOKED.
    const seatAfter = await harness.prisma.seat.findUnique({ where: { id: seats[0].id } });
    expect(seatAfter?.status).toBe("BOOKED");
  });

  it("PASSENGER path: booking stays PENDING_PAYMENT, payToken is set, no PaymentIntent yet, SMS sent with the pay link", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);

    const result: any = await guestBookingService.issueBookingFromReservation(
      seats[0].id,
      baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
      "staff-user-3",
    );

    expect(result.booking.status).toBe("PENDING_PAYMENT");
    expect(result.booking.payToken).toBeTruthy();
    expect(result.payUrl).toContain(result.booking.payToken);

    const intent = await harness.prisma.paymentIntent.findUnique({ where: { bookingId: result.booking.id } });
    expect(intent).toBeNull();

    expect(smsClient.sendSms).toHaveBeenCalledTimes(1);
    const smsArgs = smsClient.sendSms.mock.calls[0][0];
    expect(smsArgs.to).toBe("+253771234567");
    expect(smsArgs.message).toContain(result.booking.payToken);

    // BookingsService.getByPayToken — the portal pay-by-link page's data source.
    const byToken: any = await bookingsService.getByPayToken(result.booking.payToken);
    expect(byToken.id).toBe(result.booking.id);
    expect(byToken.status).toBe("PENDING_PAYMENT");
    expect(byToken.schedule.origin.id).toBe(IDS.stationA);
  });

  it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
    // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
    // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
    // no JourneySegment (only created on payment success) the instant the payment link went
    // out — fully bookable by the general public before the traveler had even paid.
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-STAYS-BLOCKED-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);

    const result: any = await guestBookingService.issueBookingFromReservation(
      seats[0].id,
      baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
      "staff-user-4",
    );
    expect(result.booking.status).toBe("PENDING_PAYMENT");

    // A member of the public trying to hold the exact same seat/leg must be rejected —
    // proves the seat is covered by a real SeatHold (or equivalent), not silently free.
    await expect(
      seatsService.holdSeats({
        scheduleId: schedule.id,
        originStationId: IDS.stationA,
        destinationStationId: IDS.stationB,
        passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
      } as any),
    ).rejects.toThrow(/already (held|booked)/i);
  });

  it("requires a phone number for a PASSENGER booking", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-PAX-NOPHONE-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);

    await expect(
      guestBookingService.issueBookingFromReservation(
        seats[0].id,
        baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: undefined }) as any,
        null,
      ),
    ).rejects.toThrow(/phone number is required/i);
  });

  it("rejects NATIONAL_ID for a non-Ethiopian nationality", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NATID-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);

    await expect(
      guestBookingService.issueBookingFromReservation(
        seats[0].id,
        baseDto({
          scheduleId: schedule.id,
          nationality: "Djiboutian",
          idDocumentType: IdDocumentType.NATIONAL_ID,
          idDocumentNumber: "ET123456789",
        }) as any,
        null,
      ),
    ).rejects.toThrow(/national id is only valid for ethiopian/i);
  });

  it("still enforces the check-in cutoff — rejects once the boarding stop is too close to departure", async () => {
    await resetAndSeedCore(harness.prisma);
    const dep = new Date(Date.now() + 5 * 60_000); // 5min out — inside the default 30-min cutoff
    const arr = new Date(dep.getTime() + 50 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CUTOFF-${Date.now()}`, departureAt: dep, arrivalAt: arr });

    await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);

    await expect(
      guestBookingService.issueBookingFromReservation(
        seats[0].id,
        baseDto({ scheduleId: schedule.id, originStationId: IDS.stationA, destinationStationId: IDS.stationB }) as any,
        null,
      ),
    ).rejects.toThrow(/not accepted within/i);
  });

  it("getByPayToken 404s for an unknown token and 400s once expired", async () => {
    await resetAndSeedCore(harness.prisma);
    await expect(bookingsService.getByPayToken("no-such-token")).rejects.toThrow(/not found/i);

    const dep = new Date(Date.now() + 3 * 60 * 60_000);
    const arr = new Date(dep.getTime() + 100 * 60_000);
    const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-EXPIRED-${Date.now()}`, departureAt: dep, arrivalAt: arr });
    await seatsService.blockSeat(seats[0].id, "Reserved", schedule.id);
    const result: any = await guestBookingService.issueBookingFromReservation(
      seats[0].id,
      baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
      null,
    );

    await harness.prisma.booking.update({
      where: { id: result.booking.id },
      data: { payTokenExpiresAt: new Date(Date.now() - 60_000) },
    });

    await expect(bookingsService.getByPayToken(result.booking.payToken)).rejects.toThrow(/expired/i);
  });
});
