import { expect, test, type Browser, 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";

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

interface NotificationPayload {
  amount: number;
  business_number: string;
  buy_company_code: string;
  card_info: string;
  cat_id: string;
  check_card: string;
  cid: string;
  client_business_number: string;
  enc_card_no: string;
  installment: string;
  issue_company_code: string;
  order_no: string;
  pay_type: string;
  pltid: string;
  pos_entry_mode: string;
  service_amount: number;
  store_number: string;
  tax_amount: number;
  tid: string;
  transaction_date: string;
  use_state: "0" | "1";
  user_name: string;
}

interface NotificationConfig {
  url: string;
  amount: number;
  expectedDateTime: string;
  expectedCancelDateTime: string;
  expectedQueryDate: string;
  payload: NotificationPayload;
  cancelPayload: NotificationPayload;
  expectedCardInfo: string;
  expectedCardNo: string;
}

interface NotificationResponse {
  status: number;
  ok: boolean;
  body: string;
}

interface AdminTransactionItem {
  transaction?: {
    id?: string;
    pgName?: string;
    approvalNo?: string;
    status?: string;
    amount?: number;
    payDate?: string;
    createdAt?: string;
    payType?: string;
    originTransactionId?: string | null;
  };
  [key: string]: unknown;
}

interface LocatedTransaction {
  item: AdminTransactionItem;
  statusText: string;
  amount: number;
  dateText: string;
  bodyText: string;
}

interface RunState {
  approved?: LocatedTransaction;
  canceled?: LocatedTransaction;
  approvalNotification?: NotificationResponse;
  cancelNotification?: NotificationResponse;
}

const runState: RunState = {};

export function runPayletterTerminalNotificationSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const transactionPage = findPageDefinition(checklist.pages, "거래내역");
  const adminEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
  const notificationConfig = getNotificationConfig();
  const blockReason = getRuntimeBlockReason(adminEnv) ?? getNotificationBlockReason();

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.describe("[ADMIN] role", () => {
      test.skip(!!blockReason, blockReason);

      test(checklist.checklist[0], async ({ browser }, testInfo) => {
        test.setTimeout(90_000);
        runState.approvalNotification = await sendNotificationIfNeeded(notificationConfig, "approval");
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "approval-notification-response", [
          "승인 통보 POST 응답입니다.",
          `통보 응답: ${formatNotificationResponse(runState.approvalNotification)}`,
          `거래 식별값: ${formatTargetKeys(notificationConfig.payload)}`
        ]);
        expect(runState.approvalNotification.ok, "승인 통보 API 응답은 성공이어야 합니다.").toBe(true);
        runState.approved = await locateTransaction(browser, adminEnv, transactionPage, notificationConfig, "approval", testInfo);

        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "approval-status", [
          "승인 통보 POST를 발송한 뒤 어드민 거래내역에서 대상 거래를 조회했습니다.",
          `통보 응답: ${formatNotificationResponse(runState.approvalNotification)}`,
          `거래 식별값: ${formatTargetKeys(notificationConfig.payload)}`,
          `거래 상태: ${runState.approved.statusText}`,
          `거래 금액: ${formatNumber(runState.approved.amount)}원`,
          `거래일시: ${runState.approved.dateText}`
        ]);

        expectStatus(runState.approved, "approval");
        expectScreenContainsStatus(runState.approved, "approval");
      });

      test(checklist.checklist[1], async ({ browser }, testInfo) => {
        test.setTimeout(30_000);
        const approved = await getApprovedTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "approval-amount", [
          "승인 거래 금액을 확인했습니다.",
          `기대 금액: ${formatNumber(notificationConfig.amount)}원`,
          `실제 금액: ${formatNumber(approved.amount)}원`,
          `화면 금액 노출 여부: ${containsAmount(approved.bodyText, notificationConfig.amount) ? "예" : "아니오"}`
        ]);

        expect(approved.amount, "승인 거래 금액은 1,004원이어야 합니다.").toBe(notificationConfig.amount);
        expect(containsAmount(approved.bodyText, notificationConfig.amount), "거래내역 화면에 승인 금액 1,004원이 보여야 합니다.").toBe(true);
      });

      test(checklist.checklist[2], async ({ browser }, testInfo) => {
        test.setTimeout(30_000);
        const approved = await getApprovedTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "approval-date", [
          "승인 거래 거래일시를 확인했습니다.",
          `기대 거래일시: ${notificationConfig.expectedDateTime}`,
          `실제 거래일시: ${approved.dateText}`,
          `화면 거래일시 노출 여부: ${containsDateTime(approved.bodyText, notificationConfig.expectedDateTime) ? "예" : "아니오"}`
        ]);

        expectDateTime(approved, notificationConfig.expectedDateTime);
        expect(containsDateTime(approved.bodyText, notificationConfig.expectedDateTime), "거래내역 화면에 승인 거래일시가 보여야 합니다.").toBe(true);
      });

      test(checklist.checklist[3], async ({ browser }, testInfo) => {
        test.setTimeout(30_000);
        const approved = await getApprovedTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        const hasCardInfo = containsCardInfo(approved, notificationConfig);
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "approval-card-info", [
          "승인 거래 카드 정보를 확인했습니다.",
          `기대 카드 정보: ${notificationConfig.expectedCardInfo} / ${notificationConfig.expectedCardNo}`,
          `화면/응답 카드 정보 확인 여부: ${hasCardInfo ? "예" : "아니오"}`
        ]);

        expect(hasCardInfo, "카드 정보는 테스트카드 / 244112******9428 기준으로 확인되어야 합니다.").toBe(true);
      });

      test(checklist.checklist[4], async ({ browser }, testInfo) => {
        test.setTimeout(90_000);
        runState.cancelNotification = await sendNotificationIfNeeded(notificationConfig, "cancel");
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "cancel-notification-response", [
          "취소 통보 POST 응답입니다.",
          `통보 응답: ${formatNotificationResponse(runState.cancelNotification)}`,
          `거래 식별값: ${formatTargetKeys(notificationConfig.payload)}`
        ]);
        expect(runState.cancelNotification.ok, "취소 통보 API 응답은 성공이어야 합니다.").toBe(true);
        runState.canceled = await locateTransaction(browser, adminEnv, transactionPage, notificationConfig, "cancel", testInfo);

        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "cancel-status", [
          "취소 통보 POST를 발송한 뒤 같은 식별값의 거래가 취소 상태로 바뀌었는지 확인했습니다.",
          `통보 응답: ${formatNotificationResponse(runState.cancelNotification)}`,
          `거래 식별값: ${formatTargetKeys(notificationConfig.payload)}`,
          `거래 상태: ${runState.canceled.statusText}`,
          `거래 금액: ${formatNumber(runState.canceled.amount)}원`,
          `거래일시: ${runState.canceled.dateText}`
        ]);

        expectStatus(runState.canceled, "cancel");
        expectScreenContainsStatus(runState.canceled, "cancel");
      });

      test(checklist.checklist[5], async ({ browser }, testInfo) => {
        test.setTimeout(30_000);
        const canceled = await getCanceledTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "cancel-amount", [
          "취소 거래 금액을 확인했습니다.",
          `기대 취소 금액: ${formatNumber(notificationConfig.amount)}원`,
          `실제 금액: ${formatNumber(canceled.amount)}원`,
          `화면 금액 노출 여부: ${containsAmount(canceled.bodyText, notificationConfig.amount) ? "예" : "아니오"}`
        ]);

        expect(canceled.amount, "취소 금액은 1,004원이어야 합니다.").toBe(notificationConfig.amount);
        expect(containsAmount(canceled.bodyText, notificationConfig.amount), "거래내역 화면에 취소 금액 1,004원이 보여야 합니다.").toBe(true);
      });

      test(checklist.checklist[6], async ({ browser }, testInfo) => {
        test.setTimeout(30_000);
        const canceled = await getCanceledTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "cancel-date", [
          "취소 거래 거래일시를 확인했습니다.",
          `기대 거래일시: ${notificationConfig.expectedCancelDateTime}`,
          `실제 거래일시: ${canceled.dateText}`,
          `화면 거래일시 노출 여부: ${containsDateTime(canceled.bodyText, notificationConfig.expectedCancelDateTime) ? "예" : "아니오"}`
        ]);

        expectDateTime(canceled, notificationConfig.expectedCancelDateTime);
        expect(containsDateTime(canceled.bodyText, notificationConfig.expectedCancelDateTime), "거래내역 화면에 취소 거래일시가 보여야 합니다.").toBe(true);
      });

      test(checklist.checklist[7], async ({ browser }, testInfo) => {
        test.setTimeout(75_000);
        const canceled = await getCanceledTransaction(browser, adminEnv, transactionPage, notificationConfig, testInfo);
        const refreshed = await locateTransaction(browser, adminEnv, transactionPage, notificationConfig, "cancel", testInfo, {
          reloadBeforeRead: true,
          suffix: "refresh-persisted"
        });

        await attachEvidenceLog(testInfo, transactionPage.name, "ADMIN", "refresh-persisted", [
          "화면 새로고침 후 상태, 금액, 거래일시가 유지되는지 확인했습니다.",
          `새로고침 전 상태: ${canceled.statusText}`,
          `새로고침 후 상태: ${refreshed.statusText}`,
          `새로고침 후 금액: ${formatNumber(refreshed.amount)}원`,
          `새로고침 후 거래일시: ${refreshed.dateText}`
        ]);

        expectStatus(refreshed, "cancel");
        expect(refreshed.amount, "새로고침 후에도 취소 금액은 1,004원이어야 합니다.").toBe(notificationConfig.amount);
        expectDateTime(refreshed, notificationConfig.expectedCancelDateTime);
        expect(containsAmount(refreshed.bodyText, notificationConfig.amount), "새로고침 후에도 화면에 금액이 보여야 합니다.").toBe(true);
        expect(containsDateTime(refreshed.bodyText, notificationConfig.expectedCancelDateTime), "새로고침 후에도 화면에 거래일시가 보여야 합니다.").toBe(true);
      });
    });
  });
}

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

