import {
  expect,
  test,
  type Browser,
  type BrowserContext,
  type Download,
  type Locator,
  type Page,
  type Response,
  type TestInfo
} from "@playwright/test";
import fs from "node:fs";
import { isLoginPage, loginWithCredentials } from "../auth";
import { loadChecklist, loadIssueMetadata } from "../checklist";
import { extractDownloadBufferText } from "../downloadText";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaEnvironment, QaPageDefinition } from "../types";

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

interface PrivacyPageDefinition extends QaPageDefinition {
  roles?: string[];
  excel?: boolean;
  editCheck?: boolean;
}

interface ExcelDocument {
  id: string;
  status?: string;
  filename?: string;
  exposureMode?: string;
}

interface ExcelRequestResult {
  id: string;
  mode: "MASKED" | "UNMASKED";
}

interface DetailResult {
  maskedCharacters: number;
  unmaskedCharacters: number;
  beforeLength: number;
  afterLength: number;
}

const FALLBACK_FROM = "2025-09-01";
const FALLBACK_TO = "2026-09-17";
const FALLBACK_MONTH = "2026-08";
const LOADING_TEXT = /목록을 불러오는 중|데이터를 불러오는 중|로딩 중/i;
const EMPTY_TEXT = /조회된 (내역|데이터)가 없습니다|검색 결과가 없습니다|데이터가 없습니다|총\s*0\s*건/i;
const PERMISSION_DENIED_TEXT = /권한이 없습니다|접근 권한이 없습니다|접근할 수 없습니다/i;

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

export function runPrivacyMaskingRegressionSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const pages = checklist.pages as PrivacyPageDefinition[];
  const requestedRole = process.env.QA_ROLE?.trim().toUpperCase();
  const selectedRole = requestedRole && requestedRole !== "ALL" ? requestedRole : undefined;

  // PIN authentication sessions and the document repository are shared by an
  // account. The canonical command runs this suite with one worker; default
  // mode still allows later checks to run when an earlier screen fails.
  test.describe.configure({ mode: "default" });
  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    for (const definition of pages) {
      for (const role of definition.roles ?? ["ADMIN"]) {
        if (selectedRole && selectedRole !== role) continue;

        test(`[${role}] ${definition.name} / 상세 마스킹 및 PIN 원문 조회`, async ({ browser }, testInfo) => {
          test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 120_000));
          const runtime = getRuntimeQaEnv(options.environment, definition.application ?? "admin", role);
          assertRuntime(runtime);

          await withRolePage(browser, runtime, async (page) => {
            await openTargetPage(page, runtime, definition);
            await ensureSearchData(page);
            const result = await verifyDetailMasking(page, runtime, definition);
            await attachEvidence(testInfo, definition.name, role, "detail-masking", [
              `경로: ${definition.path}`,
              `역할: ${role}`,
              `마스킹 문자 수(해제 전): ${result.maskedCharacters}`,
              `마스킹 문자 수(해제 후): ${result.unmaskedCharacters}`,
              `상세 텍스트 길이(해제 전/후): ${result.beforeLength}/${result.afterLength}`,
              "원문 개인정보는 증적에 기록하지 않았습니다."
            ]);
          });
        });

        if (definition.editCheck) {
          test(`[${role}] ${definition.name} / 수정 모달 마스킹 및 인증 전 수정 차단`, async ({ browser }, testInfo) => {
            test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 120_000));
            const runtime = getRuntimeQaEnv(options.environment, definition.application ?? "admin", role);
            assertRuntime(runtime);

            await withRolePage(browser, runtime, async (page) => {
              await openTargetPage(page, runtime, definition);
              await ensureSearchData(page);
              const result = await verifyEditMasking(page, definition);
              await attachEvidence(testInfo, definition.name, role, "edit-masking", [
                `경로: ${definition.path}`,
                `역할: ${role}`,
                `마스킹된 입력 필드 수: ${result.maskedInputs}`,
                `인증 전 편집 가능한 마스킹 필드 수: ${result.editableMaskedInputs}`,
                "수정이나 저장은 수행하지 않았습니다."
              ]);
            });
          });
        }

        if (definition.excel) {
          test(`[${role}] ${definition.name} / Excel 기본 마스킹 및 PIN 원문 다운로드`, async ({ browser }, testInfo) => {
            test.setTimeout(Number(process.env.QA_EXCEL_TEST_TIMEOUT_MS ?? 300_000));
            const runtime = getRuntimeQaEnv(options.environment, definition.application ?? "admin", role);
            assertRuntime(runtime);

            await withRolePage(browser, runtime, async (page) => {
              await openTargetPage(page, runtime, definition);
              await ensureSearchData(page);
              const result = await verifyExcelMasking(page, runtime, definition);
              await attachEvidence(testInfo, definition.name, role, "excel-masking", [
                `경로: ${definition.path}`,
                `역할: ${role}`,
                `기본 문서 exposureMode: ${result.masked.exposureMode}`,
                `원문 문서 exposureMode: ${result.unmasked.exposureMode}`,
                `기본 Excel 마스킹 문자 수: ${result.maskedStars}`,
                `원문 Excel 마스킹 문자 수: ${result.unmaskedStars}`,
                "다운로드 파일과 원문 개인정보는 증적에 첨부하지 않았습니다."
              ]);
            }, true);
          });
        }
      }
    }
  });
}

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

