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

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

interface GridRowSnapshot {
  id: string;
  rowNumber: string;
  corporationName: string;
  issueFeeAccount: string;
  chargeFeeAccount: string;
  rawText: string;
}

interface CapturedRequest {
  method: string;
  url: string;
  postData: string;
}

const ROLE_EXPECTATIONS: Record<string, { allowed: RegExp[]; forbidden: RegExp[]; only?: RegExp }> = {
  ADMIN: {
    allowed: [/시스템\s*운영계좌|테넌트\s*운영계좌|법인\s*운영계좌/],
    forbidden: []
  },
  TA: {
    allowed: [/테넌트\s*운영계좌|법인\s*운영계좌/],
    forbidden: [/시스템\s*운영계좌/]
  },
  CO: {
    allowed: [/법인\s*운영계좌/],
    forbidden: [/시스템\s*운영계좌|테넌트\s*운영계좌/],
    only: /법인\s*운영계좌/
  }
};

const ACCOUNT_TYPE_PATTERN = /(시스템\s*운영계좌|테넌트\s*운영계좌|법인\s*운영계좌)/;

export function runBiscuitLinkedAccountFeeSettingSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const pageDefinition = checklist.pages[0];
  const roles = resolveChecklistExecutionRoles(checklist);
  const isSubsetChecklist = checklist.checklist.length <= 5;
  const shouldRun = (keywords: string[]) => !isSubsetChecklist || checklistHasAll(checklist.checklist, keywords);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    if (shouldRun(["목록", "법인별"])) {
      test(findChecklistItem(checklist.checklist, ["목록", "법인별"], checklist.checklist[0] ?? "연결계좌설정 목록에서 법인별로 1개 행이 노출되는지 확인"), async ({ browser }, testInfo) => {
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const rows = await readGridRows(page);
        const duplicateCorporations = findDuplicates(rows.map((row) => row.corporationName));

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "list-one-row-per-corporation", [
          "연결계좌설정 목록에서 법인별 행 노출 상태를 확인했습니다.",
          `총 행 수: ${rows.length}`,
          `법인명 수: ${new Set(rows.map((row) => row.corporationName)).size}`,
          `중복 법인명: ${duplicateCorporations.join(", ") || "없음"}`,
          ...rows.map((row) => `${row.rowNumber}. ${row.corporationName} / 발급=${row.issueFeeAccount} / 충전=${row.chargeFeeAccount}`)
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "list-one-row-per-corporation");

        expect(rows.length, "법인별 연결계좌설정 목록 행이 있어야 합니다.").toBeGreaterThan(0);
        expect(duplicateCorporations, "동일 법인이 연결계좌설정 목록에서 중복 노출되면 안 됩니다.").toEqual([]);
      });
      });
    }

    if (shouldRun(["분리"]) || shouldRun(["미사용"])) {
      test(`${findChecklistItem(checklist.checklist, ["분리"], checklist.checklist[1] ?? "목록에서 발급 수수료 계좌와 충전 수수료 계좌가 각각 분리되어 표시되는지 확인")} / ${findChecklistItem(checklist.checklist, ["미사용"], checklist.checklist[2] ?? "충전 수수료 계좌가 없는 법인은 미사용 상태로 표시되는지 확인")}`, async ({ browser }, testInfo) => {
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const headerText = await page.locator("[role='columnheader']").allInnerTexts();
        const rows = await readGridRows(page);
        const rowsWithoutIssueAccount = rows.filter((row) => !row.issueFeeAccount.trim());
        const rowsWithoutChargeState = rows.filter((row) => !row.chargeFeeAccount.trim());
        const unusedChargeRows = rows.filter((row) => /미사용/.test(row.chargeFeeAccount));

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "separated-issue-charge-columns", [
          "목록에서 발급 수수료 계좌와 충전 수수료 계좌가 분리 표시되는지 확인했습니다.",
          `헤더: ${headerText.map(normalizeText).filter(Boolean).join(" | ")}`,
          `발급 수수료 계좌 누락 행: ${rowsWithoutIssueAccount.map((row) => row.corporationName).join(", ") || "없음"}`,
          `충전 수수료 계좌 상태 누락 행: ${rowsWithoutChargeState.map((row) => row.corporationName).join(", ") || "없음"}`,
          `충전 수수료 미사용 표시 행: ${unusedChargeRows.map((row) => row.corporationName).join(", ") || "없음"}`
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "separated-issue-charge-columns");

        expect(headerText.join(" "), "발급 수수료 계좌 컬럼이 있어야 합니다.").toMatch(/발급\s*수수료\s*계좌/);
        expect(headerText.join(" "), "충전 수수료 계좌 컬럼이 있어야 합니다.").toMatch(/충전\s*수수료\s*계좌/);
        expect(rowsWithoutIssueAccount, "모든 행은 발급 수수료 계좌 표시값이 있어야 합니다.").toEqual([]);
        expect(rowsWithoutChargeState, "모든 행은 충전 수수료 계좌 또는 미사용 상태가 표시되어야 합니다.").toEqual([]);
        expect(unusedChargeRows.length, "충전 수수료 계좌가 없는 법인은 미사용 상태로 표시되어야 합니다.").toBeGreaterThan(0);
      });
      });
    }

    if (shouldRun(["등록 모달"]) || shouldRun(["필수"]) || shouldRun(["토글 OFF"])) {
      test(`${findChecklistItem(checklist.checklist, ["등록 모달"], checklist.checklist[3] ?? "계좌설정 버튼 클릭 시 등록 모달이 열리는지 확인")} / ${findChecklistItem(checklist.checklist, ["필수"], checklist.checklist[4] ?? "등록 모달에서 법인 선택 후 발급 수수료 계좌 섹션이 필수로 입력되는지 확인")} / ${findChecklistItem(checklist.checklist, ["토글 OFF"], checklist.checklist[5] ?? "충전 수수료 계좌 토글 OFF 상태에서 저장 시 발급 수수료 계좌만 등록되는지 확인")}`, async ({ browser }, testInfo) => {
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const dialog = await openRegisterModal(page);
        const dialogText = normalizeText(await dialog.innerText());
        const saveButton = dialog.getByRole("button", { name: /등록|저장/ }).last();
        const chargeSwitch = dialog.locator("input[type='checkbox']").first();
        const chargeControls = await readChargeControls(dialog);

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "register-modal-required", [
          "계좌설정 버튼으로 등록 모달을 열고 필수 입력 상태를 확인했습니다.",
          `모달 제목/본문: ${dialogText.slice(0, 800)}`,
          `저장 버튼 disabled: ${await saveButton.isDisabled().catch(() => false)}`,
          `충전 수수료 토글 checked: ${await chargeSwitch.isChecked().catch(() => false)}`,
          `충전 수수료 계좌 컨트롤 disabled: ${chargeControls.map((control) => `${control.name}=${control.disabled}`).join(", ")}`
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "register-modal-required");

        expect(dialogText, "등록 모달 제목이 표시되어야 합니다.").toMatch(/연결계좌설정/);
        expect(dialogText, "법인 선택 영역이 표시되어야 합니다.").toMatch(/법인명/);
        expect(dialogText, "발급 수수료 계좌 섹션이 표시되어야 합니다.").toMatch(/발급\s*수수료\s*계좌/);
        expect(dialogText, "충전 수수료 계좌 섹션이 표시되어야 합니다.").toMatch(/충전\s*수수료\s*계좌/);
        await expect(saveButton, "필수값 입력 전 등록 버튼은 비활성 상태여야 합니다.").toBeDisabled();
        await expect(chargeSwitch, "등록 모달 기본 상태는 충전 수수료 계좌 미사용이어야 합니다.").not.toBeChecked();
        expect(chargeControls.every((control) => control.disabled), "토글 OFF 상태에서는 충전 수수료 계좌 입력이 비활성 상태여야 합니다.").toBe(true);
      });
      });
    }

    if (shouldRun(["토글 ON"]) || shouldRun(["모달 높이"])) {
      test(`${findChecklistItem(checklist.checklist, ["토글 ON"], checklist.checklist[6] ?? "충전 수수료 계좌 토글 ON 시 충전 수수료 계좌 입력 영역이 노출되는지 확인")} / ${findChecklistItem(checklist.checklist, ["모달 높이"], checklist.checklist[7] ?? "충전 수수료 계좌 토글 ON/OFF 시 모달 높이가 자연스럽게 변경되는지 확인")}`, async ({ browser }, testInfo) => {
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const dialog = await openRegisterModal(page);
        const beforeBox = await dialog.boundingBox();
        const chargeSwitch = dialog.locator("input[type='checkbox']").first();

        await setChargeSwitchState(page, dialog, true);
        const afterOnBox = await dialog.boundingBox();
        const chargeControlsOn = await readChargeControls(dialog);
        const onDialogText = normalizeText(await dialog.innerText());

        await setChargeSwitchState(page, dialog, false);
        const afterOffBox = await dialog.boundingBox();
        const chargeControlsOff = await readChargeControls(dialog);
        const offDialogText = normalizeText(await dialog.innerText());

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "charge-toggle-layout", [
          "충전 수수료 계좌 토글 ON/OFF 시 입력 영역 노출과 모달 높이 변화를 확인했습니다.",
          `OFF 높이: ${beforeBox?.height ?? 0}`,
          `ON 높이: ${afterOnBox?.height ?? 0}`,
          `OFF 복귀 높이: ${afterOffBox?.height ?? 0}`,
          `ON 상태 문구: ${onDialogText.match(/충전\s*수수료\s*계좌.*?(사용|미사용)/)?.[0] ?? "확인 불가"}`,
          `OFF 상태 문구: ${offDialogText.match(/충전\s*수수료\s*계좌.*?(사용|미사용)/)?.[0] ?? "확인 불가"}`,
          `ON 컨트롤 disabled: ${chargeControlsOn.map((control) => `${control.name}=${control.disabled}`).join(", ")}`,
          `OFF 컨트롤 disabled: ${chargeControlsOff.map((control) => `${control.name}=${control.disabled}`).join(", ")}`
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "charge-toggle-layout");

        await expect(chargeSwitch, "토글을 다시 OFF로 돌리면 미사용 상태여야 합니다.").not.toBeChecked();
        expect(chargeControlsOn.length, "토글 ON 상태에서는 충전 수수료 계좌 입력 영역이 표시되어야 합니다.").toBeGreaterThanOrEqual(2);
        expect(onDialogText, "토글 ON 상태에서는 충전 수수료 계좌가 사용 상태로 표시되어야 합니다.").toMatch(/충전\s*수수료\s*계좌[\s\S]*사용/);
        expect(chargeControlsOff.every((control) => control.disabled), "토글 OFF 상태에서는 충전 수수료 계좌 입력 영역이 비활성화되어야 합니다.").toBe(true);
        expect(offDialogText, "토글 OFF 상태에서는 충전 수수료 계좌가 미사용 상태로 표시되어야 합니다.").toMatch(/충전\s*수수료\s*계좌[\s\S]*미사용/);
      });
      });
    }

    if (shouldRun(["기존 설정"]) || shouldRun(["법인은 변경"])) {
      test(`${findChecklistItem(checklist.checklist, ["기존 설정"], checklist.checklist[9] ?? "기존 설정 행의 수정 버튼 클릭 시 발급/충전 수수료 계좌가 현재 설정값으로 표시되는지 확인")} / ${findChecklistItem(checklist.checklist, ["법인", "변경"], checklist.checklist[10] ?? "수정 모드에서 법인은 변경할 수 없는지 확인")}`, async ({ browser }, testInfo) => {
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const rows = await readGridRows(page);
        const target = selectEditableRow(rows);
        const dialog = await openEditModal(page, target.corporationName);
        const corporationInput = dialog.locator("input[role='combobox'][placeholder*='법인']").first();
        const dialogText = normalizeText(await dialog.innerText());
        const controls = await readModalControls(dialog);
        const modalControlText = controls.map((control) => normalizeText(`${control.text} ${control.value}`)).join(" | ");

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "edit-current-values", [
          "기존 설정 행 수정 모달에서 현재 발급/충전 수수료 계좌 표시와 법인 변경 제한을 확인했습니다.",
          `대상 법인: ${target.corporationName}`,
          `목록 발급 수수료 계좌: ${target.issueFeeAccount}`,
          `목록 충전 수수료 계좌: ${target.chargeFeeAccount}`,
          `법인 입력값: ${await corporationInput.inputValue().catch(() => "")}`,
          `법인 입력 disabled: ${await corporationInput.isDisabled().catch(() => false)}`,
          `모달 컨트롤: ${controls.map((control) => `${control.name}=${control.value || control.text || "-"} disabled=${control.disabled}`).join(" | ")}`
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "edit-current-values");

        expect(dialogText, "수정 모달 제목이 표시되어야 합니다.").toMatch(/연결계좌설정\s*수정/);
        expect(`${dialogText} ${modalControlText}`, "수정 모달에 현재 발급 수수료 계좌 유형이 표시되어야 합니다.").toContain(accountTypeFromCell(target.issueFeeAccount));
        expect(modalControlText, "수정 모달에 현재 발급 수수료 계좌명이 입력값으로 표시되어야 합니다.").toContain(accountNameFromCell(target.issueFeeAccount));
        if (/미사용/.test(target.chargeFeeAccount)) {
          expect(dialogText, "충전 수수료 계좌가 없는 행은 미사용으로 표시되어야 합니다.").toMatch(/미사용/);
        } else {
          expect(`${dialogText} ${modalControlText}`, "수정 모달에 현재 충전 수수료 계좌가 표시되어야 합니다.").toContain(accountNameFromCell(target.chargeFeeAccount));
        }
        await expect(corporationInput, "수정 모드에서는 법인을 변경할 수 없어야 합니다.").toBeDisabled();
      });
      });
    }

    if (shouldRun(["저장", "새로고침"]) || shouldRun(["저장한 데이터"])) {
      test(findChecklistItem(checklist.checklist, ["저장"], checklist.checklist[17] ?? "저장 후 화면 새로고침해도 발급/충전 수수료 계좌 설정이 유지되는지 확인"), async ({ browser }, testInfo) => {
      testInfo.setTimeout(120_000);
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const target = selectUnusedChargeRow(await readGridRows(page));
        let onResult: { accountType: string; accountName: string } | undefined;
        let afterOn: GridRowSnapshot | undefined;
        let afterReload: GridRowSnapshot | undefined;
        let afterOff: GridRowSnapshot | undefined;
        let operationError: unknown;
        const evidence: string[] = [
          "기존 미사용 행에서 충전 수수료 계좌를 ON으로 저장한 뒤, 다시 OFF로 저장해 원상복구되는지 확인했습니다.",
          `대상 법인: ${target.corporationName}`,
          `초기 발급 수수료 계좌: ${target.issueFeeAccount}`,
          `초기 충전 수수료 계좌: ${target.chargeFeeAccount}`
        ];

        try {
          onResult = await saveChargeFeeOn(page, target.corporationName, evidence);
          await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
          afterOn = findRowByCorporation(await readGridRows(page), target.corporationName);
          evidence.push(`ON 저장 후 목록 충전 수수료 계좌: ${afterOn.chargeFeeAccount}`);

          await page.reload();
          await waitForNavigationToSettle(page);
          afterReload = findRowByCorporation(await readGridRows(page), target.corporationName);
          evidence.push(`새로고침 후 목록 충전 수수료 계좌: ${afterReload.chargeFeeAccount}`);

          if (!isSubsetChecklist || shouldRun(["토글 OFF", "미사용"])) {
            await saveChargeFeeOff(page, target.corporationName, evidence);
            await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
            afterOff = findRowByCorporation(await readGridRows(page), target.corporationName);
            evidence.push(`OFF 저장 후 목록 충전 수수료 계좌: ${afterOff.chargeFeeAccount}`);
          }
        } catch (error) {
          operationError = error;
          evidence.push(`실행 중 오류: ${formatError(error)}`);
        } finally {
          await restoreChargeFeeOff(page, pageDefinition, runtimeEnv, target.corporationName).catch(() => undefined);
        }

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "charge-save-persist-restore", evidence);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "charge-save-persist-restore");

        if (operationError) {
          throw operationError;
        }
        expect(afterOn?.chargeFeeAccount ?? "", "충전 수수료 계좌 토글 ON 저장 후 목록에 충전 수수료 계좌가 반영되어야 합니다.").not.toMatch(/미사용/);
        expect(afterOn?.chargeFeeAccount ?? "", "ON 저장 시 선택한 충전 수수료 계좌명이 목록에 표시되어야 합니다.").toContain(onResult?.accountName);
        expect(afterReload?.chargeFeeAccount ?? "", "새로고침 후에도 충전 수수료 계좌 설정이 유지되어야 합니다.").toContain(onResult?.accountName);
        if (!isSubsetChecklist || shouldRun(["토글 OFF", "미사용"])) {
          expect(afterOff?.chargeFeeAccount ?? "", "충전 수수료 계좌 토글 OFF 저장 후 목록은 미사용 처리되어야 합니다.").toMatch(/미사용/);
        }
      });
      });
    }

    for (const role of roles) {
      const roleChecklist = isSubsetChecklist
        ? findRoleChecklistItem(role, checklist.checklist)
        : roleSpecificChecklist(role, checklist.checklist);
      if (!roleChecklist) {
        continue;
      }

      test(`${role} - ${roleChecklist}`, async ({ browser }, testInfo) => {
        const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", role);
        test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

        await withRolePage(browser, runtimeEnv, async (page) => {
          await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
          const { dialog, corporationName } = await openFirstEditableModal(page);
          const optionsText = await readIssueAccountTypeOptions(page, dialog);
          const expectation = ROLE_EXPECTATIONS[role] ?? ROLE_EXPECTATIONS.ADMIN;
          const forbiddenHits = expectation.forbidden.filter((pattern) => optionsText.some((option) => pattern.test(option)));
          const allowedHit = expectation.allowed.some((pattern) => optionsText.some((option) => pattern.test(option)));
          const onlyMismatch = expectation.only
            ? optionsText.filter((option) => isAccountTypeOption(option)).filter((option) => !expectation.only!.test(option))
            : [];

          await attachEvidenceLog(testInfo, pageDefinition, role, "role-account-type-options", [
            `${role} 권한으로 연결계좌설정 수정 모달의 계좌유형 옵션 제한을 확인했습니다.`,
            `대상 법인: ${corporationName}`,
            `수집 옵션: ${optionsText.join(", ") || "없음"}`,
            `금지 옵션 노출: ${forbiddenHits.map(String).join(", ") || "없음"}`,
            `허용 옵션 존재: ${allowedHit ? "예" : "아니오"}`,
            `only 조건 위반: ${onlyMismatch.join(", ") || "없음"}`
          ]);
          await attachPageScreenshot(page, testInfo, pageDefinition, role, "role-account-type-options");

          expect(optionsText.length, `${role} 권한에서 계좌유형 선택 옵션이 있어야 합니다.`).toBeGreaterThan(0);
          expect(allowedHit, `${role} 권한에서 허용 계좌유형 옵션이 보여야 합니다.`).toBe(true);
          expect(forbiddenHits, `${role} 권한에서 제한된 계좌유형 옵션이 보이면 안 됩니다.`).toEqual([]);
          expect(onlyMismatch, `${role} 권한의 계좌유형 옵션 범위가 role 정책과 일치해야 합니다.`).toEqual([]);
        });
      });
    }

    if (shouldRun(["검색 조건"]) || shouldRun(["조회"])) {
      test(findChecklistItem(checklist.checklist, ["검색"], checklist.checklist[18] ?? "검색 조건으로 법인명, 연결계좌명, 계좌유형 조회가 정상 동작하는지 확인"), async ({ browser }, testInfo) => {
      testInfo.setTimeout(90_000);
      const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      test.skip(!!getRuntimeBlockReason(runtimeEnv), getRuntimeBlockReason(runtimeEnv));

      await withRolePage(browser, runtimeEnv, async (page) => {
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const rows = await readGridRows(page);
        const target = rows[0] ?? blocked("검색 검증에 사용할 연결계좌설정 행이 없습니다.");

        const corporationResult = await runBoundedFilterCheck("법인명", async () => {
          await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
          return verifyTextFilter(page, pageDefinition, "법인명", target.corporationName);
        });
        await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
        const issueAccountName = accountNameFromCell(target.issueFeeAccount);
        const accountResult = await runBoundedFilterCheck("연결계좌명", async () => {
          await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
          return verifyTextFilter(page, pageDefinition, "연결계좌명", issueAccountName);
        });
        const accountType = accountTypeFromCell(target.issueFeeAccount);
        const typeResult = await runBoundedFilterCheck("계좌유형", async () => {
          await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
          return verifyAccountTypeFilter(page, pageDefinition, accountType);
        });

        await attachEvidenceLog(testInfo, pageDefinition, "ADMIN", "search-filters", [
          "법인명, 연결계좌명, 계좌유형 검색 조건이 목록에 반영되는지 확인했습니다.",
          `법인명 검색 '${target.corporationName}': ${corporationResult}`,
          `연결계좌명 검색 '${issueAccountName}': ${accountResult}`,
          `계좌유형 검색 '${accountType}': ${typeResult}`
        ]);
        await attachPageScreenshot(page, testInfo, pageDefinition, "ADMIN", "search-filters");

        expect(corporationResult, "법인명 검색 후 결과는 검색 법인명 기준이어야 합니다.").toMatch(/PASS/);
        expect(accountResult, "연결계좌명 검색 후 결과는 검색 계좌명 기준이어야 합니다.").toMatch(/PASS/);
        expect(typeResult, "계좌유형 검색 후 결과는 검색 계좌유형 기준이어야 합니다.").toMatch(/PASS/);
      });
      });
    }
  });
}

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

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 context.close();
  }
}

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

  const targetUrl = buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!);
  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), "연결계좌설정 업무 화면 진입 전 로그인 화면을 벗어나야 합니다.").toBe(false);
  const bodyText = await page.locator("body").innerText({ timeout: 5_000 }).catch(() => "");
  if (/권한이 없습니다|access denied|forbidden/i.test(bodyText)) {
    throw new Error("연결계좌설정 화면에서 권한이 없습니다 문구가 표시되었습니다.");
  }
  expect(bodyText, "연결계좌설정 화면에 업무 제목이 표시되어야 합니다.").toMatch(/연결계좌관리|연결계좌설정|비스킷 카드관리/);
}

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

