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";

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

interface TransactionListPayload {
  items?: TransactionItem[];
  content?: TransactionItem[];
}

interface TransactionItem {
  transaction?: {
    id?: string;
    status?: string;
    amount?: number;
    cancelAmount?: number;
    payDate?: string;
    cancelDate?: string;
    approvalNo?: string;
    orderNo?: string;
    tid?: string;
  };
  contractName?: string;
  companyName?: string;
  corporationName?: string;
  vendorName?: string;
  vendorCode?: string;
  vendorCommission?: number;
  departments?: Array<{
    departmentType?: string;
    departmentCode?: string;
    departmentName?: string;
  }>;
  settlementTarget?: {
    settlementAmount?: number;
    settlementCommission?: number;
    settlementCommissionRate?: number;
  };
  offset?: {
    originAmount?: number;
    amount?: number;
    offsetAt?: string;
    offsetStatus?: string;
    note?: string;
    userId?: string;
  } | null;
}

interface OffsetSubmitEvidence {
  requestMethod?: string;
  requestUrl?: string;
  requestBody?: string;
  responseStatus?: number;
  responseBody?: string;
}

interface UiOffsetSnapshot {
  actionText: string;
  offsetAt: string;
  offsetAmount: number;
  offsetUser: string;
  offsetNote: string;
}

const ROLE = "ADMIN";
const TARGET_DATE = "2026-07-27";
const TARGET_DATE_TIME = "2026-07-27 15:44:19";
const TARGET_TRANSACTION_ID = "tenant-id-redacted";
const TARGET_PG = "루시페이먼츠";
const TARGET_CONTRACT = "A_주홍석_단말기정산한도_루시";
const TARGET_CORPORATION = "A_주홍석_정산법인";
const TARGET_VENDOR = "A_주홍석_단말기정산분리_가맹";
const TARGET_VENDOR_CODE = "BP100548";
const CANCEL_AMOUNT = 80_000;
const SETTLEMENT_AMOUNT = 72_000;
const PARTIAL_REQUEST_AMOUNT = 40_000;
const PARTIAL_SETTLEMENT_AMOUNT = 36_000;
const FIRST_NOTE = "QA #3888 1차 부분상계";
const SECOND_NOTE = "QA #3888 2차 부분상계";

const runState: {
  firstSubmit?: OffsetSubmitEvidence;
  secondSubmit?: OffsetSubmitEvidence;
} = {};

export function runTransactionOffsetDedupSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const transactionPage = findPageDefinition(checklist.pages, "거래내역");
  const runtimeEnv = getRuntimeQaEnv(options.environment, transactionPage.application ?? "admin", ROLE);
  const blockReason = getRuntimeBlockReason(runtimeEnv);

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

    test(checklist.checklist[0], async ({ browser }, testInfo) => {
      test.setTimeout(90_000);
      await withRolePage(browser, runtimeEnv, async (page) => {
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const target = await loadTargetTransaction(page, runtimeEnv);
        const ui = await readUiOffsetSnapshot(page);

        await attachEvidenceLog(testInfo, transactionPage.name, "initial-state", [
          "## 대상 거래 사전 상태",
          ...formatTransaction(target),
          `목록 상계 버튼: ${ui.actionText || "-"}`,
          `목록 상계금액: ${formatMoney(ui.offsetAmount)}`,
          `목록 상계일시: ${ui.offsetAt || "-"}`,
          "결론: 지정 취소 거래의 금액과 수수료율이 일치하고 상계 이력이 없는 상태입니다."
        ]);
        await setGridScroll(page, 0);
        await attachPageScreenshot(page, testInfo, transactionPage.name, "initial-state");

        expect(transactionId(target)).toBe(TARGET_TRANSACTION_ID);
        expect(target.transaction?.status).toBe("CANCEL");
        expect(target.transaction?.payDate).toBe(TARGET_DATE_TIME);
        expect(target.transaction?.cancelDate).toBe(TARGET_DATE_TIME);
        expect(Math.abs(toNumber(target.transaction?.cancelAmount) ?? toNumber(target.transaction?.amount) ?? 0)).toBe(CANCEL_AMOUNT);
        expect(commissionRate(target)).toBe(10);
        expect(Math.abs(toNumber(target.settlementTarget?.settlementCommission) ?? 0)).toBe(8_000);
        expect(Math.abs(toNumber(target.settlementTarget?.settlementAmount) ?? 0)).toBe(SETTLEMENT_AMOUNT);
        expect(target.contractName).toBe(TARGET_CONTRACT);
        expect(corporationName(target)).toBe(TARGET_CORPORATION);
        expect(vendorName(target)).toBe(TARGET_VENDOR);
        expect(vendorCode(target)).toBe(TARGET_VENDOR_CODE);
        expect(offsetAmount(target), "최초 대상 거래는 상계 이력이 없어야 합니다.").toBe(0);
        expect(target.offset, "최초 대상 거래의 offset 객체는 비어 있어야 합니다.").toBeNull();
        expect(ui.offsetAmount).toBe(0);
      });
    });

    test(checklist.checklist[1], async ({ browser }, testInfo) => {
      test.setTimeout(120_000);
      await withRolePage(browser, runtimeEnv, async (page) => {
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const before = await loadTargetTransaction(page, runtimeEnv);
        expect(offsetAmount(before), "1차 부분상계 전 상계금액은 0원이어야 합니다.").toBe(0);

        runState.firstSubmit = await submitPartialOffset(page, PARTIAL_REQUEST_AMOUNT, FIRST_NOTE);
        const after = await waitForOffsetAmount(page, runtimeEnv, PARTIAL_SETTLEMENT_AMOUNT);
        await refreshTransactionGrid(page);
        const ui = await readUiOffsetSnapshot(page);

        await attachEvidenceLog(testInfo, transactionPage.name, "first-partial-offset", [
          "## 1차 가맹점 부분상계",
          `요청 취소금액: ${formatMoney(PARTIAL_REQUEST_AMOUNT)}`,
          `예상 정산 상계금액: ${formatMoney(PARTIAL_SETTLEMENT_AMOUNT)}`,
          `API 누적 원금: ${formatMoney(toNumber(after.offset?.originAmount) ?? 0)}`,
          `API 누적 상계금액: ${formatMoney(offsetAmount(after))}`,
          `API 상계상태: ${after.offset?.offsetStatus ?? "-"}`,
          `목록 상계금액: ${formatMoney(ui.offsetAmount)}`,
          ...formatSubmitEvidence(runState.firstSubmit),
          "결론: 40,000원 부분상계 후 수수료 10%를 제외한 36,000원만 누적되었습니다."
        ]);
        await setGridScroll(page, 3_100);
        await attachPageScreenshot(page, testInfo, transactionPage.name, "first-partial-offset");

        expect(offsetAmount(after)).toBe(PARTIAL_SETTLEMENT_AMOUNT);
        expect(Math.abs(toNumber(after.offset?.originAmount) ?? 0)).toBe(PARTIAL_REQUEST_AMOUNT);
        expect(ui.offsetAmount).toBe(PARTIAL_SETTLEMENT_AMOUNT);
      });
    });

    test(checklist.checklist[2], async ({ browser }, testInfo) => {
      test.setTimeout(120_000);
      await withRolePage(browser, runtimeEnv, async (page) => {
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const before = await loadTargetTransaction(page, runtimeEnv);
        expect(offsetAmount(before), "2차 부분상계 전 누적 상계금액은 36,000원이어야 합니다.").toBe(PARTIAL_SETTLEMENT_AMOUNT);

        runState.secondSubmit = await submitPartialOffset(page, PARTIAL_REQUEST_AMOUNT, SECOND_NOTE);
        const after = await waitForOffsetAmount(page, runtimeEnv, SETTLEMENT_AMOUNT);
        await refreshTransactionGrid(page);
        const ui = await readUiOffsetSnapshot(page);

        await attachEvidenceLog(testInfo, transactionPage.name, "second-partial-offset", [
          "## 2차 가맹점 부분상계",
          `추가 요청 취소금액: ${formatMoney(PARTIAL_REQUEST_AMOUNT)}`,
          `API 누적 원금: ${formatMoney(toNumber(after.offset?.originAmount) ?? 0)}`,
          `API 누적 상계금액: ${formatMoney(offsetAmount(after))}`,
          `API 상계상태: ${after.offset?.offsetStatus ?? "-"}`,
          `목록 상계금액: ${formatMoney(ui.offsetAmount)}`,
          ...formatSubmitEvidence(runState.secondSubmit),
          "결론: 나머지 40,000원 처리 후 상계금액은 36,000원이 한 번만 더 더해져 72,000원이 되었습니다."
        ]);
        await setGridScroll(page, 3_100);
        await attachPageScreenshot(page, testInfo, transactionPage.name, "second-partial-offset");

        expect(offsetAmount(after)).toBe(SETTLEMENT_AMOUNT);
        expect(Math.abs(toNumber(after.offset?.originAmount) ?? 0)).toBe(CANCEL_AMOUNT);
        expect(after.offset?.offsetStatus).toBe("OFFSET");
        expect(ui.offsetAmount).toBe(SETTLEMENT_AMOUNT);
      });
    });

    test(checklist.checklist[3], async ({ browser }, testInfo) => {
      test.setTimeout(120_000);
      await withRolePage(browser, runtimeEnv, async (page) => {
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const observations: string[] = [];

        for (let index = 1; index <= 2; index += 1) {
          await page.reload({ waitUntil: "domcontentloaded" });
          await waitForTransactionGrid(page);
          const apiTarget = await loadTargetTransaction(page, runtimeEnv);
          const ui = await readUiOffsetSnapshot(page);
          observations.push(`새로고침 ${index}회: API=${formatMoney(offsetAmount(apiTarget))}, 목록=${formatMoney(ui.offsetAmount)}`);
          expect(offsetAmount(apiTarget)).toBe(SETTLEMENT_AMOUNT);
          expect(ui.offsetAmount).toBe(SETTLEMENT_AMOUNT);
          expect(offsetAmount(apiTarget)).not.toBe(144_000);
        }

        await page.goto(runtimeEnv.baseUrl!, { waitUntil: "domcontentloaded" });
        await waitForNavigationToSettle(page);
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const reentered = await loadTargetTransaction(page, runtimeEnv);
        const reenteredUi = await readUiOffsetSnapshot(page);
        observations.push(`목록 이탈 후 재진입: API=${formatMoney(offsetAmount(reentered))}, 목록=${formatMoney(reenteredUi.offsetAmount)}`);

        await attachEvidenceLog(testInfo, transactionPage.name, "refresh-and-reentry", [
          "## 새로고침 및 재진입 중복 방지",
          ...observations,
          "금지값 144,000원 관측: 아니오",
          "결론: 이벤트 재조회 이후에도 누적 상계금액은 72,000원으로 유지되었습니다."
        ]);
        await setGridScroll(page, 3_100);
        await attachPageScreenshot(page, testInfo, transactionPage.name, "refresh-and-reentry");

        expect(offsetAmount(reentered)).toBe(SETTLEMENT_AMOUNT);
        expect(reenteredUi.offsetAmount).toBe(SETTLEMENT_AMOUNT);
        expect(offsetAmount(reentered)).not.toBe(144_000);
      });
    });

    test(checklist.checklist[4], async ({ browser }, testInfo) => {
      test.setTimeout(90_000);
      await withRolePage(browser, runtimeEnv, async (page) => {
        await openTransactionPage(page, runtimeEnv, transactionPage);
        const target = await loadTargetTransaction(page, runtimeEnv);
        const listUi = await readUiOffsetSnapshot(page);
        const detail = await openSettlementDetail(page, testInfo, transactionPage.name);

        await attachEvidenceLog(testInfo, transactionPage.name, "list-detail-consistency", [
          "## 목록/상세 상계 결과 일관성",
          `API 상계금액: ${formatMoney(offsetAmount(target))}`,
          `API 상계상태: ${target.offset?.offsetStatus ?? "-"}`,
          `목록 상계금액: ${formatMoney(listUi.offsetAmount)}`,
          `목록 상계 액션: ${listUi.actionText || "-"}`,
          `목록 상계일시: ${listUi.offsetAt || "-"}`,
          `상세 부분상계 금액: ${detail.offsetAmounts.map(formatMoney).join(" + ")}`,
          `상세 부분상계 합계: ${formatMoney(detail.totalOffsetAmount)}`,
          ...detail.detailTexts.map((text, index) => `상세 ${index + 1}: ${text}`),
          "결론: 거래 목록의 누적 72,000원과 정산내역 상세의 36,000원 + 36,000원 합계가 일치하고 API 상계 상태는 OFFSET입니다."
        ]);
        await attachPageScreenshot(page, testInfo, transactionPage.name, "list-detail-consistency");

        expect(offsetAmount(target)).toBe(SETTLEMENT_AMOUNT);
        expect(target.offset?.offsetStatus).toBe("OFFSET");
        expect(listUi.offsetAmount).toBe(SETTLEMENT_AMOUNT);
        expect(detail.offsetAmounts, "정산내역 상세에는 1차/2차 부분상계 36,000원이 각각 표시되어야 합니다.")
          .toEqual([PARTIAL_SETTLEMENT_AMOUNT, PARTIAL_SETTLEMENT_AMOUNT]);
        expect(detail.totalOffsetAmount, "정산내역 상세의 부분상계 합계는 목록 누적 상계금액과 같아야 합니다.")
          .toBe(listUi.offsetAmount);
      });
    });
  });
}