async function openTargetPage(page: Page, runtime: RuntimeQaEnv, definition: PrivacyPageDefinition): Promise<void> {
  if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 설정되지 않았습니다.`);
  const url = new URL(definition.path, ensureTrailingSlash(runtime.baseUrl!));
  if (definition.tenantParam && runtime.tenantId) {
    url.searchParams.set(definition.tenantParam, runtime.tenantId);
  }

  await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
  await settle(page);
  if (await isLoginPage(page, runtime)) {
    await loginWithCredentials(page, runtime);
    await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
    await settle(page);
  }

  const root = await interactionRoot(page);
  const screenText = normalize(await root.innerText({ timeout: 10_000 }).catch(() => ""));
  const bodyText = normalize(await page.locator("body").innerText({ timeout: 10_000 }).catch(() => ""));
  if (/cloudflare|sorry, you have been blocked|attention required/i.test(bodyText)) {
    blocked(`${runtime.role} ${definition.name} 접근이 Cloudflare에서 차단되었습니다.`);
  }
  expect(screenText, `${runtime.role} ${definition.name} 화면 접근 권한이 있어야 합니다.`).not.toMatch(PERMISSION_DENIED_TEXT);
  expect(await isLoginPage(page, runtime), `${runtime.role} ${definition.name} 접근 중 로그인 화면으로 돌아가면 안 됩니다.`).toBe(false);
}

async function ensureSearchData(page: Page): Promise<void> {
  await waitForLoading(page);
  if (await hasActionableRows(page)) return;

  const root = await interactionRoot(page);
  const visibleInputs = root.locator("input:visible");
  const count = await visibleInputs.count();
  const dateInputs: Locator[] = [];
  const monthInputs: Locator[] = [];
  for (let index = 0; index < count; index += 1) {
    const input = visibleInputs.nth(index);
    const type = (await input.getAttribute("type"))?.toLowerCase();
    const placeholder = (await input.getAttribute("placeholder")) ?? "";
    if (type === "date" || /날짜|YYYY-MM-DD/i.test(placeholder)) dateInputs.push(input);
    if (type === "month" || /YYYY-MM/i.test(placeholder)) monthInputs.push(input);
  }

  if (dateInputs[0]) await setInputValue(dateInputs[0], FALLBACK_FROM);
  if (dateInputs[1]) await setInputValue(dateInputs[1], FALLBACK_TO);
  for (const input of monthInputs) await setInputValue(input, FALLBACK_MONTH);

  const search = await firstVisible(root.locator("button:visible").filter({ hasText: /^(검색|조회)$/ }));
  if (search) {
    await domClick(search);
    await settle(page);
    await waitForLoading(page);
  }
}

async function verifyDetailMasking(
  page: Page,
  runtime: RuntimeQaEnv,
  definition: PrivacyPageDefinition
): Promise<DetailResult> {
  const selector = definition.detailOpenSelector;
  if (!selector) throw new Error(`${definition.name} 상세 버튼 선택자가 정의되지 않았습니다.`);
  const opened = await openDetailWithUnmaskButton(page, selector);
  if (!opened) {
    const bodyText = normalize(await page.locator("body").innerText());
    if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
      blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 상세 검증 데이터가 없습니다.`);
    }
    if ((await page.locator(selector).count()) > 0) {
      blocked(`${runtime.role} ${definition.name} 상세 데이터에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
    }
    throw new Error(`${runtime.role} ${definition.name} 목록에 데이터가 있지만 상세보기 또는 마스킹 해제 버튼이 없습니다.`);
  }

  const unmaskButton = opened.button;
  const surface = opened.surface;
  const before = normalize(await surface.innerText());
  const maskedCharacters = countMaskCharacters(before);
  if (maskedCharacters === 0) {
    await closeSurface(page, surface);
    blocked(`${runtime.role} ${definition.name} 상세에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  }

  await domClick(unmaskButton);
  const pinInput = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"), 10_000);
  if (!pinInput) {
    await closeSurface(page, surface);
    throw new Error(`${runtime.role} ${definition.name} 마스킹 해제 클릭 후 PIN 인증창이 표시되지 않았습니다.`);
  }
  await pinInput.fill(runtime.twoFactorCode ?? "secret-redacted");
  const authSurface = pinInput.locator("xpath=ancestor::*[@role='dialog'][1]");
  const confirmRoot = (await authSurface.count()) > 0 ? authSurface : page.locator("body");
  const confirm = await firstVisible(confirmRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }));
  if (!confirm) {
    await closeSurface(page, surface);
    throw new Error(`${runtime.role} ${definition.name} PIN 인증 확인 버튼을 찾지 못했습니다.`);
  }
  await domClick(confirm);

  const unmaskedState = page.getByRole("button", { name: /마스킹 해제됨/ });
  await expect(unmaskedState, `${runtime.role} ${definition.name} PIN 인증 후 마스킹 해제됨 상태가 표시되어야 합니다.`).toBeVisible({ timeout: 10_000 });
  const currentDialog = unmaskedState.locator("xpath=ancestor::*[@role='dialog'][1]");
  const currentSurface = (await currentDialog.count()) > 0 ? currentDialog : page.locator("body");
  const after = normalize(await currentSurface.innerText({ timeout: 5_000 }));
  const unmaskedCharacters = countMaskCharacters(after);
  const result = {
    maskedCharacters,
    unmaskedCharacters,
    beforeLength: before.length,
    afterLength: after.length
  };

  await closeSurface(page, currentSurface);
  expect(result.afterLength, `${runtime.role} ${definition.name} 원문 전환 후 상세 값이 유지되어야 합니다.`).toBeGreaterThan(0);
  return result;
}

