import { expect, test, type Browser, type BrowserContext, type Page, type TestInfo } from "@playwright/test";
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
import fs from "node:fs";
import path from "node:path";
import { isLoginPage, loginWithCredentials } from "../auth";
import { loadChecklist, loadIssueMetadata } from "../checklist";
import { extractDownloadRows, guessDownloadFileNameFromUrl } from "../downloadText";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaEnvironment, QaPageDefinition } from "../types";
import { buildPageUrl } from "../url";

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

type SameCardTimeLimit =
  | "UNLIMITED"
  | "MIN30"
  | "H1"
  | "H3"
  | "H6"
  | "H12"
  | "H24"
  | "H48"
  | "H72"
  | "W1"
  | "M1"
  | "CUSTOM";

interface PaymentPolicy {
  quotaLimit: number;
  dailyLimit: number;
  monthlyLimit: number;
  onceLimit: number;
  sameCardLimit: number;
  sameCardCountLimit: number;
  sameCardTimeLimit: SameCardTimeLimit;
  sameCardTimeLimitMinutes: number | null;
  useVerification: boolean;
  linkUseVerification: boolean;
  refundUseVerification: boolean;
  holidayPaymentEnabled: boolean;
  paymentStartTime: unknown;
  paymentEndTime: unknown;
  fixUseIdentityMfa?: boolean;
}

interface VendorDetail {
  id: string;
  code: string;
  businessInfo: { name: string };
  paymentPolicy: PaymentPolicy;
  terminalCount: number;
}

interface ApiEnvelope<T> {
  result?: { code?: number; message?: string };
  payload?: T;
}

interface PaymentConfig {
  amount: number;
  cardNumber: string;
  expiryMonth: string;
  expiryYear: string;
  cardPassword: string;
  birth: string;
  payerName: string;
  payerPhone: string;
}

interface PaymentAttempt {
  productName: string;
  amount: number;
  isSuccess: boolean;
  transactionId?: string;
  message: string;
  responseStatus: number;
  responseText: string;
  cancelled?: boolean;
}

interface WorkbookDownload {
  buffer: Buffer;
  fileName: string;
  documentId: string;
  rows: string[][];
}

interface VendorUploadResult {
  status: number;
  body: ApiEnvelope<{
    results?: Array<{
      isSuccess?: boolean;
      errorMessage?: string | null;
      paymentPolicy?: { sameCardTimeLimit?: string };
    }>;
    failureExcelLink?: string | null;
  }>;
  rawText: string;
}

const ADMIN_ROLE = "ADMIN";
const STORE_RUNTIME_ROLE = "QA3983_VE";
const BUSINESS_ROLE = "VE";
const TARGET_VENDOR_ID = "tenant-id-redacted";
const TARGET_VENDOR_CODE = "BP100060";
const TARGET_VENDOR_NAME = "박재민_가맹_한결데";
const PAYMENT_MUTATION_GUARD = "QA_3315_ALLOW_PAYMENT_MUTATION";

const createdPayments: PaymentAttempt[] = [];
let baselinePolicy: PaymentPolicy | undefined;
let baselineTerminalCount = 0;

export function runMerchantSameCardTimeLimitSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const merchantPage = findPageDefinition(checklist.pages, "가맹점관리");
  const paymentPage = findPageDefinition(checklist.pages, "수기결제");
  const adminEnv = getRuntimeQaEnv(options.environment, "admin", ADMIN_ROLE);
  const storeEnv = getRuntimeQaEnv(options.environment, "store", STORE_RUNTIME_ROLE);
  const paymentConfig = getPaymentConfig();

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.beforeAll(async ({ browser }) => {
      ensureRuntime(adminEnv, "ADMIN");
      ensureRuntime(storeEnv, "한결데테스트 VE");
      const detail = await withApiPage(browser, adminEnv, (page) => loadVendorDetail(page, adminEnv));
      expect(detail.code, "QA 대상 가맹점 고유코드가 BP100060이어야 합니다.").toBe(TARGET_VENDOR_CODE);
      expect(detail.businessInfo.name, "QA 대상 가맹점명이 요청 데이터와 일치해야 합니다.").toBe(TARGET_VENDOR_NAME);
      baselinePolicy = structuredClone(detail.paymentPolicy);
      baselineTerminalCount = detail.terminalCount;
    });

    test.afterEach(async ({ browser }, testInfo) => {
      const cleanupLines: string[] = [];
      if (baselinePolicy) {
        const restored = await patchVendorPolicy(
          browser,
          adminEnv,
          baselinePolicy,
          baselineTerminalCount,
          `QA #${options.issueId} 테스트 후 동일카드 정책 원복`
        ).catch((error) => ({ ok: false, status: 0, text: errorMessage(error) }));
        cleanupLines.push(`정책 원복: status=${restored.status}, ok=${restored.ok}, body=${snippet(restored.text, 500)}`);
      }

      cleanupLines.push(...(await cancelOutstandingPayments(browser, adminEnv, options.issueId)));
      await attachText(testInfo, "cleanup.md", cleanupLines);
    });

    test.afterAll(async ({ browser }) => {
      if (baselinePolicy) {
        await patchVendorPolicy(
          browser,
          adminEnv,
          baselinePolicy,
          baselineTerminalCount,
          `QA #${options.issueId} 최종 동일카드 정책 원복`
        );
      }
      const remaining = await cancelOutstandingPayments(browser, adminEnv, options.issueId);
      if (remaining.some((line) => /ok=false|취소 실패/.test(line))) {
        throw new Error(`QA 생성 결제 최종 취소 실패: ${remaining.join(" / ")}`);
      }
    });

    test(checklist.checklist[0], async ({ browser }, testInfo) => {
      test.setTimeout(420_000);
      await withTracedPage(browser, adminEnv, testInfo, "fixed-options", async (page) => {
        const cases: Array<{ requested: string; fallback?: string; value: SameCardTimeLimit }> = [
          { requested: "제한 없음", value: "UNLIMITED" },
          { requested: "30분", value: "MIN30" },
          { requested: "1시간", value: "H1" },
          { requested: "3시간", value: "H3" },
          { requested: "6시간", value: "H6" },
          { requested: "12시간", value: "H12" },
          { requested: "24시간", value: "H24" },
          { requested: "48시간", value: "H48" },
          { requested: "72시간", value: "H72" },
          { requested: "1주일", fallback: "1주", value: "W1" },
          { requested: "1개월", value: "M1" }
        ];
        const evidence: string[] = [];
        const missingLabels: string[] = [];

        for (const entry of cases) {
          const modal = await openVendorEditModal(page, adminEnv, merchantPage);
          const available = await openSameCardTimeOptions(page, modal);
          const selectedLabel = available.includes(entry.requested)
            ? entry.requested
            : entry.fallback && available.includes(entry.fallback)
              ? entry.fallback
              : undefined;
          if (!available.includes(entry.requested)) {
            missingLabels.push(entry.requested);
          }
          if (!selectedLabel) {
            evidence.push(`${entry.requested}: 선택 옵션 없음 / 실제 옵션=${available.join(", ")}`);
            await closeModal(page, modal);
            continue;
          }

          await chooseOpenOption(page, selectedLabel);
          await saveVendorModal(page, modal, `QA #3983 고정 제한시간 ${entry.requested} 저장`);
          const saved = await loadVendorDetail(page, adminEnv);
          evidence.push(`${entry.requested}: 화면 선택='${selectedLabel}', API=${saved.paymentPolicy.sameCardTimeLimit}`);
          expect(saved.paymentPolicy.sameCardTimeLimit, `${entry.requested} 저장값`).toBe(entry.value);
          expect(saved.paymentPolicy.sameCardTimeLimitMinutes, `${entry.requested} 고정값은 직접입력 분이 없어야 합니다.`).toBeNull();
        }

        const finalModal = await openVendorEditModal(page, adminEnv, merchantPage);
        await attachScreenshot(page, testInfo, "fixed-options-final.png");
        await attachText(testInfo, "fixed-options.md", evidence);
        await closeModal(page, finalModal);
        expect(missingLabels, `요청된 고정 옵션명이 모두 그대로 노출되어야 합니다. 실제 화면의 1주 옵션은 1주일과 다릅니다.`).toEqual([]);
      });
    });

    test(checklist.checklist[1], async ({ browser }, testInfo) => {
      test.setTimeout(120_000);
      await withTracedPage(browser, adminEnv, testInfo, "custom-valid-range", async (page) => {
        const evidence: string[] = [];
        for (const minutes of [525_600, 1]) {
          const modal = await openVendorEditModal(page, adminEnv, merchantPage);
          await selectCustomMinutes(page, modal, minutes);
          await saveVendorModal(page, modal, `QA #3983 직접입력 ${minutes}분 저장`);
          const saved = await loadVendorDetail(page, adminEnv);
          evidence.push(`${minutes}분 저장: type=${saved.paymentPolicy.sameCardTimeLimit}, minutes=${saved.paymentPolicy.sameCardTimeLimitMinutes}`);
          expect(saved.paymentPolicy.sameCardTimeLimit).toBe("CUSTOM");
          expect(saved.paymentPolicy.sameCardTimeLimitMinutes).toBe(minutes);
        }
        const modal = await openVendorEditModal(page, adminEnv, merchantPage);
        await expect(modal.locator("input[name='sameCardTimeLimitMinutes']"), "직접입력 분 입력란이 표시되어야 합니다.").toBeVisible();
        await attachScreenshot(page, testInfo, "custom-valid-range.png");
        await attachText(testInfo, "custom-valid-range.md", evidence);
      });
    });

    test(checklist.checklist[2], async ({ browser }, testInfo) => {
      test.setTimeout(90_000);
      await withTracedPage(browser, adminEnv, testInfo, "custom-invalid-values", async (page) => {
        const modal = await openVendorEditModal(page, adminEnv, merchantPage);
        await selectCustomMinutes(page, modal, 1);
        const input = modal.locator("input[name='sameCardTimeLimitMinutes']");
        const cases = [
          { raw: "   ", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
          { raw: "1.5", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
          { raw: "0", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
          { raw: "-1", expected: /직접입력 동일카드 제한시간은 1분 이상이어야 합니다\./ },
          { raw: "525601", expected: /직접입력 동일카드 제한시간은 최대 1년\(525,600분\)입니다\./ }
        ];
        const violations: string[] = [];
        const evidence: string[] = [];

        for (const entry of cases) {
          await input.fill(entry.raw);
          await page.waitForTimeout(500);
          const actualValue = await input.inputValue();
          const surroundingText = await modal.innerText();
          const hasGuidance = entry.expected.test(surroundingText);
          evidence.push(`입력 '${entry.raw.replace(/ /g, "<space>")}' -> 실제 '${actualValue}', 안내='${snippet(surroundingText, 240)}'`);
          if (!hasGuidance) {
            violations.push(`'${entry.raw.replace(/ /g, "<space>")}' 입력에 안내가 없음(실제 입력값 '${actualValue}')`);
          }
        }

        const unchanged = await loadVendorDetail(page, adminEnv);
        expect(unchanged.paymentPolicy.sameCardTimeLimit, "유효하지 않은 값 검증 중 저장 요청이 없어야 합니다.").toBe(baselinePolicy?.sameCardTimeLimit);
        expect(unchanged.paymentPolicy.sameCardTimeLimitMinutes).toBe(baselinePolicy?.sameCardTimeLimitMinutes);
        await attachScreenshot(page, testInfo, "custom-invalid-values.png");
        await attachText(testInfo, "custom-invalid-values.md", evidence);
        expect(violations, "공백·소수·0·음수·상한 초과 입력은 저장 가능한 값으로 조용히 변환되지 않고 안내가 보여야 합니다.").toEqual([]);
      });
    });

    test(checklist.checklist[3], async ({ browser }, testInfo) => {
      test.setTimeout(90_000);
      await withTracedPage(browser, adminEnv, testInfo, "custom-persistence", async (page) => {
        let modal = await openVendorEditModal(page, adminEnv, merchantPage);
        await selectCustomMinutes(page, modal, 90);
        await saveVendorModal(page, modal, "QA #3983 직접입력 90분 재진입 유지 검증");

        modal = await openVendorEditModal(page, adminEnv, merchantPage);
        const typeValue = await modal.locator("input[name='sameCardTimeLimit']").inputValue();
        const minutesValue = numericInputValue(await modal.locator("input[name='sameCardTimeLimitMinutes']").inputValue());
        const detail = await loadVendorDetail(page, adminEnv);
        await attachScreenshot(page, testInfo, "custom-persistence.png");
        await attachText(testInfo, "custom-persistence.md", [
          `재진입 화면 type=${typeValue}, minutes=${minutesValue}`,
          `상세 API type=${detail.paymentPolicy.sameCardTimeLimit}, minutes=${detail.paymentPolicy.sameCardTimeLimitMinutes}`
        ]);
        expect(typeValue).toBe("CUSTOM");
        expect(minutesValue).toBe(90);
        expect(detail.paymentPolicy.sameCardTimeLimit).toBe("CUSTOM");
        expect(detail.paymentPolicy.sameCardTimeLimitMinutes).toBe(90);
      });
    });

    test(checklist.checklist[4], async ({ browser }, testInfo) => {
      test.setTimeout(180_000);
      assertPaymentMutationReady(paymentConfig);
      await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 1, sameCardLimit: 5_000_000 }, "1분 1회");
      await withTracedPage(browser, storeEnv, testInfo, "count-limit-one", async (page) => {
        const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
        await attachPaymentEvidence(testInfo, "count-limit-one", attempts);
        expect(attempts.map((attempt) => attempt.isSuccess), "1회까지 성공하고 두 번째는 차단되어야 합니다.").toEqual([true, false]);
        expect(attempts[1].message, "두 번째 결제에 동일카드 횟수 초과 사유가 보여야 합니다.").toMatch(/동일\s*카드.*횟수.*초과|횟수.*초과/);
      });
    });

    test(checklist.checklist[5], async ({ browser }, testInfo) => {
      test.setTimeout(240_000);
      assertPaymentMutationReady(paymentConfig);
      await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 3, sameCardLimit: 5_000_000 }, "1분 3회");
      await withTracedPage(browser, storeEnv, testInfo, "count-limit-three", async (page) => {
        const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 4);
        await attachPaymentEvidence(testInfo, "count-limit-three", attempts);
        expect(attempts.map((attempt) => attempt.isSuccess), "3회까지 성공하고 네 번째는 차단되어야 합니다.").toEqual([true, true, true, false]);
        expect(attempts[3].message).toMatch(/동일\s*카드.*횟수.*초과|횟수.*초과/);
      });
    });

    test(checklist.checklist[6], async ({ browser }, testInfo) => {
      test.setTimeout(180_000);
      assertPaymentMutationReady(paymentConfig);
      await setPolicy(browser, adminEnv, {
        sameCardTimeLimit: "CUSTOM",
        sameCardTimeLimitMinutes: 1,
        sameCardCountLimit: 5,
        sameCardLimit: paymentConfig.amount
      }, "동일카드 금액 한도");
      await withTracedPage(browser, storeEnv, testInfo, "amount-limit", async (page) => {
        const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
        await attachPaymentEvidence(testInfo, "amount-limit", attempts);
        expect(attempts.map((attempt) => attempt.isSuccess), "첫 결제까지 한도에 포함되고 두 번째 결제는 합계 초과로 차단되어야 합니다.").toEqual([true, false]);
        expect(attempts[1].message).toMatch(/동일\s*카드.*한도.*초과|결제\s*한도.*초과|금액.*초과/);
      });
    });

    test(checklist.checklist[7], async ({ browser }, testInfo) => {
      test.setTimeout(260_000);
      assertPaymentMutationReady(paymentConfig);
      await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 1, sameCardCountLimit: 1, sameCardLimit: 5_000_000 }, "1분 경과 재결제");
      await withTracedPage(browser, storeEnv, testInfo, "window-expiry", async (page) => {
        const first = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 1);
        await page.waitForTimeout(65_000);
        const second = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 1);
        const attempts = [...first, ...second];
        await attachPaymentEvidence(testInfo, "window-expiry", attempts, ["1차 승인 후 65초 대기"]);
        expect(attempts.map((attempt) => attempt.isSuccess), "1분 집계 기간이 지난 뒤 동일카드 결제가 다시 승인되어야 합니다.").toEqual([true, true]);
      });
    });

    test(checklist.checklist[8], async ({ browser }, testInfo) => {
      test.setTimeout(180_000);
      assertPaymentMutationReady(paymentConfig);
      await setPolicy(browser, adminEnv, { sameCardTimeLimit: "UNLIMITED", sameCardTimeLimitMinutes: null, sameCardCountLimit: 1, sameCardLimit: 1 }, "제한 없음 회귀");
      await withTracedPage(browser, storeEnv, testInfo, "unlimited", async (page) => {
        const attempts = await runPaymentAttempts(page, storeEnv, paymentPage, paymentConfig, options.issueId, 2);
        await attachPaymentEvidence(testInfo, "unlimited", attempts);
        expect(attempts.map((attempt) => attempt.isSuccess), "제한 없음에서는 1회/1원 설정이 있어도 동일카드 제한이 적용되지 않아야 합니다.").toEqual([true, true]);
      });
    });

    test(checklist.checklist[9], async ({ browser }, testInfo) => {
      test.setTimeout(180_000);
      await withTracedPage(browser, adminEnv, testInfo, "excel-fixed-options", async (page) => {
        const workbook = await downloadVendorWorkbook(page, adminEnv, testInfo, "fixed-options");
        const validation = sameCardTimeValidationFormula(workbook.buffer);
        const expected = "제한 없음,30분,1시간,3시간,6시간,12시간,24시간,48시간,72시간,1주일,1개월";
        await attachText(testInfo, "excel-fixed-options.md", [
          `문서 ID: ${workbook.documentId}`,
          `동일카드 제한시간 데이터 유효성 목록: ${validation}`
        ]);
        expect(validation, "엑셀 동일카드 제한시간은 고정 선택 목록이어야 합니다.").toBe(expected);
        expect(validation, "엑셀 선택 목록에 직접입력은 없어야 합니다.").not.toContain("직접입력");
      });
    });

    test(checklist.checklist[10], async ({ browser }, testInfo) => {
      test.setTimeout(240_000);
      await setPolicy(browser, adminEnv, { sameCardTimeLimit: "CUSTOM", sameCardTimeLimitMinutes: 90 }, "엑셀 기존 직접입력 90분 준비");
      await withTracedPage(browser, adminEnv, testInfo, "excel-custom-roundtrip", async (page) => {
        const workbook = await downloadVendorWorkbook(page, adminEnv, testInfo, "custom-90");
        const header = workbook.rows[0];
        const targetRowIndex = workbook.rows.findIndex((row) => row.includes(TARGET_VENDOR_CODE));
        const limitColumnIndex = findHeaderIndex(header, "동일카드 제한시간");
        expect(targetRowIndex, "엑셀에 BP100060 가맹점 행이 있어야 합니다.").toBeGreaterThan(0);
        expect(workbook.rows[targetRowIndex][limitColumnIndex], "기존 직접입력 90분은 엑셀에서 90분으로 보여야 합니다.").toBe("90분");

        const unchanged = await uploadVendorWorkbook(page, adminEnv, workbook.buffer, "qa3983-custom-90-unchanged.xlsx");
        expectUploadResult(unchanged, true, "90분 유지 업로드");

        const cellReference = `${columnLetters(limitColumnIndex + 1)}${targetRowIndex + 1}`;
        const changed120 = replaceWorkbookCell(workbook.buffer, cellReference, "120분");
        await attachBuffer(testInfo, "qa3983-custom-120-invalid.xlsx", changed120);
        const invalid = await uploadVendorWorkbook(page, adminEnv, changed120, "qa3983-custom-120-invalid.xlsx");
        expectUploadResult(invalid, false, "120분 변경 업로드");
        expect(uploadMessage(invalid), "임의 120분 변경은 고정 목록 값만 허용한다는 안내로 실패해야 합니다.").toMatch(/목록에 있는 값만|선택.*값|입력.*불가/);

        const changedFixed = replaceWorkbookCell(workbook.buffer, cellReference, "1시간");
        await attachBuffer(testInfo, "qa3983-fixed-1hour-valid.xlsx", changedFixed);
        const fixed = await uploadVendorWorkbook(page, adminEnv, changedFixed, "qa3983-fixed-1hour-valid.xlsx");
        expectUploadResult(fixed, true, "1시간 고정값 업로드");
        const detail = await loadVendorDetail(page, adminEnv);
        expect(detail.paymentPolicy.sameCardTimeLimit, "1시간 업로드 후 API 값은 H1이어야 합니다.").toBe("H1");
        expect(detail.paymentPolicy.sameCardTimeLimitMinutes).toBeNull();

        await attachText(testInfo, "excel-custom-roundtrip.md", [
          `원본 셀 ${cellReference}: ${workbook.rows[targetRowIndex][limitColumnIndex]}`,
          `90분 유지: ${uploadSummary(unchanged)}`,
          `120분 변경: ${uploadSummary(invalid)}`,
          `1시간 변경: ${uploadSummary(fixed)}`,
          `최종 상세 API: ${detail.paymentPolicy.sameCardTimeLimit}/${detail.paymentPolicy.sameCardTimeLimitMinutes}`
        ]);
      });
    });
  });
}

