import { expect, test, type Browser, type Locator, type Page, type TestInfo } from "@playwright/test";
import { isLoginPage, loginWithCredentials } from "../auth";
import { loadChecklist, loadIssueMetadata } from "../checklist";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaEnvironment, QaPageDefinition } from "../types";
import { buildPageUrl } from "../url";

test.use({ trace: "retain-on-failure" });

interface ScenarioOptions {
  issueId: string;
  environment: QaEnvironment;
}

interface PaymentConfig {
  amount: number;
  cardNumber: string;
  expiryMonth: string;
  expiryYear: string;
  cardPassword: string;
  birth: string;
  payerName: string;
  payerPhone: string;
}

interface AdminTransactionItem {
  transaction?: {
    id?: string;
    pgName?: string;
    approvalNo?: string;
    status?: string;
    amount?: number;
    payDate?: string;
    createdAt?: string;
    payType?: string;
    originTransactionId?: string | null;
    orderNo?: string | null;
    tid?: string | null;
    cancelAmount?: number;
    canceledAmount?: number;
    cancelledAmount?: number;
    totalCancelAmount?: number;
    remainAmount?: number;
    remainingAmount?: number;
  };
  contractName?: string;
  departments?: Array<{ departmentName?: string; departmentType?: string }>;
  [key: string]: unknown;
}

interface CreatedPayment {
  label: string;
  payType: "IDENTITY" | "ISP" | "UNKNOWN";
  productName: string;
  amount: number;
  transactionId?: string;
  approvalNo?: string;
  pgName?: string;
  status?: string;
  payDate?: string;
  orderNo?: string;
  tid?: string;
  cardInfo?: string;
  lastCancelAmount?: number;
  cancelledAmount?: number;
  remainingAmount?: number;
  cancelled: boolean;
}

interface RoleRunEvidence {
  role: string;
  manualPayment?: CreatedPayment;
  ispPayment?: CreatedPayment;
  paymentLogPayment?: CreatedPayment;
  cancelPayment?: CreatedPayment;
  receiptUrl?: string;
  popupUrl?: string;
  cancelStatuses: string[];
  partialCancelLines: string[];
  idPairs: string[];
}

interface ManualPaymentResponsePayload {
  resultCode?: string;
  resultMessage?: string;
  isSuccess?: boolean;
  transactionId?: string;
}

interface IspStartResult {
  popupOpened: boolean;
  loggingStatus?: number;
  paymentStatus?: number;
  completed: boolean;
  completedStatus?: string;
  resultText?: string;
  extraConfirmButtonVisible?: boolean;
  created?: CreatedPayment;
  note: string;
}

interface CancelEvidence {
  cancelAmount: number;
  responseStatus: number;
  cancelInfoText: string;
}

const adminRoles = ["ADMIN", "TA", "CO", "BO"] as const;
type AdminRole = (typeof adminRoles)[number];

const runState: {
  manualPrimary?: CreatedPayment;
  ispPrimary?: CreatedPayment;
  ispAutoCompletes: boolean;
  partialCancelPayment?: CreatedPayment;
  roleRuns: Record<string, RoleRunEvidence>;
  createdPayments: CreatedPayment[];
} = {
  ispAutoCompletes: false,
  roleRuns: {},
  createdPayments: []
};