async function submitPartialOffset(page: Page, amount: number, note: string): Promise<OffsetSubmitEvidence> {
  await setGridScroll(page, 2_650);
  const row = await activeTargetRow(page);
  const offsetCell = row.locator('[role="gridcell"][data-field="offset"]');
  await expect(offsetCell).toContainText("상계");
  await offsetCell.getByText("상계", { exact: true }).click();

  const modal = page.locator(".MuiModal-root:visible").filter({ hasText: "취소상계 신청" }).last();
  await expect(modal).toContainText("가맹점만 상계처리");
  const merchantOnly = modal.locator('input[type="radio"][value="true"]');
  if (!(await merchantOnly.isChecked())) {
    await merchantOnly.check();
  }
  await modal.getByPlaceholder("취소금액을 입력해주세요", { exact: true }).fill(String(amount));
  await modal.getByPlaceholder("사유를 입력해주세요.", { exact: true }).fill(note);

  const evidence: OffsetSubmitEvidence = {};
  const responsePromise = page.waitForResponse(
    (response) => /offset/i.test(response.url()) && ["POST", "PUT", "PATCH"].includes(response.request().method()),
    { timeout: 20_000 }
  ).catch(() => undefined);

  await modal.getByRole("button", { name: "신청", exact: true }).click();
  const confirmation = page.locator(".swal2-popup:visible");
  if (await confirmation.isVisible({ timeout: 1_500 }).catch(() => false)) {
    const confirmButton = confirmation.locator(".swal2-confirm");
    if (await confirmButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
      await confirmButton.click();
    }
  }

  const response = await responsePromise;
  if (response) {
    evidence.requestMethod = response.request().method();
    evidence.requestUrl = redactUrl(response.url());
    evidence.requestBody = redactSensitiveJson(response.request().postData() ?? "");
    evidence.responseStatus = response.status();
    evidence.responseBody = redactSensitiveJson((await response.text().catch(() => "")).slice(0, 2_000));
    expect(response.ok(), `상계 요청이 성공해야 합니다. ${response.status()} ${evidence.responseBody}`).toBe(true);
  }

  await dismissSweetAlert(page);
  return evidence;
}