function getNotificationConfig(): NotificationConfig {
  const payload: NotificationPayload = {
    amount: Number(process.env.QA_3143_AMOUNT ?? 1004),
    business_number: process.env.QA_3143_BUSINESS_NUMBER ?? "2138140742",
    buy_company_code: "15",
    card_info: "테스트카드",
    cat_id: process.env.QA_3143_CAT_ID ?? "paylettersecret-redacted",
    check_card: "0",
    cid: process.env.QA_3143_CID ?? "000002",
    client_business_number: process.env.QA_3143_CLIENT_BUSINESS_NUMBER ?? "7318701628",
    enc_card_no: "244112******9428",
    installment: "00",
    issue_company_code: "15",
    order_no: process.env.QA_3143_ORDER_NO ?? "000000997981",
    pay_type: "offline",
    pltid: process.env.QA_3143_PLTID ?? "tsjtest1-202605149351347",
    pos_entry_mode: "S",
    service_amount: 0,
    store_number: process.env.QA_3143_STORE_NUMBER ?? "139634181",
    tax_amount: 364,
    tid: process.env.QA_3143_TID ?? "000001",
    transaction_date: process.env.QA_3143_TRANSACTION_DATE ?? "20260526153500",
    use_state: "0",
    user_name: process.env.QA_3143_USER_NAME ?? "1047263358"
  };
  const cancelPayload: NotificationPayload = {
    ...payload,
    transaction_date: process.env.QA_3143_CANCEL_TRANSACTION_DATE ?? "20260526153600",
    use_state: "1"
  };

  return {
    url: process.env.QA_3143_NOTIFICATION_URL ?? "https://api-dev.biscuitpay.com/api/v1/payment/notification/terminal/payletter-noti",
    amount: payload.amount,
    expectedDateTime: formatTransactionDate(payload.transaction_date),
    expectedCancelDateTime: formatTransactionDate(cancelPayload.transaction_date),
    expectedQueryDate: formatTransactionDate(payload.transaction_date).slice(0, 10),
    payload,
    cancelPayload,
    expectedCardInfo: payload.card_info,
    expectedCardNo: payload.enc_card_no
  };
}

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 getNotificationBlockReason(): string | undefined {
  if (getSkippedRoles().includes("ADMIN")) {
    return "ADMIN role 재로그인에 실패해 QA_SKIP_ROLES에 포함되었습니다.";
  }
  if (process.env.QA_3143_REUSE_SENT_NOTIFICATIONS === "1") {
    return undefined;
  }
  if (process.env.QA_3143_ALLOW_NOTIFICATION_MUTATION !== "1") {
    return "QA_3143_ALLOW_NOTIFICATION_MUTATION=1 설정이 필요합니다. 실제 개발계 승인/취소 통보 POST를 발송하는 QA입니다.";
  }
  return undefined;
}