export function runLucypayPaymentLogSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const storePaymentPage = findPageDefinition(checklist.pages, "가맹점 결제 테스트");
  const transactionPage = findPageDefinition(checklist.pages, "거래로그");
  const storeEnv = getRuntimeQaEnv(options.environment, "store", "LUCY_VE");
  const adminEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
  const roleEnvs = Object.fromEntries(
    adminRoles.map((role) => [role, getRuntimeQaEnv(options.environment, "admin", role)])
  ) as Record<AdminRole, RuntimeQaEnv>;
  const paymentConfig = getPaymentConfig();
  const baseBlockReason =
    getRuntimeBlockReason(storeEnv) ??
    getRuntimeBlockReason(adminEnv) ??
    getPaymentBlockReason(paymentConfig);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.afterAll(async ({ browser }) => {
      await cleanupCreatedPayments(browser, adminEnv, transactionPage);
    });

    test.describe("[VE] role", () => {
      test.skip(!!baseBlockReason, baseBlockReason);

      test(checklist.checklist[0], async ({ browser }, testInfo) => {
        test.setTimeout(90_000);
        const result = await startIspPayment(
          browser,
          storeEnv,
          adminEnv,
          storePaymentPage,
          paymentConfig,
          options.issueId,
          "isp-popup",
          false,
          testInfo
        );

        await attachEvidenceLog(testInfo, storePaymentPage.name, "VE", "isp-popup-opened", [
          "스토어 결제하기 버튼 클릭 후 루시페이먼츠 ISP 모달에서 추가 결제 버튼 클릭 없이 ISP 창이 바로 열리는지 확인했습니다.",
          `결제창 열림: ${result.popupOpened ? "예" : "아니오"}`,
          `추가 결제 버튼 노출: ${result.extraConfirmButtonVisible ? "예" : "아니오"}`,
          `ISP logging 응답 status: ${result.loggingStatus ?? "-"}`,
          `ISP payment HTML 응답 status: ${result.paymentStatus ?? "-"}`,
          `비고: ${result.note}`
        ]);

        expect(result.popupOpened, "ISP 결제창은 결제 시작 직후 새 창으로 열려야 합니다.").toBe(true);
        expect(result.extraConfirmButtonVisible, "루시페이먼츠 ISP 모달에 추가 결제 버튼이 남아 있으면 안 됩니다.").toBe(false);
        expect(result.paymentStatus, "ISP 결제 HTML 응답은 성공이어야 합니다.").toBeGreaterThanOrEqual(200);
        expect(result.paymentStatus, "ISP 결제 HTML 응답은 성공이어야 합니다.").toBeLessThan(400);
      });

      test(checklist.checklist[1], async ({ browser }, testInfo) => {
        test.setTimeout(180_000);
        const completedIspPayment = await createCompletedIspOrBlock(
          browser,
          storeEnv,
          adminEnv,
          storePaymentPage,
          paymentConfig,
          options.issueId,
          "isp-primary",
          testInfo
        );
        runState.ispPrimary = completedIspPayment;
        runState.ispAutoCompletes = true;
        await attachEvidenceLog(testInfo, storePaymentPage.name, "VE", "isp-complete", [
          "루시페이먼츠 ISP 결제가 완료되어 스토어 결과 화면의 성공 안내와 운영자 거래 생성을 확인했습니다.",
          `거래 ID: ${redactId(completedIspPayment.transactionId ?? "")}`,
          `승인번호: ${completedIspPayment.approvalNo ?? "-"}`,
          `PG사: ${completedIspPayment.pgName ?? "-"}`,
          `payType: ${completedIspPayment.payType}`
        ]);
      });
    });

    for (const [index, role] of adminRoles.entries()) {
      test.describe(`[${role}] role`, () => {
        const roleEnv = roleEnvs[role];
        const roleBlockReason = getSkippedRoleReason(role) ?? getRuntimeBlockReason(roleEnv) ?? baseBlockReason;

        test.skip(!!roleBlockReason, roleBlockReason);

        test(checklist.checklist[2 + index], async ({ browser }, testInfo) => {
          test.setTimeout(120_000);
          const evidence = getRoleEvidence(role);
          const payment = runState.ispPrimary;
          if (!payment?.approvalNo) {
            await attachEvidenceLog(testInfo, transactionPage.name, role, "payment-log-blocked", [
              `${role} 거래 로그 검증에 사용할 완료 ISP 거래가 없습니다.`,
              "선행 VE ISP 결제 완료 항목의 외부 결제창 승인 결과를 확인하세요."
            ]);
            blocked(`${role} 거래 로그 검증에 사용할 완료 ISP 거래가 없습니다.`);
          }

          await verifyIspPaymentVisibleFromRole(browser, roleEnv, transactionPage, payment, role, testInfo);
          evidence.paymentLogPayment = payment;
        });

        test(checklist.checklist[6 + index], async ({ browser }, testInfo) => {
          test.setTimeout(210_000);
          const evidence = getRoleEvidence(role);
          if (!runState.ispAutoCompletes) {
            await attachEvidenceLog(testInfo, transactionPage.name, role, "cancel-isp-blocked", [
              `${role} 취소 검증에 사용할 완료 ISP 거래가 없습니다.`,
              "선행 VE ISP 결제 완료 항목에서 외부 ISP 승인 완료 또는 거래 통보 반영이 발생하지 않았습니다.",
              "동일 외부 조건을 role별로 반복하지 않고 확인불가로 기록합니다."
            ]);
            blocked(`${role} 계정의 ISP 결제 취소 검증에 사용할 완료 ISP 거래가 없습니다.`);
          }

          const ispPayment = await createCompletedIspOrBlock(
            browser,
            storeEnv,
            adminEnv,
            storePaymentPage,
            paymentConfig,
            options.issueId,
            `${role.toLowerCase()}-isp-cancel`,
            testInfo
          );

          evidence.cancelPayment = ispPayment;
          const cancelEvidence = await cancelPaymentFromRole(browser, roleEnv, transactionPage, ispPayment, role, testInfo);
          evidence.cancelStatuses.push(`${ispPayment.payType}:${ispPayment.status ?? "-"}`);
          evidence.idPairs.push(formatIdPair(ispPayment));
          await attachEvidenceLog(testInfo, transactionPage.name, role, "cancel-manual-isp", [
            `${role} 계정으로 루시페이먼츠 ISP 결제 취소와 취소 금액 표시를 확인했습니다.`,
            `ISP 승인번호/상태: ${ispPayment.approvalNo ?? "-"} / ${ispPayment.status ?? "-"}`,
            `취소 요청 금액: ${formatNumber(cancelEvidence.cancelAmount)}원`,
            `취소 응답 status: ${cancelEvidence.responseStatus}`,
            `화면/API 취소 정보: ${cancelEvidence.cancelInfoText}`
          ]);
        });
      });
    }

    test.describe("[ADMIN/TA/CO/BO] role", () => {
      test.skip(!!baseBlockReason, baseBlockReason);

      test(checklist.checklist[10], async ({ browser }, testInfo) => {
        test.setTimeout(240_000);
        if (!runState.ispAutoCompletes) {
          await attachEvidenceLog(testInfo, transactionPage.name, "ALL", "partial-cancel-blocked", [
            "다중 부분취소 검증에 사용할 완료 ISP 거래가 없습니다.",
            "선행 VE ISP 결제 완료 항목에서 외부 ISP 승인 완료 또는 거래 통보 반영이 발생하지 않았습니다.",
            "부분취소는 완료 승인 거래가 필요한 항목이므로 확인불가로 기록합니다."
          ]);
          blocked("다중 부분취소 검증에 사용할 완료 ISP 거래가 없습니다.");
        }

        const partialPayment = await createCompletedIspOrBlock(
          browser,
          storeEnv,
          adminEnv,
          storePaymentPage,
          paymentConfig,
          options.issueId,
          "isp-partial-cancel",
          testInfo
        );
        runState.partialCancelPayment = partialPayment;

        const [firstCancelAmount, secondCancelAmount] = resolvePartialCancelAmounts(partialPayment.amount);
        await cancelPaymentPartiallyFromRole(
          browser,
          adminEnv,
          transactionPage,
          partialPayment,
          "ADMIN",
          firstCancelAmount,
          1,
          testInfo
        );
        await cancelPaymentPartiallyFromRole(
          browser,
          adminEnv,
          transactionPage,
          partialPayment,
          "ADMIN",
          secondCancelAmount,
          2,
          testInfo
        );

        const cumulativeCancelAmount = firstCancelAmount + secondCancelAmount;
        for (const role of adminRoles) {
          const lines = await verifyPartialCancelVisibleFromRole(
            browser,
            roleEnvs[role],
            transactionPage,
            partialPayment,
            role,
            secondCancelAmount,
            cumulativeCancelAmount,
            testInfo
          );
          getRoleEvidence(role).partialCancelLines.push(...lines);
        }

        await attachEvidenceLog(testInfo, transactionPage.name, "ALL", "partial-cancel-summary", [
          "루시페이먼츠 ISP 거래에 대해 부분취소를 2회 진행하고 ADMIN/TA/CO/BO 거래 로그에서 현재 취소금액과 누적 취소금액 표시를 확인했습니다.",
          `승인번호: ${partialPayment.approvalNo ?? "-"}`,
          `1회 취소금액: ${formatNumber(firstCancelAmount)}원`,
          `2회 취소금액: ${formatNumber(secondCancelAmount)}원`,
          `누적 취소금액: ${formatNumber(cumulativeCancelAmount)}원`,
          ...adminRoles.flatMap((role) => getRoleEvidence(role).partialCancelLines.map((line) => `${role}: ${line}`))
        ]);
      });

      test(checklist.checklist[11], async ({ browser }, testInfo) => {
        test.setTimeout(150_000);
        const payment = runState.ispPrimary;
        if (!payment?.approvalNo) {
          await attachEvidenceLog(testInfo, transactionPage.name, "ALL", "receipt-summary-blocked", [
            "승인번호 영수증 검증에 사용할 완료 ISP 거래가 없습니다.",
            "선행 VE ISP 결제 완료 항목의 외부 결제창 승인 결과를 확인하세요."
          ]);
          blocked("승인번호 영수증 검증에 사용할 완료 ISP 거래가 없습니다.");
        }

        for (const role of adminRoles) {
          const receipt = await verifyReceiptFromRole(browser, roleEnvs[role], transactionPage, payment, role, testInfo);
          const evidence = getRoleEvidence(role);
          evidence.receiptUrl = receipt.receiptUrl;
          evidence.popupUrl = receipt.popupUrl;
        }

        await attachEvidenceLog(testInfo, transactionPage.name, "ALL", "receipt-summary", [
          "ADMIN/TA/CO/BO 거래 로그 화면에서 승인번호 버튼 클릭 시 루시페이먼츠 영수증 화면이 열리는지 확인했습니다.",
          ...adminRoles.map((role) => {
            const evidence = runState.roleRuns[role];
            return `${role}: receipt=${evidence.receiptUrl ?? "-"}, popup=${evidence.popupUrl ?? "-"}`;
          })
        ]);
      });
    });
  });
}

function findPageDefinition(pages: QaPageDefinition[], name: string): QaPageDefinition {
  const pageDefinition =
    pages.find((page) => page.name === name) ??
    pages.find((page) => page.name.includes(name));
  if (!pageDefinition) {
    throw new Error(`checklist.json pages에 '${name}' 페이지 정의가 필요합니다.`);
  }
  return pageDefinition;
}

function getRuntimeBlockReason(runtimeEnv: RuntimeQaEnv): string | undefined {
  const appKey = runtimeEnv.application.toUpperCase();
  const envKey = runtimeEnv.environment.toUpperCase();

  if (!runtimeEnv.baseUrl) {
    return `BIX_${appKey}_${envKey}_BASE_URL 또는 BIX_${appKey}_${envKey}_LOGIN_URL이 필요합니다.`;
  }
  if (!runtimeEnv.tenantId) {
    return `BIX_${appKey}_${envKey}_TENANT_ID가 필요합니다.`;
  }
  if (!runtimeEnv.storageState && (!runtimeEnv.username || !runtimeEnv.password || !runtimeEnv.loginUrl)) {
    return `${runtimeEnv.application}/${runtimeEnv.environment}/${runtimeEnv.role} role의 storageState 또는 로그인 계정 정보가 필요합니다.`;
  }
  return undefined;
}

function getSkippedRoleReason(role: string): string | undefined {
  const skippedRoles = (process.env.QA_SKIP_ROLES ?? "")
    .split(",")
    .map((item) => item.trim().toUpperCase())
    .filter(Boolean);
  return skippedRoles.includes(role.toUpperCase())
    ? `BLOCKED: ${role} role은 실행 전 재로그인 실패로 QA_SKIP_ROLES에 포함되었습니다.`
    : undefined;
}

function getPaymentConfig(): PaymentConfig {
  const expiry = (process.env.QA_2911_CARD_EXPIRY ?? process.env.QA_3315_CARD_EXPIRY ?? "").replace(/[^0-9]/g, "");
  const amount = Number(process.env.QA_2911_PAYMENT_AMOUNT ?? 1004);

  return {
    amount: Number.isFinite(amount) && amount > 0 ? amount : 1004,
    cardNumber: process.env.QA_2911_CARD_NUMBER ?? process.env.QA_3315_CARD_NUMBER ?? "",
    expiryMonth: expiry.slice(0, 2),
    expiryYear: expiry.length >= 4 ? `20${expiry.slice(-2)}` : "",
    cardPassword: process.env.QA_2911_CARD_PASSWORD ?? process.env.QA_3315_CARD_PASSWORD ?? "",
    birth: process.env.QA_2911_CARD_BIRTH ?? process.env.QA_3315_CARD_BIRTH ?? "",
    payerName: process.env.QA_2911_PAYER_NAME ?? "QA2911",
    payerPhone: process.env.QA_2911_PAYER_PHONE ?? "phone-redacted"
  };
}