async function setPolicy(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  overrides: Partial<PaymentPolicy>,
  label: string
): Promise<void> {
  if (!baselinePolicy) {
    blocked("원본 결제 정책을 불러오지 못했습니다.");
  }
  const policy = { ...structuredClone(baselinePolicy), ...overrides };
  const result = await patchVendorPolicy(browser, runtimeEnv, policy, baselineTerminalCount, `QA #3983 ${label}`);
  expect(result.ok, `${label} 정책 저장 응답`).toBe(true);
  const saved = await withApiPage(browser, runtimeEnv, (page) => loadVendorDetail(page, runtimeEnv));
  for (const [key, value] of Object.entries(overrides)) {
    expect(saved.paymentPolicy[key as keyof PaymentPolicy], `${label}: ${key} 저장값`).toEqual(value);
  }
}

async function patchVendorPolicy(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  policy: PaymentPolicy,
  terminalCount: number,
  reason: string
): Promise<{ ok: boolean; status: number; text: string }> {
  return withApiPage(browser, runtimeEnv, async (page) => {
    const response = await page.request.patch(apiUrl(runtimeEnv, `/api/v2/vendors/${TARGET_VENDOR_ID}`), {
      headers: apiHeaders(runtimeEnv),
      data: { paymentPolicy: policy, terminalCount, reason }
    });
    return { ok: response.ok(), status: response.status(), text: await response.text() };
  });
}

