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

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

type AssertionSurface = "screen" | "excel";

export function runAccessHistoryPermissionSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const checklistPath = getChecklistPath(options.issueId, options.environment);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    for (const role of resolveChecklistExecutionRoles(checklist)) {
      test.describe(`[${role}] role`, () => {
        for (const pageDefinition of checklist.pages) {
          if (!pageRunsForRole(checklist, pageDefinition, role)) {
            continue;
          }

          const runtimeEnv = getRuntimeQaEnv(
            options.environment,
            pageDefinition.application ?? "admin",
            role
          );
          const blockReason = getRuntimeBlockReason(runtimeEnv);
          const roleBlockReason = pageDefinition.blockedByRole?.[role];

          test.describe(pageDefinition.name, () => {
            test.skip(!!blockReason, blockReason);
            test.skip(!!roleBlockReason, roleBlockReason);

            test("변경이력 진입 권한이 role 정책과 일치한다", async ({ browser }, testInfo) => {
              await withRolePage(browser, runtimeEnv, async (page) => {
                await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
                const expectation = getAccessExpectation(pageDefinition, role);

                if (expectation === "denied") {
                  const denied = await isAccessDenied(page, pageDefinition);
                  const historyEntryVisible = await getHistoryEntry(page, pageDefinition)
                    .first()
                    .isVisible({ timeout: 2_000 })
                    .catch(() => false);
                  expect(
                    denied || !historyEntryVisible,
                    `${role} role은 ${pageDefinition.name} 변경이력 진입이 제한되어야 합니다.`
                  ).toBe(true);
                  await attachPageScreenshot(page, testInfo, pageDefinition, role, "access-denied");
                  return;
                }

                await expect(
                  getHistoryEntry(page, pageDefinition).first(),
                  `${role} role은 ${pageDefinition.name} 변경이력 화면 또는 버튼에 접근 가능해야 합니다. ` +
                    `${checklistPath}의 historyButtonSelector/path를 확인하세요.`
                ).toBeVisible();
                await attachPageScreenshot(page, testInfo, pageDefinition, role, "access-allowed");
              });
            });

            test("권한 범위의 변경이력만 노출된다", async ({ browser }, testInfo) => {
              test.skip(
                getAccessExpectation(pageDefinition, role) === "denied",
                `${role} role은 접근 제한 대상이므로 범위 검증을 생략합니다.`
              );
              test.skip(
                !hasRoleTextExpectations(pageDefinition, role),
                `${checklistPath}에 ${role} role의 expected/forbidden 노출 텍스트를 설정해야 합니다.`
              );

              await withRolePage(browser, runtimeEnv, async (page) => {
                await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
                await openHistoryEntry(page, pageDefinition);
                await assertRoleTextExpectations(page.locator("body"), pageDefinition, role);
                await attachPageScreenshot(page, testInfo, pageDefinition, role, "history-scope");
              });
            });

            test("상세 구성원 목록이 role 범위와 일치한다", async ({ browser }, testInfo) => {
              test.skip(
                getAccessExpectation(pageDefinition, role) === "denied",
                `${role} role은 접근 제한 대상이므로 상세 구성원 검증을 생략합니다.`
              );
              test.skip(
                !pageDefinition.detailOpenSelector || !pageDefinition.memberListSelector,
                `${checklistPath}에 detailOpenSelector/memberListSelector를 설정해야 합니다.`
              );
              test.skip(
                !hasDetailTextExpectations(pageDefinition, role),
                `${checklistPath}에 ${role} role의 상세 구성원 expected/forbidden 텍스트를 설정해야 합니다.`
              );

              await withRolePage(browser, runtimeEnv, async (page) => {
                await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
                await page.locator(pageDefinition.detailOpenSelector!).first().click();
                await waitForNavigationToSettle(page);
                const memberList = page.locator(pageDefinition.memberListSelector!).first();
                await expect(memberList).toBeVisible();
                await assertDetailTextExpectations(memberList, pageDefinition, role);
                await attachPageScreenshot(page, testInfo, pageDefinition, role, "detail-members");
              });
            });

            test("엑셀 다운로드 범위가 화면 노출 범위와 일치한다", async ({ browser }, testInfo) => {
              test.setTimeout(
                Math.max(240_000, (pageDefinition.excelDocumentReadyTimeoutMs ?? 10_000) + 180_000)
              );
              test.skip(
                getAccessExpectation(pageDefinition, role) === "denied",
                `${role} role은 접근 제한 대상이므로 엑셀 다운로드 검증을 생략합니다.`
              );
              test.skip(
                !pageDefinition.excelDownloadButtonSelector,
                `${checklistPath}에 excelDownloadButtonSelector를 설정해야 합니다.`
              );
              test.skip(
                !hasExcelTextExpectations(pageDefinition, role),
                `${checklistPath}에 ${role} role의 엑셀 expected/forbidden 텍스트를 설정해야 합니다.`
              );

              await withRolePage(browser, runtimeEnv, async (page) => {
                await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
                await openHistoryEntry(page, pageDefinition);

                const downloadPromise = page
                  .waitForEvent("download", {
                    timeout: pageDefinition.excelDirectDownloadTimeoutMs ?? 3_000
                  })
                  .catch(() => undefined);
                await page.locator(pageDefinition.excelDownloadButtonSelector!).first().click();
                const directDownload = await downloadPromise;

                if (directDownload) {
                  await assertDirectDownloadText(directDownload, pageDefinition, role, testInfo);
                  return;
                }

                await assertQueuedExcelRequest(page, pageDefinition);
                await assertQueuedDocumentDownloadText(page, pageDefinition, runtimeEnv, role, testInfo);
              });
            });
          });
        }
      });
    }
  });
}