function getPaymentBlockReason(paymentConfig: PaymentConfig): string | undefined {
  if (process.env.QA_2911_ALLOW_PAYMENT_MUTATION !== "1") {
    return "QA_2911_ALLOW_PAYMENT_MUTATION=1 설정이 필요합니다. 실제 테스트 결제를 생성하고 취소하는 QA입니다.";
  }

  const missing = [
    ["QA_2911_CARD_NUMBER 또는 QA_3315_CARD_NUMBER", paymentConfig.cardNumber],
    ["QA_2911_CARD_EXPIRY 또는 QA_3315_CARD_EXPIRY", paymentConfig.expiryMonth && paymentConfig.expiryYear],
    ["QA_2911_CARD_PASSWORD 또는 QA_3315_CARD_PASSWORD", paymentConfig.cardPassword],
    ["QA_2911_CARD_BIRTH 또는 QA_3315_CARD_BIRTH", paymentConfig.birth]
  ].filter(([, value]) => !value);

  if (missing.length > 0) {
    return `결제 카드 정보가 필요합니다: ${missing.map(([key]) => key).join(", ")}`;
  }
  return undefined;
}

async function createManualPayment(
  browser: Browser,
  storeEnv: RuntimeQaEnv,
  adminEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: PaymentConfig,
  issueId: string,
  label: string,
  testInfo: TestInfo
): Promise<CreatedPayment> {
  const productName = buildProductName(issueId, label);
  const payment: CreatedPayment = {
    label,
    payType: "IDENTITY",
    productName,
    amount: config.amount,
    cancelled: false
  };

  await withRolePage(browser, storeEnv, async (page) => {
    await openAuthenticatedUrl(page, storeEnv, pageDefinition.path);
    await fillManualPaymentForm(page, config, productName);

    const paymentResponsePromise = page.waitForResponse(
      (response) =>
        response.request().method() === "POST" &&
        response.url().includes("/api/v1/store/identity/payment"),
      { timeout: 45_000 }
    );

    await page.getByRole("button", { name: "결제하기" }).click();
    await expect(page.locator("body"), "수기결제 확인 모달에 결제금액이 표시되어야 합니다.").toContainText(
      `합계 ${formatNumber(config.amount)}원 결제`,
      { timeout: 10_000 }
    );
    await page.getByRole("button", { name: /^결제$/ }).last().click();

    const response = await paymentResponsePromise;
    const json = await response.json().catch(() => undefined);
    const payload = json?.payload as ManualPaymentResponsePayload | undefined;
    if (!response.ok()) {
      throw new Error(`루시페이먼츠 수기결제 요청 실패: status=${response.status()} body=${JSON.stringify(json)}`);
    }
    expect(payload?.isSuccess, "루시페이먼츠 수기결제 응답은 성공이어야 합니다.").toBe(true);
    expect(payload?.transactionId, "루시페이먼츠 수기결제 응답에는 거래 ID가 있어야 합니다.").toBeTruthy();
    payment.transactionId = payload!.transactionId;

    await expect(page.locator("body"), "결제 완료 화면이 표시되어야 합니다.").toContainText("결제가 완료되었습니다.", {
      timeout: 20_000
    });
    await attachPageScreenshot(page, testInfo, pageDefinition.name, "VE", `${label}-success`);
  });

  const created = await resolveCreatedTransaction(browser, adminEnv, payment, config.amount);
  applyTransactionItem(payment, created);
  runState.createdPayments.push(payment);
  return payment;
}

async function startIspPayment(
  browser: Browser,
  storeEnv: RuntimeQaEnv,
  adminEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: PaymentConfig,
  issueId: string,
  label: string,
  waitForCompletion: boolean,
  testInfo: TestInfo
): Promise<IspStartResult> {
  const productName = buildProductName(issueId, label);
  const startedAt = new Date();
  let loggingStatus: number | undefined;
  let paymentStatus: number | undefined;
  let completedStatus: string | undefined;
  let popupOpened = false;
  let extraConfirmButtonVisible = false;
  let resultText: string | undefined;
  let created: CreatedPayment | undefined;

  await withRolePage(browser, storeEnv, async (page) => {
    await openAuthenticatedUrl(page, storeEnv, pageDefinition.path);
    await selectIspPaymentMode(page);
    await fillIspPaymentForm(page, config, productName);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, "VE", `${label}-isp-form`);

    const popupPromise = page.context().waitForEvent("page", { timeout: 8_000 }).catch(() => undefined);
    const loggingResponsePromise = page.waitForResponse(
      (response) =>
        response.request().method() === "POST" &&
        response.url().includes("/api/v1/store/isp/payment/logging"),
      { timeout: 12_000 }
    ).catch(() => undefined);
    const paymentResponsePromise = page.waitForResponse(
      (response) =>
        response.request().method() === "POST" &&
        response.url().includes("/api/v1/store/isp/payment"),
      { timeout: 12_000 }
    ).catch(() => undefined);

    await clickIspSubmit(page);

    let [popup, loggingResponse, paymentResponse] = await Promise.all([
      popupPromise,
      loggingResponsePromise,
      paymentResponsePromise
    ]);
    popupOpened = !!popup;
    loggingStatus = loggingResponse?.status();
    paymentStatus = paymentResponse?.status();
    extraConfirmButtonVisible = await page
      .getByRole("button", { name: /^결제$/ })
      .last()
      .isVisible({ timeout: 1_000 })
      .catch(() => false);

    if (waitForCompletion && !popup && extraConfirmButtonVisible) {
      const followPopupPromise = page.context().waitForEvent("page", { timeout: 15_000 }).catch(() => undefined);
      const followLoggingResponsePromise = page.waitForResponse(
        (response) =>
          response.request().method() === "POST" &&
          response.url().includes("/api/v1/store/isp/payment/logging"),
        { timeout: 20_000 }
      ).catch(() => undefined);
      const followPaymentResponsePromise = page.waitForResponse(
        (response) =>
          response.request().method() === "POST" &&
          response.url().includes("/api/v1/store/isp/payment"),
        { timeout: 20_000 }
      ).catch(() => undefined);

      await page.getByRole("button", { name: /^결제$/ }).last().click();
      const followResults = await Promise.all([
        followPopupPromise,
        followLoggingResponsePromise,
        followPaymentResponsePromise
      ]);
      popup = followResults[0] ?? popup;
      loggingResponse = followResults[1] ?? loggingResponse;
      paymentResponse = followResults[2] ?? paymentResponse;
      popupOpened = !!popup;
      loggingStatus = loggingResponse?.status();
      paymentStatus = paymentResponse?.status();
    }

    if (!waitForCompletion) {
      await popup?.close().catch(() => undefined);
      await attachPageScreenshot(page, testInfo, pageDefinition.name, "VE", `${label}-isp-popup-started`);
      return;
    }

    if (popup) {
      await completeIspPopupIfPossible(popup, config, testInfo, pageDefinition.name, label);
    }

    const completion = await waitForIspCompletion(page);
    completedStatus = completion.status;
    resultText = completion.resultText;
    if (completion.completed) {
      await expect(page.locator("body"), "ISP 결제 완료 결과 화면이 표시되어야 합니다.").toContainText(
        /결제요청에 성공했습니다|결제가 완료|결제 완료|완료되었습니다/,
        { timeout: 15_000 }
      );
      await attachPageScreenshot(page, testInfo, pageDefinition.name, "VE", `${label}-isp-result`);
      const payment: CreatedPayment = {
        label,
        payType: "ISP",
        productName,
        amount: config.amount,
        cancelled: false
      };
      const item = await resolveRecentTransaction(browser, adminEnv, {
        productName,
        amount: config.amount,
        payTypeCandidates: ["ISP", "ISP_SMS"],
        startedAt
      });
      applyTransactionItem(payment, item);
      created = payment;
      runState.createdPayments.push(payment);
    }

    await popup?.close().catch(() => undefined);
  });

  return {
    popupOpened,
    loggingStatus,
    paymentStatus,
    completed: !!created,
    completedStatus,
    resultText,
    extraConfirmButtonVisible,
    created,
    note: waitForCompletion
      ? "ISP 완료 폴링까지 시도했습니다."
      : "결제창 즉시 오픈만 확인하고 외부 결제창은 닫았습니다."
  };
}