async function loadVendorDetail(page: Page, runtimeEnv: RuntimeQaEnv): Promise<VendorDetail> {
  const response = await page.request.get(apiUrl(runtimeEnv, `/api/v2/vendors/${TARGET_VENDOR_ID}`), {
    headers: apiHeaders(runtimeEnv)
  });
  const text = await response.text();
  if (!response.ok()) {
    throw new Error(`가맹점 상세 조회 실패: status=${response.status()} body=${snippet(text, 1000)}`);
  }
  const body = parseJson(text) as ApiEnvelope<VendorDetail>;
  if (!body.payload?.paymentPolicy) {
    throw new Error(`가맹점 상세 응답에 paymentPolicy가 없습니다: ${snippet(text, 1000)}`);
  }
  return body.payload;
}

async function openVendorEditModal(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition
): Promise<ReturnType<Page["locator"]>> {
  const target = new URL(buildPageUrl(requireBaseUrl(runtimeEnv), pageDefinition, runtimeEnv.tenantId ?? ""));
  target.searchParams.set("vendorIds", TARGET_VENDOR_ID);
  target.searchParams.set("exactMatch", "true");
  let row = page.locator("[role='row'], tr").filter({ hasText: TARGET_VENDOR_NAME }).last();
  const alreadyOnTarget = page.url().startsWith(target.origin) && new URL(page.url()).pathname === target.pathname;
  if (!alreadyOnTarget || !(await row.isVisible({ timeout: 2_000 }).catch(() => false))) {
    await openAuthenticatedUrl(page, runtimeEnv, target.toString());
    row = page.locator("[role='row'], tr").filter({ hasText: TARGET_VENDOR_NAME }).last();
  }
  await expect(row, `${TARGET_VENDOR_NAME} 대상 행이 보여야 합니다.`).toBeVisible({ timeout: 20_000 });
  const pencil = row.locator("svg.lucide-pencil, svg[data-lucide='pencil'], svg[class*='pencil']").first();
  await expect(pencil, "가맹점 수정 연필 버튼이 보여야 합니다.").toBeVisible();
  await pencil.click({ force: true });
  const modal = page.locator("[role='dialog']").filter({ hasText: "가맹점 수정" }).last();
  await expect(modal, "가맹점 수정 모달이 열려야 합니다.").toBeVisible({ timeout: 20_000 });
  await modal.getByText("수기 결제 제한", { exact: true }).scrollIntoViewIfNeeded();
  return modal;
}

