import { expect, type Locator, type Page, type Route } from "@playwright/test";
import { API_URL, CURRENCY_BY_NATIONALITY, resultsUrl } from "./data";

export type Nationality = "Ethiopian" | "Djiboutian" | "Other";

export interface PaxSpec {
  category: "ADULT" | "CHILD";
  name: string;
  gender: "Male" | "Female";
  /** Date of birth. Adults: age 6–110. Children: age < 5 (to be free-eligible). */
  dob: { d: number; m: number; y: number };
  /** Adults only. */
  phone?: string;
  /** Non-Ethiopian adults only. */
  passport?: { number: string; country: string; issue: string; expiry: string };
}

export interface TripOptions {
  nationality?: Nationality;
  tripType?: "ONE_WAY" | "ROUND_TRIP";
  /** If `passengers` is omitted, N adults + M children are generated. */
  adults?: number;
  children?: number;
  passengers?: PaxSpec[];
  /** Promo code injected via the results URL (`?promoCode=`) — the portal has no promo input. */
  promoCode?: string;
  /** Mutate the outgoing POST /bookings(/guest) body (e.g. forge reviewedTotalMinor). */
  mutateBookingBody?: (body: any) => any;
  /**
   * When the POST /bookings(/guest) is expected to be rejected (e.g. a forged total the server
   * must refuse): don't assert a bookingId and return early with `bookingStatus` set, instead of
   * driving on to payment. Lets a spec assert the server refused the booking.
   */
  tolerateBookingError?: boolean;
  paymentMethod?: "WALLET" | "TELEBIRR";
  /**
   * For TELEBIRR: after initiate, abort the external gateway redirect and forge settlement via the
   * internal mark-paid endpoint. `amountMinor` lets a test short-pay (settle for the wrong amount).
   * Defaults to settling for the real booking total.
   */
  forgeSettlement?: { amountMinor?: number };
}

export interface BookingResult {
  /** displayAmountMinor on the results card (what the passenger sees — passenger currency). */
  cardDisplayMinor: number;
  /** baseFareMinor on the results card (internal ETB fare; diverges from display for USD/DJF). */
  cardBaseFareMinor: number;
  /** The search response's displayCurrency (ETB/USD/DJF). */
  displayCurrency: string;
  /** reviewedTotalMinor the browser actually sent to POST /bookings. */
  reviewedTotalMinor: number;
  /** HTTP status the POST /bookings(/guest) returned (2xx on success, 4xx when the server rejects). */
  bookingStatus: number;
  bookingId: string;
  /** Whether the flow used /bookings/guest. */
  guest: boolean;
  initiateStatus: number;
  /** merchantOrderId returned by POST /payments/initiate (gateway methods). */
  merchantOrderId?: string;
  /** true once /booking/confirmation is reached. */
  confirmed: boolean;
  /** The GET /search/fare-breakdown payload seen on the review page (per-pax fares + discount). */
  fareBreakdown: any;
}

const PHONE_BY_NATIONALITY: Record<Nationality, string> = {
  Ethiopian: "912345678",
  Djiboutian: "77123456",
  Other: "14155552671",
};

const PASSPORT_COUNTRY: Record<Nationality, string> = {
  Ethiopian: "",
  Djiboutian: "Djibouti",
  Other: "Canada",
};

/** Build a default passenger list: adults first, then children (matches form index → category). */
export function makePassengers(adults: number, children: number, nationality: Nationality): PaxSpec[] {
  const list: PaxSpec[] = [];
  for (let i = 0; i < adults; i++) {
    list.push({
      category: "ADULT",
      name: `Adult ${i + 1}`,
      gender: i % 2 === 0 ? "Male" : "Female",
      dob: { d: 15, m: 6, y: 1990 },
      phone: PHONE_BY_NATIONALITY[nationality],
      passport:
        nationality === "Ethiopian"
          ? undefined
          : { number: "P1234567", country: PASSPORT_COUNTRY[nationality], issue: "2020-01-01", expiry: "2032-01-01" },
    });
  }
  for (let j = 0; j < children; j++) {
    // Age ~3 as of 2026 → strictly under 5, so isChild() and the free-child policy apply.
    list.push({ category: "CHILD", name: `Child ${j + 1}`, gender: "Female", dob: { d: 10, m: 3, y: 2023 } });
  }
  return list;
}