async function openDetailWithUnmaskButton(
  page: Page,
  selector: string
): Promise<{ button: Locator; surface: Locator } | undefined> {
  const root = await interactionRoot(page);
  const candidates = root.locator(selector);
  const count = Math.min(await candidates.count(), 12);
  for (let index = 0; index < count; index += 1) {
    const candidate = candidates.nth(index);
    if (!(await candidate.isVisible().catch(() => false))) continue;
    if (!(await isActiveElement(candidate))) continue;
    await domClick(candidate);
    await page.waitForTimeout(700);
    // Detail dialogs are rendered in a portal outside the keep-alive route.
    const button = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
    if (button) {
      const dialog = button.locator("xpath=ancestor::*[@role='dialog'][1]");
      const surface = (await dialog.count()) > 0 ? dialog : page.locator("body");
      const detailText = normalize(await surface.innerText().catch(() => ""));
      if (countMaskCharacters(detailText) > 0) {
        return { button, surface };
      }
      await closeSurface(page, surface);
      continue;
    }
    await page.keyboard.press("Escape").catch(() => undefined);
  }
  return undefined;
}

async function verifyEditMasking(
  page: Page,
  definition: PrivacyPageDefinition
): Promise<{ maskedInputs: number; editableMaskedInputs: number }> {
  const opened = await openEditModal(page);
  if (!opened) {
    const bodyText = normalize(await page.locator("body").innerText());
    if (EMPTY_TEXT.test(bodyText) || !(await hasActionableRows(page))) {
      blocked(`${definition.name}은 기간 확장 후에도 수정 모달 검증 데이터가 없습니다.`);
    }
    throw new Error(`${definition.name} 목록에 데이터가 있지만 수정 버튼 또는 수정 모달을 찾지 못했습니다.`);
  }

  const modalText = normalize(await opened.surface.innerText());
  expect(modalText, `${definition.name} 수정 모달에 마스킹 해제 기능이 있어야 합니다.`).toMatch(/마스킹 해제/);

  const inputs = opened.surface.locator("input, textarea");
  const inputCount = await inputs.count();
  let maskedInputs = 0;
  let editableMaskedInputs = 0;
  for (let index = 0; index < inputCount; index += 1) {
    const input = inputs.nth(index);
    const value = await input.inputValue().catch(() => "");
    if (!/[＊*•]/.test(value)) continue;
    maskedInputs += 1;
    if (await input.isEditable().catch(() => false)) editableMaskedInputs += 1;
  }

  await closeSurface(page, opened.surface);
  if (maskedInputs === 0) {
    blocked(`${definition.name} 수정 모달에 수정 차단을 검증할 마스킹 입력값이 없습니다.`);
  }
  expect(editableMaskedInputs, `${definition.name} 수정 모달의 마스킹 개인정보는 인증 전에 편집할 수 없어야 합니다.`).toBe(0);
  return { maskedInputs, editableMaskedInputs };
}