async function openSameCardTimeOptions(page: Page, modal: ReturnType<Page["locator"]>): Promise<string[]> {
  const label = modal.locator("label").filter({ hasText: /동일카드 제한시간\s*\*/ }).first();
  await expect(label).toBeVisible();
  const controlId = await label.getAttribute("for");
  if (!controlId) {
    throw new Error("동일카드 제한시간 label의 for 속성이 없습니다.");
  }
  await modal.locator(`#${cssEscape(controlId)}`).click({ force: true });
  const options = page.locator("[role='option']:visible, li.MuiMenuItem-root:visible");
  await expect(options.first(), "동일카드 제한시간 선택 옵션이 열려야 합니다.").toBeVisible();
  return (await options.allTextContents()).map(normalizeText);
}

async function chooseOpenOption(page: Page, label: string): Promise<void> {
  const roleOption = page.getByRole("option", { name: label, exact: true }).last();
  if (await roleOption.isVisible({ timeout: 1_000 }).catch(() => false)) {
    await roleOption.click();
    return;
  }
  const menuItem = page.locator("li.MuiMenuItem-root:visible").filter({ hasText: new RegExp(`^${escapeRegExp(label)}$`) }).last();
  await expect(menuItem, `${label} 옵션이 보여야 합니다.`).toBeVisible();
  await menuItem.click();
}

async function selectCustomMinutes(page: Page, modal: ReturnType<Page["locator"]>, minutes: number): Promise<void> {
  await openSameCardTimeOptions(page, modal);
  await chooseOpenOption(page, "직접입력");
  const input = modal.locator("input[name='sameCardTimeLimitMinutes']");
  await expect(input, "직접입력 선택 시 분 입력란이 보여야 합니다.").toBeVisible();
  await input.fill(String(minutes));
  await page.waitForTimeout(400);
}

async function saveVendorModal(page: Page, modal: ReturnType<Page["locator"]>, reason: string): Promise<void> {
  const responsePromise = page.waitForResponse(
    (response) => response.request().method() === "PATCH" && response.url().includes(`/api/v2/vendors/${TARGET_VENDOR_ID}`),
    { timeout: 30_000 }
  );
  await modal.getByRole("button", { name: "수정", exact: true }).last().click();
  const reasonInput = page.locator("textarea[aria-label='Type your message here']");
  await expect(reasonInput, "변경사유 입력창이 보여야 합니다.").toBeVisible({ timeout: 10_000 });
  await reasonInput.fill(reason);
  await page.locator(".swal2-confirm").filter({ hasText: "확인" }).click();
  const response = await responsePromise;
  const body = await response.text();
  expect(response.ok(), `가맹점 수정 응답: ${snippet(body, 800)}`).toBe(true);
  const successConfirm = page.locator(".swal2-confirm").filter({ hasText: "확인" });
  if (await successConfirm.isVisible({ timeout: 2_000 }).catch(() => false)) {
    await successConfirm.click({ force: true, timeout: 2_000 }).catch(() => undefined);
  }
  await page.waitForTimeout(600);
}

async function closeModal(page: Page, modal: ReturnType<Page["locator"]>): Promise<void> {
  const close = modal.getByRole("button", { name: /닫기|취소/ }).last();
  if (await close.isVisible({ timeout: 500 }).catch(() => false)) {
    await close.click();
    return;
  }
  await page.keyboard.press("Escape").catch(() => undefined);
}

async function runPaymentAttempts(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: PaymentConfig,
  issueId: string,
  count: number
): Promise<PaymentAttempt[]> {
  const attempts: PaymentAttempt[] = [];
  for (let index = 0; index < count; index += 1) {
    const attempt = await createPaymentAttempt(page, runtimeEnv, pageDefinition, config, issueId, index + 1);
    attempts.push(attempt);
    if (attempt.isSuccess && attempt.transactionId) {
      createdPayments.push(attempt);
    }
  }
  return attempts;
}

