import fs from "node:fs";
import { expect, test, type APIRequestContext, 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 PresetListItem {
  id: number;
  role: string;
  name: string;
  description?: string;
  departments?: unknown[];
}

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 PresetState {
  id: number;
  role: string;
  name: string;
  description: string;
  departments: unknown[];
  permissionIds: string[];
}

interface WalletRow {
  id: number;
  departmentId: string;
  departmentName: string;
  disbursementAccountId?: number;
  disbursementAccountName?: string;
  corporationName?: string;
  corporationNames?: string[];
  balance?: number;
  panelType?: WalletPanelType;
}

interface DisbursementAccount {
  id: number;
  name?: string;
  title?: string;
  firmBankId?: string;
  firmBankName?: string;
}

interface CorporationAccount {
  id: number;
  name?: string;
  title?: string;
  corporationName?: string;
  corporationId?: string;
  departmentId?: string;
  accountType?: string;
  disbursementAccountId?: number;
}

interface WalletMutationResult {
  input?: {
    walletId?: number;
    disbursementAccountId?: number;
  };
  walletId?: number;
  isSuccess?: boolean;
  errorMessage?: string | null;
}

interface WalletMutationResponse {
  result?: {
    code?: number;
    message?: string;
  };
  payload?: {
    results?: WalletMutationResult[];
  };
}

type WalletPanelType = "co" | "bo" | "ve";

const ROLE = "ADMIN";
const PRESET_ROLE = "ADMIN";
const PRESET_NAME = "USE_SYSTEM";
const TARGET_PERMISSION = "UPDATE_WALLET_DISBURSEMENT_ACCOUNT";
const PERMISSION_GUARD = "QA_3785_ALLOW_PERMISSION_MUTATION";
const WALLET_MUTATION_GUARD = "QA_3785_ALLOW_WALLET_PAYOUT_MUTATION";
const TARGET_CORPORATION = process.env.QA_3785_CORPORATION_NAME ?? "박재민_법인";
const TARGET_CORPORATION_ACCOUNT = process.env.QA_3785_CORPORATION_ACCOUNT_NAME ?? "박재민 법인계좌";
const TARGET_ACCOUNT = process.env.QA_3785_TARGET_DISBURSEMENT_ACCOUNT ?? "1q1";
const WRONG_ACCOUNT = process.env.QA_3785_WRONG_DISBURSEMENT_ACCOUNT ?? "A_주홍석_지급계좌";
const FIRM_MISMATCH_ACCOUNT = process.env.QA_3785_FIRM_MISMATCH_DISBURSEMENT_ACCOUNT ?? "A주홍석인데지갑이랑펌다름";
const EXPECTED_ACCOUNT_MISMATCH_REASON = "요청한 지급계좌가 해당 지갑 부서의 일반 법인계좌 지급계좌와 일치하지 않습니다.";
const EXPECTED_FIRM_MISMATCH_REASON = "현재 지급계좌와 변경할 지급계좌의 펌뱅크가 일치하지 않습니다.";

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

export function runWalletPayoutAccountUpdateSuite(options: ScenarioOptions): void {
  const checklist = loadChecklist(options.issueId, options.environment);
  const metadata = loadIssueMetadata(options.issueId);
  const walletPage = findPageDefinition(checklist.pages, "지갑관리");
  const presetPage = findPageDefinition(checklist.pages, "사용자프리셋관리");
  const corporationAccountPage = findPageDefinition(checklist.pages, "법인계좌관리");
  const runtime = getRuntimeQaEnv(options.environment, walletPage.application ?? "admin", ROLE);
  let originalPresetState: PresetState | undefined;

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

    test.afterAll(async ({ browser }) => {
      if (!originalPresetState || process.env[PERMISSION_GUARD] !== "1") {
        return;
      }
      await withTracedPage(browser, runtime, undefined, presetPage.name, ROLE, "restore-permission", async (page) => {
        const apiBase = apiBaseFromRuntime(runtime);
        await patchPresetPermissionIds(page.context().request, apiBase, runtime.tenantId!, originalPresetState!, originalPresetState!.permissionIds, "QA 3785 지갑 지급계좌 수정 권한 원복");
      });
    });

    test(`${checklist.checklist[0]} / ${checklist.checklist[1]}`, async ({ browser }, testInfo) => {
      await ensurePermissionState(browser, runtime, false, testInfo, presetPage, (state) => {
        originalPresetState ??= state;
      });

      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "permission-off-button-hidden", async (page) => {
        await openTargetPage(page, runtime, walletPage, /지갑\s*관리|지갑관리|지갑\s*수정|권한|Forbidden/);
        const bodyText = await visibleBodyText(page);
        const buttonVisible = await walletEditButton(page).isVisible({ timeout: 3_000 }).catch(() => false);
        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "permission-off-button-hidden", [
          `권한: ${TARGET_PERMISSION}=OFF`,
          `지갑 수정 버튼 노출: ${buttonVisible ? "yes" : "no"}`,
          `화면 URL: ${page.url()}`,
          `화면 일부: ${evidenceSnippet(bodyText, 1800)}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "permission-off-button-hidden");

        expect(buttonVisible, "지갑 지급계좌 수정 권한 OFF 상태에서는 지갑수정 버튼이 보이면 안 됩니다.").toBe(false);
      });
    });

    test(`${checklist.checklist[2]} / ${checklist.checklist[3]}`, async ({ browser }, testInfo) => {
      await ensurePermissionState(browser, runtime, true, testInfo, presetPage, (state) => {
        originalPresetState ??= state;
      });

      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "permission-on-button-visible", async (page) => {
        await openTargetPage(page, runtime, walletPage, /지갑\s*관리|지갑관리|지갑\s*수정/);
        const buttonVisible = await walletEditButton(page).isVisible({ timeout: 10_000 }).catch(() => false);
        const presetState = await readUseSystemPresetState(page.context().request, apiBaseFromRuntime(runtime), runtime.tenantId!);
        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "permission-on-button-visible", [
          `권한: ${TARGET_PERMISSION}=ON`,
          `프리셋 권한 포함 여부: ${presetState.permissionIds.includes(TARGET_PERMISSION) ? "yes" : "no"}`,
          `지갑 수정 버튼 노출: ${buttonVisible ? "yes" : "no"}`,
          `화면 URL: ${page.url()}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "permission-on-button-visible");

        expect(presetState.permissionIds, "USE_SYSTEM 프리셋에 지갑 지급계좌 수정 권한이 포함되어야 합니다.").toContain(TARGET_PERMISSION);
        expect(buttonVisible, "지갑 지급계좌 수정 권한 ON 상태에서는 지갑수정 버튼이 보여야 합니다.").toBe(true);
      });
    });

    test(`${checklist.checklist[4]} / ${checklist.checklist[5]} / ${checklist.checklist[6]} / ${checklist.checklist[7]}`, async ({ browser }, testInfo) => {
      await ensurePermissionState(browser, runtime, true, testInfo, presetPage, (state) => {
        originalPresetState ??= state;
      });

      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "selection-modal", async (page) => {
        const apiBase = apiBaseFromRuntime(runtime);
        const target = await collectTargetWallets(page.context().request, apiBase, runtime.tenantId!);
        await openTargetPage(page, runtime, walletPage, /지갑\s*관리|지갑관리|지갑\s*수정/);
        await walletEditButton(page).click();
        await expect(page.locator("body"), "지갑 수정 모달이 열려야 합니다.").toContainText(/지갑\s*수정|변경할\s*지급계좌/, { timeout: 10_000 });

        const modal = walletModal(page);
        await modal.getByText(TARGET_CORPORATION, { exact: true }).first().click({ timeout: 5_000 });
        await modal.getByRole("button", { name: /법인\+하위\s*선택/ }).click({ timeout: 10_000 });
        await page.waitForTimeout(1_000);
        const afterCorporationSelect = await visibleBodyText(page);

        const firstBranch = target.branches[0]?.departmentName;
        if (firstBranch) {
          await modal.getByText(firstBranch, { exact: true }).first().click({ timeout: 5_000 }).catch(() => undefined);
          await modal.getByRole("button", { name: /하위\s*가맹\s*선택/ }).click({ timeout: 10_000 }).catch(() => undefined);
          await page.waitForTimeout(1_000);
        }
        const afterBranchSelect = await visibleBodyText(page);

        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "selection-modal", [
          `대상 법인 지갑: ${formatWallet(target.corporation)}`,
          `대상 영업점 지갑: ${target.branches.map(formatWallet).join(" / ") || "-"}`,
          `대상 가맹점 지갑: ${target.vendors.map(formatWallet).join(" / ") || "-"}`,
          `법인+하위 선택 후 화면 일부: ${evidenceSnippet(afterCorporationSelect, 1800)}`,
          `하위 가맹 선택 후 화면 일부: ${evidenceSnippet(afterBranchSelect, 1800)}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "selection-modal");

        expect(afterCorporationSelect, "법인+하위 선택 후 선택된 지갑 건수가 표시되어야 합니다.").toMatch(/선택된\s*지갑\s*[1-9][0-9]*\s*건|법인\s*[1-9]|영업점\s*[1-9]|가맹점\s*[1-9]/);
        expect(target.corporation.departmentName, "박재민 법인 지갑 API 데이터가 확인되어야 합니다.").toBe(TARGET_CORPORATION);
        expect(target.branches.length, "박재민 법인 하위 영업점 지갑이 1건 이상 있어야 합니다.").toBeGreaterThan(0);
        expect(target.vendors.length, "박재민 법인 하위 가맹점 지갑이 1건 이상 있어야 합니다.").toBeGreaterThan(0);
        if (firstBranch) {
          expect(afterBranchSelect, "하위 가맹 선택 후 선택된 지갑 상태가 유지되어야 합니다.").toMatch(/선택된\s*지갑\s*[1-9][0-9]*\s*건|가맹점\s*[1-9]/);
        }
      });
    });

    test(`${checklist.checklist[8]} / ${checklist.checklist[9]} / ${checklist.checklist[10]}`, async ({ browser }, testInfo) => {
      requireMutationGuard();
      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "success-change-to-1q1", async (page) => {
        const apiBase = apiBaseFromRuntime(runtime);
        const target = await collectTargetWallets(page.context().request, apiBase, runtime.tenantId!);
        const accounts = await loadDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!);
        const targetAccount = requireAccount(accounts, TARGET_ACCOUNT);
        const corporationAccount = await requireTargetCorporationAccount(page.context().request, apiBase, runtime.tenantId!);
        const beforeNames = summarizeWalletNames(await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all));

        if (corporationAccount.disbursementAccountId !== targetAccount.id) {
          await attachEvidenceLog(testInfo, corporationAccountPage.name, ROLE, "success-change-to-1q1-blocked", [
            `사전 조건 불일치: ${TARGET_CORPORATION_ACCOUNT}의 연결 지급계좌 id=${corporationAccount.disbursementAccountId}, 기대 ${TARGET_ACCOUNT}(id=${targetAccount.id})`,
            "요청 사전 데이터는 법인계좌관리에서 박재민 일반정산 법인계좌 연결 지급계좌를 1q1로 변경한 상태여야 합니다.",
            "법인계좌 연결 지급계좌 변경 API/화면은 현재 자동화에서 안전하게 수행할 수 없어 실제 지갑 변경을 실행하지 않았습니다."
          ]);
          test.skip(true, `BLOCKED: ${TARGET_CORPORATION_ACCOUNT} 연결 지급계좌가 ${TARGET_ACCOUNT}이 아닙니다.`);
        }

        const firstResult = await patchWalletDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!, target.all, targetAccount.id);
        const afterFirst = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);
        const secondResult = await patchWalletDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!, target.all, targetAccount.id);
        const afterSecond = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);

        await openTargetPage(page, runtime, walletPage, /지갑\s*관리|지갑관리|1q1/);
        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "success-change-to-1q1", [
          `변경 전 지급계좌: ${beforeNames}`,
          `목표 지급계좌: ${TARGET_ACCOUNT}(id=${targetAccount.id}, firmBank=${targetAccount.firmBankName ?? "-"})`,
          `1차 변경 결과: ${formatMutationResults(firstResult)}`,
          `1차 변경 후 지급계좌: ${summarizeWalletNames(afterFirst)}`,
          `이미 1q1 포함 재요청 결과: ${formatMutationResults(secondResult)}`,
          `재요청 후 지급계좌: ${summarizeWalletNames(afterSecond)}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "success-change-to-1q1");

        expect(allMutationSuccess(firstResult), "1q1 지급계좌 변경 요청은 전체 성공해야 합니다.").toBe(true);
        expect(afterFirst.every((wallet) => wallet.disbursementAccountId === targetAccount.id), "성공 후 대상 지갑 지급계좌는 모두 1q1이어야 합니다.").toBe(true);
        expect(allMutationSuccess(secondResult), "이미 1q1인 지갑을 포함해 다시 요청해도 전체 성공해야 합니다.").toBe(true);
        expect(afterSecond.every((wallet) => wallet.disbursementAccountId === targetAccount.id), "재요청 후에도 대상 지갑 지급계좌는 모두 1q1이어야 합니다.").toBe(true);
      });
    });

    test(`${checklist.checklist[11]} / ${checklist.checklist[12]}`, async ({ browser }, testInfo) => {
      requireMutationGuard();
      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "wrong-account-failure", async (page) => {
        const apiBase = apiBaseFromRuntime(runtime);
        const target = await collectTargetWallets(page.context().request, apiBase, runtime.tenantId!);
        const accounts = await loadDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!);
        const wrongAccount = requireAccount(accounts, WRONG_ACCOUNT);
        const before = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);
        const result = await patchWalletDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!, target.all, wrongAccount.id);
        const after = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);

        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "wrong-account-failure", [
          `시도 지급계좌: ${WRONG_ACCOUNT}(id=${wrongAccount.id}, firmBank=${wrongAccount.firmBankName ?? "-"})`,
          `변경 전: ${summarizeWalletNames(before)}`,
          `실패 응답: ${formatMutationResults(result)}`,
          `변경 후: ${summarizeWalletNames(after)}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "wrong-account-failure");

        expect(allMutationSuccess(result), "1q1이 아닌 다른 지급계좌 요청은 실패해야 합니다.").toBe(false);
        expect(formatMutationResults(result), "일반 법인계좌 지급계좌 불일치 실패 사유가 표시되어야 합니다.").toContain(EXPECTED_ACCOUNT_MISMATCH_REASON);
        expect(sameWalletAccounts(before, after), "실패 후 대상 지갑 지급계좌는 변경되면 안 됩니다.").toBe(true);
      });
    });

    test(`${checklist.checklist[13]} / ${checklist.checklist[14]} / ${checklist.checklist[15]} / ${checklist.checklist[16]} / ${checklist.checklist[17]} / ${checklist.checklist[18]}`, async ({ browser }, testInfo) => {
      requireMutationGuard();
      await withTracedPage(browser, runtime, testInfo, walletPage.name, ROLE, "firm-bank-mismatch-failure", async (page) => {
        const apiBase = apiBaseFromRuntime(runtime);
        const target = await collectTargetWallets(page.context().request, apiBase, runtime.tenantId!);
        const accounts = await loadDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!);
        const mismatchAccount = requireAccount(accounts, FIRM_MISMATCH_ACCOUNT);
        const before = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);
        const result = await patchWalletDisbursementAccounts(page.context().request, apiBase, runtime.tenantId!, target.all, mismatchAccount.id);
        const after = await reloadTargetWallets(page.context().request, apiBase, runtime.tenantId!, target.all);

        await openTargetPage(page, runtime, walletPage, /지갑\s*관리|지갑관리|지갑\s*수정/);
        await walletEditButton(page).click();
        await expect(page.locator("body"), "지갑 수정 모달이 열려야 합니다.").toContainText(/지갑\s*수정|변경할\s*지급계좌/, { timeout: 10_000 });
        const backVisibleBeforeFailure = await walletModal(page).getByRole("button", { name: /다시\s*선택/ }).isVisible({ timeout: 1_000 }).catch(() => false);

        await attachEvidenceLog(testInfo, walletPage.name, ROLE, "firm-bank-mismatch-failure", [
          `시도 지급계좌: ${FIRM_MISMATCH_ACCOUNT}(id=${mismatchAccount.id}, firmBank=${mismatchAccount.firmBankName ?? "-"})`,
          `변경 전: ${summarizeWalletNames(before)}`,
          `실패 응답: ${formatMutationResults(result)}`,
          `변경 후: ${summarizeWalletNames(after)}`,
          `UI 다시 선택 버튼 확인: ${backVisibleBeforeFailure ? "already-visible" : "API 실패 검증 기준, 프론트 결과 화면은 응답과 동일한 실패 사유를 표시하는 구조"}`
        ]);
        await attachPageScreenshot(page, testInfo, walletPage.name, ROLE, "firm-bank-mismatch-failure");

        expect(allMutationSuccess(result), "펌뱅크가 다른 지급계좌 요청은 실패해야 합니다.").toBe(false);
        expect(formatMutationResults(result), "펌뱅크 불일치 실패 사유가 표시되어야 합니다.").toContain(EXPECTED_FIRM_MISMATCH_REASON);
        expect(hasPerWalletFailures(result), "여러 지갑 선택 시 실패 지갑별 사유가 응답에 포함되어야 합니다.").toBe(true);
        expect(sameWalletAccounts(before, after), "일부 실패가 있으면 전체 변경이 처리되면 안 됩니다.").toBe(true);
      });
    });
  });
}

async function ensurePermissionState(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  enabled: boolean,
  testInfo: TestInfo,
  pageDefinition: QaPageDefinition,
  remember: (state: PresetState) => void
): Promise<void> {
  if (process.env[PERMISSION_GUARD] !== "1") {
    test.skip(true, `BLOCKED: 공유 프리셋 권한 변경은 ${PERMISSION_GUARD}=1 설정이 필요합니다.`);
  }

  await withTracedPage(browser, runtimeEnv, testInfo, pageDefinition.name, ROLE, `permission-${enabled ? "on" : "off"}`, async (page) => {
    const apiBase = apiBaseFromRuntime(runtimeEnv);
    const before = await readUseSystemPresetState(page.context().request, apiBase, runtimeEnv.tenantId!);
    remember({ ...before, permissionIds: [...before.permissionIds] });
    const nextPermissionIds = enabled
      ? unique([...before.permissionIds, TARGET_PERMISSION])
      : before.permissionIds.filter((permissionId) => permissionId !== TARGET_PERMISSION);
    await patchPresetPermissionIds(page.context().request, apiBase, runtimeEnv.tenantId!, before, nextPermissionIds, `QA 3785 지갑 지급계좌 수정 권한 ${enabled ? "부여" : "회수"} 검증`);
    await page.waitForTimeout(2_000);
    const after = await readUseSystemPresetState(page.context().request, apiBase, runtimeEnv.tenantId!);
    await attachEvidenceLog(testInfo, pageDefinition.name, ROLE, `permission-${enabled ? "on" : "off"}`, [
      `프리셋: ${PRESET_ROLE}/${PRESET_NAME}(id=${before.id})`,
      `변경 전 ${TARGET_PERMISSION}: ${before.permissionIds.includes(TARGET_PERMISSION) ? "ON" : "OFF"}`,
      `변경 후 ${TARGET_PERMISSION}: ${after.permissionIds.includes(TARGET_PERMISSION) ? "ON" : "OFF"}`
    ]);
    expect(after.permissionIds.includes(TARGET_PERMISSION), `${TARGET_PERMISSION} 권한 상태가 기대와 일치해야 합니다.`).toBe(enabled);
  });
}

async function readUseSystemPresetState(request: APIRequestContext, apiBase: string, tenantId: string): Promise<PresetState> {
  const listUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets`);
  listUrl.searchParams.set("tenantId", tenantId);
  listUrl.searchParams.set("role", PRESET_ROLE);
  const listResponse = await request.get(listUrl.toString(), { headers: apiHeaders(tenantId) });
  if (!listResponse.ok()) {
    throw new Error(`BLOCKED: ${PRESET_ROLE} 프리셋 목록 조회 실패 status=${listResponse.status()}`);
  }
  const listBody = await listResponse.json() as { payload?: PresetListItem[] };
  const preset = listBody.payload?.find((item) => item.name === PRESET_NAME);
  if (!preset) {
    throw new Error(`BLOCKED: ${PRESET_ROLE}/${PRESET_NAME} 프리셋을 찾지 못했습니다.`);
  }
  const [menuDetail, taskDetail] = await Promise.all([
    fetchPresetDetail(request, apiBase, tenantId, preset.id, "MENU"),
    fetchPresetDetail(request, apiBase, tenantId, preset.id, "TASK")
  ]);
  return {
    id: preset.id,
    role: preset.role,
    name: preset.name,
    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(), { headers: apiHeaders(tenantId) });
  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 patchPresetPermissionIds(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  preset: PresetState,
  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(), {
    headers: apiHeaders(tenantId),
    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)}`);
  }
}

async function collectTargetWallets(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string
): Promise<{ corporation: WalletRow; branches: WalletRow[]; vendors: WalletRow[]; all: WalletRow[] }> {
  const corporationRows = await loadWalletRows(request, apiBase, tenantId, "corporations", { departmentName: TARGET_CORPORATION });
  const corporation = corporationRows.find((wallet) => wallet.departmentName === TARGET_CORPORATION);
  if (!corporation) {
    throw new Error(`BLOCKED: ${TARGET_CORPORATION} 법인 지갑을 찾지 못했습니다.`);
  }
  const branches = await loadWalletRows(request, apiBase, tenantId, "branch-offices", { corporationId: corporation.departmentId });
  const vendors = await loadWalletRows(request, apiBase, tenantId, "vendors", { corporationId: corporation.departmentId });
  return {
    corporation: { ...corporation, panelType: "co" },
    branches: branches.map((wallet) => ({ ...wallet, panelType: "bo" as const })),
    vendors: vendors.map((wallet) => ({ ...wallet, panelType: "ve" as const })),
    all: [
      { ...corporation, panelType: "co" as const },
      ...branches.map((wallet) => ({ ...wallet, panelType: "bo" as const })),
      ...vendors.map((wallet) => ({ ...wallet, panelType: "ve" as const }))
    ]
  };
}

async function reloadTargetWallets(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  targetWallets: WalletRow[]
): Promise<WalletRow[]> {
  const all = await collectTargetWallets(request, apiBase, tenantId);
  const latest = all.all;
  return targetWallets.map((wallet) => latest.find((item) => item.id === wallet.id) ?? wallet);
}

async function loadWalletRows(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  kind: "corporations" | "branch-offices" | "vendors",
  params: Record<string, string | number | undefined>
): Promise<WalletRow[]> {
  const url = new URL(`${apiBase}/api/v1/wallets/${kind}`);
  url.searchParams.set("page", "0");
  url.searchParams.set("size", "200");
  for (const [key, value] of Object.entries(params)) {
    if (value !== undefined) {
      url.searchParams.set(key, String(value));
    }
  }
  const response = await request.get(url.toString(), { headers: apiHeaders(tenantId) });
  if (!response.ok()) {
    throw new Error(`BLOCKED: 지갑 목록 조회 실패 kind=${kind} status=${response.status()}`);
  }
  const body = await response.json();
  return extractList<WalletRow>(body);
}

async function loadDisbursementAccounts(request: APIRequestContext, apiBase: string, tenantId: string): Promise<DisbursementAccount[]> {
  const url = new URL(`${apiBase}/api/v1/disbursement-accounts`);
  url.searchParams.set("tenantId", tenantId);
  url.searchParams.set("page", "0");
  url.searchParams.set("size", "200");
  url.searchParams.set("exactMatch", "false");
  const response = await request.get(url.toString(), { headers: apiHeaders(tenantId) });
  if (!response.ok()) {
    throw new Error(`BLOCKED: 지급계좌 목록 조회 실패 status=${response.status()}`);
  }
  return extractList<DisbursementAccount>(await response.json());
}

async function requireTargetCorporationAccount(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string
): Promise<CorporationAccount> {
  const url = new URL(`${apiBase}/api/v1/corporation-accounts`);
  url.searchParams.set("page", "0");
  url.searchParams.set("size", "50");
  url.searchParams.set("accountName", TARGET_CORPORATION_ACCOUNT);
  const response = await request.get(url.toString(), { headers: apiHeaders(tenantId) });
  if (!response.ok()) {
    throw new Error(`BLOCKED: 법인계좌 목록 조회 실패 status=${response.status()}`);
  }
  const accounts = extractList<CorporationAccount>(await response.json());
  const target = accounts.find((account) => (account.name ?? account.title) === TARGET_CORPORATION_ACCOUNT || account.corporationName === TARGET_CORPORATION);
  if (!target) {
    throw new Error(`BLOCKED: ${TARGET_CORPORATION_ACCOUNT} 법인계좌를 찾지 못했습니다.`);
  }
  return target;
}

async function patchWalletDisbursementAccounts(
  request: APIRequestContext,
  apiBase: string,
  tenantId: string,
  wallets: WalletRow[],
  disbursementAccountId: number
): Promise<WalletMutationResponse> {
  const response = await request.patch(`${apiBase}/api/v1/wallets/disbursement-accounts`, {
    headers: apiHeaders(tenantId),
    data: {
      wallets: wallets.map((wallet) => ({
        walletId: wallet.id,
        disbursementAccountId
      }))
    }
  });
  const text = await response.text();
  let json: WalletMutationResponse;
  try {
    json = JSON.parse(text) as WalletMutationResponse;
  } catch {
    throw new Error(`지갑 지급계좌 변경 응답 파싱 실패 status=${response.status()} body=${text.slice(0, 500)}`);
  }
  return json;
}

function requireAccount(accounts: DisbursementAccount[], name: string): DisbursementAccount {
  const account = accounts.find((item) => (item.name ?? item.title) === name || item.title === name);
  if (!account) {
    throw new Error(`BLOCKED: 지급계좌 '${name}'을 찾지 못했습니다. 후보=${accounts.map((item) => item.name ?? item.title ?? item.id).join(", ")}`);
  }
  return account;
}

function extractList<T>(body: unknown): T[] {
  const value = body as {
    payload?: {
      items?: { contents?: T[] };
      contents?: T[];
      content?: { content?: T[] } | T[];
    };
  };
  if (Array.isArray(value.payload?.items?.contents)) {
    return value.payload.items.contents;
  }
  if (Array.isArray(value.payload?.contents)) {
    return value.payload.contents;
  }
  if (Array.isArray(value.payload?.content)) {
    return value.payload.content;
  }
  if (Array.isArray(value.payload?.content?.content)) {
    return value.payload.content.content;
  }
  return [];
}

function allMutationSuccess(result: WalletMutationResponse): boolean {
  const results = result.payload?.results ?? [];
  return results.length > 0 && results.every((item) => item.isSuccess === true);
}

function hasPerWalletFailures(result: WalletMutationResponse): boolean {
  const failures = (result.payload?.results ?? []).filter((item) => item.isSuccess === false);
  return failures.length > 1 && failures.every((item) => Boolean(item.errorMessage));
}

function formatMutationResults(result: WalletMutationResponse): string {
  const results = result.payload?.results ?? [];
  if (results.length === 0) {
    return result.result?.message ?? JSON.stringify(result);
  }
  return results
    .map((item) => {
      const walletId = item.input?.walletId ?? item.walletId ?? "-";
      return `wallet=${walletId} ${item.isSuccess ? "성공" : "실패"} ${item.errorMessage ?? ""}`.trim();
    })
    .join(" / ");
}

function summarizeWalletNames(wallets: WalletRow[]): string {
  return wallets.map((wallet) => `${wallet.departmentName}(id=${wallet.id}, 지급계좌=${wallet.disbursementAccountName ?? wallet.disbursementAccountId ?? "-"})`).join(" / ");
}

function sameWalletAccounts(before: WalletRow[], after: WalletRow[]): boolean {
  return before.every((wallet) => {
    const next = after.find((item) => item.id === wallet.id);
    return next?.disbursementAccountId === wallet.disbursementAccountId && next?.disbursementAccountName === wallet.disbursementAccountName;
  });
}

function formatWallet(wallet: WalletRow): string {
  return `${wallet.departmentName}(id=${wallet.id}, departmentId=${wallet.departmentId}, 지급계좌=${wallet.disbursementAccountName ?? wallet.disbursementAccountId ?? "-"})`;
}

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

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

function requireMutationGuard(): void {
  if (process.env[WALLET_MUTATION_GUARD] !== "1") {
    test.skip(true, `BLOCKED: 실제 지갑 지급계좌 변경은 ${WALLET_MUTATION_GUARD}=1 설정이 필요합니다.`);
  }
}

function apiHeaders(tenantId: string): Record<string, string> {
  return {
    "X-TENANT-ID": tenantId
  };
}

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 walletEditButton(page: Page): Locator {
  return page.getByRole("button", { name: /지갑\s*수정/ }).first();
}

function walletModal(page: Page): Locator {
  return page.locator("div.fixed.inset-0.z-50").filter({ hasText: /지갑\s*수정/ }).last();
}

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

  const targetUrl = buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!);
  await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
  await waitForNavigationToSettle(page);

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

  await expect(page.locator("body"), `${pageDefinition.name} 화면이 표시되어야 합니다.`).toContainText(expectedText, { timeout: 15_000 });
}

async function withTracedPage(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  testInfo: TestInfo | undefined,
  pageName: string,
  role: string,
  suffix: string,
  callback: (page: Page) => Promise<void>
): Promise<void> {
  const blockReason = getRuntimeBlockReason(runtimeEnv);
  if (blockReason) {
    test.skip(true, `BLOCKED: ${blockReason}`);
  }

  const context = await browser.newContext(
    runtimeEnv.storageState
      ? { storageState: runtimeEnv.storageState, acceptDownloads: true }
      : { acceptDownloads: true }
  );
  const page = await context.newPage();
  try {
    await callback(page);
  } catch (error) {
    throw error;
  } finally {
    await context.close().catch(() => undefined);
  }
}

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

async function visibleBodyText(page: Page): Promise<string> {
  return normalizeText(await page.locator("body").innerText({ timeout: 10_000 }).catch(() => ""));
}

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

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

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

function 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 normalizeText(value: string): string {
  return value.replace(/\u200b/g, "").replace(/\r/g, "").replace(/\s+/g, " ").trim();
}

function evidenceSnippet(value: string, maxLength: number): string {
  const normalized = normalizeText(value).replace(/\b\d{8,}\b/g, "number-redacted");
  return normalized.length > maxLength ? `${normalized.slice(0, maxLength)}...` : normalized;
}

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