async function openSettlementDetail(
  page: Page,
  testInfo: TestInfo,
  pageName: string
): Promise<{
  detailTexts: string[];
  offsetAmounts: number[];
  totalOffsetAmount: number;
}> {
  await setGridScroll(page, 900);
  const row = await activeTargetRow(page);
  const detailCell = row.locator('[role="gridcell"][data-field="settlementDetails"]');
  await expect(detailCell).toContainText("정산내역");
  await detailCell.getByText("정산내역", { exact: true }).click();

  const modal = page.locator(".MuiModal-root:visible").filter({ hasText: "정산내역 조회" }).last();
  await expect(modal).toContainText(TARGET_VENDOR_CODE, { timeout: 15_000 });
  const modalButtons = modal.getByRole("button");
  const offsetAmounts: number[] = [];
  const detailTexts: string[] = [];
  const count = await modalButtons.count();

  for (let index = 0; index < count; index += 1) {
    const button = modalButtons.nth(index);
    const buttonText = normalizeText(await button.innerText());
    if (!buttonText.includes(TARGET_VENDOR)) {
      continue;
    }
    await button.click();
    const region = modal.locator('[role="region"]:visible').last();
    if (!(await region.isVisible({ timeout: 2_000 }).catch(() => false))) {
      continue;
    }
    const detailText = normalizeText(await region.innerText());
    const amount = extractDetailOffsetAmount(detailText);
    if (amount > 0) {
      offsetAmounts.push(amount);
      detailTexts.push(`${buttonText} / ${detailText}`);
      await attachPageScreenshot(
        page,
        testInfo,
        pageName,
        `detail-partial-offset-${offsetAmounts.length}-${formatMoney(amount)}`
      );
    }
    await button.click();
  }

  return {
    detailTexts,
    offsetAmounts,
    totalOffsetAmount: offsetAmounts.reduce((sum, amount) => sum + amount, 0)
  };
}