async function createCompletedIspOrBlock(
  browser: Browser,
  storeEnv: RuntimeQaEnv,
  adminEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: PaymentConfig,
  issueId: string,
  label: string,
  testInfo: TestInfo
): Promise<CreatedPayment> {
  const result = await startIspPayment(
    browser,
    storeEnv,
    adminEnv,
    pageDefinition,
    config,
    issueId,
    label,
    true,
    testInfo
  );

  if (!result.completed || !result.created) {
    await attachEvidenceLog(testInfo, pageDefinition.name, "VE", `${label}-isp-complete-blocked`, [
      "ISP 결제창은 열렸지만 자동 QA 세션에서 외부 ISP 승인 완료 신호 또는 거래 통보 반영 결과를 확보하지 못했습니다.",
      `결제창 열림: ${result.popupOpened ? "예" : "아니오"}`,
      `추가 결제 버튼 노출: ${result.extraConfirmButtonVisible ? "예" : "아니오"}`,
      `ISP logging 응답 status: ${result.loggingStatus ?? "-"}`,
      `ISP payment HTML 응답 status: ${result.paymentStatus ?? "-"}`,
      `ISP completed 최종 상태: ${result.completedStatus ?? "-"}`,
      `스토어 결과 화면 텍스트: ${result.resultText ?? "-"}`,
      `비고: ${result.note}`,
      "완료 ISP 거래가 필요한 후속 조회/취소/영수증 항목은 이 증적을 기준으로 확인 불가 처리합니다."
    ]);
    blocked("ISP 외부 결제창 승인 완료 또는 거래 통보 반영이 자동화 세션에서 발생하지 않아 완료 ISP 거래를 확보하지 못했습니다.");
  }

  return result.created;
}

async function verifyIspPaymentVisibleFromRole(
  browser: Browser,
  roleEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  payment: CreatedPayment,
  role: string,
  testInfo: TestInfo
): Promise<void> {
  await withRolePage(browser, roleEnv, async (page) => {
    await openAdminTransactionPage(page, pageDefinition, roleEnv);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-payment-log`);
    const items = await loadAdminTransactions(page, roleEnv, 100);
    const current = findMatchingTransaction(items, payment);
    if (!current?.transaction?.id) {
      throw new Error(`${role} 거래 조회 API에서 루시페이먼츠 ISP 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }

    applyTransactionItem(payment, current);
    const cardInfo = extractCardInfo(current);
    const bodyText = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
    const approvalNo = payment.approvalNo ?? current.transaction.approvalNo ?? "";

    expect(current.transaction.status, `${role} 거래 로그의 ISP 거래 상태는 결제 완료여야 합니다.`).toBe("PAYMENT");
    expect(approvalNo, `${role} 거래 로그의 ISP 거래 승인번호가 있어야 합니다.`).toBeTruthy();
    expect(Number(current.transaction.amount), `${role} 거래 로그의 ISP 거래 금액이 결제 금액과 일치해야 합니다.`).toBe(payment.amount);
    expect(cardInfo.length, `${role} 거래 로그 API에는 카드 정보 후보가 있어야 합니다.`).toBeGreaterThan(0);
    expect(bodyText, `${role} 거래 로그 화면에 ISP 승인번호가 표시되어야 합니다.`).toContain(approvalNo);
    expect(containsAmount(bodyText, payment.amount), `${role} 거래 로그 화면에 ISP 결제 금액이 표시되어야 합니다.`).toBe(true);

    await attachEvidenceLog(testInfo, pageDefinition.name, role, `${payment.label}-payment-log`, [
      `${role} 거래 로그에서 루시페이먼츠 ISP 통보 반영 후 결제 완료 상태, 승인번호, 금액, 카드 정보를 확인했습니다.`,
      `상태: ${current.transaction.status ?? "-"}`,
      `승인번호: ${approvalNo}`,
      `금액: ${formatNumber(payment.amount)}원`,
      `카드 정보 후보: ${cardInfo.map(maskPotentialCard).join(" / ")}`,
      `PG사: ${payment.pgName ?? "-"}`,
      `payType: ${payment.payType}`
    ]);
  });
}

async function verifyReceiptFromRole(
  browser: Browser,
  roleEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  payment: CreatedPayment,
  role: string,
  testInfo: TestInfo
): Promise<{ receiptUrl: string; popupUrl: string }> {
  return withRolePage(browser, roleEnv, async (page) => {
    await openAdminTransactionPage(page, pageDefinition, roleEnv);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-before-receipt`);
    const items = await loadAdminTransactions(page, roleEnv, 100);
    const current = findMatchingTransaction(items, payment);
    if (!current?.transaction?.id) {
      throw new Error(`${role} 거래 조회 API에서 영수증 대상 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }
    applyTransactionItem(payment, current);

    const approvalVisible = payment.approvalNo
      ? await page.locator("body").innerText().then((text) => text.includes(payment.approvalNo!)).catch(() => false)
      : false;
    const receipt = approvalVisible
      ? await clickReceiptAndCapture(page, page.getByText(payment.approvalNo!, { exact: true }).first())
      : await requestReceiptByApi(page, roleEnv, payment);
    await attachEvidenceLog(testInfo, pageDefinition.name, role, `${payment.label}-receipt`, [
      `${role} 거래 화면에서 승인번호를 클릭해 루시페이먼츠 영수증 새 창이 열리는지 확인했습니다.`,
      approvalVisible
        ? "화면 승인번호 버튼 클릭으로 영수증을 확인했습니다."
        : "현재 테이블 프리셋에서 승인번호 컬럼이 화면에 노출되지 않아 동일 role 세션의 영수증 API로 URL 생성 가능 여부를 확인했습니다.",
      `승인번호: ${payment.approvalNo ?? "-"}`,
      `PG사: ${payment.pgName ?? "-"}`,
      `영수증 URL: ${receipt.receiptUrl}`,
      `새 창 URL: ${receipt.popupUrl}`
    ]);
    return receipt;
  });
}

