import { expect, test, type APIRequestContext, type Browser, type Locator, type Page, 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 type { QaEnvironment, QaPageDefinition } from "../types";
import { buildPageUrl } from "../url";

test.use({ trace: "on", video: "off", screenshot: "off" });

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

interface TargetPreset {
  role: "ADMIN";
  name: string;
}

interface ResolvedPreset extends TargetPreset {
  id: number;
  description: string;
  departments: unknown[];
  userCount?: number;
  isDefault?: boolean;
}

interface PresetPermissionState extends ResolvedPreset {
  permissionIds: string[];
}

interface PermissionNode {
  id?: string;
  name?: string;
  children?: PermissionNode[];
}

interface PermissionGroup {
  groupName?: string;
  permissions?: PermissionNode[];
}

interface PresetDetailPayload {
  id: number;
  role: string;
  name: string;
  description?: string;
  departments?: unknown[];
  permissions?: {
    menu?: PermissionGroup[];
    task?: PermissionGroup[];
  };
}

interface PresetListItem {
  id: number;
  role: string;
  name: string;
  description?: string;
  departments?: unknown[];
  userCount?: number;
  isDefault?: boolean;
}

interface PageTransferSnapshot {
  pageName: string;
  url: string;
  rootText: string;
  transferButtonCount: number;
  transferButtonLabels: string[];
  transferColumnVisible: boolean;
}

interface FirmBankManagePayload {
  content?: FirmBankManageItem[];
}

interface FirmBankManageItem {
  id: string;
  name: string;
  balance?: number;
  tenant?: {
    id?: string;
    name?: string;
  };
  firmBankType?: {
    value?: string;
    label?: string;
  };
}

interface TransferSource {
  index: number;
  id: string;
  name: string;
  balance: number;
  firmBankType: string;
}

interface PreparedTransfer {
  source: TransferSource;
  swalText: string;
}

interface TransferResponseEvidence {
  verifyStatus: number;
  verifyText: string;
  transferStatus: number;
  transferText: string;
  successText: string;
}

interface TransferState {
  issueId: string;
  environment: QaEnvironment;
  sourceFirmBankId: string;
  sourceFirmBankName: string;
  amount: number;
  receiverBankName: string;
  receiverAccountNumber: string;
  receiverAccountHolder: string;
  completedAt: string;
  verifyStatus: number;
  transferStatus: number;
}

const ADMIN_ROLE = "ADMIN";
const TARGET_PRESET: TargetPreset = { role: ADMIN_ROLE, name: "USE_SYSTEM" };
const REQUESTED_PERMISSION_ID = "FIRM_BANK_TRANSFER_REQUEST";
const PRESET_MUTATION_GUARD = "QA_3583_ALLOW_PRESET_MUTATION";
const TRANSFER_ALLOWED_ROLE = "ADMIN";
const TRANSFER_DENIED_ROLE = "ADMIN_NO_TRANSFER";
const TRANSFER_EXECUTION_GUARD = "QA_3583_ALLOW_FIRM_BANK_TRANSFER";
const TRANSFER_AMOUNT = Number(process.env.QA_3583_TRANSFER_AMOUNT ?? "1000");
const RECEIVER_BANK_NAME = process.env.QA_3583_RECEIVER_BANK_NAME ?? "카카오뱅크";
const RECEIVER_BANK_CODE = process.env.QA_3583_RECEIVER_BANK_CODE ?? "090";
const RECEIVER_ACCOUNT_NUMBER = process.env.QA_3583_RECEIVER_ACCOUNT_NUMBER ?? "3333088854750";
const RECEIVER_ACCOUNT_HOLDER = process.env.QA_3583_RECEIVER_ACCOUNT_HOLDER ?? "이의현";
const RECEIVER_CONTENT = "QA3583";
const TRANSFER_NOTE = "QA3583";

export function runFirmBankTransferSecondaryAuthSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const integratedPage = requirePage(checklist.pages, "통합계좌 관리");
  const firmBankPage = requirePage(checklist.pages, "펌뱅킹 등록");
  const transferAllowedRuntime = getRuntimeQaEnv(options.environment, firmBankPage.application ?? "admin", TRANSFER_ALLOWED_ROLE);
  const transferDeniedRuntime = getRuntimeQaEnv(options.environment, firmBankPage.application ?? "admin", TRANSFER_DENIED_ROLE);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 120_000));

    test(checklist.checklist[0] ?? "[secret-redacted2] 펌뱅크 송금 권한 미보유 계정 이체 컬럼/버튼 미노출", async ({ browser }, testInfo) => {
      const snapshots: PageTransferSnapshot[] = [];
      await withRolePage(browser, transferDeniedRuntime, async (page) => {
        snapshots.push(await collectTransferSnapshot(page, transferDeniedRuntime, integratedPage));
        await attachPageScreenshot(page, testInfo, integratedPage.name, TRANSFER_DENIED_ROLE, "permission-denied");
      });
      await withRolePage(browser, transferDeniedRuntime, async (page) => {
        snapshots.push(await collectTransferSnapshot(page, transferDeniedRuntime, firmBankPage));
        await attachPageScreenshot(page, testInfo, firmBankPage.name, TRANSFER_DENIED_ROLE, "permission-denied");
      });

      await attachEvidenceLog(testInfo, firmBankPage.name, TRANSFER_DENIED_ROLE, "permission-denied-summary", [
        "secret-redacted2 계정으로 펌뱅크 송금 권한 미보유 상태의 이체 컬럼/버튼 노출 여부를 확인했습니다.",
        ...snapshots.flatMap(formatSnapshotLines),
        "기대값: 통합계좌 관리와 펌뱅킹 등록 화면 모두 이체 컬럼/버튼 미노출"
      ]);

      for (const snapshot of snapshots) {
        expect(snapshot.transferColumnVisible, `${snapshot.pageName} 이체 컬럼은 OFF 상태에서 보이면 안 됩니다.`).toBe(false);
        expect(snapshot.transferButtonCount, `${snapshot.pageName} 이체 버튼은 OFF 상태에서 보이면 안 됩니다.`).toBe(0);
      }
    });

    test(checklist.checklist[1] ?? "[secret-redacted] 펌뱅크 송금 권한 보유 계정 이체 컬럼/버튼 노출", async ({ browser }, testInfo) => {
      const snapshots: PageTransferSnapshot[] = [];
      await withRolePage(browser, transferAllowedRuntime, async (page) => {
        snapshots.push(await collectTransferSnapshot(page, transferAllowedRuntime, integratedPage));
        await attachPageScreenshot(page, testInfo, integratedPage.name, TRANSFER_ALLOWED_ROLE, "permission-allowed");
      });
      await withRolePage(browser, transferAllowedRuntime, async (page) => {
        snapshots.push(await collectTransferSnapshot(page, transferAllowedRuntime, firmBankPage));
        await attachPageScreenshot(page, testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "permission-allowed");
      });

      await attachEvidenceLog(testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "permission-allowed-summary", [
        "secret-redacted 계정으로 펌뱅크 송금 권한 보유 상태의 이체 컬럼/버튼 노출 여부를 확인했습니다.",
        ...snapshots.flatMap(formatSnapshotLines),
        "기대값: 통합계좌 관리와 펌뱅킹 등록 화면 모두 이체 컬럼/버튼 노출"
      ]);

      for (const snapshot of snapshots) {
        expect(
          snapshot.transferColumnVisible || snapshot.transferButtonCount > 0,
          `${snapshot.pageName} 이체 컬럼 또는 버튼은 ON 상태에서 보여야 합니다.`
        ).toBe(true);
      }
    });

    test(checklist.checklist[2] ?? "[secret-redacted] 올바른 2차 인증번호 입력 후 이체 진행 확인", async ({ browser }, testInfo) => {
      if (process.env[TRANSFER_EXECUTION_GUARD] !== "1") {
        test.skip(true, `BLOCKED: 실제 펌뱅킹 이체 실행은 ${TRANSFER_EXECUTION_GUARD}=1일 때만 수행합니다.`);
      }

      await withRolePage(browser, transferAllowedRuntime, async (page) => {
        const existingState = readTransferState(options);
        if (existingState) {
          await openTargetPage(page, transferAllowedRuntime, firmBankPage);
          await attachEvidenceLog(testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-success-reused", [
            "동일 실행 내 실제 이체 성공 state가 있어 이체를 재실행하지 않았습니다.",
            `기록된 펌뱅킹: ${existingState.sourceFirmBankName} / id=${existingState.sourceFirmBankId}`,
            `기록된 금액: ${formatNumber(existingState.amount)}원`,
            `verify status=${existingState.verifyStatus}, transfer status=${existingState.transferStatus}`,
            `completedAt=${existingState.completedAt}`
          ]);
          await attachPageScreenshot(page, testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-success-reused");
          expect(existingState.verifyStatus, "기존 2차 인증 verify 응답은 성공 status여야 합니다.").toBeGreaterThanOrEqual(200);
          expect(existingState.verifyStatus, "기존 2차 인증 verify 응답은 성공 status여야 합니다.").toBeLessThan(300);
          expect(existingState.transferStatus, "기존 이체 요청 응답은 성공 status여야 합니다.").toBeGreaterThanOrEqual(200);
          expect(existingState.transferStatus, "기존 이체 요청 응답은 성공 status여야 합니다.").toBeLessThan(300);
          return;
        }

        const authCode = process.env.QA_3583_SECONDARY_AUTH_CODE ?? transferAllowedRuntime.twoFactorCode;
        if (!authCode) {
          test.skip(true, "BLOCKED: 2차 인증번호 입력에 사용할 QA_3583_SECONDARY_AUTH_CODE 또는 ADMIN 2FA 설정이 없습니다.");
        }

        const prepared = await prepareSingleFirmBankTransferConfirmation(page, transferAllowedRuntime, firmBankPage);
        const authText = await confirmSwalAndWaitForSecondaryAuth(page);
        const transferEvidence = await submitSecondaryAuthAndWaitForTransfer(page, authCode!);

        const state: TransferState = {
          issueId: options.issueId,
          environment: options.environment,
          sourceFirmBankId: prepared.source.id,
          sourceFirmBankName: prepared.source.name,
          amount: TRANSFER_AMOUNT,
          receiverBankName: RECEIVER_BANK_NAME,
          receiverAccountNumber: RECEIVER_ACCOUNT_NUMBER,
          receiverAccountHolder: RECEIVER_ACCOUNT_HOLDER,
          completedAt: new Date().toISOString(),
          verifyStatus: transferEvidence.verifyStatus,
          transferStatus: transferEvidence.transferStatus
        };
        writeTransferState(options, state);

        await attachEvidenceLog(testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-success", [
          `실제 이체 실행 guard(${TRANSFER_EXECUTION_GUARD}): enabled`,
          `선택 펌뱅킹: ${prepared.source.name} / id=${prepared.source.id} / balance=${formatNumber(prepared.source.balance)}원`,
          `확인창 텍스트: ${prepared.swalText}`,
          `2차 인증창 텍스트: ${authText}`,
          `verify 응답: status=${transferEvidence.verifyStatus} body=${snippet(transferEvidence.verifyText, 800)}`,
          `이체 응답: status=${transferEvidence.transferStatus} body=${snippet(transferEvidence.transferText, 1200)}`,
          `완료 안내: ${transferEvidence.successText}`
        ]);
        await attachPageScreenshot(page, testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-success");

        expect(authText, "2차 인증 입력창이 표시되어야 합니다.").toMatch(/PIN\s*인증|OTP\s*인증|SMS\s*인증|이메일\s*인증|2차\s*인증/);
        expect(transferEvidence.verifyStatus, "2차 인증 verify API가 성공해야 합니다.").toBeGreaterThanOrEqual(200);
        expect(transferEvidence.verifyStatus, "2차 인증 verify API가 성공해야 합니다.").toBeLessThan(300);
        expect(transferEvidence.transferStatus, "이체 요청 API가 성공해야 합니다.").toBeGreaterThanOrEqual(200);
        expect(transferEvidence.transferStatus, "이체 요청 API가 성공해야 합니다.").toBeLessThan(300);
        expect(transferEvidence.successText, "이체 완료 안내가 표시되어야 합니다.").toMatch(/정상적으로\s*처리|완료|성공/);
      });
    });

    test(checklist.checklist[3] ?? "[secret-redacted] 2차 인증 취소 시 이체 미진행 확인", async ({ browser }, testInfo) => {
      await withRolePage(browser, transferAllowedRuntime, async (page) => {
        const transferEvents: string[] = [];
        page.on("request", (request) => {
          if (isFirmBankTransferMutationUrl(request.url())) {
            transferEvents.push(`REQ ${request.method()} ${sanitizeUrl(request.url())}`);
          }
        });
        page.on("response", (response) => {
          if (isFirmBankTransferMutationUrl(response.url())) {
            transferEvents.push(`RES ${response.status()} ${sanitizeUrl(response.url())}`);
          }
        });

        const prepared = await prepareSingleFirmBankTransferConfirmation(page, transferAllowedRuntime, firmBankPage);
        const authText = await confirmSwalAndWaitForSecondaryAuth(page);
        const authInput = page.locator("input[autocomplete='one-time-code']").last();
        await authInput.waitFor({ state: "visible", timeout: 10_000 });
        const authDialog = authInput.locator("xpath=ancestor::*[.//button[normalize-space()='취소']][1]");
        await authDialog.getByRole("button", { name: "취소" }).click();
        await expect(authInput, "2차 인증 취소 후 인증 입력창은 닫혀야 합니다.").toBeHidden({ timeout: 5_000 });
        const authStillVisible = await page.locator("input[autocomplete='one-time-code']:visible").count();
        const verifyOrTransferEvents = transferEvents.filter((event) => /\/transfer\/verify|\/firm-banks\/[^/]+\/transfer(?:\?|$)/.test(event));

        await attachEvidenceLog(testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-cancel", [
          `선택 펌뱅킹: ${prepared.source.name} / id=${prepared.source.id}`,
          `확인창 텍스트: ${prepared.swalText}`,
          `2차 인증창 텍스트: ${authText}`,
          `취소 후 인증창 표시 수: ${authStillVisible}`,
          `캡처된 이체 관련 요청: ${transferEvents.join(" / ") || "-"}`,
          `취소 후 verify/transfer 요청: ${verifyOrTransferEvents.join(" / ") || "-"}`
        ]);
        await attachPageScreenshot(page, testInfo, firmBankPage.name, TRANSFER_ALLOWED_ROLE, "secondary-auth-cancel");

        expect(authText, "2차 인증 입력창이 표시되어야 합니다.").toMatch(/PIN\s*인증|OTP\s*인증|SMS\s*인증|이메일\s*인증|2차\s*인증/);
        expect(verifyOrTransferEvents, "2차 인증 취소 시 verify/transfer API가 호출되면 안 됩니다.").toHaveLength(0);
      });
    });
  });
}