async function openEditModal(page: Page): Promise<{ surface: Locator } | undefined> {
  const root = await interactionRoot(page);
  const editCandidates = root.locator(
    "button[aria-label*='수정'], button[title*='수정'], button:has(svg.lucide-pencil), [role='button']:has(svg.lucide-pencil), svg.lucide-pencil"
  );
  const count = Math.min(await editCandidates.count(), 20);
  for (let index = 0; index < count; index += 1) {
    const candidate = editCandidates.nth(index);
    if (!(await candidate.isVisible().catch(() => false))) continue;
    if (!(await isActiveElement(candidate))) continue;
    await candidate.click({ force: true });
    await page.waitForTimeout(700);
    const unmask = await firstVisible(page.getByRole("button", { name: /^마스킹 해제$/ }));
    if (unmask) {
      const dialog = unmask.locator("xpath=ancestor::*[@role='dialog'][1]");
      return { surface: (await dialog.count()) > 0 ? dialog : page.locator("body") };
    }
    await page.keyboard.press("Escape").catch(() => undefined);
  }
  return undefined;
}

async function verifyExcelMasking(
  page: Page,
  runtime: RuntimeQaEnv,
  definition: PrivacyPageDefinition
): Promise<{
  masked: ExcelDocument;
  unmasked: ExcelDocument;
  maskedStars: number;
  unmaskedStars: number;
}> {
  if (!(await hasActionableRows(page))) {
    blocked(`${runtime.role} ${definition.name}은 기간 확장 후에도 Excel 검증 데이터가 없습니다.`);
  }

  const maskedRequest = await requestExcelDocument(page, runtime, definition, false);
  await openTargetPage(page, runtime, definition);
  await ensureSearchData(page);
  const unmaskedRequest = await requestExcelDocument(page, runtime, definition, true);

  const documents = await waitForExcelDocuments(page, runtime, [maskedRequest.id, unmaskedRequest.id]);
  const masked = requireDocument(documents, maskedRequest.id);
  const unmasked = requireDocument(documents, unmaskedRequest.id);
  expect(normalizeMode(masked.exposureMode), `${definition.name} 기본 Excel은 MASKED여야 합니다.`).toBe("MASKED");
  expect(normalizeMode(unmasked.exposureMode), `${definition.name} 원문 Excel은 UNMASKED여야 합니다.`).toBe("UNMASKED");

  const maskedBuffer = await downloadDocument(page, masked);
  const unmaskedBuffer = await downloadDocument(page, unmasked);
  const maskedText = extractDownloadBufferText(maskedBuffer, masked.filename ?? "masked.xlsx");
  const unmaskedText = extractDownloadBufferText(unmaskedBuffer, unmasked.filename ?? "unmasked.xlsx");
  const maskedStars = countMaskCharacters(maskedText);
  const unmaskedStars = countMaskCharacters(unmaskedText);

  if (maskedStars === 0) {
    blocked(`${runtime.role} ${definition.name} 기본 Excel에 마스킹 검증이 가능한 개인정보 값이 없습니다.`);
  }
  expect(unmaskedStars, `${definition.name} 원문 Excel은 기본 Excel보다 마스킹 문자가 적어야 합니다.`).toBeLessThan(maskedStars);
  return { masked, unmasked, maskedStars, unmaskedStars };
}