async function cancelPaymentFromRole(
  browser: Browser,
  roleEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  payment: CreatedPayment,
  role: string,
  testInfo: TestInfo
): Promise<CancelEvidence> {
  return withRolePage(browser, roleEnv, async (page) => {
    await openAdminTransactionPage(page, pageDefinition, roleEnv);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-before-cancel`);
    const beforeItems = await loadAdminTransactions(page, roleEnv, 100);
    const before = findMatchingTransaction(beforeItems, payment);
    if (!before?.transaction?.id) {
      throw new Error(`${role} 거래 조회 API에서 취소 대상 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }
    applyTransactionItem(payment, before);

    const row = page.getByRole("row").filter({ hasText: payment.approvalNo! }).first();
    const cancelControl = (await row.isVisible({ timeout: 2_000 }).catch(() => false))
      ? row.getByText("PG취소", { exact: true }).first()
      : page.getByText("PG취소", { exact: true }).first();
    let response: { status: number; ok: boolean; body: string; mode: "ui" | "api" };
    if (await cancelControl.isVisible({ timeout: 3_000 }).catch(() => false)) {
      const responsePromise = page.waitForResponse(
        (candidate) =>
          candidate.request().method() === "POST" &&
          candidate.url().includes("/api/v1/transaction/cancel"),
        { timeout: 45_000 }
      );
      await cancelControl.click();
      const reasonInput = page.locator("textarea:visible, input[placeholder*='취소사유']:visible").first();
      await expect(reasonInput, `${role} PG 취소 사유 입력란이 보여야 합니다.`).toBeVisible({ timeout: 10_000 });
      await reasonInput.fill(`QA #2911 루시페이먼츠 ${payment.payType} 취소 ${formatDate(new Date())}`);
      await page.getByRole("button", { name: /^신청$/ }).last().click();

      const uiResponse = await responsePromise;
      response = {
        status: uiResponse.status(),
        ok: uiResponse.ok(),
        body: JSON.stringify(await uiResponse.json().catch(() => undefined)),
        mode: "ui"
      };
    } else {
      response = await cancelPaymentByRoleApi(page, roleEnv, payment);
    }

    if (!response.ok) {
      throw new Error(`${role} PG 취소 요청 실패: status=${response.status} body=${response.body}`);
    }

    await page.waitForTimeout(1_000);
    const refreshed = await loadAdminTransactions(page, roleEnv, 100);
    const current = findMatchingTransaction(refreshed, payment);
    if (current) {
      applyTransactionItem(payment, current);
    }
    expect(payment.status, `${role} PG 취소 후 거래 상태가 취소로 바뀌어야 합니다.`).toBe("CANCEL");
    payment.cancelled = true;
    payment.cancelledAmount = payment.amount;
    payment.remainingAmount = 0;

    const bodyText = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
    expect(bodyText, `${role} 거래 로그 화면에 취소 상태가 표시되어야 합니다.`).toMatch(/취소|CANCEL/i);
    expect(containsAmount(bodyText, payment.amount), `${role} 거래 로그 화면에 취소 금액이 표시되어야 합니다.`).toBe(true);
    const cancelInfoText = formatCancelInfo(current ?? before, payment.amount, payment.amount);

    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-pg-cancel`);
    await attachEvidenceLog(testInfo, pageDefinition.name, role, `${payment.label}-pg-cancel`, [
      `${role} 계정으로 루시페이먼츠 ${payment.payType} 거래 PG취소를 완료했습니다.`,
      `취소 방식: ${response.mode === "ui" ? "화면 PG취소 버튼" : "동일 role 세션 API"}`,
      `승인번호: ${payment.approvalNo ?? "-"}`,
      `거래 ID: ${redactId(payment.transactionId ?? "")}`,
      `주문번호: ${payment.orderNo ?? "-"}`,
      `TID: ${payment.tid ?? "-"}`,
      `PG사: ${payment.pgName ?? "-"}`,
      `취소 응답 status: ${response.status}`,
      `최종 상태: ${payment.status ?? "-"}`,
      `취소 금액 정보: ${cancelInfoText}`
    ]);

    return {
      cancelAmount: payment.amount,
      responseStatus: response.status,
      cancelInfoText
    };
  });
}

async function cancelPaymentPartiallyFromRole(
  browser: Browser,
  roleEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  payment: CreatedPayment,
  role: string,
  cancelAmount: number,
  sequence: number,
  testInfo: TestInfo
): Promise<CancelEvidence> {
  return withRolePage(browser, roleEnv, async (page) => {
    await openAdminTransactionPage(page, pageDefinition, roleEnv);
    const beforeItems = await loadAdminTransactions(page, roleEnv, 100);
    const before = findMatchingTransaction(beforeItems, payment);
    if (!before?.transaction?.id) {
      throw new Error(`${role} 거래 조회 API에서 부분취소 대상 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }
    applyTransactionItem(payment, before);

    const response = await cancelPaymentByRoleApi(
      page,
      roleEnv,
      payment,
      cancelAmount,
      `QA #2911 루시페이먼츠 ISP ${sequence}회 부분취소 ${formatDate(new Date())}`
    );
    if (!response.ok) {
      throw new Error(`${role} ${sequence}회 부분취소 요청 실패: status=${response.status} body=${response.body}`);
    }

    payment.lastCancelAmount = cancelAmount;
    payment.cancelledAmount = (payment.cancelledAmount ?? 0) + cancelAmount;
    payment.remainingAmount = Math.max(0, payment.amount - payment.cancelledAmount);
    await page.waitForTimeout(1_000);

    const refreshed = await loadAdminTransactions(page, roleEnv, 100);
    const current = findMatchingTransaction(refreshed, payment);
    if (current) {
      applyTransactionItem(payment, current);
    }
    payment.cancelled = payment.remainingAmount === 0 || payment.status === "CANCEL";

    const cumulativeCancelAmount = payment.cancelledAmount ?? cancelAmount;
    const bodyText = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
    expect(bodyText, `${role} 거래 로그 화면에 부분취소 후 취소 상태가 표시되어야 합니다.`).toMatch(/취소|CANCEL|부분/i);
    expect(
      containsAmount(bodyText, cancelAmount) || transactionHasAmount(current, cancelAmount),
      `${role} 거래 로그 화면/API에 현재 취소 금액 ${formatNumber(cancelAmount)}원이 표시되어야 합니다.`
    ).toBe(true);
    expect(
      containsAmount(bodyText, cumulativeCancelAmount) || transactionHasAmount(current, cumulativeCancelAmount),
      `${role} 거래 로그 화면/API에 누적 취소 금액 ${formatNumber(cumulativeCancelAmount)}원이 표시되어야 합니다.`
    ).toBe(true);

    const cancelInfoText = formatCancelInfo(current ?? before, cancelAmount, cumulativeCancelAmount);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-partial-cancel-${sequence}`);
    await attachEvidenceLog(testInfo, pageDefinition.name, role, `${payment.label}-partial-cancel-${sequence}`, [
      `${role} 계정으로 루시페이먼츠 ISP 거래 ${sequence}회 부분취소를 완료했습니다.`,
      `승인번호: ${payment.approvalNo ?? "-"}`,
      `현재 취소금액: ${formatNumber(cancelAmount)}원`,
      `누적 취소금액: ${formatNumber(cumulativeCancelAmount)}원`,
      `남은 금액: ${formatNumber(payment.remainingAmount ?? Math.max(0, payment.amount - cumulativeCancelAmount))}원`,
      `취소 응답 status: ${response.status}`,
      `화면/API 취소 정보: ${cancelInfoText}`
    ]);

    return {
      cancelAmount,
      responseStatus: response.status,
      cancelInfoText
    };
  });
}

async function verifyPartialCancelVisibleFromRole(
  browser: Browser,
  roleEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  payment: CreatedPayment,
  role: string,
  currentCancelAmount: number,
  cumulativeCancelAmount: number,
  testInfo: TestInfo
): Promise<string[]> {
  return withRolePage(browser, roleEnv, async (page) => {
    await openAdminTransactionPage(page, pageDefinition, roleEnv);
    await attachPageScreenshot(page, testInfo, pageDefinition.name, role, `${payment.label}-partial-visible`);
    const items = await loadAdminTransactions(page, roleEnv, 100);
    const current = findMatchingTransaction(items, payment);
    if (!current?.transaction?.id) {
      throw new Error(`${role} 거래 조회 API에서 부분취소 표시 대상 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }
    applyTransactionItem(payment, current);

    const bodyText = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
    const hasCurrent = containsAmount(bodyText, currentCancelAmount) || transactionHasAmount(current, currentCancelAmount);
    const hasCumulative = containsAmount(bodyText, cumulativeCancelAmount) || transactionHasAmount(current, cumulativeCancelAmount);
    expect(hasCurrent, `${role} 거래 로그 화면/API에 현재 취소 금액이 표시되어야 합니다.`).toBe(true);
    expect(hasCumulative, `${role} 거래 로그 화면/API에 누적 취소 금액이 표시되어야 합니다.`).toBe(true);
    expect(currentCancelAmount, "현재 취소금액과 누적 취소금액은 서로 다른 값으로 구분되어야 합니다.").not.toBe(cumulativeCancelAmount);

    const lines = [
      `승인번호=${payment.approvalNo ?? "-"}`,
      `현재취소=${formatNumber(currentCancelAmount)}원`,
      `누적취소=${formatNumber(cumulativeCancelAmount)}원`,
      `상태=${payment.status ?? current.transaction.status ?? "-"}`,
      `정보=${formatCancelInfo(current, currentCancelAmount, cumulativeCancelAmount)}`
    ];
    await attachEvidenceLog(testInfo, pageDefinition.name, role, `${payment.label}-partial-visible`, [
      `${role} 거래 로그에서 다중 부분취소 후 현재 취소 금액과 누적 취소 금액이 구분되어 표시되는지 확인했습니다.`,
      ...lines
    ]);
    return lines;
  });
}

async function cleanupCreatedPayments(
  browser: Browser,
  adminEnv: RuntimeQaEnv,
  transactionPage: QaPageDefinition
): Promise<void> {
  void transactionPage;
  for (const payment of runState.createdPayments) {
    if (!payment.transactionId || payment.cancelled) {
      continue;
    }
    try {
      await cancelPaymentByApi(browser, adminEnv, payment);
    } catch (error) {
      console.error(`[qa:2911] PG 취소 보정 실패: ${payment.label} ${error instanceof Error ? error.message : String(error)}`);
    }
  }
}

async function cancelPaymentByApi(
  browser: Browser,
  adminEnv: RuntimeQaEnv,
  payment: CreatedPayment
): Promise<void> {
  await withRolePage(browser, adminEnv, async (page) => {
    await openAuthenticatedUrl(page, adminEnv, "/");
    const items = await loadAdminTransactions(page, adminEnv, 100);
    const current = findMatchingTransaction(items, payment);
    if (current?.transaction?.status === "CANCEL") {
      applyTransactionItem(payment, current);
      payment.cancelled = true;
      return;
    }
    if (!current?.transaction?.id) {
      throw new Error(`취소 보정 대상 거래를 찾지 못했습니다: ${payment.approvalNo ?? redactId(payment.transactionId ?? "")}`);
    }
    const cancelAmount = resolveRemainingCancelAmount(payment);
    if (cancelAmount <= 0) {
      payment.cancelled = true;
      return;
    }
    const response = await page.evaluate(async ({ apiBase, transactionId, amount }) => {
      const result = await fetch(`${apiBase}/api/v1/transaction/cancel`, {
        method: "POST",
        credentials: "include",
        headers: {
          "content-type": "application/json"
        },
        body: JSON.stringify({
          transactionId,
          cancelAmount: String(amount),
          reason: `QA #2911 실패/중단 보정 취소 ${new Date().toISOString().slice(0, 10)}`,
          pgResponseRequired: true
        })
      });
      return {
        status: result.status,
        ok: result.ok,
        body: await result.text()
      };
    }, {
      apiBase: apiBaseFromRuntime(adminEnv),
      transactionId: current.transaction.id,
      amount: cancelAmount
    });
    if (!response.ok) {
      throw new Error(`취소 보정 API 실패: status=${response.status} body=${response.body}`);
    }
    payment.cancelled = true;
  });
}