async function readGridRows(page: Page): Promise<GridRowSnapshot[]> {
  await expect(page.locator("[role='row'][data-id]").first(), "연결계좌설정 목록 행이 표시되어야 합니다.").toBeVisible();
  const rows = page.locator("[role='row'][data-id]");
  const count = await rows.count();
  const snapshots: GridRowSnapshot[] = [];

  for (let index = 0; index < count; index += 1) {
    const row = rows.nth(index);
    const cells = await row.locator("[role='gridcell']").evaluateAll((elements) =>
      elements.map((element) => {
        const text = ((element as HTMLElement).innerText ?? element.textContent ?? "")
          .replace(/\u200b/g, "")
          .replace(/\r/g, "")
          .replace(/\n+/g, " ")
          .replace(/[ \t]+/g, " ")
          .trim();
        return {
          field: element.getAttribute("data-field") ?? "",
          text
        };
      })
    );
    const byField = new Map(cells.map((cell) => [cell.field, cell.text]));
    snapshots.push({
      id: await row.getAttribute("data-id") ?? "",
      rowNumber: byField.get("rowNumber") ?? String(index + 1),
      corporationName: byField.get("corporationName") ?? "",
      issueFeeAccount: byField.get("issueFeeAccount") ?? "",
      chargeFeeAccount: byField.get("chargeFeeAccount") ?? "",
      rawText: normalizeCellText(await row.innerText())
    });
  }

  return snapshots.filter((row) => row.corporationName);
}