function getSkippedRoles(): string[] {
  return (process.env.QA_SKIP_ROLES ?? "")
    .split(",")
    .map((role) => role.trim().toUpperCase())
    .filter(Boolean);
}

async function sendNotification(
  config: NotificationConfig,
  kind: "approval" | "cancel"
): Promise<NotificationResponse> {
  const payload = kind === "approval" ? config.payload : config.cancelPayload;
  const response = await fetch(config.url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Forwarded-Host": "api.biscuitpay.com",
      "X-Forwarded-Proto": "https"
    },
    body: JSON.stringify(payload)
  });
  const body = await response.text();
  return {
    status: response.status,
    ok: response.ok,
    body: body.slice(0, 500)
  };
}

async function sendNotificationIfNeeded(
  config: NotificationConfig,
  kind: "approval" | "cancel"
): Promise<NotificationResponse> {
  if (process.env.QA_3143_REUSE_SENT_NOTIFICATIONS === "1") {
    return {
      status: 0,
      ok: true,
      body: `${kind === "approval" ? "승인" : "취소"} 통보는 이미 발송된 이번 테스트 세트를 재사용했습니다.`
    };
  }
  return sendNotification(config, kind);
}

async function locateTransaction(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: NotificationConfig,
  expectedStatus: "approval" | "cancel",
  testInfo: TestInfo,
  options: { reloadBeforeRead?: boolean; suffix?: string } = {}
): Promise<LocatedTransaction> {
  let located: LocatedTransaction | undefined;

  await withRolePage(browser, runtimeEnv, async (page) => {
    await openTransactionPage(page, pageDefinition, runtimeEnv, config);
    await applySearchIfPossible(page, pageDefinition, config);
    if (options.reloadBeforeRead) {
      await page.reload();
      await waitForNavigationToSettle(page);
      await applySearchIfPossible(page, pageDefinition, config);
    }

    const item = await waitForTransaction(page, runtimeEnv, config, expectedStatus);
    await page.waitForTimeout(500);
    located = {
      item,
      statusText: extractStatusText(item),
      amount: extractAmount(item),
      dateText: extractDateText(item, expectedStatus === "approval" ? config.expectedDateTime : config.expectedCancelDateTime),
      bodyText: extractEvidenceText(item)
    };
    await attachPageScreenshot(page, testInfo, pageDefinition.name, "ADMIN", options.suffix ?? `${expectedStatus}-visible`);
  });

  if (!located) {
    throw new Error("거래내역에서 대상 거래를 확인하지 못했습니다.");
  }
  return located;
}

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

  try {
    await callback(page);
  } finally {
    await closeContextWithTimeout(context.close(), 3_000);
  }
}