async function requestExcelDocument(
  page: Page,
  runtime: RuntimeQaEnv,
  definition: PrivacyPageDefinition,
  unmasked: boolean
): Promise<ExcelRequestResult> {
  const interaction = await interactionRoot(page);
  const excelButton = await firstVisible(interaction.locator("button:visible").filter({ hasText: /^엑셀$/ }));
  if (!excelButton) {
    throw new Error(`${runtime.role} ${definition.name} 화면에 Excel 버튼이 표시되지 않습니다.`);
  }
  await domClick(excelButton);
  await page.waitForTimeout(300);

  let dialog = await findExcelDialog(page);
  if (!dialog) {
    const menu = await firstVisible(
      page.locator("li:visible, [role='menuitem']:visible, button:visible, a:visible")
        .filter({ hasText: /조회결과\s*다운로드|엑셀\s*다운로드/ })
    );
    if (!menu) throw new Error(`${runtime.role} ${definition.name} Excel 조회결과 다운로드 메뉴가 없습니다.`);
    await domClick(menu);
    dialog = await findExcelDialog(page, 10_000);
  }
  if (!dialog) throw new Error(`${definition.name} Excel 마스킹 안내 모달이 표시되지 않았습니다.`);
  const root = dialog;
  const checkbox = root.getByLabel(/마스킹 해제 후 다운로드/).last();
  if (unmasked) {
    await expect(checkbox, `${definition.name} Excel 원문 다운로드 선택 항목이 표시되어야 합니다.`).toBeVisible();
    await checkbox.check();
  }

  const responses: Response[] = [];
  const observedRequests: string[] = [];
  const requestListener = (request: { method: () => string; url: () => string }): void => {
    if (/excel|masking|authentication/i.test(request.url())) {
      observedRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
    }
  };
  const listener = (response: Response): void => {
    const request = response.request();
    if (request.method() !== "POST") return;
    if (!/\/api\/v1\/excel(?:[/?]|$)/.test(response.url())) return;
    if (/export-authorizations/.test(response.url())) return;
    responses.push(response);
  };
  page.on("request", requestListener);
  page.on("response", listener);
  try {
    const downloadButton = await firstVisible(root.getByRole("button", { name: /다운로드/ }));
    if (!downloadButton) throw new Error(`${definition.name} Excel 다운로드 실행 버튼을 찾지 못했습니다.`);
    await domClick(downloadButton);

    if (unmasked) {
      const pin = await firstVisible(page.locator("input[placeholder*='PIN'], input[name='pin'], input[type='password']"), 10_000);
      if (!pin) throw new Error(`${definition.name} 원문 Excel 선택 후 PIN 인증창이 표시되지 않았습니다.`);
      await pin.fill(runtime.twoFactorCode ?? "secret-redacted");
      const pinDialog = pin.locator("xpath=ancestor::*[@role='dialog'][1]");
      const pinRoot = (await pinDialog.count()) > 0 ? pinDialog : page.locator("body");
      const confirm = await firstVisible(pinRoot.locator("button:visible").filter({ hasText: /^(확인|인증)$/ }), 5_000);
      if (!confirm) {
        const buttonNames = (await pinRoot.locator("button:visible").allTextContents()).map(normalize).filter(Boolean);
        throw new Error(`${definition.name} 원문 Excel PIN 확인 버튼을 찾지 못했습니다. 표시 버튼=${buttonNames.join(" | ")}`);
      }
      await confirm.click({ force: true });
    }

    const response = await waitForCollectedExcelResponse(page, responses, observedRequests);
    const json = await response.json().catch(() => undefined);
    const id = findDocumentId(json);
    if (!id) throw new Error(`${definition.name} Excel 생성 응답에서 문서 ID를 찾지 못했습니다.`);
    await closeVisibleDialogs(page);
    return { id, mode: unmasked ? "UNMASKED" : "MASKED" };
  } finally {
    page.off("request", requestListener);
    page.off("response", listener);
  }
}

async function findExcelDialog(page: Page, timeoutMs = 1_000): Promise<Locator | undefined> {
  const checkbox = await firstVisible(page.getByLabel(/마스킹 해제 후 다운로드/), timeoutMs);
  if (!checkbox) return undefined;
  const dialog = checkbox.locator("xpath=ancestor::*[@role='dialog'][1]");
  return (await dialog.count()) > 0 ? dialog : page.locator("body");
}

async function waitForCollectedExcelResponse(
  page: Page,
  responses: Response[],
  observedRequests: string[]
): Promise<Response> {
  const deadline = Date.now() + 30_000;
  while (Date.now() < deadline) {
    if (responses.length > 0) return responses.at(-1)!;
    await page.waitForTimeout(200);
  }
  throw new Error(`Excel 생성 POST 응답을 30초 안에 확인하지 못했습니다. 관련 요청=${observedRequests.join(" | ") || "없음"}`);
}