/** Passenger card locator (scoped by the "Passenger N" heading; N is 1-based). */
function card(page: Page, i: number): Locator {
  return page.locator("div.card").filter({ hasText: new RegExp(`Passenger ${i + 1}\\b`) });
}

/** Open the DOB modal for a passenger card, enter the date manually, and confirm. */
async function fillDob(page: Page, c: Locator, dob: { d: number; m: number; y: number }) {
  await c.getByRole("button", { name: /select date of birth/i }).click();
  await page.getByRole("button", { name: /enter manually/i }).click();
  await page.getByPlaceholder("DD").fill(String(dob.d));
  await page.getByPlaceholder("MM").fill(String(dob.m));
  await page.getByPlaceholder("YYYY").fill(String(dob.y));
  await page.getByRole("button", { name: /^confirm/i }).click();
}

/** Fill one passenger card (adult or child), revealing the manual form if it's gated. */
async function fillPassenger(page: Page, i: number, spec: PaxSpec) {
  const c = card(page, i);
  const nameInput = page.locator(`input[name="passengers.${i}.name"]`);
  // Adults may sit behind a Fayda gate that must be toggled open. Wait for whichever appears first —
  // the name field (already expanded) or the reveal button — so we never toggle an open form closed.
  const reveal = c.getByRole("button", { name: /enter details manually|skip for now/i }).first();
  await Promise.race([
    nameInput.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
    reveal.waitFor({ state: "visible", timeout: 15_000 }).catch(() => {}),
  ]);
  if (!(await nameInput.isVisible().catch(() => false)) && (await reveal.isVisible().catch(() => false))) {
    await reveal.click();
  }
  await nameInput.waitFor({ state: "visible", timeout: 15_000 });

  await nameInput.fill(spec.name);
  await page.locator(`select[name="passengers.${i}.gender"]`).selectOption(spec.gender);
  if (spec.category === "ADULT" && spec.phone) {
    await c.locator('input[type="tel"]').first().fill(spec.phone);
  }
  if (spec.passport) {
    await page.locator(`input[name="passengers.${i}.passportNumber"]`).fill(spec.passport.number);
    await page.locator(`select[name="passengers.${i}.passportCountry"]`).selectOption(spec.passport.country);
    await page.locator(`input[name="passengers.${i}.passportIssueDate"]`).fill(spec.passport.issue);
    await page.locator(`input[name="passengers.${i}.passportExpiryDate"]`).fill(spec.passport.expiry);
  }
  await fillDob(page, c, spec.dob);
}

/** Select a coach + continue, once for a one-way leg or twice for a round trip. */
async function selectResultsAndContinue(page: Page, roundTrip: boolean) {
  const pickCoach = async (scope: Locator | Page) => {
    await (scope as Page).getByTestId("result-select-btn").first().click();
    await page.getByTestId("coach-option").first().click();
    await page.getByTestId("continue-passenger-details").first().click();
  };
  await pickCoach(page); // outbound (advances to the inbound step for a round trip)
  if (roundTrip) {
    // The inbound step re-renders result cards; scope to the inbound section if present.
    const inbound = page.locator("#inbound-section");
    const scope = (await inbound.count()) > 0 ? inbound : page;
    await scope.getByTestId("result-select-btn").first().click();
    await page.getByTestId("coach-option").first().click();
    await page.getByTestId("continue-passenger-details").first().click();
  }
}

/** Auto-assign seats (fills all passengers at once and auto-continues). Twice for a round trip. */
async function assignSeatsAndContinue(page: Page, roundTrip: boolean) {
  const autoAssign = () => page.getByRole("button", { name: /auto assign seats/i }).first().click();
  await autoAssign(); // outbound
  if (roundTrip) {
    // After the outbound hold, the page switches to the return-seat map.
    await page.getByRole("heading", { name: /return seats/i }).waitFor({ timeout: 20_000 });
    await autoAssign(); // inbound
  }
  await page.waitForURL(/\/booking\/review/, { timeout: 30_000 });
}

/**
 * Drives the real portal booking flow end to end for an arbitrary passenger mix, nationality,
 * trip type, promo, and payment method. Captures the price at each hop for DB cross-checks.
 * Runs as a guest when the page context has no auth token (the `guest` Playwright project).
 */