function getRuntimeBlockReason(runtimeEnv: RuntimeQaEnv): string | undefined {
  const appKey = runtimeEnv.application.toUpperCase();
  const envKey = runtimeEnv.environment.toUpperCase();

  if (!runtimeEnv.baseUrl) {
    return `BIX_${appKey}_${envKey}_BASE_URL 또는 BIX_${appKey}_${envKey}_LOGIN_URL이 필요합니다.`;
  }

  if (!runtimeEnv.tenantId) {
    return `BIX_${appKey}_${envKey}_TENANT_ID가 필요합니다.`;
  }

  if (!runtimeEnv.storageState && (!runtimeEnv.username || !runtimeEnv.password || !runtimeEnv.loginUrl)) {
    return `${runtimeEnv.application}/${runtimeEnv.environment}/${runtimeEnv.role} role의 storageState 또는 로그인 계정 정보가 필요합니다.`;
  }

  return undefined;
}

async function withRolePage(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  callback: (page: Page) => Promise<void>
): Promise<void> {
  const context = await browser.newContext(
    runtimeEnv.storageState
      ? { storageState: runtimeEnv.storageState, acceptDownloads: true }
      : { acceptDownloads: true }
  );
  const page = await context.newPage();

  try {
    await callback(page);
  } finally {
    await context.close();
  }
}

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

  const targetUrl = buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!);
  await page.goto(targetUrl);
  await waitForNavigationToSettle(page);

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

  expect(await isLoginPage(page, runtimeEnv), `${pageDefinition.name} 업무 화면 진입 전 로그인 화면을 벗어나야 합니다.`).toBe(false);
}

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

function getHistoryEntry(page: Page, pageDefinition: QaPageDefinition): Locator {
  if (pageDefinition.historyButtonSelector) {
    return page.locator(pageDefinition.historyButtonSelector);
  }

  return page.getByRole("button", { name: /변경이력|이력|History/i }).or(
    page.getByRole("link", { name: /변경이력|이력|History/i })
  );
}

async function openHistoryEntry(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const historyEntry = getHistoryEntry(page, pageDefinition).first();
  await expect(
    historyEntry,
    `${pageDefinition.name} 변경이력 화면 또는 버튼에 접근 가능해야 합니다.`
  ).toBeVisible();
  await historyEntry.click();
  await waitForNavigationToSettle(page);
}

async function isAccessDenied(page: Page, pageDefinition: QaPageDefinition): Promise<boolean> {
  const bodyText = await page.locator("body").innerText({ timeout: 2_000 }).catch(() => "");
  const deniedPatterns = pageDefinition.accessDeniedText ?? [
    "권한이 없습니다",
    "접근 권한",
    "접근이 제한",
    "Forbidden",
    "Unauthorized",
    "404"
  ];

  return deniedPatterns.some((text) => bodyText.includes(text));
}

function getAccessExpectation(
  pageDefinition: QaPageDefinition,
  role: string
): "allowed" | "denied" {
  return pageDefinition.accessByRole?.[role] ?? "allowed";
}

function hasRoleTextExpectations(pageDefinition: QaPageDefinition, role: string): boolean {
  return (
    (pageDefinition.expectedVisibleTextsByRole?.[role]?.length ?? 0) > 0 ||
    (pageDefinition.forbiddenVisibleTextsByRole?.[role]?.length ?? 0) > 0
  );
}