function requirePage(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;
}

async function ensureRequestedPermission(
  browser: Browser,
  adminRuntime: RuntimeQaEnv,
  originalPresetStates: Map<string, PresetPermissionState>,
  enabled: boolean
): Promise<void> {
  if (process.env[PRESET_MUTATION_GUARD] !== "1") {
    test.skip(true, `BLOCKED: 공유 프리셋 권한 변경은 ${PRESET_MUTATION_GUARD}=1 설정이 필요합니다.`);
  }

  await withRolePage(browser, adminRuntime, async (page) => {
    await ensureAuthenticatedPage(page, adminRuntime);
    const apiBase = apiBaseFromRuntime(adminRuntime);
    const preset = await resolveTargetPreset(page.context().request, apiBase, adminRuntime.tenantId!);
    const state = await readPresetState(page.context().request, apiBase, adminRuntime.tenantId!, preset);
    rememberOriginalState(originalPresetStates, state);
    await setRequestedPermission(page.context().request, apiBase, adminRuntime.tenantId!, state, enabled);
    await page.waitForTimeout(1_500);
  });
}

function rememberOriginalState(
  originalPresetStates: Map<string, PresetPermissionState>,
  state: PresetPermissionState
): void {
  const key = presetKey(state);
  if (!originalPresetStates.has(key)) {
    originalPresetStates.set(key, {
      ...state,
      permissionIds: [...state.permissionIds]
    });
  }
}