export async function bookTrip(page: Page, opts: TripOptions = {}): Promise<BookingResult> {
  const nationality = opts.nationality ?? "Ethiopian";
  const tripType = opts.tripType ?? "ONE_WAY";
  const roundTrip = tripType === "ROUND_TRIP";
  const passengers =
    opts.passengers ?? makePassengers(opts.adults ?? 1, opts.children ?? 0, nationality);
  const adults = passengers.filter((p) => p.category === "ADULT").length;
  const children = passengers.filter((p) => p.category === "CHILD").length;

  // Optional: forge the POST /bookings body before it leaves the browser.
  if (opts.mutateBookingBody) {
    await page.route(/\/bookings(\/guest)?(\?|$)/, async (route: Route) => {
      if (route.request().method() !== "POST") return route.continue();
      const body = route.request().postDataJSON();
      await route.continue({ postData: JSON.stringify(opts.mutateBookingBody!(body)) });
    });
  }

  // ── Search / results ────────────────────────────────────────────────────────
  const searchDone = page.waitForResponse(
    (r) => r.url().includes("/search") && r.request().method() === "POST",
  );
  const base = resultsUrl({ nationality, adults, children, tripType });
  await page.goto(opts.promoCode ? `${base}&promoCode=${encodeURIComponent(opts.promoCode)}` : base);
  const search = await searchDone;
  const out = (await search.json())?.data?.outbound?.[0];
  const cardCls = out?.faresByClass?.[0];
  const cardBaseFareMinor = cardCls?.baseFareMinor;
  const cardDisplayMinor = cardCls?.displayAmountMinor ?? cardBaseFareMinor;
  const displayCurrency = out?.displayCurrency ?? CURRENCY_BY_NATIONALITY[nationality];
  expect(cardBaseFareMinor).toBeGreaterThan(0);

  await selectResultsAndContinue(page, roundTrip);
  // Both authenticated users and guests may pass through the auth-check interstitial: authenticated
  // users auto-forward to passengers, guests must click "Continue as guest". Handle whichever wins.
  await page.waitForURL(/\/booking\/(passengers|auth-check)/, { timeout: 30_000 });
  if (/\/booking\/auth-check/.test(page.url())) {
    await Promise.race([
      page.waitForURL(/\/booking\/passengers/, { timeout: 15_000 }).catch(() => {}),
      page
        .getByRole("button", { name: /continue as guest/i })
        .click({ timeout: 15_000 })
        .catch(() => {}),
    ]);
    await page.waitForURL(/\/booking\/passengers/, { timeout: 30_000 });
  }

  // ── Passenger form ────────────────────────────────────────────────────────────
  for (let i = 0; i < passengers.length; i++) await fillPassenger(page, i, passengers[i]);
  await page.getByRole("button", { name: /continue to seat selection/i }).click();
  await page.waitForURL(/\/booking\/seats/, { timeout: 30_000 });

  // ── Seats: auto-assign → hold → review ────────────────────────────────────────
  const fbDone = page
    .waitForResponse((r) => r.url().includes("/search/fare-breakdown"), { timeout: 25_000 })
    .catch(() => null);
  await assignSeatsAndContinue(page, roundTrip);
  const fbRes = await fbDone;
  const fbJson = fbRes ? await fbRes.json() : null;
  const fareBreakdown = fbJson?.data ?? fbJson;

  // ── Review: confirm → POST /bookings(/guest) ──────────────────────────────────
  const bookingDone = page.waitForResponse(
    (r) => /\/bookings(\/guest)?(\?|$)/.test(r.url()) && r.request().method() === "POST",
  );
  await page.getByRole("button", { name: /^confirm/i }).first().click();
  const bookingRes = await bookingDone;
  const bookingStatus = bookingRes.status();
  const guest = bookingRes.url().includes("/bookings/guest");
  const reviewedTotalMinor = bookingRes.request().postDataJSON()?.reviewedTotalMinor;

  // Expected-rejection path: the server refused the booking (e.g. a forged total). Return early
  // with the status so the caller can assert the refusal; there is no booking to drive to payment.
  if (opts.tolerateBookingError && !bookingRes.ok()) {
    return {
      cardDisplayMinor,
      cardBaseFareMinor,
      displayCurrency,
      reviewedTotalMinor,
      bookingStatus,
      bookingId: "",
      guest,
      initiateStatus: 0,
      confirmed: false,
      fareBreakdown,
    };
  }

  const bookingData = (await bookingRes.json())?.data ?? {};
  const bookingId = bookingData.id ?? bookingData.bookingId;
  expect(bookingId).toBeTruthy();
  await page.waitForURL(/\/booking\/(payment|confirmation)/, { timeout: 30_000 });

  const result: BookingResult = {
    cardDisplayMinor,
    cardBaseFareMinor,
    displayCurrency,
    reviewedTotalMinor,
    bookingStatus,
    bookingId,
    guest,
    initiateStatus: 0,
    confirmed: false,
    fareBreakdown,
  };

  // A zero-total booking skips payment and lands straight on confirmation.
  if (/\/booking\/confirmation/.test(page.url())) {
    result.confirmed = true;
    return result;
  }

  // ── Payment ─────────────────────────────────────────────────────────────────
  const method = opts.paymentMethod ?? "WALLET";

  if (method === "WALLET") {
    // WALLET settles fully server-side, synchronously → straight to /booking/confirmation.
    const initiateDone = page.waitForResponse(
      (r) => r.url().includes("/payments/initiate") && r.request().method() === "POST",
    );
    await page.getByTestId("pay-method-WALLET").first().click();
    await page.getByRole("button", { name: /^pay\b/i }).first().click();
    result.initiateStatus = (await initiateDone).status();
    result.confirmed = await page
      .waitForURL(/\/booking\/confirmation/, { timeout: 25_000 })
      .then(() => true)
      .catch(() => false);
    return result;
  }

  // Gateway (TELEBIRR): the real provider is unreachable in the test env (initiate 502s), so we do
  // what the matrix prescribes — inject settlement. The booking is already created through the real
  // browser flow and sits in PENDING_PAYMENT; we forge the payment.succeeded event to the internal
  // mark-paid endpoint (ungated when SERVICE_AUTH_TOKEN is unset), then let the confirmation page's
  // poll flip to CONFIRMED. `forgeSettlement.amountMinor` lets a test short-pay (settle wrong amount).
  const amountMinor = opts.forgeSettlement?.amountMinor ?? reviewedTotalMinor;
  // mark-paid sits behind the global JwtGuard (any valid token passes; ServiceAuthGuard is a no-op
  // when SERVICE_AUTH_TOKEN is unset). Reuse the logged-in passenger's token from localStorage.
  const authToken = await page.evaluate(() => localStorage.getItem("auth_token"));
  const markPaid = await page.request.post(`${API_URL}/internal/payments/mark-paid`, {
    headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
    data: {
      version: 1,
      eventId: crypto.randomUUID(), // @IsUUID
      eventType: "payment.succeeded",
      occurredAt: new Date().toISOString(),
      service: "PASSENGER",
      intentId: crypto.randomUUID(), // @IsUUID
      referenceType: "BOOKING",
      referenceId: bookingId,
      merchantOrderId: `e2e-${bookingId}`,
      provider: "TELEBIRR",
      amountMinor,
      currency: "ETB",
      providerTxnId: `e2e-txn-${bookingId}`,
      paidAt: new Date().toISOString(),
    },
  });
  result.initiateStatus = markPaid.status();
  // mark-paid finalizes synchronously; confirm authoritatively via the booking status API (the
  // confirmation page's DOM depends on the client store, which a direct navigation may not carry).
  await page.goto("/booking/confirmation");
  for (let attempt = 0; attempt < 10 && !result.confirmed; attempt++) {
    const res = await page.request.get(`${API_URL}/bookings/${bookingId}`, {
      headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
    });
    const status = ((await res.json().catch(() => ({})))?.data ?? {})?.status;
    if (status === "CONFIRMED") result.confirmed = true;
    else await page.waitForTimeout(500);
  }
  return result;
}

/** Back-compat wrapper: one-way single adult (used by the original UA-1/8/13 specs). */
export interface BookingOptions {
  nationality?: Nationality;
  promoCode?: string;
  mutateBookingBody?: (body: any) => any;
  tolerateBookingError?: boolean;
  paymentMethod?: "WALLET" | "TELEBIRR";
}
export async function bookOneAdult(page: Page, opts: BookingOptions = {}) {
  const r = await bookTrip(page, { ...opts, adults: 1, children: 0, tripType: "ONE_WAY" });
  // Preserve the original field name used by the existing specs.
  return { ...r, cardFareMinor: r.cardBaseFareMinor };
}