async function closeContextWithTimeout(closePromise: Promise<void>, timeoutMs: number): Promise<void> {
  await Promise.race([
    closePromise.catch(() => undefined),
    new Promise<void>((resolve) => setTimeout(resolve, timeoutMs))
  ]);
}

async function openTransactionPage(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  config: NotificationConfig
): Promise<void> {
  if (!runtimeEnv.storageState) {
    await loginWithCredentials(page, runtimeEnv);
  }

  const targetUrl = new URL(buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!));
  targetUrl.searchParams.set("from", config.expectedQueryDate);
  targetUrl.searchParams.set("to", config.expectedQueryDate);
  targetUrl.searchParams.set("exactMatch", "false");
  await page.goto(targetUrl.toString());
  await waitForNavigationToSettle(page);

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

  expect(await isLoginPage(page, runtimeEnv), "거래내역 진입 전 로그인 화면을 벗어나야 합니다.").toBe(false);
  await expect(page.locator("body"), "거래내역 화면 본문이 표시되어야 합니다.").toContainText("거래내역");
}

async function applySearchIfPossible(
  page: Page,
  pageDefinition: QaPageDefinition,
  config: NotificationConfig
): Promise<void> {
  if (process.env.QA_3143_REUSE_SENT_NOTIFICATIONS === "1") {
    return;
  }

  const candidates = [
    "input[placeholder*='검색']",
    "input[placeholder*='승인']",
    "input[placeholder*='거래']",
    "input[placeholder*='주문']",
    "input[name*='keyword' i]",
    "input[name*='search' i]"
  ];
  const searchInput = await findEditableInput(page, candidates);
  if (!searchInput) {
    return;
  }

  await searchInput.fill(config.payload.tid);
  const searchButton = page.locator(pageDefinition.searchButtonSelector ?? "button:has-text('검색'), button:has-text('조회')").first();
  if (await searchButton.isVisible({ timeout: 1_500 }).catch(() => false)) {
    await searchButton.click();
    await waitForNavigationToSettle(page);
  } else {
    await searchInput.press("Enter");
    await waitForNavigationToSettle(page);
  }
}