async function createPaymentAttempt(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  config: PaymentConfig,
  issueId: string,
  sequence: number
): Promise<PaymentAttempt> {
  await openAuthenticatedUrl(
    page,
    runtimeEnv,
    buildPageUrl(requireBaseUrl(runtimeEnv), pageDefinition, runtimeEnv.tenantId ?? "")
  );
  const productName = `QA${issueId}-${Date.now().toString(36)}-${sequence}`;
  await page.getByPlaceholder("상품명을 입력해주세요.").fill(productName);
  await page.getByPlaceholder("판매가격").fill(String(config.amount));
  await page.getByPlaceholder("수량").fill("1");
  await page.getByPlaceholder("- 없이 입력해주세요.").fill(config.cardNumber);
  await selectStoreDropdown(page, "MM", config.expiryMonth);
  await selectStoreDropdown(page, "YYYY", config.expiryYear);
  await page.getByPlaceholder("6자리").fill(config.birth);
  await page.getByPlaceholder("2자리").fill(config.cardPassword);
  await page.getByPlaceholder("휴대폰 번호").fill(config.payerPhone.replace(/^010/, ""));
  await page.getByPlaceholder("이름을 입력해주세요.").fill(config.payerName);
  const submit = page.getByRole("button", { name: "결제하기", exact: true });
  await expect(submit, "결제하기 버튼이 활성화되어야 합니다.").toBeEnabled();

  const responsePromise = page.waitForResponse(
    (response) => response.request().method() === "POST" && response.url().includes("/api/v1/store/identity/payment"),
    { timeout: 45_000 }
  );
  await submit.click();
  await expect(page.locator("body"), "수기결제 확인 모달이 보여야 합니다.").toContainText(`합계 ${formatNumber(config.amount)}원 결제`);
  await page.getByRole("button", { name: /^결제$/ }).last().click();
  const response = await responsePromise;
  const responseText = await response.text();
  const body = parseJson(responseText) as ApiEnvelope<Record<string, unknown>> & Record<string, unknown>;
  const payload = (body.payload ?? {}) as Record<string, unknown>;
  const isSuccess = payload.isSuccess === true;
  const transactionId = stringValue(payload.transactionId);
  await page.waitForTimeout(700);
  const pageText = await page.locator("body").innerText().catch(() => "");
  const message = [
    stringValue(payload.message),
    stringValue(payload.resultMessage),
    stringValue(payload.errorMessage),
    body.result?.message,
    pageText
  ].filter(Boolean).join(" | ");

  return {
    productName,
    amount: config.amount,
    isSuccess,
    transactionId,
    message,
    responseStatus: response.status(),
    responseText: redactResponse(responseText)
  };
}

async function selectStoreDropdown(page: Page, buttonText: string, value: string): Promise<void> {
  await page.getByRole("button", { name: buttonText, exact: true }).first().click();
  const option = page.getByRole("option", { name: value, exact: true }).first();
  if (await option.isVisible({ timeout: 1_500 }).catch(() => false)) {
    await option.click();
    return;
  }
  await page.getByText(value, { exact: true }).last().click();
}

async function cancelOutstandingPayments(browser: Browser, adminEnv: RuntimeQaEnv, issueId: string): Promise<string[]> {
  const lines: string[] = [];
  for (const payment of createdPayments) {
    if (!payment.transactionId || payment.cancelled) {
      continue;
    }
    try {
      const result = await withApiPage(browser, adminEnv, async (page) => {
        const response = await page.request.post(apiUrl(adminEnv, "/api/v1/transaction/cancel"), {
          headers: apiHeaders(adminEnv),
          data: {
            transactionId: payment.transactionId,
            cancelAmount: String(payment.amount),
            pgResponseRequired: true,
            reason: `QA #${issueId} 완료 후 생성 결제 정리`
          }
        });
        return { ok: response.ok(), status: response.status(), body: await response.text() };
      });
      payment.cancelled = result.ok;
      lines.push(`결제 취소 ${redactId(payment.transactionId)}: status=${result.status}, ok=${result.ok}, body=${snippet(result.body, 500)}`);
    } catch (error) {
      lines.push(`결제 취소 ${redactId(payment.transactionId)}: 취소 실패 ${errorMessage(error)}`);
    }
  }
  return lines.length > 0 ? lines : ["취소할 미정리 승인 거래 없음"];
}

async function downloadVendorWorkbook(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  testInfo: TestInfo,
  suffix: string
): Promise<WorkbookDownload> {
  const params = new URLSearchParams({
    includeData: "true",
    vendorIds: TARGET_VENDOR_ID,
    exactMatch: "true",
    statuses: "ACTIVE"
  });
  const create = await page.request.post(apiUrl(runtimeEnv, `/api/v1/excel/vendors?${params.toString()}`), {
    headers: apiHeaders(runtimeEnv),
    data: {}
  });
  const createText = await create.text();
  const createBody = parseJson(createText) as ApiEnvelope<string>;
  if (!create.ok() || !createBody.payload) {
    throw new Error(`가맹점 엑셀 생성 요청 실패: status=${create.status()} body=${snippet(createText, 1000)}`);
  }
  const documentId = createBody.payload;
  const link = await waitForExcelLink(page, runtimeEnv, documentId);
  const download = await page.request.get(link);
  if (!download.ok()) {
    throw new Error(`가맹점 엑셀 다운로드 실패: status=${download.status()}`);
  }
  const buffer = await download.body();
  const fileName = guessDownloadFileNameFromUrl(link, `qa3983-vendors-${suffix}.xlsx`);
  await attachBuffer(testInfo, fileName, buffer);
  return { buffer, fileName, documentId, rows: extractDownloadRows(buffer, fileName) };
}