async function openRegisterModal(page: Page): Promise<Locator> {
  const button = page.locator("button").filter({ hasText: /^계좌설정$/ }).last();
  await expect(button, "계좌설정 등록 버튼이 표시되어야 합니다.").toBeVisible({ timeout: 5_000 });
  await button.scrollIntoViewIfNeeded().catch(() => undefined);
  await button.click({ force: true });
  const dialog = page.locator("[role='dialog']").last();
  await expect(dialog, "계좌설정 등록 모달이 표시되어야 합니다.").toBeVisible({ timeout: 5_000 });
  return dialog;
}

async function openEditModal(page: Page, corporationName: string): Promise<Locator> {
  const row = page.locator("[role='row'][data-id]").filter({ hasText: corporationName }).first();
  await expect(row, `${corporationName} 행이 표시되어야 합니다.`).toBeVisible();
  const editIcon = row.locator("svg.lucide-pencil").first();
  if (!(await editIcon.isVisible({ timeout: 2_000 }).catch(() => false))) {
    blocked(`${corporationName} 행의 수정 버튼을 찾지 못했습니다.`);
  }
  await editIcon.click({ force: true });
  const dialog = page.locator("[role='dialog']").last();
  await expect(dialog, "연결계좌설정 수정 모달이 표시되어야 합니다.").toBeVisible();
  return dialog;
}