async function findEditableInput(page: Page, selectors: string[]) {
  const inputs = page.locator(selectors.join(", "));
  const count = await inputs.count().catch(() => 0);
  for (let index = 0; index < count; index += 1) {
    const input = inputs.nth(index);
    const visible = await input.isVisible({ timeout: 500 }).catch(() => false);
    if (!visible) {
      continue;
    }
    const editable = await input.isEditable({ timeout: 500 }).catch(() => false);
    if (editable) {
      return input;
    }
  }
  return 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 waitForTransaction(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  config: NotificationConfig,
  expectedStatus: "approval" | "cancel"
): Promise<AdminTransactionItem> {
  let lastItems: AdminTransactionItem[] = [];
  const reuseSentNotifications = process.env.QA_3143_REUSE_SENT_NOTIFICATIONS === "1";
  const attempts = reuseSentNotifications ? 8 : 18;
  for (let attempt = 0; attempt < attempts; attempt += 1) {
    const visibleItem = await findVisibleTargetTransaction(page, config, expectedStatus);
    if (visibleItem) {
      return visibleItem;
    }

    if (!reuseSentNotifications) {
      lastItems = await loadAdminTransactions(page, runtimeEnv, config).catch(() => []);
      const matched = findTargetTransaction(lastItems, config, expectedStatus);
      if (matched) {
        return matched;
      }
    }
    await page.waitForTimeout(1_500);
  }

  throw new Error(
    `거래내역 화면/API에서 ${expectedStatus === "approval" ? "승인" : "취소"} 상태 대상 거래를 찾지 못했습니다. ` +
      `조회 건수=${lastItems.length}, 식별값=${formatTargetKeys(config.payload)}`
  );
}

async function findVisibleTargetTransaction(
  page: Page,
  config: NotificationConfig,
  expectedStatus: "approval" | "cancel"
): Promise<AdminTransactionItem | undefined> {
  const rowTexts = await page
    .locator('[role="row"], tr')
    .evaluateAll((rows) => rows.map((row) => row.textContent ?? ""))
    .catch(() => []);
  const expectedDateTime = expectedStatus === "approval" ? config.expectedDateTime : config.expectedCancelDateTime;
  const candidates = rowTexts.filter((rowText) =>
    containsAmount(rowText, config.amount) &&
    hasAnyIdentifier(rowText, config) &&
    (containsDateTime(rowText, expectedDateTime) || containsDateTime(rowText, config.expectedDateTime))
  );
  if (candidates.length === 0) {
    return undefined;
  }

  const preferred =
    candidates.find((rowText) =>
      containsDateTime(rowText, expectedDateTime) &&
      (expectedStatus === "approval" ? isApprovalStatus(rowText) : isCancelStatus(rowText))
    ) ??
    candidates.find((rowText) => containsDateTime(rowText, expectedDateTime)) ??
    candidates.find((rowText) => expectedStatus === "approval" ? isApprovalStatus(rowText) : isCancelStatus(rowText)) ??
    candidates[0];

  return {
    rawText: preferred,
    transaction: {
      approvalNo: config.payload.cid,
      amount: config.amount,
      payDate: containsDateTime(preferred, expectedDateTime) ? expectedDateTime : undefined,
      status: inferStatusFromText(preferred)
    }
  };
}

async function loadAdminTransactions(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  config: NotificationConfig
): Promise<AdminTransactionItem[]> {
  await ensureApiContext(page, runtimeEnv);
  const queryVariants = [
    {},
    { keyword: config.payload.tid },
    { searchKeyword: config.payload.tid },
    { approvalNo: config.payload.tid },
    { orderNo: config.payload.order_no },
    { transactionId: config.payload.tid }
  ];
  const chunks = await Promise.all(
    queryVariants.map((filters) =>
      page.evaluate(async ({ apiBase, tenantId, date, queryFilters }) => {
        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", "100");
        for (const [key, value] of Object.entries(queryFilters)) {
          if (value) {
            url.searchParams.set(key, String(value));
          }
        }

        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 ?? json?.payload?.content ?? [];
      }, { apiBase: apiBaseFromRuntime(runtimeEnv), tenantId: runtimeEnv.tenantId!, date: config.expectedQueryDate, queryFilters: filters })
    )
  );

  return uniqueObjects(chunks.flat() as AdminTransactionItem[]);
}

async function ensureApiContext(page: Page, runtimeEnv: RuntimeQaEnv): Promise<void> {
  if (page.url() !== "about:blank") {
    return;
  }
  if (!runtimeEnv.baseUrl) {
    throw new Error("baseUrl이 필요합니다.");
  }
  if (!runtimeEnv.storageState) {
    await loginWithCredentials(page, runtimeEnv);
  }
  await page.goto(runtimeEnv.baseUrl);
  await waitForNavigationToSettle(page);
}

function findTargetTransaction(
  items: AdminTransactionItem[],
  config: NotificationConfig,
  expectedStatus: "approval" | "cancel"
): AdminTransactionItem | undefined {
  const candidates = items.filter((item) => matchesTargetKeys(item, config));
  return candidates.find((item) => matchesExpectedStatus(item, expectedStatus)) ?? candidates[0];
}

function matchesTargetKeys(item: AdminTransactionItem, config: NotificationConfig): boolean {
  const serialized = JSON.stringify(item);
  const hasIdentifier = [
    config.payload.tid,
    config.payload.cid,
    config.payload.order_no,
    config.payload.pltid,
    config.payload.cat_id
  ].some((value) => value && serialized.includes(value));
  return hasIdentifier && extractAmount(item) === config.amount;
}

function hasAnyIdentifier(value: string, config: NotificationConfig): boolean {
  return [
    config.payload.tid,
    config.payload.cid,
    config.payload.order_no,
    config.payload.pltid,
    config.payload.cat_id
  ].some((identifier) => identifier && value.includes(identifier));
}

function matchesExpectedStatus(item: AdminTransactionItem, expectedStatus: "approval" | "cancel"): boolean {
  const statusText = extractStatusText(item);
  return expectedStatus === "approval" ? isApprovalStatus(statusText) : isCancelStatus(statusText);
}

function extractStatusText(item: AdminTransactionItem): string {
  const statusValues = collectStringValues(item).filter((value) => /PAYMENT|APPROV|PAID|CANCEL|취소|승인|결제|완료/i.test(value));
  return [item.transaction?.status, ...statusValues].filter(Boolean).join(" / ");
}

function extractAmount(item: AdminTransactionItem): number {
  const values: number[] = [];
  collectNumericValues(item, values);
  const exact = values.find((value) => Math.abs(value) === 1004);
  if (exact !== undefined) {
    return Math.abs(exact);
  }
  const transactionAmount = Number(item.transaction?.amount);
  return Number.isFinite(transactionAmount) ? Math.abs(transactionAmount) : 0;
}

function extractDateText(item: AdminTransactionItem, expectedDateTime: string): string {
  const strings = collectStringValues(item);
  return strings.find((value) => containsDateTime(value, expectedDateTime)) ?? item.transaction?.payDate ?? item.transaction?.createdAt ?? "";
}

function extractEvidenceText(item: AdminTransactionItem): string {
  const strings = collectStringValues(item);
  if (strings.length > 0) {
    return strings.join(" ");
  }
  return JSON.stringify(item);
}

function collectStringValues(value: unknown): string[] {
  if (typeof value === "string") {
    return [value];
  }
  if (!value || typeof value !== "object") {
    return [];
  }
  if (Array.isArray(value)) {
    return value.flatMap((item) => collectStringValues(item));
  }
  return Object.values(value as Record<string, unknown>).flatMap((item) => collectStringValues(item));
}

function collectNumericValues(value: unknown, output: number[]): void {
  if (typeof value === "number" && Number.isFinite(value)) {
    output.push(value);
    return;
  }
  if (!value || typeof value !== "object") {
    return;
  }
  for (const nested of Object.values(value as Record<string, unknown>)) {
    collectNumericValues(nested, output);
  }
}

function uniqueObjects(items: AdminTransactionItem[]): AdminTransactionItem[] {
  const seen = new Set<string>();
  return items.filter((item) => {
    const key = item.transaction?.id ?? item.transaction?.approvalNo ?? JSON.stringify(item);
    if (seen.has(key)) {
      return false;
    }
    seen.add(key);
    return true;
  });
}

function expectStatus(target: LocatedTransaction, expectedStatus: "approval" | "cancel"): void {
  if (expectedStatus === "approval") {
    expect(isApprovalStatus(target.statusText), `거래 상태가 승인/결제완료 계열이어야 합니다. 실제=${target.statusText}`).toBe(true);
    return;
  }
  expect(isCancelStatus(target.statusText), `거래 상태가 취소 계열이어야 합니다. 실제=${target.statusText}`).toBe(true);
}

function expectScreenContainsStatus(target: LocatedTransaction, expectedStatus: "approval" | "cancel"): void {
  const pattern = expectedStatus === "approval" ? /승인|결제\s*완료|결제완료|완료/ : /취소|취소\s*완료|취소완료/;
  expect(pattern.test(target.bodyText), `거래내역 화면에 ${expectedStatus === "approval" ? "승인" : "취소"} 상태 문구가 보여야 합니다.`).toBe(true);
}

function expectDateTime(target: LocatedTransaction, expectedDateTime: string): void {
  expect(
    containsDateTime(target.dateText, expectedDateTime) || containsDateTime(JSON.stringify(target.item), expectedDateTime),
    `거래일시는 ${expectedDateTime}이어야 합니다. 실제=${target.dateText}`
  ).toBe(true);
}

function isApprovalStatus(value: string): boolean {
  return /PAYMENT|APPROV|PAID|승인|결제\s*완료|결제완료/i.test(value) && !isCancelStatus(value);
}

function isCancelStatus(value: string): boolean {
  return /CANCEL|CANCELED|CANCELLED|취소/i.test(value);
}

function inferStatusFromText(value: string): string {
  if (isCancelStatus(value)) {
    return "CANCEL";
  }
  if (isApprovalStatus(value)) {
    return "PAYMENT";
  }
  return value;
}

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

function containsCardInfo(target: LocatedTransaction, config: NotificationConfig): boolean {
  return target.bodyText.includes(config.expectedCardInfo) && target.bodyText.includes(config.expectedCardNo);
}

function containsDateTime(value: string, expectedDateTime: string): boolean {
  const normalizedValue = value.replace(/[./]/g, "-").replace("T", " ");
  const withoutSeconds = expectedDateTime.slice(0, 16);
  const compact = expectedDateTime.replace(/\D/g, "");
  return normalizedValue.includes(expectedDateTime) || normalizedValue.includes(withoutSeconds) || value.replace(/\D/g, "").includes(compact);
}

async function getApprovedTransaction(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: NotificationConfig,
  testInfo: TestInfo
): Promise<LocatedTransaction> {
  runState.approved ??= await locateTransaction(browser, runtimeEnv, pageDefinition, config, "approval", testInfo, {
    suffix: "approval-visible"
  });
  return runState.approved;
}

async function getCanceledTransaction(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: NotificationConfig,
  testInfo: TestInfo
): Promise<LocatedTransaction> {
  runState.canceled ??= await locateTransaction(browser, runtimeEnv, pageDefinition, config, "cancel", testInfo, {
    suffix: "cancel-visible"
  });
  return runState.canceled;
}

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: false, timeout: 10_000 });
  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-/, "api-");
  return url.origin;
}

function formatNotificationResponse(response: NotificationResponse): string {
  return `status=${response.status}, ok=${response.ok}, body=${response.body || "-"}`;
}

function formatTargetKeys(payload: NotificationPayload): string {
  return [`MID=${payload.cat_id}`, `tid=${payload.tid}`, `order_no=${payload.order_no}`, `pltid=${payload.pltid}`].join(" / ");
}

function formatTransactionDate(raw: string): string {
  return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)} ${raw.slice(8, 10)}:${raw.slice(10, 12)}:${raw.slice(12, 14)}`;
}

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

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

function blocked(message: string): never {
  throw new Error(`BLOCKED: ${message}`);
}