async function cancelPaymentByRoleApi(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  payment: CreatedPayment,
  cancelAmount = resolveRemainingCancelAmount(payment),
  reason = `QA #2911 루시페이먼츠 취소 ${new Date().toISOString().slice(0, 10)}`
): Promise<{ status: number; ok: boolean; body: string; mode: "api" }> {
  if (!payment.transactionId) {
    throw new Error("취소할 transactionId가 없습니다.");
  }

  return page.evaluate(async ({ apiBase, transactionId, amount, reasonText }) => {
    const result = await fetch(`${apiBase}/api/v1/transaction/cancel`, {
      method: "POST",
      credentials: "include",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({
        transactionId,
        cancelAmount: String(amount),
        pgResponseRequired: true,
        reason: reasonText
      })
    });
    return {
      status: result.status,
      ok: result.ok,
      body: await result.text(),
      mode: "api" as const
    };
  }, {
    apiBase: apiBaseFromRuntime(runtimeEnv),
    transactionId: payment.transactionId,
    amount: cancelAmount,
    reasonText: reason
  });
}

async function withRolePage<T>(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  callback: (page: Page) => Promise<T>
): Promise<T> {
  const context = await browser.newContext(
    runtimeEnv.storageState
      ? { storageState: runtimeEnv.storageState, acceptDownloads: true }
      : { acceptDownloads: true }
  );
  const page = await context.newPage();

  try {
    return await callback(page);
  } finally {
    await context.close();
  }
}

async function openAuthenticatedUrl(page: Page, runtimeEnv: RuntimeQaEnv, path: string): Promise<void> {
  if (!runtimeEnv.baseUrl) {
    throw new Error("baseUrl이 필요합니다.");
  }
  const targetUrl = new URL(path, ensureTrailingSlash(runtimeEnv.baseUrl)).toString();

  if (!runtimeEnv.storageState) {
    await loginWithCredentials(page, runtimeEnv);
  }

  await page.goto(targetUrl);
  await waitForNavigationToSettle(page);

  if (await isLoginPage(page, runtimeEnv)) {
    await loginWithCredentials(page, runtimeEnv);
    await page.goto(targetUrl);
    await waitForNavigationToSettle(page);
  }

  expect(await isLoginPage(page, runtimeEnv), `${runtimeEnv.application} 업무 화면 진입 전 로그인 화면을 벗어나야 합니다.`).toBe(false);
}

async function openAdminTransactionPage(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv
): Promise<void> {
  const targetUrl = new URL(buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!));
  const today = formatDate(new Date());
  targetUrl.searchParams.set("from", today);
  targetUrl.searchParams.set("to", today);
  targetUrl.searchParams.set("exactMatch", "false");

  await openAuthenticatedUrl(page, runtimeEnv, `${targetUrl.pathname}${targetUrl.search}`);
  await expect(page.locator("body"), "거래 화면이 표시되어야 합니다.").toContainText(/거래내역|거래로그/);
}

async function fillManualPaymentForm(page: Page, config: PaymentConfig, productName: string): Promise<void> {
  await page.getByPlaceholder("상품명을 입력해주세요.").fill(productName);
  await page.getByPlaceholder("판매가격").fill(String(config.amount));
  await page.getByPlaceholder("수량").fill("1");
  await page.getByPlaceholder("- 없이 입력해주세요.").fill(config.cardNumber);
  await selectDropdownButton(page, "MM", config.expiryMonth);
  await selectDropdownButton(page, "YYYY", config.expiryYear);
  await page.getByPlaceholder("6자리").fill(config.birth);
  await page.getByPlaceholder("2자리").fill(config.cardPassword);
  await page.getByPlaceholder("휴대폰 번호").fill(config.payerPhone.replace(/^010/, ""));
  await page.getByPlaceholder("이름을 입력해주세요.").fill(config.payerName);
  await expect(page.getByRole("button", { name: "결제하기" }), "결제하기 버튼이 활성화되어야 합니다.").toBeEnabled();
}

async function selectIspPaymentMode(page: Page): Promise<void> {
  await page.getByText("ISP 결제", { exact: true }).click();
  const corporateButton = page.getByRole("button", { name: /사업자|법인/ }).first();
  if (await corporateButton.isVisible({ timeout: 1_500 }).catch(() => false)) {
    await corporateButton.click();
  }
  await expect(page.locator("body"), "ISP 결제 선택 후 카드번호 입력란은 사라져야 합니다.").not.toContainText("카드번호");
}

async function fillIspPaymentForm(page: Page, config: PaymentConfig, productName: string): Promise<void> {
  await page.getByPlaceholder("상품명을 입력해주세요.").fill(productName);
  await page.getByPlaceholder("판매가격").fill(String(config.amount));
  await page.getByPlaceholder("수량").fill("1");
  await page.getByPlaceholder("휴대폰 번호").fill(config.payerPhone.replace(/^010/, ""));
  await page.getByPlaceholder("이름을 입력해주세요.").fill(config.payerName);
  const submit = findIspSubmitButton(page);
  await expect(submit, "ISP 결제 시작 버튼이 활성화되어야 합니다.").toBeEnabled();
}

async function clickIspSubmit(page: Page): Promise<void> {
  await findIspSubmitButton(page).click();
}

async function confirmIspPaymentIfPresent(page: Page): Promise<void> {
  const confirmButton = page.getByRole("button", { name: /^결제$/ }).last();
  if (await confirmButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
    await confirmButton.click();
  }
}

function findIspSubmitButton(page: Page): Locator {
  return page.getByRole("button", { name: /ISP 결제하기|결제하기/ }).last();
}

async function waitForIspCompletion(page: Page): Promise<{ completed: boolean; status?: string; resultText?: string }> {
  const deadline = Date.now() + 60_000;
  let status = "";
  let lastBodyText = "";

  while (Date.now() < deadline) {
    const bodyText = await page.locator("body").innerText({ timeout: 1_000 }).catch(() => "");
    lastBodyText = bodyText;
    if (/결제요청에 성공했습니다|결제가 완료|결제 완료|완료되었습니다/.test(bodyText)) {
      return { completed: true, status: "COMPLETED", resultText: summarizeText(bodyText) };
    }
    if (/결제가 실패|결제 실패|실패했습니다/.test(bodyText)) {
      return { completed: false, status: "FAILED", resultText: summarizeText(bodyText) };
    }
    status = bodyText.match(/COMPLETED|FAILED|PENDING/)?.[0] ?? status;
    await page.waitForTimeout(1_000);
  }

  return { completed: false, status: status || "TIMEOUT", resultText: summarizeText(lastBodyText) };
}

async function selectDropdownButton(page: Page, buttonText: string, value: string): Promise<void> {
  await page.getByRole("button", { name: buttonText }).first().click();
  const option = page.getByRole("option", { name: value }).first();
  if (await option.isVisible({ timeout: 1_500 }).catch(() => false)) {
    await option.click();
    return;
  }
  await page.getByText(value, { exact: true }).last().click();
}

async function resolveCreatedTransaction(
  browser: Browser,
  adminEnv: RuntimeQaEnv,
  payment: CreatedPayment,
  amount: number
): Promise<AdminTransactionItem> {
  let found: AdminTransactionItem | undefined;

  await withRolePage(browser, adminEnv, async (page) => {
    await openAuthenticatedUrl(page, adminEnv, "/");
    for (let attempt = 0; attempt < 20; attempt += 1) {
      const items = await loadAdminTransactions(page, adminEnv, 100);
      found = findMatchingTransaction(items, payment) ??
        items.find((item) =>
          item.transaction?.amount === amount &&
          item.transaction?.status === "PAYMENT" &&
          item.transaction?.approvalNo &&
          /루시|LUCY/i.test(item.transaction?.pgName ?? "")
        );
      if (found?.transaction?.approvalNo) {
        return;
      }
      await page.waitForTimeout(1_000);
    }
  });

  if (!found?.transaction?.approvalNo) {
    throw new Error(`운영자 거래내역에서 생성 거래를 찾지 못했습니다: ${redactId(payment.transactionId ?? payment.productName)}`);
  }
  return found;
}