async function waitForExcelDocuments(
  page: Page,
  runtime: RuntimeQaEnv,
  ids: string[]
): Promise<ExcelDocument[]> {
  if (!runtime.baseUrl) blocked(`${runtime.role} baseUrl이 없습니다.`);
  const documentUrl = new URL("/document", ensureTrailingSlash(runtime.baseUrl!));
  if (runtime.tenantId) documentUrl.searchParams.set("tenantId", runtime.tenantId);
  const deadline = Date.now() + 120_000;
  let latest: ExcelDocument[] = [];

  while (Date.now() < deadline) {
    const responsePromise = page.waitForResponse(
      (response) => response.request().method() === "GET" && /\/api\/v1\/excel\?/.test(response.url()),
      { timeout: 15_000 }
    ).catch(() => undefined);
    await page.goto(documentUrl.toString(), { waitUntil: "domcontentloaded", timeout: 30_000 });
    const response = await responsePromise;
    await settle(page);
    if (response) {
      const json = await response.json().catch(() => undefined);
      latest = collectDocuments(json);
      const selected = ids.map((id) => latest.find((document) => document.id === id)).filter(Boolean) as ExcelDocument[];
      if (selected.length === ids.length && selected.every((document) => normalizeMode(document.status) === "AVAILABLE")) {
        return selected;
      }
    }
    await page.waitForTimeout(2_000);
  }
  blocked(`문서보관함에서 Excel ${ids.join(", ")} 생성 완료를 120초 안에 확인하지 못했습니다. 마지막 문서 수=${latest.length}`);
}

async function downloadDocument(page: Page, document: ExcelDocument): Promise<Buffer> {
  const root = await interactionRoot(page);
  const row = root.locator(`[data-id='${cssEscape(document.id)}']`).first();
  await expect(row, `문서보관함에 ${document.id} 행이 표시되어야 합니다.`).toBeVisible({ timeout: 10_000 });
  const control = await firstVisible(row.getByRole("link", { name: /다운로드/ }))
    ?? await firstVisible(row.getByRole("button", { name: /다운로드/ }))
    ?? await firstVisible(row.getByText("다운로드", { exact: true }));
  if (!control) throw new Error(`${document.id} 문서 다운로드 버튼을 찾지 못했습니다.`);
  const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
  await domClick(control);
  const download = await downloadPromise;
  return readDownloadBuffer(download);
}

async function readDownloadBuffer(download: Download): Promise<Buffer> {
  const filePath = await download.path();
  if (!filePath) throw new Error(`${download.suggestedFilename()} 다운로드 임시 파일 경로가 없습니다.`);
  return fs.readFileSync(filePath);
}

function findDocumentId(value: unknown): string | undefined {
  if (typeof value === "string" && /^[a-zA-Z0-9-]{16,}$/.test(value)) return value;
  if (!value || typeof value !== "object") return undefined;
  const object = value as Record<string, unknown>;
  for (const key of ["documentId", "excelId", "id"]) {
    const candidate = object[key];
    if (typeof candidate === "string" && candidate.length >= 16) return candidate;
  }
  for (const key of ["payload", "data", "result"]) {
    const candidate = findDocumentId(object[key]);
    if (candidate) return candidate;
  }
  return undefined;
}

function collectDocuments(value: unknown): ExcelDocument[] {
  const result: ExcelDocument[] = [];
  const visit = (candidate: unknown): void => {
    if (!candidate || typeof candidate !== "object") return;
    if (Array.isArray(candidate)) {
      candidate.forEach(visit);
      return;
    }
    const object = candidate as Record<string, unknown>;
    if (typeof object.id === "string" && (object.filename || object.fileName || object.exposureMode || object.status)) {
      result.push({
        id: object.id,
        status: String(object.status ?? ""),
        filename: String(object.filename ?? object.fileName ?? ""),
        exposureMode: String(object.exposureMode ?? "")
      });
    }
    for (const nested of Object.values(object)) visit(nested);
  };
  visit(value);
  return result;
}

function requireDocument(documents: ExcelDocument[], id: string): ExcelDocument {
  const document = documents.find((candidate) => candidate.id === id);
  if (!document) throw new Error(`문서보관함 응답에서 ${id} 문서를 찾지 못했습니다.`);
  return document;
}