async function openFirstEditableModal(page: Page): Promise<{ dialog: Locator; corporationName: string }> {
  const rows = page.locator("[role='row'][data-id]");
  const count = await rows.count();
  for (let index = 0; index < count; index += 1) {
    const row = rows.nth(index);
    const icon = row.locator("svg.lucide-pencil").first();
    if (!(await icon.isVisible({ timeout: 500 }).catch(() => false))) {
      continue;
    }
    const iconClass = await icon.getAttribute("class").catch(() => "");
    if (/text-gray-300|cursor-not-allowed/.test(iconClass ?? "")) {
      continue;
    }
    const cells = await row.locator("[role='gridcell']").allInnerTexts();
    const corporationName = normalizeCellText(cells[2] ?? cells.join(" "));
    await icon.click({ force: true });
    const dialog = page.locator("[role='dialog']").last();
    await expect(dialog, "연결계좌설정 수정 모달이 표시되어야 합니다.").toBeVisible({ timeout: 7_000 });
    return { dialog, corporationName };
  }

  const registerButton = page.locator("button").filter({ hasText: /계좌설정/ }).last();
  if (await registerButton.isVisible({ timeout: 1_000 }).catch(() => false)) {
    await registerButton.click({ force: true });
    const dialog = page.locator("[role='dialog']").last();
    await expect(dialog, "연결계좌설정 등록 모달이 표시되어야 합니다.").toBeVisible({ timeout: 7_000 });
    return { dialog, corporationName: "등록 모달" };
  }

  blocked("수정 가능한 연결계좌설정 행을 찾지 못했습니다.");
}