function extractDetailOffsetAmount(detailText: string): number {
  const lines = detailText
    .split(/\s+/)
    .map((line) => line.trim())
    .filter(Boolean);
  return toNumber(lines.at(-1)) ?? 0;
}

async function readUiOffsetSnapshot(page: Page): Promise<UiOffsetSnapshot> {
  await setGridScroll(page, 3_100);
  const row = await activeTargetRow(page);
  return {
    actionText: await cellText(row, "offset"),
    offsetAt: await cellText(row, "offsetAt"),
    offsetAmount: toNumber(await cellText(row, "offsetAmount")) ?? 0,
    offsetUser: await cellText(row, "offsetId"),
    offsetNote: await cellText(row, "offsetNote")
  };
}

async function cellText(row: Locator, field: string): Promise<string> {
  const cell = row.locator(`[role="gridcell"][data-field="${field}"]`);
  return normalizeText(await cell.innerText());
}

async function refreshTransactionGrid(page: Page): Promise<void> {
  await page.reload({ waitUntil: "domcontentloaded" });
  await waitForTransactionGrid(page);
}

async function setGridScroll(page: Page, scrollLeft: number): Promise<void> {
  const row = await activeTargetRow(page);
  const scroller = row.locator(
    "xpath=ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' MuiDataGrid-virtualScroller ')]"
  );
  await scroller.evaluate((element, value) => {
    element.scrollLeft = Number(value);
    element.dispatchEvent(new Event("scroll", { bubbles: true }));
  }, scrollLeft);
  await page.waitForTimeout(250);
}

async function activeTargetRow(page: Page): Promise<Locator> {
  const rows = page.locator(`[role="row"][data-id="${TARGET_TRANSACTION_ID}"]`);
  await expect.poll(() => rows.count(), { timeout: 15_000 }).toBeGreaterThan(0);
  const viewport = page.viewportSize();
  let bestIndex = -1;
  let bestY = Number.NEGATIVE_INFINITY;

  for (let index = 0; index < await rows.count(); index += 1) {
    const candidate = rows.nth(index);
    const box = await candidate.boundingBox();
    if (!box || box.height <= 0 || box.width <= 0) {
      continue;
    }
    const withinViewport = !viewport || (
      box.y < viewport.height &&
      box.y + box.height > 0 &&
      box.x < viewport.width &&
      box.x + box.width > 0
    );
    if (withinViewport && box.y > bestY) {
      bestIndex = index;
      bestY = box.y;
    }
  }

  if (bestIndex < 0) {
    throw new Error(`대상 거래 행을 현재 표시 중인 그리드에서 찾지 못했습니다: ${TARGET_TRANSACTION_ID}`);
  }
  return rows.nth(bestIndex);
}