function hasExcelTextExpectations(pageDefinition: QaPageDefinition, role: string): boolean {
  return (
    (pageDefinition.excelExpectedVisibleTextsByRole?.[role]?.length ?? 0) > 0 ||
    (pageDefinition.excelForbiddenVisibleTextsByRole?.[role]?.length ?? 0) > 0
  );
}

function hasDetailTextExpectations(pageDefinition: QaPageDefinition, role: string): boolean {
  return (
    (pageDefinition.detailExpectedVisibleTextsByRole?.[role]?.length ?? 0) > 0 ||
    (pageDefinition.detailForbiddenVisibleTextsByRole?.[role]?.length ?? 0) > 0
  );
}

async function assertRoleTextExpectations(
  locator: Locator,
  pageDefinition: QaPageDefinition,
  role: string
): Promise<void> {
  const text = await locator.innerText();
  assertTextExpectations(text, pageDefinition, role, "screen");
}

async function assertDetailTextExpectations(
  locator: Locator,
  pageDefinition: QaPageDefinition,
  role: string
): Promise<void> {
  const text = await locator.innerText();
  for (const expectedText of pageDefinition.detailExpectedVisibleTextsByRole?.[role] ?? []) {
    expect(text, `${role} role 상세 구성원 목록에 '${expectedText}'가 보여야 합니다.`).toContain(expectedText);
  }
  for (const forbiddenText of pageDefinition.detailForbiddenVisibleTextsByRole?.[role] ?? []) {
    expect(text, `${role} role 상세 구성원 목록에 '${forbiddenText}'가 보이면 안 됩니다.`).not.toContain(forbiddenText);
  }
}

function assertTextExpectations(
  text: string,
  pageDefinition: QaPageDefinition,
  role: string,
  surface: AssertionSurface
): void {
  const surfaceLabel = surface === "excel" ? "엑셀" : "화면";
  for (const expectedText of getExpectedTexts(pageDefinition, role, surface)) {
    expect(text, `${role} role ${surfaceLabel}에 '${expectedText}'가 보여야 합니다.`).toContain(expectedText);
  }
  for (const forbiddenText of getForbiddenTexts(pageDefinition, role, surface)) {
    expect(text, `${role} role ${surfaceLabel}에 '${forbiddenText}'가 보이면 안 됩니다.`).not.toContain(forbiddenText);
  }
}

function getExpectedTexts(
  pageDefinition: QaPageDefinition,
  role: string,
  surface: AssertionSurface
): string[] {
  if (surface === "excel" && hasOwnRole(pageDefinition.excelExpectedVisibleTextsByRole, role)) {
    return pageDefinition.excelExpectedVisibleTextsByRole?.[role] ?? [];
  }
  if (surface === "excel") {
    return [];
  }

  return pageDefinition.expectedVisibleTextsByRole?.[role] ?? [];
}

function getForbiddenTexts(
  pageDefinition: QaPageDefinition,
  role: string,
  surface: AssertionSurface
): string[] {
  if (surface === "excel" && hasOwnRole(pageDefinition.excelForbiddenVisibleTextsByRole, role)) {
    return pageDefinition.excelForbiddenVisibleTextsByRole?.[role] ?? [];
  }
  if (surface === "excel") {
    return [];
  }

  return pageDefinition.forbiddenVisibleTextsByRole?.[role] ?? [];
}

function hasOwnRole(
  valuesByRole: Record<string, string[]> | undefined,
  role: string
): boolean {
  return !!valuesByRole && Object.prototype.hasOwnProperty.call(valuesByRole, role);
}