async function readChargeControls(dialog: Locator): Promise<Array<{ name: string; disabled: boolean }>> {
  const controls = await readModalControls(dialog);
  return controls
    .filter((control) => /계좌유형을 선택해주세요|계좌유형을 먼저 선택하세요|연결계좌를 선택하세요/.test(`${control.text} ${control.placeholder} ${control.value}`))
    .slice(-2)
    .map((control, index) => ({ name: index === 0 ? "충전 계좌유형" : "충전 연결계좌", disabled: control.disabled }));
}

async function readModalControls(dialog: Locator): Promise<Array<{
  tag: string;
  text: string;
  value: string;
  placeholder: string;
  disabled: boolean;
  name: string;
}>> {
  return dialog.locator("input,[role='combobox'],button").evaluateAll((elements) =>
    elements.map((element, index) => {
      const input = element as HTMLInputElement;
      const text = ((element as HTMLElement).innerText ?? element.textContent ?? "")
        .replace(/\u200b/g, "")
        .replace(/\r/g, "")
        .replace(/[ \t]+/g, " ")
        .trim();
      return {
        tag: element.tagName,
        text,
        value: input.value ?? "",
        placeholder: input.getAttribute("placeholder") ?? "",
        disabled: Boolean(input.disabled) || /Mui-disabled/.test(input.className),
        name: input.getAttribute("aria-label") ?? input.getAttribute("name") ?? `control-${index}`
      };
    })
  );
}

async function saveChargeFeeOn(
  page: Page,
  corporationName: string,
  evidence: string[]
): Promise<{ accountType: string; accountName: string }> {
  const dialog = await openEditModal(page, corporationName);
  await setChargeSwitchState(page, dialog, true);

  const issueType = normalizeText(await dialog.locator("[role='combobox']").nth(1).innerText().catch(() => ""));
  const accountType = /법인\s*운영계좌/.test(issueType) ? "법인 운영계좌" : /테넌트\s*운영계좌/.test(issueType) ? "테넌트 운영계좌" : "시스템 운영계좌";
  const currentChargeAccountName = normalizeText(await dialog.locator("input[role='combobox']").last().inputValue().catch(() => ""));
  await selectMuiSelectOption(page, dialog.locator("[role='combobox']").nth(3), accountType);
  const accountName = await selectFirstAutocompleteOption(page, dialog.locator("input[role='combobox']").last(), currentChargeAccountName);
  await clickSaveAndWait(page, dialog);
  evidence.push(`ON 저장 선택값: ${accountType} / ${accountName}`);
  return { accountType, accountName: accountNameFromOption(accountName) };
}

