import {
  verifyBookingAdjustmentItemsTable,
  verifyBookingDetailsItemsSection,
} from '@/manager/modules/booking/booking-assertions';
import { getUnitTypeBookingPlan } from '@/manager/modules/booking/booking-factories';
import { test } from '@/manager/modules/booking/booking-fixtures';
import {
  applyBookingAdjustmentToBookingCreateData,
  toBookingItemsDetails,
} from '@/manager/modules/booking/booking-mappers';
import { getBookingDetailsDiscountName, getBookingStatusOnMoveIn } from '@/manager/modules/booking/booking-utils';
import { bookingAdjustmentHappyTestCases } from '@/manager/modules/booking/test-cases/booking-adjustment-happy-test-cases';
import { expectLocatorToBeEmpty, expectSingleLocatorToHaveText } from '@/manager/shared/utils/expect-utils';
import { BookingItems } from '@/shared/types/booking-types';
import { formatBillingPeriod, formatCurrency, formatDate, formatDateRange } from '@/shared/utils/formatters';
import { expect } from '@/shared/utils/matchers';
import { pollOrSkip } from '@/shared/utils/poll-utils';
import dayjs from 'dayjs';

// Immediate adjustments run through a chain of backend jobs (booking activation, then the apply job) —
// observed anywhere between 1s and ~90s — so the applied-poll gets a 120s window; the proration cases
// additionally reload-poll the async invoicing for up to 120s, and the per-test timeout leaves both
// polls room to expire into their skip lane instead of dying on the test timeout.
test.describe.configure({ mode: 'default', timeout: 240_000 });

// toBookingItemsDetails models only the recurring composition (unit, protection plan, recurring
// products), so the dialog totals are only comparable when nothing unmodeled (deposit, discount,
// one-time products) contributes to them. The row-level assertions don't need the gate.
function hasOnlyModeledItems(items: BookingItems): boolean {
  return !items.discount && !items.deposit && !items.oneTimeProducts?.length;
}