async function resolveTargetPreset(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string
): Promise<ResolvedPreset> {
  const listUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets`);
  listUrl.searchParams.set("tenantId", tenantId);
  listUrl.searchParams.set("role", TARGET_PRESET.role);
  const response = await request.get(listUrl.toString());
  if (!response.ok()) {
    throw new Error(`BLOCKED: ${TARGET_PRESET.role} 프리셋 목록 조회 실패 status=${response.status()}`);
  }
  const body = await response.json() as { payload?: PresetListItem[] };
  const preset = body.payload?.find((item) => item.name === TARGET_PRESET.name);
  if (!preset) {
    throw new Error(`BLOCKED: 대상 사용자 프리셋을 찾지 못했습니다. target=${TARGET_PRESET.role}/${TARGET_PRESET.name}`);
  }
  return {
    role: TARGET_PRESET.role,
    name: preset.name,
    id: preset.id,
    description: preset.description ?? "",
    departments: preset.departments ?? [],
    userCount: preset.userCount,
    isDefault: preset.isDefault
  };
}

async function readPresetState(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  preset: ResolvedPreset
): Promise<PresetPermissionState> {
  const [menuDetail, taskDetail] = await Promise.all([
    fetchPresetDetail(request, apiBase, tenantId, preset.id, "MENU"),
    fetchPresetDetail(request, apiBase, tenantId, preset.id, "TASK")
  ]);
  return {
    ...preset,
    description: taskDetail.description ?? menuDetail.description ?? preset.description,
    departments: taskDetail.departments ?? menuDetail.departments ?? preset.departments,
    permissionIds: unique([
      ...extractPermissionIds(menuDetail.permissions?.menu ?? []),
      ...extractPermissionIds(taskDetail.permissions?.task ?? [])
    ])
  };
}

async function fetchPresetDetail(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  presetId: number,
  type: "MENU" | "TASK"
): Promise<PresetDetailPayload> {
  const detailUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets/${presetId}`);
  detailUrl.searchParams.set("tenantId", tenantId);
  detailUrl.searchParams.set("type", type);
  const response = await request.get(detailUrl.toString());
  if (!response.ok()) {
    throw new Error(`BLOCKED: 프리셋 상세 조회 실패 id=${presetId} type=${type} status=${response.status()}`);
  }
  const body = await response.json() as { payload?: PresetDetailPayload };
  if (!body.payload) {
    throw new Error(`BLOCKED: 프리셋 상세 payload가 비어 있습니다. id=${presetId} type=${type}`);
  }
  return body.payload;
}