async function saveChargeFeeOff(page: Page, corporationName: string, evidence: string[]): Promise<void> {
  const dialog = await openEditModal(page, corporationName);
  await setChargeSwitchState(page, dialog, false);
  await clickSaveAndWait(page, dialog);
  evidence.push("OFF 저장 완료: 충전 수수료 계좌 미사용으로 저장했습니다.");
}

async function restoreChargeFeeOff(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  corporationName: string
): Promise<void> {
  await openLinkedAccountPage(page, pageDefinition, runtimeEnv);
  const row = findRowByCorporation(await readGridRows(page), corporationName);
  if (/미사용/.test(row.chargeFeeAccount)) {
    return;
  }
  const dialog = await openEditModal(page, corporationName);
  await setChargeSwitchState(page, dialog, false);
  await clickSaveAndWait(page, dialog);
}

async function setChargeSwitchState(page: Page, dialog: Locator, checked: boolean): Promise<void> {
  const input = dialog.locator("input[type='checkbox']").first();
  const current = await input.isChecked().catch(() => false);
  if (current === checked) {
    return;
  }
  const switchRoot = dialog.locator(".MuiSwitch-root").first();
  if (await switchRoot.isVisible({ timeout: 1_000 }).catch(() => false)) {
    await switchRoot.click({ force: true });
  } else {
    await input.click({ force: true });
  }
  await page.waitForTimeout(600);
}

async function selectMuiSelectOption(page: Page, select: Locator, label: string): Promise<void> {
  await select.scrollIntoViewIfNeeded().catch(() => undefined);
  await select.click({ force: true });
  await page.waitForTimeout(300);
  const option = page.getByRole("option", { name: new RegExp(escapeRegExp(label)) }).first();
  if (!(await option.isVisible({ timeout: 3_000 }).catch(() => false))) {
    blocked(`계좌유형 옵션 '${label}'을 찾지 못했습니다.`);
  }
  await option.click({ force: true });
  await page.waitForTimeout(500);
}

async function selectFirstAutocompleteOption(page: Page, input: Locator, excludedAccountName = ""): Promise<string> {
  await input.scrollIntoViewIfNeeded().catch(() => undefined);
  await input.click({ force: true });
  await input.press("ArrowDown").catch(() => undefined);
  await page.waitForTimeout(700);
  const options = page.locator(".MuiAutocomplete-option").filter({ hasText: /\S/ });
  const count = await options.count().catch(() => 0);
  if (count === 0) {
    blocked("선택 가능한 연결계좌 옵션을 찾지 못했습니다.");
  }
  let option = options.first();
  for (let index = 0; index < count; index += 1) {
    const candidate = options.nth(index);
    const candidateText = normalizeText(await candidate.innerText());
    if (!excludedAccountName || !candidateText.includes(excludedAccountName)) {
      option = candidate;
      break;
    }
  }
  const text = normalizeText(await option.innerText());
  await option.click({ force: true });
  await page.waitForTimeout(500);
  return text;
}

async function clickSaveAndWait(page: Page, dialog: Locator): Promise<void> {
  const saveButton = dialog.getByRole("button", { name: /저장|등록/ }).last();
  await expect(saveButton, "저장 버튼이 활성화되어야 합니다.").toBeEnabled();
  const requests = captureRequests(page);
  await Promise.all([
    page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => undefined),
    saveButton.click()
  ]);
  await page.waitForTimeout(1_500);
  const bodyText = await page.locator("body").innerText({ timeout: 3_000 }).catch(() => "");
  if (/실패|오류|에러|error/i.test(bodyText) && !/오류내역|오류\s*동기화/.test(bodyText)) {
    throw new Error(`저장 후 오류 문구가 표시되었습니다. 요청: ${formatRequests(requests)}`);
  }
}

function captureRequests(page: Page): CapturedRequest[] {
  const requests: CapturedRequest[] = [];
  page.on("request", (request: Request) => {
    const url = request.url();
    if (!/linked|biscuit|account|fee|charge|corporation/i.test(url)) {
      return;
    }
    requests.push({
      method: request.method(),
      url,
      postData: request.postData() ?? ""
    });
  });
  return requests;
}

async function readIssueAccountTypeOptions(page: Page, dialog: Locator): Promise<string[]> {
  const typeSelect = dialog.locator("[role='combobox']").nth(1);
  await typeSelect.click({ force: true });
  await page.waitForTimeout(500);
  const options = await page.locator("[role='option'], .MuiMenuItem-root").evaluateAll((elements) =>
    elements
      .filter((element) => {
        const rect = (element as HTMLElement).getBoundingClientRect();
        return rect.width > 0 && rect.height > 0;
      })
      .map((element) =>
        ((element as HTMLElement).innerText ?? element.textContent ?? "")
          .replace(/\u200b/g, "")
          .replace(/\r/g, "")
          .replace(/[ \t]+/g, " ")
          .trim()
      )
      .filter(Boolean)
  );
  await page.keyboard.press("Escape").catch(() => undefined);
  return uniqueValues(options.filter(isAccountTypeOption));
}

async function verifyTextFilter(
  page: Page,
  pageDefinition: QaPageDefinition,
  label: string,
  value: string
): Promise<string> {
  await openAdvancedSearchIfPresent(page);
  const input = await findFilterInput(page, label);
  if (!input) {
    blocked(`${label} 검색 입력창을 찾지 못했습니다.`);
  }
  const appliedValue = await setFilterInputValue(page, input, label, value);
  await clickSearch(page, pageDefinition);
  const rows = await readGridRows(page);
  const matched = rows.length > 0 && rows.every((row) => row.rawText.includes(appliedValue));
  return `${matched ? "PASS" : "FAIL"} value=${appliedValue} rows=${rows.length}`;
}