for (const tc of bookingAdjustmentHappyTestCases) {
  test(
    tc.description,
    async ({ setupBookingCreateData, createBooking, getBookingAdjustmentEntityFactory, bookingDetailsPage }) => {
      const data = await setupBookingCreateData(tc.setup, tc.options);
      const [booking, adjustment] = await Promise.all([
        createBooking(data),
        tc.getAdjustment(data, getBookingAdjustmentEntityFactory(data)),
      ]);

      const adjustedData = applyBookingAdjustmentToBookingCreateData(data, adjustment);
      const { currency, timezone } = data.location;
      const status = getBookingStatusOnMoveIn(data.moveInDate, timezone);
      const applied = adjustment.applyType === 'immediately';
      const originalItemsDetails = toBookingItemsDetails(data);
      const adjustedItemsDetails = toBookingItemsDetails(adjustedData);
      const itemsDetails = applied ? adjustedItemsDetails : originalItemsDetails;
      const amountExclVat = formatCurrency(itemsDetails.totals.exclVat, currency);
      const expectedPlan = applied ? adjustedData.unitType.plan : data.unitType.plan;
      const planLabel = formatBillingPeriod(expectedPlan.period, expectedPlan.amount, expectedPlan.name);
      const periodUnit = data.unitType.plan.period === 'monthly' ? 'month' : 'week';
      const currentPeriodEnd = dayjs(data.moveInDate).add(data.unitType.plan.amount, periodUnit).toDate();
      const periodUnchanged = !adjustment.plan && !adjustment.unitType;
      const bannerDate = adjustment.applyType === 'custom' ? adjustment.applyDate! : currentPeriodEnd;
      const verifyDialogTotals = hasOnlyModeledItems(data) && hasOnlyModeledItems(adjustedData);

      await bookingDetailsPage.goto(booking.id);

      const dialog = await test.step('open the adjustment dialog', () =>
        bookingDetailsPage.openBookingAdjustmentDialog());

      await test.step('verify the current items in the dialog', async () => {
        await verifyBookingAdjustmentItemsTable(dialog.currentItemsTable, originalItemsDetails, currency);

        if (verifyDialogTotals) {
          await expectSingleLocatorToHaveText(
            dialog.currentItemsTable.totalExclVatValue,
            formatCurrency(originalItemsDetails.totals.exclVat, currency)
          );
          await expectSingleLocatorToHaveText(
            dialog.currentItemsTable.vatValue,
            formatCurrency(originalItemsDetails.totals.vat, currency)
          );
          await expectSingleLocatorToHaveText(
            dialog.currentItemsTable.totalInclVatValue,
            formatCurrency(originalItemsDetails.totals.inclVat, currency)
          );
        }
      });

      await test.step('fill the adjustment form', () => dialog.fill(adjustment));

      await test.step('verify the adjusted items in the dialog', async () => {
        await dialog.adjustedItemsTable.loadingBar.waitFor({ state: 'hidden' });
        await verifyBookingAdjustmentItemsTable(dialog.adjustedItemsTable, adjustedItemsDetails, currency);

        if (verifyDialogTotals) {
          await expectSingleLocatorToHaveText(
            dialog.adjustedItemsTable.totalExclVatValue,
            formatCurrency(adjustedItemsDetails.totals.exclVat, currency)
          );
          await expectSingleLocatorToHaveText(
            dialog.adjustedItemsTable.vatValue,
            formatCurrency(adjustedItemsDetails.totals.vat, currency)
          );
          await expectSingleLocatorToHaveText(
            dialog.adjustedItemsTable.totalInclVatValue,
            formatCurrency(adjustedItemsDetails.totals.inclVat, currency)
          );
        }

        // One-time rows only render for scheduled bookings and sit outside BookingItemsDetails, so
        // they are covered by a count on the cases that change them.
        if (adjustment.oneTimeProducts) {
          await expect(dialog.adjustedItemsTable.oneTimeProductRows).toHaveCount(adjustedData.oneTimeProducts!.length);
        }
      });

      await test.step('apply the adjustment', () => dialog.adjustAfterFill(adjustment));

      if (applied) {
        // Immediate adjustments are applied by an async backend job. The banner alone is no applied
        // signal — an API-created booking can idle in scheduled state waiting for its activation job,
        // during which the pending adjustment shows no banner either — so the poll additionally waits
        // for the headline billing values (and the case's deposit/discount, which change neither the
        // plan nor the subscription amount) to reflect the adjustment. The banner check runs last:
        // while the reloaded page still shows the loading skeleton the banner is absent too, so a
        // hidden check would pass against an unrendered page.
        await test.step('wait for the adjustment to be applied', () =>
          pollOrSkip(
            async () => {
              await bookingDetailsPage.reload();
              await expectSingleLocatorToHaveText(bookingDetailsPage.billingCard.bookingPlan, planLabel);
              await expectSingleLocatorToHaveText(bookingDetailsPage.billingCard.amountExclVat, amountExclVat);
              await expectSingleLocatorToHaveText(
                bookingDetailsPage.itemsCard.itemsSection.unitLabel,
                itemsDetails.unitType.unitName
              );

              if (adjustment.deposit) {
                await expect(bookingDetailsPage.generalCard.deposit).toContainText(
                  formatCurrency(adjustment.deposit.priceExclVat, currency)
                );
              }

              if (adjustment.discount) {
                await expectSingleLocatorToHaveText(
                  bookingDetailsPage.generalCard.discount,
                  getBookingDetailsDiscountName(adjustment.discount)
                );
              }

              await expect(bookingDetailsPage.adjustmentNotificationCard.main).toBeHidden();
            },
            {
              timeout: 120_000,
              reason: 'booking adjustment not applied within 120s — test-env queue backlog (KIN-4692)',
            }
          ));
      } else {
        await test.step('verify the adjustment notification', async () => {
          await expect(bookingDetailsPage.adjustmentNotificationCard.main).toBeVisible();
          await expect(bookingDetailsPage.adjustmentNotificationCard.main).toContainText(
            `The changes will be applied on ${formatDate(bannerDate, timezone)}`
          );
        });
      }

      await test.step('verify the billing values on the details page', async () => {
        if (!applied) {
          await expectSingleLocatorToHaveText(bookingDetailsPage.billingCard.bookingPlan, planLabel);
          await expectSingleLocatorToHaveText(bookingDetailsPage.billingCard.amountExclVat, amountExclVat);
        }

        if (status === 'active' && (periodUnchanged || !applied)) {
          await expectSingleLocatorToHaveText(
            bookingDetailsPage.billingCard.currentPeriod,
            formatDateRange(data.moveInDate, currentPeriodEnd, timezone)
          );
          await expectSingleLocatorToHaveText(
            bookingDetailsPage.billingCard.nextInvoice,
            formatDate(currentPeriodEnd, timezone)
          );
        }

        if (status === 'scheduled') {
          await expectLocatorToBeEmpty(bookingDetailsPage.billingCard.currentPeriod);
          await expectSingleLocatorToHaveText(
            bookingDetailsPage.billingCard.nextInvoice,
            formatDate(data.moveInDate, timezone)
          );
        }

        await verifyBookingDetailsItemsSection(bookingDetailsPage.itemsCard.itemsSection, itemsDetails, currency);
      });

      if (tc.expectProrationInvoice) {
        // The prorated delta is invoiced next to the first-period invoice through the async billing
        // queue, observed to take ~90s from booking creation even on an idle stack — reload-poll for
        // both invoices with matching headroom, degrading to a skip on the scheduled lane.
        await test.step('verify the invoices on the details page', () =>
          pollOrSkip(
            async () => {
              await bookingDetailsPage.reload();
              await expect(bookingDetailsPage.invoicesCard.dataTable.getRows()).toHaveCount(2);
            },
            { timeout: 120_000, reason: 'proration invoice not issued within 120s — test-env queue backlog (KIN-4692)' }
          ));
      }
    }
  );
}