async function waitForExcelLink(page: Page, runtimeEnv: RuntimeQaEnv, documentId: string): Promise<string> {
  const deadline = Date.now() + 120_000;
  let last = "";
  while (Date.now() < deadline) {
    const response = await page.request.get(apiUrl(runtimeEnv, `/api/v1/excel/link/${encodeURIComponent(documentId)}`), {
      headers: apiHeaders(runtimeEnv)
    });
    last = await response.text();
    if (response.ok()) {
      const body = parseJson(last) as ApiEnvelope<string>;
      if (body.payload) {
        return body.payload;
      }
    }
    await page.waitForTimeout(2_000);
  }
  blocked(`가맹점 엑셀 생성이 완료되지 않았습니다: documentId=${documentId}, last=${snippet(last, 800)}`);
}

async function uploadVendorWorkbook(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  buffer: Buffer,
  fileName: string
): Promise<VendorUploadResult> {
  const response = await page.request.post(apiUrl(runtimeEnv, "/api/v1/excel/upload/vendors"), {
    headers: {
      accept: "application/json",
      ...(runtimeEnv.tenantId ? { "x-tenant-id": runtimeEnv.tenantId } : {})
    },
    multipart: {
      file: {
        name: fileName,
        mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        buffer
      }
    }
  });
  const rawText = await response.text();
  return {
    status: response.status(),
    body: (parseJson(rawText) ?? {}) as VendorUploadResult["body"],
    rawText
  };
}

function expectUploadResult(result: VendorUploadResult, expectedSuccess: boolean, label: string): void {
  expect(result.status, `${label} HTTP 응답`).toBeLessThan(400);
  const first = result.body.payload?.results?.[0];
  expect(first, `${label} 결과 행`).toBeTruthy();
  expect(first?.isSuccess, `${label} 성공 여부: ${snippet(result.rawText, 1200)}`).toBe(expectedSuccess);
}

function uploadMessage(result: VendorUploadResult): string {
  return [result.body.result?.message, result.body.payload?.results?.[0]?.errorMessage, result.rawText]
    .filter(Boolean)
    .join(" | ");
}

function uploadSummary(result: VendorUploadResult): string {
  return `http=${result.status}, isSuccess=${result.body.payload?.results?.[0]?.isSuccess}, message=${snippet(uploadMessage(result), 500)}`;
}

function sameCardTimeValidationFormula(buffer: Buffer): string {
  const entries = unzipSync(buffer);
  for (const [entryName, data] of Object.entries(entries)) {
    if (!/^xl\/worksheets\/sheet\d+\.xml$/.test(entryName)) {
      continue;
    }
    const xml = strFromU8(data);
    const match = xml.match(/<dataValidation\b[^>]*\bsqref="AH2:AH5002"[^>]*>[\s\S]*?<formula1>"([^"]+)"<\/formula1>[\s\S]*?<\/dataValidation>/);
    if (match?.[1]) {
      return decodeXml(match[1]);
    }
  }
  throw new Error("엑셀에서 동일카드 제한시간(AH) 데이터 유효성 목록을 찾지 못했습니다.");
}

function replaceWorkbookCell(buffer: Buffer, reference: string, value: string): Buffer {
  const entries = unzipSync(buffer);
  const replacement = `<c r="${reference}" t="inlineStr"><is><t>${escapeXml(value)}</t></is></c>`;
  let replaced = false;
  for (const [entryName, data] of Object.entries(entries)) {
    if (!/^xl\/worksheets\/sheet\d+\.xml$/.test(entryName)) {
      continue;
    }
    const xml = strFromU8(data);
    const pattern = new RegExp(`<c\\b[^>]*\\br="${escapeRegExp(reference)}"[^>]*>[\\s\\S]*?<\\/c>`);
    if (!pattern.test(xml)) {
      continue;
    }
    entries[entryName] = strToU8(xml.replace(pattern, replacement));
    replaced = true;
    break;
  }
  if (!replaced) {
    throw new Error(`엑셀 셀을 찾지 못했습니다: ${reference}`);
  }
  return Buffer.from(zipSync(entries));
}

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

async function withTracedPage(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  _testInfo: TestInfo,
  _slug: string,
  callback: (page: Page) => Promise<void>
): Promise<void> {
  const context = await browser.newContext({
    ...(runtimeEnv.storageState ? { storageState: runtimeEnv.storageState } : {}),
    acceptDownloads: true,
    viewport: { width: 1600, height: 1000 }
  });
  const page = await context.newPage();
  try {
    await callback(page);
  } finally {
    await context.close();
  }
}

async function openAuthenticatedUrl(page: Page, runtimeEnv: RuntimeQaEnv, url: string): Promise<void> {
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
  await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
  if (await isLoginPage(page, runtimeEnv)) {
    if (!runtimeEnv.username || !runtimeEnv.password) {
      blocked(`${runtimeEnv.role} storage state가 만료되었고 재로그인 자격증명이 현재 실행 환경에 없습니다.`);
    }
    await loginWithCredentials(page, runtimeEnv);
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
  }
  expect(await isLoginPage(page, runtimeEnv), `${runtimeEnv.role} 로그인 상태`).toBe(false);
  await page.waitForTimeout(700);
}

function getPaymentConfig(): PaymentConfig {
  const expiry = (process.env.QA_3315_CARD_EXPIRY ?? "").replace(/\D/g, "");
  return {
    amount: positiveNumber(process.env.QA_3315_PAYMENT_AMOUNT, 100),
    cardNumber: process.env.QA_3315_CARD_NUMBER ?? "",
    expiryMonth: expiry.slice(0, 2),
    expiryYear: expiry.length >= 4 ? `20${expiry.slice(-2)}` : "",
    cardPassword: process.env.QA_3315_CARD_PASSWORD ?? "",
    birth: process.env.QA_3315_CARD_BIRTH ?? "",
    payerName: "QA3983",
    payerPhone: process.env.QA_3315_PAYER_PHONE ?? "phone-redacted"
  };
}