async function verifyAccountTypeFilter(
  page: Page,
  pageDefinition: QaPageDefinition,
  accountType: string
): Promise<string> {
  await openAdvancedSearchIfPresent(page);
  if (!accountType) {
    blocked("계좌유형 검색 검증에 사용할 계좌유형 값을 목록에서 찾지 못했습니다.");
  }
  const typeSelect = page.locator("xpath=//*[normalize-space()='계좌유형']/following::*[@role='combobox'][1]").first();
  if (!(await typeSelect.isVisible({ timeout: 2_000 }).catch(() => false))) {
    blocked("계좌유형 검색 컨트롤을 찾지 못했습니다.");
  }
  await selectMuiSelectOption(page, typeSelect, accountType);
  await clickSearch(page, pageDefinition);
  const rows = await readGridRows(page);
  const matched = rows.length > 0 && rows.every((row) => row.issueFeeAccount.includes(accountType) || row.chargeFeeAccount.includes(accountType));
  return `${matched ? "PASS" : "FAIL"} rows=${rows.length}`;
}

async function runBoundedFilterCheck(label: string, callback: () => Promise<string>): Promise<string> {
  const timeoutMs = 25_000;
  let timeout: NodeJS.Timeout | undefined;
  try {
    return await Promise.race([
      callback(),
      new Promise<string>((resolve) => {
        timeout = setTimeout(() => resolve(`FAIL ${label} 필터 검증이 ${timeoutMs / 1_000}초 안에 완료되지 않았습니다.`), timeoutMs);
      })
    ]);
  } finally {
    if (timeout) {
      clearTimeout(timeout);
    }
  }
}

async function findFilterInput(page: Page, label: string): Promise<Locator | undefined> {
  const byRole = page.getByRole("textbox", { name: new RegExp(`^${escapeRegExp(label)}$`) }).first();
  if (await byRole.isVisible({ timeout: 1_000 }).catch(() => false)) {
    return byRole;
  }
  const byPlaceholder = page.locator(`input[placeholder*='${label.replace(/명$/, "")}']`).first();
  if (await byPlaceholder.isVisible({ timeout: 1_000 }).catch(() => false)) {
    return byPlaceholder;
  }
  const byFollowing = page.locator(`xpath=//*[normalize-space()='${label}']/following::input[not(@type='hidden')][1]`).first();
  if (await byFollowing.isVisible({ timeout: 1_000 }).catch(() => false)) {
    return byFollowing;
  }
  return undefined;
}

async function clickSearch(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const button = await findVisibleLocator(page.locator(pageDefinition.searchButtonSelector ?? "button:has-text('검색')"));
  const responsePromise = waitForBusinessResponse(page, pageDefinition);
  await button.click({ force: true, timeout: 3_000 });
  await responsePromise;
  await waitForNavigationToSettle(page);
}

async function clearSearch(page: Page): Promise<void> {
  const clearButton = await findVisibleLocator(page.getByRole("button", { name: /^초기화$/ })).catch(() => undefined);
  if (clearButton) {
    const responsePromise = waitForBusinessResponse(page);
    await clearButton.click({ force: true, timeout: 3_000 }).catch(() => undefined);
    await responsePromise;
    await waitForNavigationToSettle(page);
  }
}

async function openAdvancedSearchIfPresent(page: Page): Promise<void> {
  const button = page.getByRole("button", { name: /상세\s*검색|필터/i }).first();
  if (await button.isVisible({ timeout: 700 }).catch(() => false)) {
    await button.click().catch(() => undefined);
    await waitForNavigationToSettle(page);
  }
}

async function setFilterInputValue(page: Page, input: Locator, label: string, value: string): Promise<string> {
  await input.scrollIntoViewIfNeeded().catch(() => undefined);
  if (/법인/.test(label)) {
    const selectedValue = await selectAutocompleteOption(page, input, value, label);
    await expect(input, `${label} 검색 입력값이 입력되어야 합니다.`).toHaveValue(/\S/, {
      timeout: 3_000
    });
    return selectedValue;
  }

  await input.click({ force: true });
  await input.fill("");
  await input.fill(value);
  await expect(input, `${label} 검색 입력값이 입력되어야 합니다.`).toHaveValue(new RegExp(escapeRegExp(value)), {
    timeout: 3_000
  });
  return value;
}

async function selectAutocompleteOption(page: Page, input: Locator, value: string, label: string): Promise<string> {
  const root = input.locator("xpath=ancestor::div[contains(@class,'MuiAutocomplete-root')][1]");
  const popupIndicator = root.locator(".MuiAutocomplete-popupIndicator").first();

  await input.click({ force: true }).catch(() => undefined);
  const optionLocator = page
    .locator(
      ".MuiAutocomplete-popper [role='option'], .MuiAutocomplete-popper li, .MuiAutocomplete-option, .MuiPopper-root .MuiButtonBase-root"
    )
    .filter({ hasText: /\S/ });
  if (!(await optionLocator.first().isVisible({ timeout: 700 }).catch(() => false))) {
    if (await popupIndicator.isVisible({ timeout: 700 }).catch(() => false)) {
      await popupIndicator.click({ force: true });
    }
  }

  const option = optionLocator.filter({ hasText: new RegExp(escapeRegExp(value)) }).first();
  if (await option.isVisible({ timeout: 3_000 }).catch(() => false)) {
    await option.click({ force: true });
    return normalizeText(await input.inputValue().catch(() => value)) || value;
  }

  const visibleOptions = await optionLocator.evaluateAll((elements) =>
    elements
      .map((element, index) => ({
        index,
        text: ((element as HTMLElement).innerText ?? element.textContent ?? "").replace(/\s+/g, " ").trim()
      }))
      .filter((option) => option.text)
  ).catch(() => []);

  if (visibleOptions.length === 0) {
    blocked(`${label} 선택 옵션을 찾지 못했습니다.`);
  }

  const normalizedValue = value.replace(/^[A-Z]_/, "");
  const selected = visibleOptions.find((optionText) => optionText.text.includes(normalizedValue)) ?? visibleOptions[0];
  const fallback = optionLocator.nth(selected.index);
  await fallback.click({ force: true });
  const inputValue = normalizeText(await input.inputValue().catch(() => ""));
  return inputValue || selected.text.replace(/^BP\d+\s*/, "").trim();
}