async function loadTargetTransaction(page: Page, runtimeEnv: RuntimeQaEnv): Promise<TransactionItem> {
  const params = new URLSearchParams({
    from: TARGET_DATE,
    to: TARGET_DATE,
    exactMatch: "false",
    size: "500",
    page: "0"
  });
  const response = await page.request.get(`${apiBaseFromRuntime(runtimeEnv)}/v2/transactions?${params.toString()}`, {
    headers: {
      accept: "application/json",
      ...(runtimeEnv.tenantId ? { "x-tenant-id": runtimeEnv.tenantId } : {})
    }
  });
  const json = await response.json().catch(() => undefined) as {
    result?: { message?: string };
    payload?: TransactionListPayload;
    items?: TransactionItem[];
    content?: TransactionItem[];
  } | undefined;
  expect(response.ok(), `거래내역 API 조회가 성공해야 합니다: ${response.status()} ${json?.result?.message ?? ""}`).toBe(true);
  const items = json?.payload?.items ?? json?.payload?.content ?? json?.items ?? json?.content ?? [];
  const exact = items.filter((item) => transactionId(item) === TARGET_TRANSACTION_ID);
  expect(exact, "지정 거래 ID는 조회 결과에 정확히 1건이어야 합니다.").toHaveLength(1);
  return exact[0];
}

async function waitForOffsetAmount(page: Page, runtimeEnv: RuntimeQaEnv, expected: number): Promise<TransactionItem> {
  let target: TransactionItem | undefined;
  await expect.poll(async () => {
    target = await loadTargetTransaction(page, runtimeEnv);
    return offsetAmount(target);
  }, {
    timeout: 30_000,
    intervals: [500, 1_000, 1_500, 2_000]
  }).toBe(expected);
  return target!;
}

async function openTransactionPage(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition
): Promise<void> {
  if (!runtimeEnv.baseUrl) {
    blocked("전산 baseUrl 설정이 필요합니다.");
  }
  const targetUrl = new URL(buildPageUrl(runtimeEnv.baseUrl, pageDefinition, runtimeEnv.tenantId ?? ""));
  targetUrl.searchParams.set("from", TARGET_DATE);
  targetUrl.searchParams.set("to", TARGET_DATE);
  targetUrl.searchParams.set("exactMatch", "false");

  if (!runtimeEnv.storageState) {
    await loginWithCredentials(page, runtimeEnv);
  }
  await page.goto(targetUrl.toString(), { waitUntil: "domcontentloaded" });
  await waitForNavigationToSettle(page);
  if (await isLoginPage(page, runtimeEnv)) {
    await loginWithCredentials(page, runtimeEnv);
    await page.goto(targetUrl.toString(), { waitUntil: "domcontentloaded" });
    await waitForNavigationToSettle(page);
  }
  expect(await isLoginPage(page, runtimeEnv), "거래내역 진입 전 로그인 화면을 벗어나야 합니다.").toBe(false);
  await waitForTransactionGrid(page);
}

async function waitForTransactionGrid(page: Page): Promise<void> {
  await page.locator("body").waitFor({ state: "visible", timeout: 15_000 });
  await expect(page.locator(`[role="row"][data-id="${TARGET_TRANSACTION_ID}"]`)).toHaveCount(2, { timeout: 15_000 });
}

async function dismissSweetAlert(page: Page): Promise<void> {
  const popup = page.locator(".swal2-popup:visible");
  if (!(await popup.isVisible({ timeout: 1_500 }).catch(() => false))) {
    return;
  }
  const confirmButton = popup.locator(".swal2-confirm");
  if (await confirmButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
    await confirmButton.click();
  }
}

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

async function waitForNavigationToSettle(page: Page): Promise<void> {
  await page.waitForLoadState("domcontentloaded", { timeout: 15_000 }).catch(() => undefined);
  await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
  await page.locator("body").waitFor({ state: "visible", timeout: 15_000 });
}

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

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

function formatTransaction(item: TransactionItem): string[] {
  return [
    `거래 ID: ${redactId(transactionId(item) ?? "-")}`,
    `거래/취소일시: ${item.transaction?.payDate ?? "-"} / ${item.transaction?.cancelDate ?? "-"}`,
    `PG사: ${TARGET_PG}`,
    `계약·MID명: ${item.contractName ?? "-"}`,
    `법인: ${corporationName(item)}`,
    `가맹점: ${vendorName(item)} (${vendorCode(item)})`,
    `취소금액: ${formatMoney(Math.abs(toNumber(item.transaction?.cancelAmount) ?? toNumber(item.transaction?.amount) ?? 0))}`,
    `가맹점 수수료율: ${commissionRate(item)}%`,
    `정산 수수료: ${formatMoney(Math.abs(toNumber(item.settlementTarget?.settlementCommission) ?? 0))}`,
    `정산금액: ${formatMoney(Math.abs(toNumber(item.settlementTarget?.settlementAmount) ?? 0))}`,
    `API 상계 객체: ${item.offset ? JSON.stringify(item.offset) : "null"}`
  ];
}