async function assertDirectDownloadText(
  download: Download,
  pageDefinition: QaPageDefinition,
  role: string,
  testInfo: TestInfo
): Promise<void> {
  if (!("suggestedFilename" in download)) {
    throw new Error("download 이벤트를 해석할 수 없습니다.");
  }

  const downloadPath = testInfo.outputPath(
    `${safeFilename(pageDefinition.name)}-${role}-download${path.extname(download.suggestedFilename())}`
  );
  await download.saveAs(downloadPath);
  await testInfo.attach(download.suggestedFilename(), {
    path: downloadPath,
    contentType: "application/octet-stream"
  });

  const downloadText = extractDownloadText(downloadPath);
  const rows = extractDownloadRows(fs.readFileSync(downloadPath), downloadPath);
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-download-text.txt`, {
    body: downloadText,
    contentType: "text/plain"
  });
  assertExcelExpectations(downloadText, rows, pageDefinition, role);
}

async function assertQueuedExcelRequest(
  page: Page,
  pageDefinition: QaPageDefinition
): Promise<void> {
  for (const successText of pageDefinition.excelQueuedSuccessText ?? []) {
    await expect(
      page.locator("body"),
      `엑셀 생성 요청 후 '${successText}' 문구가 보여야 합니다.`
    ).toContainText(successText);
  }
}

async function assertQueuedDocumentDownloadText(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  role: string,
  testInfo: TestInfo
): Promise<void> {
  if (!pageDefinition.excelDocumentPath || !pageDefinition.excelDocumentDownloadSelector) {
    throw new Error("queued 엑셀 검증에는 excelDocumentPath/excelDocumentDownloadSelector가 필요합니다.");
  }

  const documentUrl = buildAuxiliaryUrl(
    runtimeEnv.baseUrl!,
    pageDefinition.excelDocumentPath,
    runtimeEnv.tenantId!
  );
  await page.goto(documentUrl);
  await waitForNavigationToSettle(page);

  const redirectedFromDocument = !isSamePath(page.url(), documentUrl);
  await openDownloadHistoryPanel(page);

  const fileNameText = pageDefinition.excelDocumentFileNameIncludes;
  if (fileNameText) {
    try {
      await expect(
        page.locator("body"),
        `문서보관함에 '${fileNameText}' 파일이 생성되어야 합니다.`
      ).toContainText(fileNameText, { timeout: pageDefinition.excelDocumentReadyTimeoutMs ?? 10_000 });
    } catch (error) {
      await attachPageScreenshot(page, testInfo, pageDefinition, role, "excel-document-blocked");
      const redirectDetail = redirectedFromDocument
        ? ` 문서보관함 경로가 '${new URL(documentUrl).pathname}'에서 '${new URL(page.url()).pathname}'로 리다이렉트되었습니다.`
        : "";
      throw new Error(
        `BLOCKED: 다운로드 이력에서 '${fileNameText}' 생성 파일을 확인할 수 없습니다.${redirectDetail} ` +
          `currentUrl=${page.url()} cause=${formatError(error)}`
      );
    }
  }

  const documentEntry = fileNameText
    ? await findDocumentEntry(page, fileNameText)
    : undefined;
  const downloadControl = await findDocumentDownloadControl(
    page,
    documentEntry,
    pageDefinition.excelDocumentDownloadSelector
  );

  await expect(downloadControl, "문서보관함 다운로드 버튼이 보여야 합니다.").toBeVisible();

  const responsePattern = new RegExp(
    pageDefinition.excelResponseUrlPattern ?? String.raw`\.xlsx(\?|$)|cloudflarestorage.*excel`,
    "i"
  );
  const downloadPromise = page.waitForEvent("download", { timeout: 30_000 }).catch(() => undefined);
  const responsePromise = page
    .waitForResponse(
      (response) => responsePattern.test(response.url()),
      { timeout: 30_000 }
    )
    .catch(() => undefined);
  await downloadControl.click();
  const directDownload = await downloadPromise;
  if (directDownload) {
    await assertDirectDownloadText(directDownload, pageDefinition, role, testInfo);
    return;
  }

  const response = await responsePromise;
  if (!response) {
    throw new Error("BLOCKED: 다운로드 이력 파일을 찾았지만 엑셀 다운로드 응답을 감지하지 못했습니다.");
  }
  const buffer = await readResponseBody(response.url(), response.body.bind(response));
  const fileName = guessDownloadFileNameFromUrl(
    response.url(),
    `${safeFilename(pageDefinition.name)}-${role}-document.xlsx`
  );
  const downloadPath = testInfo.outputPath(fileName);
  fs.writeFileSync(downloadPath, buffer);
  await testInfo.attach(fileName, {
    path: downloadPath,
    contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  });

  const downloadText = extractDownloadBufferText(buffer, fileName);
  const rows = extractDownloadRows(buffer, fileName);
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-document-download-text.txt`, {
    body: downloadText,
    contentType: "text/plain"
  });
  assertExcelExpectations(downloadText, rows, pageDefinition, role);
}

function assertExcelExpectations(
  text: string,
  rows: string[][],
  pageDefinition: QaPageDefinition,
  role: string
): void {
  assertTextExpectations(text, pageDefinition, role, "excel");
  assertExcelColumnExpectations(rows, pageDefinition);
  assertExcelGrouping(rows, pageDefinition);
}