function assertPaymentMutationReady(config: PaymentConfig): void {
  if (process.env[PAYMENT_MUTATION_GUARD] !== "1") {
    blocked(`${PAYMENT_MUTATION_GUARD}=1 설정이 필요합니다. 성공 거래는 QA 종료 후 모두 취소합니다.`);
  }
  const missing = [
    ["QA_3315_CARD_NUMBER", config.cardNumber],
    ["QA_3315_CARD_EXPIRY", config.expiryMonth && config.expiryYear],
    ["QA_3315_CARD_PASSWORD", config.cardPassword],
    ["QA_3315_CARD_BIRTH", config.birth]
  ].filter(([, value]) => !value);
  if (missing.length > 0) {
    blocked(`수기결제 카드 정보가 필요합니다: ${missing.map(([name]) => name).join(", ")}`);
  }
}

async function attachPaymentEvidence(
  testInfo: TestInfo,
  slug: string,
  attempts: PaymentAttempt[],
  extra: string[] = []
): Promise<void> {
  await attachText(testInfo, `${slug}.md`, [
    ...extra,
    ...attempts.map((attempt, index) =>
      [
        `${index + 1}차`,
        `상품=${attempt.productName}`,
        `금액=${formatNumber(attempt.amount)}원`,
        `성공=${attempt.isSuccess}`,
        `거래=${redactId(attempt.transactionId ?? "")}`,
        `HTTP=${attempt.responseStatus}`,
        `메시지=${snippet(attempt.message, 700)}`,
        `응답=${snippet(attempt.responseText, 700)}`
      ].join(" / ")
    )
  ]);
}

async function attachText(testInfo: TestInfo, fileName: string, lines: string[]): Promise<void> {
  await testInfo.attach(fileName, {
    body: `${lines.join("\n")}\n`,
    contentType: "text/markdown"
  });
}

async function attachBuffer(testInfo: TestInfo, fileName: string, buffer: Buffer): Promise<void> {
  await testInfo.attach(fileName, {
    body: buffer,
    contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  });
}

async function attachScreenshot(page: Page, testInfo: TestInfo, fileName: string): Promise<void> {
  const outputPath = testInfo.outputPath(fileName);
  await page.screenshot({ path: outputPath, fullPage: true });
  await testInfo.attach(fileName, { path: outputPath, contentType: "image/png" });
}

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

function ensureRuntime(runtimeEnv: RuntimeQaEnv, label: string): void {
  if (!runtimeEnv.baseUrl) {
    throw new Error(`${label} base URL이 없습니다.`);
  }
  if (!runtimeEnv.storageState || !fs.existsSync(runtimeEnv.storageState)) {
    throw new Error(`${label} storage state가 없습니다.`);
  }
}

function apiHeaders(runtimeEnv: RuntimeQaEnv): Record<string, string> {
  return {
    accept: "application/json",
    "content-type": "application/json",
    ...(runtimeEnv.tenantId ? { "x-tenant-id": runtimeEnv.tenantId } : {})
  };
}

function apiUrl(runtimeEnv: RuntimeQaEnv, pathname: string): string {
  return new URL(pathname, resolveApiBase(runtimeEnv)).toString();
}

function resolveApiBase(runtimeEnv: RuntimeQaEnv): string {
  const envKey = runtimeEnv.environment.toUpperCase();
  const configured =
    process.env[`BIX_API_${envKey}_BASE_URL`] ??
    process.env[`BIX_${envKey}_API_BASE_URL`] ??
    process.env.BIX_API_BASE_URL;
  if (configured) {
    return configured.endsWith("/") ? configured : `${configured}/`;
  }
  const base = new URL(requireBaseUrl(runtimeEnv));
  base.hostname = base.hostname
    .replace(/^admin-dev\./, "api-dev.")
    .replace(/^store-dev\./, "api-dev.")
    .replace(/^admin-stg\./, "api-stg.")
    .replace(/^store-stg\./, "api-stg.");
  return `${base.origin}/`;
}

function requireBaseUrl(runtimeEnv: RuntimeQaEnv): string {
  if (!runtimeEnv.baseUrl) {
    blocked(`${runtimeEnv.role} base URL이 없습니다.`);
  }
  return runtimeEnv.baseUrl;
}

function findHeaderIndex(headers: string[], expected: string): number {
  const index = headers.findIndex((header) => normalizeText(header).replace(/\s/g, "") === expected.replace(/\s/g, ""));
  if (index < 0) {
    throw new Error(`엑셀 헤더를 찾지 못했습니다: ${expected} / ${headers.join(" | ")}`);
  }
  return index;
}

function numericInputValue(value: string): number {
  return Number(value.replace(/,/g, ""));
}

function positiveNumber(value: string | undefined, fallback: number): number {
  const parsed = Number(value);
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function parseJson(value: string): unknown {
  try {
    return JSON.parse(value);
  } catch {
    return undefined;
  }
}

function stringValue(value: unknown): string {
  return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}

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

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

function redactResponse(value: string): string {
  return value
    .replace(/("(?:cardNo|cardNumber)"\s*:\s*")[^"]+("?)/gi, "$1***$2")
    .replace(/("(?:birth|password|cardPassword)"\s*:\s*")[^"]+("?)/gi, "$1***$2");
}

function redactId(value: string): string {
  if (!value) return "-";
  return value.length <= 10 ? `${value.slice(0, 3)}***` : `${value.slice(0, 6)}...${value.slice(-4)}`;
}

function snippet(value: string, length: number): string {
  return normalizeText(value).slice(0, length);
}

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

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

function cssEscape(value: string): string {
  return value.replace(/([ #;?%&,.+*~':"!^$[\]()=>|/@])/g, "\\$1");
}

function escapeXml(value: string): string {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

function decodeXml(value: string): string {
  return value
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&amp;/g, "&")
    .replace(/&quot;/g, '"')
    .replace(/&apos;/g, "'");
}

function columnLetters(index: number): string {
  let value = index;
  let output = "";
  while (value > 0) {
    const remainder = (value - 1) % 26;
    output = String.fromCharCode(65 + remainder) + output;
    value = Math.floor((value - 1) / 26);
  }
  return output;
}

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

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