async function hasActionableRows(page: Page): Promise<boolean> {
  const root = await interactionRoot(page);
  // Header-only grids expose a role=row even when the result set is empty.
  const rows = root.locator("tbody tr:visible, [role='row']:visible:has([role='cell'])");
  const count = await rows.count().catch(() => 0);
  for (let index = 0; index < Math.min(count, 30); index += 1) {
    if (!(await isActiveElement(rows.nth(index)))) continue;
    const text = normalize(await rows.nth(index).innerText().catch(() => ""));
    if (text && !EMPTY_TEXT.test(text) && !/No rows/i.test(text)) return true;
  }
  const screen = normalize(await root.innerText().catch(() => ""));
  return /총\s*[1-9][0-9,]*(?:\s*\/\s*[0-9,]+)?\s*건/.test(screen);
}

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

async function waitForLoading(page: Page): Promise<void> {
  const deadline = Date.now() + 15_000;
  while (Date.now() < deadline) {
    const text = normalize(await page.locator("body").innerText().catch(() => ""));
    if (!LOADING_TEXT.test(text)) return;
    await page.waitForTimeout(300);
  }
}

async function closeSurface(page: Page, surface: Locator): Promise<void> {
  const close = await firstVisible(surface.getByRole("button", { name: /^(닫기|취소)$/ }))
    ?? await firstVisible(surface.locator("button[aria-label='Close'], button[aria-label='닫기']"));
  if (close) await domClick(close).catch(() => undefined);
  else await page.keyboard.press("Escape").catch(() => undefined);
  await page.waitForTimeout(200);
}

async function closeVisibleDialogs(page: Page): Promise<void> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const dialog = await firstVisible(page.locator("[role='dialog']:visible"));
    if (!dialog) return;
    const close = await firstVisible(dialog.getByRole("button", { name: /^(확인|닫기|취소)$/ }));
    if (close) await domClick(close).catch(() => undefined);
    else await page.keyboard.press("Escape").catch(() => undefined);
    await page.waitForTimeout(200);
  }
}

async function setInputValue(input: Locator, value: string): Promise<void> {
  await input.evaluate((element, nextValue) => {
    const target = element as HTMLInputElement;
    const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
    setter?.call(target, nextValue);
    target.dispatchEvent(new Event("input", { bubbles: true }));
    target.dispatchEvent(new Event("change", { bubbles: true }));
  }, value);
}

async function firstVisible(locator: Locator, timeoutMs = 1_000): Promise<Locator | undefined> {
  const deadline = Date.now() + timeoutMs;
  do {
    const count = await locator.count().catch(() => 0);
    for (let index = 0; index < count; index += 1) {
      const candidate = locator.nth(index);
      const visible = await candidate.isVisible().catch(() => false);
      if (!visible) continue;
      const active = await candidate.evaluate((element) => {
        const hiddenAncestor = element.closest("[inert], [aria-hidden='true']");
        return !hiddenAncestor;
      }).catch(() => false);
      if (active) return candidate;
    }
    if (Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 100));
  } while (Date.now() < deadline);
  return undefined;
}

async function domClick(locator: Locator): Promise<void> {
  await locator.evaluate((element) => (element as HTMLElement).click());
}

async function isActiveElement(locator: Locator): Promise<boolean> {
  return locator.evaluate((element) => !element.closest("[inert], [aria-hidden='true']")).catch(() => false);
}

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

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

function assertRuntime(runtime: RuntimeQaEnv): void {
  if (!runtime.baseUrl) blocked(`${runtime.application}/${runtime.environment}/${runtime.role} baseUrl이 없습니다.`);
  if (!runtime.storageState && (!runtime.loginUrl || !runtime.username || !runtime.password)) {
    blocked(`${runtime.application}/${runtime.environment}/${runtime.role} 로그인 상태 또는 계정 정보가 없습니다.`);
  }
}

function countMaskCharacters(value: string): number {
  return (value.match(/[＊*•]/g) ?? []).length;
}

function normalizeMode(value: string | undefined): string {
  return String(value ?? "").trim().toUpperCase();
}

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

function ensureTrailingSlash(value: string): string {
  return value.endsWith("/") ? value : `${value}/`;
}

function cssEscape(value: string): string {
  return value.replace(/['\\]/g, "\\$&");
}

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

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