function assertExcelColumnExpectations(rows: string[][], pageDefinition: QaPageDefinition): void {
  const expectedHeaders = pageDefinition.excelExpectedColumnHeaders ?? [];
  const forbiddenHeaders = pageDefinition.excelForbiddenColumnHeaders ?? [];
  if (expectedHeaders.length === 0 && forbiddenHeaders.length === 0) {
    return;
  }

  const headerRow = findHeaderRow(rows, [...expectedHeaders, ...forbiddenHeaders]);
  expect(headerRow, "엑셀 헤더 행을 찾을 수 있어야 합니다.").toBeTruthy();
  const normalizedHeaders = new Set(headerRow!.map(normalizeCell));

  for (const expectedHeader of expectedHeaders) {
    expect(
      normalizedHeaders.has(normalizeCell(expectedHeader)),
      `엑셀에 '${expectedHeader}' 컬럼이 보여야 합니다.`
    ).toBe(true);
  }

  for (const forbiddenHeader of forbiddenHeaders) {
    expect(
      normalizedHeaders.has(normalizeCell(forbiddenHeader)),
      `엑셀에 '${forbiddenHeader}' 컬럼이 노출되면 안 됩니다.`
    ).toBe(false);
  }
}

function assertExcelGrouping(rows: string[][], pageDefinition: QaPageDefinition): void {
  const groupByHeader = pageDefinition.excelGroupByColumnHeader;
  if (!groupByHeader) {
    return;
  }

  const headerRowIndex = findHeaderRowIndex(rows, [groupByHeader]);
  expect(headerRowIndex, `엑셀에서 '${groupByHeader}' 헤더 행을 찾을 수 있어야 합니다.`).toBeGreaterThanOrEqual(0);
  const headerRow = rows[headerRowIndex];
  const groupColumnIndex = headerRow.findIndex((cell) => normalizeCell(cell) === normalizeCell(groupByHeader));
  expect(groupColumnIndex, `엑셀에서 '${groupByHeader}' 컬럼 위치를 찾을 수 있어야 합니다.`).toBeGreaterThanOrEqual(0);

  const dataRows = rows
    .slice(headerRowIndex + 1)
    .filter((row) => row.some((cell) => cell.trim()));
  const groupValues = dataRows
    .map((row) => row[groupColumnIndex]?.trim() ?? "")
    .filter(Boolean);
  expect(groupValues.length, "엑셀에 계약 이름 기준 데이터 행이 있어야 합니다.").toBeGreaterThan(0);

  const counts = new Map<string, number>();
  const groupedSpanCounts = new Map<string, number>();
  const completedGroups = new Set<string>();
  let currentGroup = "";
  let currentGroupRowCount = 0;
  for (const row of dataRows) {
    const value = row[groupColumnIndex]?.trim() ?? "";
    if (!value) {
      if (currentGroup && rowHasDataOutsideColumn(row, groupColumnIndex)) {
        currentGroupRowCount += 1;
      }
      continue;
    }

    counts.set(value, (counts.get(value) ?? 0) + 1);
    if (currentGroup && currentGroupRowCount > 1) {
      groupedSpanCounts.set(currentGroup, currentGroupRowCount);
    }
    if (currentGroup) {
      completedGroups.add(currentGroup);
    }
    expect(
      completedGroups.has(value),
      `엑셀에서 '${value}' 계약 이력이 계약 이름 기준으로 연속 묶음이어야 합니다.`
    ).toBe(false);
    currentGroup = value;
    currentGroupRowCount = 1;
  }
  if (currentGroup && currentGroupRowCount > 1) {
    groupedSpanCounts.set(currentGroup, currentGroupRowCount);
  }

  if (pageDefinition.excelRequireRepeatedGroup) {
    const repeatedGroupCount = Array.from(counts.values()).filter((count) => count > 1).length;
    expect(
      repeatedGroupCount + groupedSpanCounts.size,
      "엑셀에 같은 계약 이름으로 묶인 변경이력이 1개 이상 있어야 합니다."
    ).toBeGreaterThan(0);
  }
}

function rowHasDataOutsideColumn(row: string[], columnIndex: number): boolean {
  return row.some((cell, index) => index !== columnIndex && cell.trim());
}

function findHeaderRow(rows: string[][], candidates: string[]): string[] | undefined {
  const index = findHeaderRowIndex(rows, candidates);
  return index >= 0 ? rows[index] : undefined;
}