function formatSubmitEvidence(evidence: OffsetSubmitEvidence | undefined): string[] {
  if (!evidence) {
    return ["상계 요청 네트워크 증빙: 없음"];
  }
  return [
    `상계 요청: ${evidence.requestMethod ?? "-"} ${evidence.requestUrl ?? "-"}`,
    `상계 요청 본문: ${evidence.requestBody || "-"}`,
    `상계 응답 상태: ${evidence.responseStatus ?? "-"}`,
    `상계 응답 본문: ${normalizeText(evidence.responseBody ?? "-").slice(0, 1_000)}`
  ];
}

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 getRuntimeBlockReason(runtimeEnv: RuntimeQaEnv): string | undefined {
  if (!runtimeEnv.baseUrl) {
    return "BLOCKED: 전산 baseUrl 설정이 필요합니다.";
  }
  if (!runtimeEnv.storageState && (!runtimeEnv.loginUrl || !runtimeEnv.username || !runtimeEnv.password)) {
    return "BLOCKED: ADMIN 로그인 정보 또는 storage state가 필요합니다.";
  }
  if (process.env.QA_3888_ALLOW_MUTATION !== "1") {
    return "BLOCKED: 실제 부분상계를 수행하려면 QA_3888_ALLOW_MUTATION=1 설정이 필요합니다.";
  }
  return undefined;
}

function transactionId(item: TransactionItem): string | undefined {
  return item.transaction?.id;
}

function corporationName(item: TransactionItem): string {
  return item.companyName ??
    item.corporationName ??
    item.departments?.find((department) => department.departmentType === "CO")?.departmentName ??
    "";
}

function vendorName(item: TransactionItem): string {
  return item.vendorName ??
    item.departments?.find((department) => department.departmentType === "VE")?.departmentName ??
    "";
}

function vendorCode(item: TransactionItem): string {
  return item.vendorCode ??
    item.departments?.find((department) => department.departmentType === "VE")?.departmentCode ??
    "";
}

function offsetAmount(item: TransactionItem | undefined): number {
  return Math.abs(toNumber(item?.offset?.amount) ?? 0);
}

function commissionRate(item: TransactionItem): number {
  return toNumber(item.vendorCommission) ??
    toNumber(item.settlementTarget?.settlementCommissionRate) ??
    0;
}

function toNumber(value: unknown): number | null {
  if (typeof value === "number" && Number.isFinite(value)) {
    return value;
  }
  if (typeof value === "string") {
    const normalized = value.replace(/,/g, "").trim();
    if (!normalized) {
      return null;
    }
    const parsed = Number(normalized);
    return Number.isFinite(parsed) ? parsed : null;
  }
  return null;
}

function apiBaseFromRuntime(runtimeEnv: RuntimeQaEnv): string {
  const base = runtimeEnv.environment === "dev"
    ? process.env.BIX_ADMIN_DEV_API_BASE_URL ?? process.env.BIX_API_DEV_BASE_URL ?? "https://api-dev.biscuitpay.com/api"
    : process.env.BIX_ADMIN_STAGE_API_BASE_URL ?? process.env.BIX_API_STAGE_BASE_URL ?? "https://api-stg.biscuitpay.com/api";
  return base.replace(/\/+$/, "").replace(/\/api$/, "/api");
}

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

function formatMoney(value: number): string {
  return `${Math.abs(value).toLocaleString("ko-KR")}원`;
}

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

function redactId(value: string): string {
  if (!value || value === "-") {
    return value;
  }
  return value.length <= 14 ? value : `${value.slice(0, 8)}...${value.slice(-6)}`;
}

function redactUrl(value: string): string {
  return value.replace(/([?&](?:token|key|authorization|access_token)=)[^&]+/gi, "$1redacted");
}

function redactSensitiveJson(value: string): string {
  return value
    .replace(/("password"\s*:\s*")[^"]+/gi, "$1redacted")
    .replace(/("authorization"\s*:\s*")[^"]+/gi, "$1redacted")
    .replace(/("token"\s*:\s*")[^"]+/gi, "$1redacted");
}

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