async function setRequestedPermission(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  state: PresetPermissionState,
  enabled: boolean
): Promise<void> {
  const permissionIds = enabled
    ? unique([...state.permissionIds, REQUESTED_PERMISSION_ID])
    : state.permissionIds.filter((permissionId) => permissionId !== REQUESTED_PERMISSION_ID);

  await patchPresetPermissionIds(
    request,
    apiBase,
    tenantId,
    state,
    permissionIds,
    `QA 3583 펌뱅크 송금 권한 ${enabled ? "부여" : "회수"} 검증`
  );
}

async function patchPresetPermissionIds(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  preset: ResolvedPreset,
  permissionIds: string[],
  reason: string
): Promise<void> {
  const patchUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets/${preset.id}`);
  patchUrl.searchParams.set("tenantId", tenantId);
  const response = await request.patch(patchUrl.toString(), {
    data: {
      name: preset.name,
      permissionIds: unique(permissionIds),
      departments: preset.departments,
      description: preset.description,
      reason
    }
  });
  if (!response.ok()) {
    const responseText = await response.text().catch(() => "");
    throw new Error(`BLOCKED: 프리셋 권한 저장 실패 ${preset.role}/${preset.name} status=${response.status()} body=${responseText.slice(0, 500)}`);
  }
}

function extractPermissionIds(groups: PermissionGroup[]): string[] {
  const ids: string[] = [];
  for (const group of groups) {
    for (const permission of group.permissions ?? []) {
      collectPermissionIds(permission, ids);
    }
  }
  return ids;
}

function collectPermissionIds(node: PermissionNode, ids: string[]): void {
  if (node.id) {
    ids.push(node.id);
  }
  for (const child of node.children ?? []) {
    collectPermissionIds(child, ids);
  }
}

async function collectTransferSnapshot(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition
): Promise<PageTransferSnapshot> {
  await openTargetPage(page, runtimeEnv, pageDefinition);
  const root = await activeRouteRoot(page);
  const rootText = normalizeText(await root.innerText({ timeout: 10_000 }));
  const transferButtons = root.locator("button[title*='이체'], button:has-text('이체')");
  const transferButtonCount = await transferButtons.count();
  const transferButtonLabels = await transferButtons.evaluateAll((buttons) =>
    buttons.map((button) => (
      (button.textContent ?? button.getAttribute("title") ?? button.getAttribute("aria-label") ?? "").trim()
    )).filter(Boolean)
  );
  const transferColumnVisible = /(^|\s)작업\s+이체\s+/i.test(rootText) || /(^|\s)이체\s+송금대행사\s+/i.test(rootText);

  return {
    pageName: pageDefinition.name,
    url: page.url(),
    rootText,
    transferButtonCount,
    transferButtonLabels,
    transferColumnVisible
  };
}

async function prepareSingleFirmBankTransferConfirmation(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition
): Promise<PreparedTransfer> {
  await openTargetPage(page, runtimeEnv, pageDefinition);
  const apiBase = apiBaseFromRuntime(runtimeEnv);
  const source = await findSingleTransferSource(page.context().request, apiBase);
  const root = await activeRouteRoot(page);
  const transferButtons = root.locator("button[title*='이체'], button[aria-label*='이체'], button:has-text('이체')");
  const transferButtonCount = await transferButtons.count();
  expect(transferButtonCount, "펌뱅킹 등록 화면에 행별 이체하기 버튼이 있어야 합니다.").toBeGreaterThan(source.index);
  await transferButtons.nth(source.index).click({ force: true });
  const modal = await waitForFirmBankTransferModal(page);

  await chooseSingleTransferType(page, modal);
  await chooseReceiverBank(page, modal);
  await fillReceiverAccountAndConfirmHolder(modal);
  await fillSingleTransferFields(modal);

  const transferButton = modal.locator("button").filter({ hasText: "이체" }).last();
  await expect(transferButton, "필수 입력 후 이체 버튼이 활성화되어야 합니다.").toBeEnabled();
  await transferButton.click({ force: true });
  const swal = page.locator(".swal2-container").last();
  await expect(swal, "이체 확인 SweetAlert가 표시되어야 합니다.").toBeVisible({ timeout: 10_000 });
  const swalText = normalizeText(await swal.innerText());

  return {
    source,
    swalText
  };
}

async function findSingleTransferSource(
  request: APIRequestContext,
  apiBase: string
): Promise<TransferSource> {
  const response = await request.get(`${apiBase}/api/v2/firm-banks/manage?size=50&page=0`);
  if (!response.ok()) {
    throw new Error(`BLOCKED: 펌뱅킹 목록 조회 실패 status=${response.status()}`);
  }
  const body = await response.json() as { payload?: FirmBankManagePayload };
  const content = body.payload?.content ?? [];
  const index = content.findIndex((item) =>
    (item.balance ?? 0) >= TRANSFER_AMOUNT &&
    item.firmBankType?.value !== "SETTLEMENT_ACCOUNT"
  );
  if (index < 0) {
    throw new Error(`BLOCKED: ${formatNumber(TRANSFER_AMOUNT)}원 이상 단건 이체 가능한 펌뱅킹 계좌가 없습니다.`);
  }
  const item = content[index];
  return {
    index,
    id: item.id,
    name: item.name,
    balance: item.balance ?? 0,
    firmBankType: item.firmBankType?.value ?? "-"
  };
}

async function chooseSingleTransferType(page: Page, modal: Locator): Promise<void> {
  const modalText = normalizeText(await modal.innerText());
  if (/단건이체/.test(modalText) && !/복수이체/.test(modalText)) {
    return;
  }

  await modal.locator(".MuiSelect-select").first().click({ force: true });
  const singleOption = page.locator("[role='option']").filter({ hasText: "단건이체" }).first();
  await expect(singleOption, "이체형식 옵션에 단건이체가 있어야 합니다.").toBeVisible({ timeout: 5_000 });
  await singleOption.click({ force: true });
  await expect(modal, "단건이체 입력 필드가 표시되어야 합니다.").toContainText("예금주확인", { timeout: 5_000 });
}

async function chooseReceiverBank(page: Page, modal: Locator): Promise<void> {
  const bankCombo = modal.getByRole("combobox", { name: "은행" });
  await expect(bankCombo, "은행 자동완성 입력란이 보여야 합니다.").toBeVisible({ timeout: 5_000 });
  await bankCombo.click({ force: true });
  await bankCombo.fill(RECEIVER_BANK_NAME);
  const bankOption = page.getByRole("option", { name: new RegExp(escapeRegExp(RECEIVER_BANK_NAME)) }).first();
  await expect(bankOption, `${RECEIVER_BANK_NAME} 은행 옵션이 보여야 합니다.`).toBeVisible({ timeout: 5_000 });
  await bankOption.click();
  await expect(bankCombo, "수취 은행 선택값이 입력란에 반영되어야 합니다.").toHaveValue(new RegExp(escapeRegExp(RECEIVER_BANK_NAME)), { timeout: 5_000 });
}

async function fillReceiverAccountAndConfirmHolder(modal: Locator): Promise<void> {
  const accountInput = modal.getByRole("textbox", { name: "계좌번호" });
  await expect(accountInput, "계좌번호 입력란이 보여야 합니다.").toBeVisible({ timeout: 5_000 });
  await accountInput.fill(RECEIVER_ACCOUNT_NUMBER);
  await expect(accountInput, "계좌번호 입력값이 반영되어야 합니다.").toHaveValue(RECEIVER_ACCOUNT_NUMBER);
  await modal.locator("button").filter({ hasText: "예금주확인" }).first().click({ force: true });
  await expect(modal, "예금주 확인 후 수취인명이 표시되어야 합니다.").toContainText(RECEIVER_ACCOUNT_HOLDER, { timeout: 10_000 });
}

async function fillSingleTransferFields(modal: Locator): Promise<void> {
  const formattedAmountPattern = new RegExp(
    `^${escapeRegExp(formatNumber(TRANSFER_AMOUNT))}$|^${escapeRegExp(String(TRANSFER_AMOUNT))}$`
  );
  await fillNamedTextbox(modal, "이체금액", String(TRANSFER_AMOUNT), formattedAmountPattern);
  await fillNamedTextbox(modal, "입금계좌적요", RECEIVER_CONTENT);
  await fillNamedTextbox(modal, "비고", TRANSFER_NOTE);
}

async function fillNamedTextbox(modal: Locator, name: string, value: string, expectedValue: string | RegExp = value): Promise<void> {
  const textbox = modal.getByRole("textbox", { name });
  await expect(textbox, `${name} 입력란이 보여야 합니다.`).toBeVisible({ timeout: 5_000 });
  await textbox.fill(value);
  await expect(textbox, `${name} 입력값이 반영되어야 합니다.`).toHaveValue(expectedValue);
}

async function confirmSwalAndWaitForSecondaryAuth(page: Page): Promise<string> {
  await page.locator(".swal2-confirm").last().click({ force: true });
  const authInput = page.locator("input[autocomplete='one-time-code']").last();
  await expect(authInput, "2차 인증 입력창이 표시되어야 합니다.").toBeVisible({ timeout: 15_000 });
  return normalizeText(await page.locator("body").innerText()).match(/(PIN 인증|OTP 인증|SMS 인증|이메일 인증|2차 인증).{0,120}/)?.[0] ?? normalizeText(await page.locator("body").innerText()).slice(-500);
}

async function submitSecondaryAuthAndWaitForTransfer(
  page: Page,
  authCode: string
): Promise<TransferResponseEvidence> {
  const verifyResponsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().includes("/api/v2/firm-banks/transfer/verify"),
    { timeout: 30_000 }
  );
  const transferResponsePromise = page.waitForResponse(
    (response) =>
      response.request().method() === "POST" &&
      /\/api\/v2\/firm-banks\/[^/]+\/transfer(?:\?|$)/.test(response.url()),
    { timeout: 30_000 }
  );

  const authInput = page.locator("input[autocomplete='one-time-code']").last();
  await authInput.fill(authCode);
  await page.locator("button").filter({ hasText: "확인" }).last().click({ force: true });

  const [verifyResponse, transferResponse] = await Promise.all([
    verifyResponsePromise,
    transferResponsePromise
  ]);
  const verifyText = await verifyResponse.text().catch(() => "");
  const transferText = await transferResponse.text().catch(() => "");
  const successSwal = page.locator(".swal2-container").last();
  await expect(successSwal, "이체 처리 완료 안내가 표시되어야 합니다.").toBeVisible({ timeout: 15_000 });
  const successText = normalizeText(await successSwal.innerText());
  await page.locator(".swal2-confirm").last().click({ force: true }).catch(() => {});

  return {
    verifyStatus: verifyResponse.status(),
    verifyText,
    transferStatus: transferResponse.status(),
    transferText,
    successText
  };
}

async function waitForFirmBankTransferModal(page: Page): Promise<Locator> {
  const modal = page.locator(".modal.show").last();
  await expect(modal, "펌뱅킹 이체 모달이 열려야 합니다.").toContainText("이체하기", { timeout: 10_000 });
  return modal;
}

async function openTargetPage(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition
): Promise<void> {
  await ensureAuthenticatedPage(page, runtimeEnv);
  await page.goto(buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!));
  await waitForNavigationToSettle(page);
  if (await isLoginPage(page, runtimeEnv)) {
    await loginWithCredentials(page, runtimeEnv);
    await page.goto(buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!));
    await waitForNavigationToSettle(page);
  }
  await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => {});
  await page.waitForTimeout(2_000);
}

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

  const url = new URL(runtimeEnv.baseUrl!);
  if (runtimeEnv.tenantId) {
    url.searchParams.set("tenantId", runtimeEnv.tenantId);
  }
  await page.goto(url.toString());
  await waitForNavigationToSettle(page);

  if (await isLoginPage(page, runtimeEnv)) {
    await loginWithCredentials(page, runtimeEnv);
  }
}

async function activeRouteRoot(page: Page): Promise<Locator> {
  const activeRoutes = page.locator("[data-route-keep-alive-active='true']");
  if (await activeRoutes.count() > 0) {
    return activeRoutes.last();
  }
  return page.locator("body");
}

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, viewport: { width: 1600, height: 1000 } }
      : { acceptDownloads: true, viewport: { width: 1600, height: 1000 } }
  );
  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: 20_000 }).catch(() => {});
  await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => {});
}

function apiBaseFromRuntime(runtimeEnv: RuntimeQaEnv): string {
  const envKey = runtimeEnv.environment.toUpperCase();
  const configured =
    process.env[`BIX_ADMIN_${envKey}_API_BASE_URL`] ??
    process.env[`BIX_API_${envKey}_BASE_URL`] ??
    process.env[`BIX_${envKey}_API_BASE_URL`] ??
    process.env.BIX_API_BASE_URL;

  if (configured) {
    return configured.replace(/\/api\/?$/, "").replace(/\/$/, "");
  }

  const url = new URL(runtimeEnv.baseUrl!);
  url.hostname = url.hostname
    .replace(/^admin-dev\./, "api-dev.")
    .replace(/^admin-stg\./, "api-stg.")
    .replace(/^admin-stage\./, "api-stg.");
  return url.origin;
}

function isFirmBankTransferMutationUrl(rawUrl: string): boolean {
  return /\/api\/v2\/firm-banks\/[^/]+\/transfer\/auth-sessions(?:\?|$)/.test(rawUrl) ||
    /\/api\/v2\/firm-banks\/transfer\/verify(?:\?|$)/.test(rawUrl) ||
    /\/api\/v2\/firm-banks\/[^/]+\/transfer(?:\?|$)/.test(rawUrl);
}

async function attachEvidenceLog(
  testInfo: TestInfo,
  pageName: string,
  role: string,
  slug: string,
  lines: string[]
): Promise<void> {
  const fileName = sanitizeFileName(`${testInfo.testId}-${pageName}-${role}-${slug}.md`);
  const logPath = testInfo.outputPath("logs", fileName);
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
  fs.writeFileSync(logPath, `${lines.join("\n")}\n`, "utf8");
  await testInfo.attach(`${pageName}/${role}/${slug}`, {
    path: logPath,
    contentType: "text/markdown"
  });
}

async function attachPageScreenshot(
  page: Page,
  testInfo: TestInfo,
  pageName: string,
  role: string,
  slug: string
): Promise<void> {
  const fileName = sanitizeFileName(`${testInfo.testId}-${pageName}-${role}-${slug}.png`);
  const screenshotPath = testInfo.outputPath("screenshots", fileName);
  fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
  await page.screenshot({ path: screenshotPath, fullPage: true }).catch(async () => {
    await page.screenshot({ path: screenshotPath, fullPage: false });
  });
  await testInfo.attach(`${pageName}/${role}/${slug} screenshot`, {
    path: screenshotPath,
    contentType: "image/png"
  });
}

function formatSnapshotLines(snapshot: PageTransferSnapshot): string[] {
  return [
    `[${snapshot.pageName}] URL: ${snapshot.url}`,
    `[${snapshot.pageName}] 이체 컬럼 감지: ${snapshot.transferColumnVisible ? "yes" : "no"}`,
    `[${snapshot.pageName}] 이체 버튼 수: ${snapshot.transferButtonCount}`,
    `[${snapshot.pageName}] 이체 버튼 라벨: ${snapshot.transferButtonLabels.join(" / ") || "-"}`,
    `[${snapshot.pageName}] 화면 일부: ${snippet(snapshot.rootText, 1600)}`
  ];
}

function readTransferState(options: ScenarioOptions): TransferState | undefined {
  const statePath = transferStatePath(options);
  if (!fs.existsSync(statePath)) {
    return undefined;
  }
  try {
    return JSON.parse(fs.readFileSync(statePath, "utf8")) as TransferState;
  } catch {
    return undefined;
  }
}

function writeTransferState(options: ScenarioOptions, state: TransferState): void {
  const statePath = transferStatePath(options);
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
  fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}

function transferStatePath(options: ScenarioOptions): string {
  return path.join(process.cwd(), "qa-results", "issues", options.issueId, options.environment, "runtime", "firm-bank-transfer-secondary-auth-state.json");
}

function sanitizeFileName(input: string): string {
  return input.replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/-+/g, "-").slice(0, 180);
}

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

function snippet(value: string, maxLength: number): string {
  const normalized = normalizeText(value);
  return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
}

function sanitizeUrl(rawUrl: string): string {
  return rawUrl.replace(/([?&](?:token|accessToken|refreshToken|Authorization)=)[^&]+/gi, "$1***");
}

function maskAccount(accountNumber: string): string {
  if (accountNumber.length <= 6) {
    return accountNumber;
  }
  return `${accountNumber.slice(0, 3)}***${accountNumber.slice(-4)}`;
}

function formatNumber(value: number): string {
  return Number(value).toLocaleString("ko-KR");
}

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

function unique(values: string[]): string[] {
  return Array.from(new Set(values));
}

function presetKey(preset: TargetPreset): string {
  return `${preset.role}:${preset.name}`;
}