async function resolveRecentTransaction(
  browser: Browser,
  adminEnv: RuntimeQaEnv,
  options: {
    productName: string;
    amount: number;
    payTypeCandidates: string[];
    startedAt: Date;
  }
): Promise<AdminTransactionItem> {
  let found: AdminTransactionItem | undefined;

  await withRolePage(browser, adminEnv, async (page) => {
    await openAuthenticatedUrl(page, adminEnv, "/");
    for (let attempt = 0; attempt < 20; attempt += 1) {
      const items = await loadAdminTransactions(page, adminEnv, 100);
      found = items.find((item) => {
        const tx = item.transaction;
        if (!tx?.approvalNo || tx.status !== "PAYMENT") {
          return false;
        }
        const payType = String(tx.payType ?? "").toUpperCase();
        if (!options.payTypeCandidates.includes(payType)) {
          return false;
        }
        return Number(tx.amount) === options.amount;
      });
      if (found) {
        return;
      }
      await page.waitForTimeout(1_000);
    }
  });

  if (!found) {
    throw new Error(`운영자 거래내역에서 최근 ${options.payTypeCandidates.join("/")} 거래를 찾지 못했습니다.`);
  }
  return found;
}

async function loadAdminTransactions(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  size: number
): Promise<AdminTransactionItem[]> {
  return page.evaluate(async ({ apiBase, tenantId, size: pageSize }) => {
    const today = new Date();
    const date = [
      today.getFullYear(),
      String(today.getMonth() + 1).padStart(2, "0"),
      String(today.getDate()).padStart(2, "0")
    ].join("-");
    const url = new URL(`${apiBase}/api/v2/transactions`);
    url.searchParams.set("from", date);
    url.searchParams.set("to", date);
    url.searchParams.set("exactMatch", "false");
    url.searchParams.set("size", String(pageSize));

    const response = await fetch(url.toString(), {
      credentials: "include",
      headers: {
        accept: "application/json",
        "x-tenant-id": tenantId
      }
    });
    const json = await response.json();
    if (!response.ok) {
      throw new Error(`거래내역 조회 실패: status=${response.status} message=${json?.result?.message ?? response.statusText}`);
    }
    return json?.payload?.items ?? [];
  }, { apiBase: apiBaseFromRuntime(runtimeEnv), tenantId: runtimeEnv.tenantId!, size });
}

function findMatchingTransaction(items: AdminTransactionItem[], payment: CreatedPayment): AdminTransactionItem | undefined {
  return items.find((item) => {
    const tx = item.transaction;
    if (!tx) {
      return false;
    }
    return (
      (!!payment.transactionId && tx.id === payment.transactionId) ||
      (!!payment.approvalNo && tx.approvalNo === payment.approvalNo)
    );
  });
}

function applyTransactionItem(payment: CreatedPayment, item: AdminTransactionItem): void {
  const tx = item.transaction ?? {};
  payment.transactionId = tx.id ?? payment.transactionId;
  payment.approvalNo = tx.approvalNo ?? payment.approvalNo;
  payment.pgName = tx.pgName ?? payment.pgName;
  payment.status = tx.status ?? payment.status;
  payment.payDate = tx.payDate ?? payment.payDate;
  payment.orderNo = tx.orderNo ?? payment.orderNo;
  payment.tid = tx.tid ?? payment.tid;
  const payType = String(tx.payType ?? payment.payType ?? "").toUpperCase();
  payment.payType = payType === "IDENTITY" || payType === "ISP" ? payType : payment.payType;
  const cardInfo = extractCardInfo(item);
  payment.cardInfo = cardInfo.length > 0 ? cardInfo.map(maskPotentialCard).join(" / ") : payment.cardInfo;
  const amountInfo = extractCancelAmounts(item);
  payment.lastCancelAmount = amountInfo.current ?? payment.lastCancelAmount;
  payment.cancelledAmount = amountInfo.cumulative ?? payment.cancelledAmount;
  payment.remainingAmount = amountInfo.remaining ?? payment.remainingAmount;
}

async function clickReceiptAndCapture(page: Page, trigger: Locator): Promise<{ receiptUrl: string; popupUrl: string }> {
  const popupPromise = page.context().waitForEvent("page", { timeout: 15_000 }).catch(() => undefined);
  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/receipt"),
    { timeout: 15_000 }
  );

  await trigger.click();
  const response = await responsePromise;
  const json = await response.json().catch(() => undefined);
  const receiptUrl = json?.payload?.url;
  if (!response.ok() || !receiptUrl) {
    throw new Error(`영수증 조회 실패: status=${response.status()} body=${JSON.stringify(json)}`);
  }

  const popup = await popupPromise;
  if (!popup) {
    throw new Error(`영수증 URL은 응답되었지만 새 창이 열리지 않았습니다: ${receiptUrl}`);
  }

  await popup.waitForLoadState("domcontentloaded", { timeout: 15_000 }).catch(() => undefined);
  await popup.waitForTimeout(500);
  const popupUrl = popup.url();
  await popup.close().catch(() => undefined);

  expect(popupUrl, "영수증 새 창 URL은 빈 화면이면 안 됩니다.").not.toMatch(/^about:blank/i);
  return { receiptUrl, popupUrl };
}

async function requestReceiptByApi(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  payment: CreatedPayment
): Promise<{ receiptUrl: string; popupUrl: string }> {
  if (!payment.transactionId) {
    throw new Error("영수증을 확인할 transactionId가 없습니다.");
  }

  const response = await page.evaluate(async ({ apiBase, transactionId }) => {
    const result = await fetch(`${apiBase}/api/v1/receipt`, {
      method: "POST",
      credentials: "include",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({ id: transactionId })
    });
    return {
      status: result.status,
      ok: result.ok,
      body: await result.json().catch(() => undefined)
    };
  }, {
    apiBase: apiBaseFromRuntime(runtimeEnv),
    transactionId: payment.transactionId
  });
  const receiptUrl = response.body?.payload?.url;
  if (!response.ok || !receiptUrl) {
    throw new Error(`영수증 API 조회 실패: status=${response.status} body=${JSON.stringify(response.body)}`);
  }
  return {
    receiptUrl,
    popupUrl: receiptUrl
  };
}

function getRoleEvidence(role: string): RoleRunEvidence {
  if (!runState.roleRuns[role]) {
    runState.roleRuns[role] = {
      role,
      cancelStatuses: [],
      partialCancelLines: [],
      idPairs: []
    };
  }
  return runState.roleRuns[role];
}

async function completeIspPopupIfPossible(
  popup: Page,
  config: PaymentConfig,
  testInfo: TestInfo,
  pageName: string,
  label: string
): Promise<void> {
  await popup.waitForLoadState("domcontentloaded", { timeout: 20_000 }).catch(() => undefined);
  await popup.waitForTimeout(1_000);
  await attachPageScreenshot(popup, testInfo, pageName, "VE", `${label}-isp-popup`);

  await fillPopupFieldByHints(popup, [/카드\s*번호|card\s*number|cardno|card_no/i], config.cardNumber);
  await fillPopupFieldByHints(popup, [/유효\s*기간|expiry|expire|valid/i], `${config.expiryMonth}${config.expiryYear.slice(-2)}`);
  await fillPopupFieldByHints(popup, [/비밀번호|password|passwd|pwd/i], config.cardPassword);
  await fillPopupFieldByHints(popup, [/생년월일|사업자|birth|brn|biz/i], config.birth);

  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline && !popup.isClosed()) {
    const clicked = await clickPopupButtonByText(popup, [
      /결제하기/,
      /^결제$/,
      /승인/,
      /확인/,
      /다음/,
      /완료/
    ]);
    if (!clicked) {
      await popup.waitForTimeout(1_000);
      continue;
    }
    await popup.waitForLoadState("domcontentloaded", { timeout: 5_000 }).catch(() => undefined);
    await popup.waitForTimeout(1_000);
  }
}

async function fillPopupFieldByHints(page: Page, hints: RegExp[], value: string): Promise<void> {
  if (!value) {
    return;
  }

  for (const hint of hints) {
    const byPlaceholder = page.getByPlaceholder(hint).first();
    if (await byPlaceholder.isVisible({ timeout: 500 }).catch(() => false)) {
      await byPlaceholder.fill(value).catch(() => undefined);
      return;
    }

    const byLabel = page.getByLabel(hint).first();
    if (await byLabel.isVisible({ timeout: 500 }).catch(() => false)) {
      await byLabel.fill(value).catch(() => undefined);
      return;
    }
  }
}