test('shows an info message when saving without changes', async ({
  setupBookingCreateData,
  createBooking,
  bookingDetailsPage,
}) => {
  const data = await setupBookingCreateData();
  const booking = await createBooking(data);
  const plan = data.unitType.plan;
  const planLabel = formatBillingPeriod(plan.period, plan.amount, plan.name);

  await bookingDetailsPage.goto(booking.id);

  const dialog = await test.step('open the adjustment dialog', () => bookingDetailsPage.openBookingAdjustmentDialog());

  await test.step('save without changes', () => dialog.submitWithoutChanges());

  await test.step('verify no adjustment was created', async () => {
    // Assert a loaded value first: while the details page is still loading, the whole content —
    // notification banner included — is absent, so the hidden check alone passes against the skeleton.
    await expectSingleLocatorToHaveText(bookingDetailsPage.billingCard.bookingPlan, planLabel);
    await expect(bookingDetailsPage.adjustmentNotificationCard.main).toBeHidden();
  });
});

test('rejects a custom date adjustment without a date', async ({
  setupBookingCreateData,
  createBooking,
  bookingDetailsPage,
}) => {
  const data = await setupBookingCreateData();
  const booking = await createBooking(data);
  const newPlan = getUnitTypeBookingPlan(data.unitType.unitType, data.unitType.plan.period, 'Advanced');

  await bookingDetailsPage.goto(booking.id);

  const dialog = await test.step('open the adjustment dialog', () => bookingDetailsPage.openBookingAdjustmentDialog());

  await test.step('fill the adjustment form', async () => {
    await dialog.itemsFieldSet.bookingPlanAutocomplete.select(newPlan.name!);
    await dialog.applyChangesRadioGroup.customDateRadioButton.check();
  });

  await test.step('submit the adjustment form', () => dialog.submit());

  await test.step('verify errors on form', async () => {
    await expect(dialog.errors).toHaveCountGreaterThan(0);
  });
});