function findHeaderRowIndex(rows: string[][], candidates: string[]): number {
  const normalizedCandidates = candidates.map(normalizeCell);
  return rows.findIndex((row) => {
    const normalizedCells = new Set(row.map(normalizeCell));
    return normalizedCandidates.some((candidate) => normalizedCells.has(candidate));
  });
}

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

async function findDocumentDownloadControl(
  page: Page,
  documentEntry: Locator | undefined,
  configuredSelector: string | undefined
): Promise<Locator> {
  const scope = documentEntry ?? page.locator("body");
  const candidates = [
    configuredSelector ? scope.locator(configuredSelector).first() : undefined,
    scope.getByRole("button", { name: /다운로드/ }).first(),
    scope.getByRole("link", { name: /다운로드/ }).first(),
    scope.getByText("다운로드", { exact: true }).first(),
    documentEntry
  ].filter((candidate): candidate is Locator => !!candidate);

  for (const candidate of candidates) {
    if (await candidate.isVisible({ timeout: 1_000 }).catch(() => false)) {
      return candidate;
    }
  }

  return candidates[0] ?? page.getByText("다운로드", { exact: true }).first();
}

async function findDocumentEntry(page: Page, fileNameText: string): Promise<Locator | undefined> {
  const todayToken = formatKstDateToken(new Date());
  const currentFileName = page
    .getByText(new RegExp(`${escapeRegExp(fileNameText)}.*${todayToken}|${todayToken}.*${escapeRegExp(fileNameText)}`))
    .first();
  if (await currentFileName.isVisible({ timeout: 1_000 }).catch(() => false)) {
    return currentFileName.locator("xpath=ancestor::*[contains(normalize-space(.), '다운로드')][1]");
  }

  const anyFileName = page.getByText(fileNameText, { exact: false }).first();
  if (await anyFileName.isVisible({ timeout: 1_000 }).catch(() => false)) {
    const hasVisibleFailure = await page.getByText("실패", { exact: true }).first().isVisible({ timeout: 1_000 }).catch(() => false);
    if (hasVisibleFailure) {
      throw new Error(`엑셀 생성 요청이 실패 상태로 표시되었습니다. currentDateToken=${todayToken}`);
    }
    return anyFileName.locator("xpath=ancestor::*[contains(normalize-space(.), '다운로드')][1]");
  }

  return undefined;
}

function formatKstDateToken(date: Date): string {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: "Asia/Seoul",
    year: "numeric",
    month: "2-digit",
    day: "2-digit"
  }).formatToParts(date);
  const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
  return `${values.year}${values.month}${values.day}`;
}

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

async function openDownloadHistoryPanel(page: Page): Promise<void> {
  const downloadHistoryButton = page
    .getByRole("button", { name: /다운로드 이력/ })
    .or(page.locator("button[aria-label='다운로드 이력']"))
    .first();
  const isVisible = await downloadHistoryButton.isVisible({ timeout: 2_000 }).catch(() => false);
  if (!isVisible) {
    return;
  }

  await downloadHistoryButton.click();
  await page.waitForTimeout(1_000);
}

async function readResponseBody(
  url: string,
  readBody: () => Promise<Buffer>
): Promise<Buffer> {
  try {
    return await readBody();
  } catch {
    const response = await fetch(url);
    return Buffer.from(await response.arrayBuffer());
  }
}

function buildAuxiliaryUrl(baseUrl: string, pathname: string, tenantId: string): string {
  const url = new URL(pathname, baseUrl);
  url.searchParams.set("tenantId", tenantId);
  return url.toString();
}

function isSamePath(actualUrl: string, expectedUrl: string): boolean {
  return new URL(actualUrl).pathname === new URL(expectedUrl).pathname;
}

async function attachPageScreenshot(
  page: Page,
  testInfo: TestInfo,
  pageDefinition: QaPageDefinition,
  role: string,
  action: string
): Promise<void> {
  const screenshotName = `${safeFilename(pageDefinition.name)}-${role}-${action}.png`;
  const screenshotPath = testInfo.outputPath(screenshotName);

  await page.screenshot({ path: screenshotPath, fullPage: false });
  await testInfo.attach(screenshotName, {
    path: screenshotPath,
    contentType: "image/png"
  });
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-${action}-url.txt`, {
    body: page.url(),
    contentType: "text/plain"
  });
}

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

function formatError(error: unknown): string {
  return error instanceof Error ? error.message.replace(/\s+/g, " ").slice(0, 500) : String(error);
}