async function clickPopupButtonByText(page: Page, names: RegExp[]): Promise<boolean> {
  for (const name of names) {
    const button = page.getByRole("button", { name }).first();
    if (await button.isVisible({ timeout: 500 }).catch(() => false)) {
      await button.click().catch(() => undefined);
      return true;
    }

    const textButton = page.getByText(name).first();
    if (await textButton.isVisible({ timeout: 500 }).catch(() => false)) {
      await textButton.click().catch(() => undefined);
      return true;
    }
  }
  return false;
}

function resolvePartialCancelAmounts(totalAmount: number): [number, number] {
  if (totalAmount >= 1000) {
    return [101, 203];
  }
  const first = Math.max(1, Math.floor(totalAmount * 0.2));
  const second = Math.max(1, Math.floor(totalAmount * 0.3));
  if (first + second >= totalAmount) {
    return [1, 1];
  }
  return [first, second];
}

function resolveRemainingCancelAmount(payment: CreatedPayment): number {
  const remaining = payment.remainingAmount ?? payment.amount - (payment.cancelledAmount ?? 0);
  return Math.max(0, remaining);
}

function containsAmount(text: string, amount: number): boolean {
  const plain = String(amount);
  const formatted = formatNumber(amount);
  return text.includes(plain) || text.includes(formatted);
}

function transactionHasAmount(item: AdminTransactionItem | undefined, amount: number): boolean {
  if (!item) {
    return false;
  }
  const values = collectPrimitiveValues(item);
  return values.some((value) => {
    if (typeof value === "number") {
      return value === amount;
    }
    if (typeof value === "string") {
      return value.replace(/,/g, "") === String(amount) || value.includes(formatNumber(amount));
    }
    return false;
  });
}

function extractCardInfo(item: AdminTransactionItem): string[] {
  const values = collectPrimitiveEntries(item)
    .filter(([key, value]) => /(card|카드|issuer|acquirer|매입|발급|bin|installment|할부)/i.test(key) && value !== "")
    .map(([key, value]) => `${key}=${String(value)}`);
  return Array.from(new Set(values)).slice(0, 8);
}

function extractCancelAmounts(item: AdminTransactionItem | undefined): {
  current?: number;
  cumulative?: number;
  remaining?: number;
} {
  if (!item) {
    return {};
  }

  let current: number | undefined;
  let cumulative: number | undefined;
  let remaining: number | undefined;

  for (const [key, value] of collectPrimitiveEntries(item)) {
    const amount = toNumber(value);
    if (amount === undefined) {
      continue;
    }
    if (/(^|\.)(cancelAmount|currentCancelAmount|lastCancelAmount|취소금액)$/i.test(key)) {
      current = amount;
    }
    if (/(totalCancelAmount|cumulativeCancelAmount|cancelledAmount|canceledAmount|accumulatedCancelAmount|누적)/i.test(key)) {
      cumulative = amount;
    }
    if (/(remainAmount|remainingAmount|remainPaymentAmount|잔여)/i.test(key)) {
      remaining = amount;
    }
  }

  return { current, cumulative, remaining };
}

function formatCancelInfo(
  item: AdminTransactionItem | undefined,
  expectedCurrent: number,
  expectedCumulative: number
): string {
  const amounts = extractCancelAmounts(item);
  return [
    `current=${amounts.current !== undefined ? `${formatNumber(amounts.current)}원` : `${formatNumber(expectedCurrent)}원(요청값)`}`,
    `cumulative=${amounts.cumulative !== undefined ? `${formatNumber(amounts.cumulative)}원` : `${formatNumber(expectedCumulative)}원(기대값)`}`,
    `remaining=${amounts.remaining !== undefined ? `${formatNumber(amounts.remaining)}원` : "-"}`
  ].join(", ");
}

function collectPrimitiveEntries(value: unknown, prefix = ""): Array<[string, string | number | boolean]> {
  if (Array.isArray(value)) {
    return value.flatMap((item, index) => collectPrimitiveEntries(item, `${prefix}[${index}]`));
  }
  if (value && typeof value === "object") {
    return Object.entries(value).flatMap(([key, child]) => {
      const path = prefix ? `${prefix}.${key}` : key;
      return collectPrimitiveEntries(child, path);
    });
  }
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
    return [[prefix, value]];
  }
  return [];
}

function collectPrimitiveValues(value: unknown): Array<string | number | boolean> {
  return collectPrimitiveEntries(value).map(([, item]) => item);
}

function toNumber(value: string | number | boolean): number | undefined {
  if (typeof value === "number" && Number.isFinite(value)) {
    return value;
  }
  if (typeof value !== "string") {
    return undefined;
  }
  const normalized = value.replace(/[^0-9.-]/g, "");
  if (!normalized) {
    return undefined;
  }
  const numberValue = Number(normalized);
  return Number.isFinite(numberValue) ? numberValue : undefined;
}

async function waitForNavigationToSettle(page: Page): Promise<void> {
  await page.waitForLoadState("domcontentloaded");
  await page.waitForLoadState("networkidle", { timeout: 7_000 }).catch(() => undefined);
  await page.waitForTimeout(700);
}

async function attachEvidenceLog(
  testInfo: TestInfo,
  pageName: string,
  role: string,
  suffix: string,
  lines: string[]
): Promise<void> {
  await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.md`, {
    body: lines.join("\n"),
    contentType: "text/markdown"
  });
}

async function attachPageScreenshot(
  page: Page,
  testInfo: TestInfo,
  pageName: string,
  role: string,
  suffix: string
): Promise<void> {
  const screenshotPath = testInfo.outputPath(`${safeFilename(pageName)}-${role}-${suffix}.png`);
  await page.screenshot({ path: screenshotPath, fullPage: true });
  await testInfo.attach(`${safeFilename(pageName)}-${role}-${suffix}.png`, {
    path: screenshotPath,
    contentType: "image/png"
  });
}

function apiBaseFromRuntime(runtimeEnv: RuntimeQaEnv): string {
  const baseUrl = runtimeEnv.baseUrl;
  if (!baseUrl) {
    throw new Error("baseUrl이 필요합니다.");
  }
  const url = new URL(baseUrl);
  url.hostname = url.hostname.replace(/^(admin|store)-/, "api-");
  return url.origin;
}

function buildProductName(issueId: string, label: string): string {
  const now = new Date();
  const timestamp = [
    now.getFullYear(),
    String(now.getMonth() + 1).padStart(2, "0"),
    String(now.getDate()).padStart(2, "0"),
    String(now.getHours()).padStart(2, "0"),
    String(now.getMinutes()).padStart(2, "0"),
    String(now.getSeconds()).padStart(2, "0")
  ].join("");
  return `QA ${issueId} 루시 ${label} ${timestamp}`;
}

function formatIdPair(payment: CreatedPayment): string {
  const orderNo = payment.orderNo ?? "-";
  const tid = payment.tid ?? "-";
  const transactionId = payment.transactionId ? redactId(payment.transactionId) : "-";
  if (orderNo !== "-" && tid !== "-") {
    expect(orderNo, "거래번호와 주문번호가 서로 바뀌거나 동일하게 표시되면 안 됩니다.").not.toBe(tid);
  }
  return `${payment.payType} approvalNo=${payment.approvalNo ?? "-"}, transactionId=${transactionId}, orderNo=${orderNo}, tid=${tid}, status=${payment.status ?? "-"}`;
}

function formatDate(date: Date): string {
  return [
    date.getFullYear(),
    String(date.getMonth() + 1).padStart(2, "0"),
    String(date.getDate()).padStart(2, "0")
  ].join("-");
}

function formatNumber(value: number): string {
  return new Intl.NumberFormat("ko-KR").format(value);
}

function maskCard(value: string): string {
  const normalized = value.replace(/\D/g, "");
  if (normalized.length < 10) {
    return "<masked>";
  }
  return `${normalized.slice(0, 6)}******${normalized.slice(-4)}`;
}

function maskPotentialCard(value: string): string {
  return value.replace(/\d{10,19}/g, (match) => maskCard(match));
}

function summarizeText(value: string): string {
  return value.replace(/\s+/g, " ").trim().slice(0, 240);
}

function redactId(value: string): string {
  const normalized = String(value);
  if (normalized.length <= 8) {
    return "<redacted>";
  }
  return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
}

function ensureTrailingSlash(rawUrl: string): string {
  return rawUrl.endsWith("/") ? rawUrl : `${rawUrl}/`;
}

function safeFilename(value: string): string {
  return value
    .replace(/[^a-zA-Z0-9가-힣_.-]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 80);
}

function blocked(message: string): never {
  test.skip(true, `BLOCKED: ${message}`);
  throw new Error(`BLOCKED: ${message}`);
}