async function waitForBusinessResponse(page: Page, pageDefinition?: QaPageDefinition): Promise<void> {
  const pattern = pageDefinition?.dataRequestUrlPattern
    ? new RegExp(pageDefinition.dataRequestUrlPattern, "i")
    : /biscuit|account|linked|fee|charge|corporation/i;
  await page.waitForResponse(
    (response) => response.request().method() !== "OPTIONS" && pattern.test(response.url()),
    { timeout: 8_000 }
  ).catch(() => undefined);
}

async function findVisibleLocator(locator: Locator): Promise<Locator> {
  const count = await locator.count();
  for (let index = 0; index < count; index += 1) {
    const candidate = locator.nth(index);
    if (await candidate.isVisible({ timeout: 300 }).catch(() => false)) {
      return candidate;
    }
  }
  blocked("화면에 표시된 버튼 또는 입력 컨트롤을 찾지 못했습니다.");
}

function selectEditableRow(rows: GridRowSnapshot[]): GridRowSnapshot {
  return findUnusedChargeRow(rows) ?? rows[0] ?? blocked("수정 검증에 사용할 연결계좌설정 행이 없습니다.");
}

function selectUnusedChargeRow(rows: GridRowSnapshot[]): GridRowSnapshot {
  return findUnusedChargeRow(rows) ?? blocked("충전 수수료 계좌 미사용 행이 없어 ON/OFF 저장 검증을 진행할 수 없습니다.");
}

function findUnusedChargeRow(rows: GridRowSnapshot[]): GridRowSnapshot | undefined {
  const configured = process.env.QA_3375_TARGET_CORPORATION;
  const byConfigured = configured ? rows.find((row) => row.corporationName.includes(configured)) : undefined;
  if (byConfigured) {
    return byConfigured;
  }
  return rows.find((row) => /미사용/.test(row.chargeFeeAccount));
}

function findRowByCorporation(rows: GridRowSnapshot[], corporationName: string): GridRowSnapshot {
  return rows.find((row) => row.corporationName === corporationName) ?? blocked(`${corporationName} 행을 목록에서 찾지 못했습니다.`);
}

function findDuplicates(values: string[]): string[] {
  const seen = new Set<string>();
  const duplicates = new Set<string>();
  for (const value of values) {
    if (seen.has(value)) {
      duplicates.add(value);
    }
    seen.add(value);
  }
  return [...duplicates];
}

function roleSpecificChecklist(role: string, checklistItems: string[]): string {
  const matched = findRoleChecklistItem(role, checklistItems);
  if (matched) {
    return matched;
  }
  if (role === "ADMIN") {
    return "admin 권한 계좌유형 옵션 확인";
  }
  if (role === "TA") {
    return "TA 권한 시스템 운영계좌 선택 제한 확인";
  }
  if (role === "CO") {
    return "CO 권한에서 법인 운영계좌만 선택 가능한지 확인";
  }
  return `${role} 권한 계좌유형 옵션 확인`;
}

function findRoleChecklistItem(role: string, checklistItems: string[]): string | undefined {
  if (role === "ADMIN") {
    return checklistItems.find((item) => /admin/i.test(item) && /계좌유형/.test(item));
  }
  if (role === "TA") {
    return checklistItems.find((item) => /TA/i.test(item) && /시스템\s*운영계좌/.test(item));
  }
  if (role === "CO") {
    return checklistItems.find((item) => /CO/i.test(item) && /법인\s*운영계좌/.test(item));
  }
  return checklistItems.find((item) => item.toLowerCase().includes(role.toLowerCase()));
}

function findChecklistItem(items: string[], keywords: string[], fallback: string): string {
  return items.find((item) => keywords.every((keyword) => item.toLowerCase().includes(keyword.toLowerCase()))) ?? fallback;
}

function checklistHasAll(items: string[], keywords: string[]): boolean {
  return items.some((item) => keywords.every((keyword) => item.toLowerCase().includes(keyword.toLowerCase())));
}

function firstLine(value: string): string {
  return normalizeText(value).split(/\n|\s{2,}/)[0] ?? normalizeText(value);
}

function accountTypeFromCell(value: string): string {
  return normalizeText(value).match(ACCOUNT_TYPE_PATTERN)?.[1] ?? "";
}

function accountNameFromCell(value: string): string {
  const normalized = normalizeText(value);
  const accountType = accountTypeFromCell(normalized);
  return accountType ? normalized.replace(accountType, "").trim() : firstLine(normalized);
}

function accountNameFromOption(value: string): string {
  return normalizeText(value).split("/")[0]?.trim() ?? normalizeText(value);
}

function normalizeText(value: string): string {
  return value.replace(/\u200b/g, "").replace(/\r/g, "").replace(/[ \t]+/g, " ").trim();
}

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

function isAccountTypeOption(value: string): boolean {
  return /시스템\s*운영계좌|테넌트\s*운영계좌|법인\s*운영계좌/.test(value);
}

function uniqueValues(values: string[]): string[] {
  const unique: string[] = [];
  for (const value of values) {
    if (!unique.includes(value)) {
      unique.push(value);
    }
  }
  return unique;
}

function formatRequests(requests: CapturedRequest[]): string {
  return requests
    .slice(-5)
    .map((request) => `${request.method} ${new URL(request.url).pathname}${request.postData ? ` body=${request.postData.slice(0, 300)}` : ""}`)
    .join(" | ");
}

function formatError(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

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

function escapeRegExp(value: string): string {
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

async function attachEvidenceLog(
  testInfo: TestInfo,
  pageDefinition: Pick<QaPageDefinition, "name">,
  role: string,
  slug: string,
  lines: string[]
): Promise<void> {
  const evidencePath = testInfo.outputPath(`${safeFilename(pageDefinition.name)}-${role}-${slug}.md`);
  fs.writeFileSync(evidencePath, `${lines.join("\n")}\n`, "utf8");
  await testInfo.attach(path.basename(evidencePath), {
    path: evidencePath,
    contentType: "text/markdown"
  });
}

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

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