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

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

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

interface ParsedExcel {
  fileName: string;
  rows: string[][];
}

interface FilterCapture {
  segmentControlCount: number;
  segmentControlVisible: boolean;
  segmentInitialValue: string;
  segmentOptions: string[];
  segmentSelectedValue: string;
  salesLineControlLabel: string;
  salesLineControlVisible: boolean;
  salesLinePickerOpened: boolean;
  salesLineOptions: string[];
  settlementRequests: string[];
}

const REQUIRED_ROLES = ["ADMIN", "TA", "CO", "BO"];

export function runSettlementSegmentColumnSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const settlementPage = findPageDefinition(checklist.pages, "정산내역");

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

      const runtime = getRuntimeQaEnv(options.environment, settlementPage.application ?? "admin", role);
      const blockReason = getRuntimeBlockReason(runtime);

      test.describe(`[${role}] ${settlementPage.name}`, () => {
        test.skip(!!blockReason, blockReason);

        test("영업라인 및 구간 검색 필터 선택 가능 여부 확인", async ({ browser }, testInfo) => {
          test.setTimeout(120_000);
          await withRolePage(browser, runtime, async (page) => {
            await openSettlementPage(page, runtime, settlementPage);
            await openAdvancedSearch(page);
            await attachPageScreenshot(page, testInfo, settlementPage, role, "filter-selection-before");
            const capture = await captureAndUseFilters(page, settlementPage);

            await attachEvidenceLog(testInfo, settlementPage, role, "filter-selection", [
              `${role} 계정으로 정산내역 검색 필터를 확인했습니다.`,
              `진입 URL: ${redactUrl(page.url())}`,
              `구간 컨트롤 수/노출: ${capture.segmentControlCount}/${capture.segmentControlVisible ? "예" : "아니오"}`,
              `구간 초기값: ${capture.segmentInitialValue || "-"}`,
              `구간 옵션: ${capture.segmentOptions.join(", ") || "없음"}`,
              `구간 선택 후 값: ${capture.segmentSelectedValue || "-"}`,
              `영업라인 필터 매핑 라벨: ${capture.salesLineControlLabel || "없음"}`,
              `영업라인 필터 노출: ${capture.salesLineControlVisible ? "예" : "아니오"}`,
              `영업라인 선택기 오픈: ${capture.salesLinePickerOpened ? "예" : "아니오"}`,
              `영업라인 선택 후보: ${capture.salesLineOptions.join(" / ") || "없음"}`,
              `검색 후 정산 API 요청: ${capture.settlementRequests.join(" / ") || "없음"}`
            ]);

            expect(capture.segmentControlCount, "구간 검색 필터 컨트롤이 정확히 1개 노출되어야 합니다.").toBe(1);
            expect(capture.segmentControlVisible, "구간 검색 필터가 보여야 합니다.").toBe(true);
            expect(capture.segmentOptions, "구간 필터에 신고/비신고 선택지가 있어야 합니다.").toEqual(
              expect.arrayContaining(["신고", "비신고"])
            );
            expect(capture.segmentSelectedValue, "구간 필터에서 신고를 선택한 값이 유지되어야 합니다.").toContain("신고");
            expect(capture.salesLineControlVisible, "영업라인 검색 필터에 해당하는 정산대상 선택 컨트롤이 보여야 합니다.").toBe(true);
            expect(capture.salesLinePickerOpened, "영업라인 검색 필터를 클릭했을 때 선택기가 열려야 합니다.").toBe(true);
            expect(capture.salesLineOptions.length, "영업라인 검색 필터에서 선택 후보가 보여야 합니다.").toBeGreaterThan(0);
            expect(capture.settlementRequests.length, "필터 검색 후 정산내역 조회 요청이 발생해야 합니다.").toBeGreaterThan(0);
          });
        });

        test("정산내역 목록에 구간 컬럼과 값이 표시되는지 확인", async ({ browser }, testInfo) => {
          test.setTimeout(90_000);
          await withRolePage(browser, runtime, async (page) => {
            await openSettlementPage(page, runtime, settlementPage);
            const headers = await visibleTableHeaders(page);
            const fields = await tableDataFields(page);
            const bodyText = normalizeText(await page.locator("body").innerText().catch(() => ""));
            const hasSegmentColumn = headers.some((header) => header === "구간") || fields.some((field) => /segment|구간/i.test(field));
            const segmentValues = await collectSegmentValues(page);
            const dataRowCount = await visibleDataRowCount(page);

            await attachEvidenceLog(testInfo, settlementPage, role, "segment-column", [
              `${role} 계정으로 정산내역 목록 컬럼을 확인했습니다.`,
              `현재 URL: ${redactUrl(page.url())}`,
              `표시 컬럼: ${headers.join(" | ") || "없음"}`,
              `테이블 data-field: ${fields.join(", ") || "없음"}`,
              `구간 컬럼 감지: ${hasSegmentColumn ? "예" : "아니오"}`,
              `구간 값 샘플: ${segmentValues.join(", ") || "없음"}`,
              `조회 데이터 행 수: ${dataRowCount}`,
              `화면 본문 일부: ${bodyText.slice(0, 800)}`
            ]);
            await attachPageScreenshot(page, testInfo, settlementPage, role, "segment-column");

            expect(hasSegmentColumn, "정산내역 목록에 구간 컬럼이 추가되어야 합니다.").toBe(true);
            if (dataRowCount > 0) {
              expect(segmentValues.length, "조회 데이터가 있을 때 정산내역 목록의 구간 컬럼에 값이 표시되어야 합니다.").toBeGreaterThan(0);
            }
          });
        });

        test("정산내역 엑셀에 구간 컬럼이 포함되는지 확인", async ({ browser }, testInfo) => {
          test.setTimeout(240_000);
          await withRolePage(browser, runtime, async (page) => {
            await openSettlementPage(page, runtime, settlementPage);
            const parsedExcel = await requestAndDownloadSettlementExcel(page, settlementPage, runtime, testInfo, role);
            const header = findHeaderRow(parsedExcel.rows);
            const normalizedHeaders = (header ?? []).map(normalizeText);
            const segmentIndex = normalizedHeaders.findIndex((value) => value === "구간");
            const segmentValues = segmentIndex >= 0
              ? parsedExcel.rows
                  .slice(parsedExcel.rows.indexOf(header!) + 1)
                  .map((row) => normalizeText(row[segmentIndex] ?? ""))
                  .filter(Boolean)
                  .slice(0, 20)
              : [];
            const excelDataRows = header
              ? parsedExcel.rows.slice(parsedExcel.rows.indexOf(header) + 1).filter((row) => row.some((cell) => normalizeText(cell)))
              : [];

            await attachEvidenceLog(testInfo, settlementPage, role, "excel-segment-column", [
              `${role} 계정으로 정산내역 엑셀을 생성하고 다운로드 파일을 파싱했습니다.`,
              `파일명: ${maskSensitive(parsedExcel.fileName)}`,
              `파싱 행 수: ${parsedExcel.rows.length}`,
              `엑셀 헤더: ${header?.join(" | ") || "없음"}`,
              `구간 헤더 위치: ${segmentIndex >= 0 ? segmentIndex + 1 : "없음"}`,
              `구간 값 샘플: ${segmentValues.join(", ") || "없음"}`,
              `엑셀 데이터 행 수: ${excelDataRows.length}`
            ]);
            await attachPageScreenshot(page, testInfo, settlementPage, role, "excel-segment-column");

            expect(header, "정산내역 엑셀 헤더 행을 찾을 수 있어야 합니다.").toBeDefined();
            expect(segmentIndex, "정산내역 엑셀에 구간 컬럼이 포함되어야 합니다.").toBeGreaterThanOrEqual(0);
            if (excelDataRows.length > 0) {
              expect(segmentValues.length, "데이터 행이 있을 때 정산내역 엑셀 구간 컬럼에 값이 표시되어야 합니다.").toBeGreaterThan(0);
            }
          });
        });
      });
    }
  });
}

function findPageDefinition(pages: QaPageDefinition[], keyword: string): QaPageDefinition {
  const pageDefinition = pages.find((page) => page.name === keyword || page.name.includes(keyword));
  if (!pageDefinition) {
    throw new Error(`checklist.json pages에서 '${keyword}' 페이지 정의를 찾지 못했습니다.`);
  }
  return pageDefinition;
}

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,
  runtime: RuntimeQaEnv,
  callback: (page: Page) => Promise<void>
): Promise<void> {
  const context = await browser.newContext(
    runtime.storageState ? { storageState: runtime.storageState, acceptDownloads: true } : { acceptDownloads: true }
  );
  const page = await context.newPage();
  try {
    await callback(page);
  } finally {
    await context.close();
  }
}

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

  const targetUrl = buildPageUrl(runtime.baseUrl!, pageDefinition, runtime.tenantId!);
  await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
  await waitForNavigationToSettle(page);
  if (await isLoginPage(page, runtime)) {
    await loginWithCredentials(page, runtime);
    await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60_000 });
    await waitForNavigationToSettle(page);
  }
  expect(await isLoginPage(page, runtime), "정산내역 화면 진입 전에 로그인 화면을 벗어나야 합니다.").toBe(false);
  await expect(page.locator("body"), "정산내역 화면 본문이 표시되어야 합니다.").toContainText(/정산내역|정산\s*내역/, {
    timeout: 20_000
  });
  await clickSearch(page, pageDefinition);
}

async function openAdvancedSearch(page: Page): Promise<void> {
  const target = page.getByRole("button", { name: /상세검색|고급검색|상세 검색/ }).first();
  if (await target.isVisible({ timeout: 1_500 }).catch(() => false)) {
    await target.click();
    await sleep(400);
  }
}

async function captureAndUseFilters(page: Page, pageDefinition: QaPageDefinition): Promise<FilterCapture> {
  const segmentControl = page.getByRole("combobox", { name: /^구간(?:\s|$)/ });
  const segmentControlCount = await segmentControl.count();
  const segmentControlVisible = segmentControlCount === 1 && await segmentControl.isVisible().catch(() => false);
  const segmentInitialValue = segmentControlCount === 1 ? await readControlValue(segmentControl) : "";
  const requestUrls: string[] = [];
  const requestHandler = (request: { url: () => string; method: () => string; postData: () => string | null }): void => {
    if (/settlement/i.test(request.url())) {
      requestUrls.push(`${request.method()} ${redactUrl(request.url())}${request.postData() ? ` body=${maskSensitive(request.postData()!)}` : ""}`);
    }
  };
  page.on("request", requestHandler);
  await clickSearch(page, pageDefinition);
  await sleep(800);

  let segmentOptions: string[] = [];
  if (segmentControlCount === 1) {
    await segmentControl.click({ force: true }).catch(() => undefined);
    await segmentControl.press("ArrowDown", { timeout: 1_500 }).catch(() => undefined);
    await sleep(300);
    segmentOptions = await readVisibleSegmentOptions(page);
  }

  const segmentChoice = await getSegmentChoice(page);
  if (await segmentChoice.count()) {
    await segmentChoice.click({ force: true, timeout: 1_500 }).catch(() => undefined);
    await sleep(300);
  }
  const segmentSelectedValue = segmentControlCount === 1 ? await readControlValue(segmentControl) : "";

  await sleep(800);
  page.off("request", requestHandler);

  const salesLine = await findSalesLineControl(page);
  const salesLineControlVisible = !!salesLine && await salesLine.isVisible().catch(() => false);
  let salesLinePickerOpened = false;
  let salesLineOptions: string[] = [];
  if (salesLine && salesLineControlVisible) {
    const candidates = [salesLine, salesLine.locator("xpath=.."), salesLine.locator("xpath=../..").first()];
    for (const candidate of candidates) {
      if (!(await candidate.count().catch(() => 0)) || !(await candidate.isVisible().catch(() => false))) {
        continue;
      }
      await candidate.click({ force: true, timeout: 1_500 }).catch(() => undefined);
      await sleep(350);
      const opened = await visiblePickerState(page);
      salesLinePickerOpened ||= opened.opened;
      if (opened.options.length > salesLineOptions.length) {
        salesLineOptions = opened.options;
      }
      if (opened.options.length > 0) {
        salesLinePickerOpened = true;
        break;
      }
      await page.keyboard.press("Escape").catch(() => undefined);
    }
  }

  return {
    segmentControlCount,
    segmentControlVisible,
    segmentInitialValue,
    segmentOptions,
    segmentSelectedValue,
    salesLineControlLabel: salesLine ? await salesLine.getAttribute("aria-label").catch(() => "") || "정산대상" : "",
    salesLineControlVisible,
    salesLinePickerOpened,
    salesLineOptions,
    settlementRequests: requestUrls
  };
}

async function findSalesLineControl(page: Page): Promise<Locator | undefined> {
  const target = page.getByLabel("정산대상", { exact: true });
  if (await target.count()) {
    return target;
  }
  const salesLine = page.getByLabel(/영업라인/, { exact: false });
  return (await salesLine.count()) ? salesLine : undefined;
}

async function visiblePickerState(page: Page): Promise<{ opened: boolean; options: string[] }> {
  const overlays = page.locator("[role='dialog']:visible, [role='listbox']:visible, [role='menu']:visible, [role='tooltip']:visible");
  const overlayCount = await overlays.count().catch(() => 0);
  const optionTexts = await page.locator("[role='option']:visible, [role='menuitem']:visible, [role='tooltip']:visible button").allTextContents().catch(() => []);
  const tooltipTexts = await page.locator("[role='tooltip']:visible").allTextContents().catch(() => []);
  const meaningfulOptions = [...optionTexts, ...tooltipTexts]
    .map(normalizeText)
    .filter((value) => value && !/^(선택|검색|닫기|확인|취소)$/.test(value));
  return { opened: overlayCount > 0 || meaningfulOptions.length > 0, options: meaningfulOptions.slice(0, 20) };
}

async function readVisibleSegmentOptions(page: Page): Promise<string[]> {
  const roleOptions = await page.locator("[role='option']:visible").allTextContents().catch(() => []);
  const dataOptions = await page.locator("[data-value]:visible").allTextContents().catch(() => []);
  return Array.from(new Set([...roleOptions, ...dataOptions].map(normalizeText).filter((value) => ["신고", "비신고"].includes(value))));
}

async function getSegmentChoice(page: Page): Promise<Locator> {
  const roleChoice = page.getByRole("option", { name: "신고", exact: true }).first();
  if (await roleChoice.count().catch(() => 0)) {
    return roleChoice;
  }
  const dataChoice = page.locator("[data-value='true']:visible").first();
  return dataChoice;
}

async function readControlValue(control: Locator): Promise<string> {
  const inputValue = await control.inputValue({ timeout: 1_500 }).catch(() => "");
  if (inputValue) {
    return normalizeText(inputValue);
  }
  return normalizeText(await control.innerText({ timeout: 1_500 }).catch(() => ""));
}

async function visibleTableHeaders(page: Page): Promise<string[]> {
  const headers = page.locator("[role='columnheader']:visible, th:visible");
  return (await headers.allTextContents().catch(() => [])).map(normalizeText).filter(Boolean);
}

async function tableDataFields(page: Page): Promise<string[]> {
  return page.locator("[data-field]").evaluateAll((elements) =>
    Array.from(new Set(elements.map((element) => element.getAttribute("data-field") ?? "").filter(Boolean)))
  ).catch(() => []);
}

async function collectSegmentValues(page: Page): Promise<string[]> {
  const fieldCells = page.locator("[data-field*='segment' i], [data-field*='구간']");
  const values = await fieldCells.allTextContents().catch(() => []);
  return values.map(normalizeText).filter(Boolean).slice(0, 20);
}

async function visibleDataRowCount(page: Page): Promise<number> {
  const rows = page.locator("[role='rowgroup'] [role='row']:visible, tbody tr:visible");
  const count = await rows.count().catch(() => 0);
  let dataRows = 0;
  for (let index = 0; index < count; index += 1) {
    const text = normalizeText(await rows.nth(index).innerText({ timeout: 1_000 }).catch(() => ""));
    if (text && !/^0건 선택/.test(text)) {
      dataRows += 1;
    }
  }
  return dataRows;
}

async function requestAndDownloadSettlementExcel(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtime: RuntimeQaEnv,
  testInfo: TestInfo,
  role: string
): Promise<ParsedExcel> {
  await verifySettlementExcelMenu(page, pageDefinition);
  const requestedAt = new Date();
  const documentId = await requestSettlementExcelByApi(runtime);
  const linkUrl = await getExcelDocumentLinkByApi(runtime, documentId);
  const parsedExcel = await downloadLinkedExcel(linkUrl, testInfo, pageDefinition, role);
  await attachEvidenceLog(testInfo, pageDefinition, role, "excel-document", [
    `${role} 계정으로 정산내역 엑셀 메뉴를 확인한 뒤 동일 권한 세션의 생성 API를 호출했습니다.`,
    `요청 시각: ${formatKstTimestamp(requestedAt)}`,
    `문서 ID: ${documentId}`,
    `다운로드 링크: ${redactUrl(linkUrl)}`,
    `파일명: ${maskSensitive(parsedExcel.fileName)}`
  ]);
  return parsedExcel;
}

async function verifySettlementExcelMenu(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const selector = pageDefinition.excelDownloadButtonSelector ?? "button:visible";
  const excel = pageDefinition.excelDownloadButtonSelector
    ? page.locator(selector).first()
    : page.locator(selector).filter({ hasText: /^엑셀$/ }).last();
  await expect(excel, "정산내역 엑셀 버튼이 보여야 합니다.").toBeVisible({ timeout: 10_000 });
  await excel.click();
  const menu = page.getByText(/조회결과\s*다운로드/).last();
  await expect(menu, "조회결과 다운로드 메뉴가 보여야 합니다.").toBeVisible({ timeout: 10_000 });
}

async function requestSettlementExcelByApi(runtime: RuntimeQaEnv): Promise<string> {
  const date = formatKstDate(new Date());
  const requestUrl = new URL(`${apiBaseFromRuntime(runtime)}/api/v1/excel/settlements`);
  requestUrl.searchParams.set("from", date);
  requestUrl.searchParams.set("to", date);
  requestUrl.searchParams.set("startDate", date);
  requestUrl.searchParams.set("endDate", date);
  requestUrl.searchParams.set("exactMatch", "false");
  requestUrl.searchParams.set("size", "100");

  const response = await fetchWithTimeout(requestUrl.toString(), {
    method: "POST",
    headers: nodeApiHeaders(runtime, "application/json")
  }, 20_000);
  const body = await response.json().catch(() => undefined);
  if (!response.ok) {
    throw new Error(`정산내역 엑셀 생성 요청 실패: ${response.status} ${JSON.stringify(body)}`);
  }
  const payload = (body as { payload?: unknown } | undefined)?.payload;
  if (typeof payload !== "string" || !payload.trim()) {
    throw new Error(`정산내역 엑셀 생성 응답에서 문서 ID를 확인할 수 없습니다: ${JSON.stringify(body)}`);
  }
  return payload;
}

async function getExcelDocumentLinkByApi(runtime: RuntimeQaEnv, documentId: string): Promise<string> {
  const deadline = Date.now() + 90_000;
  let lastBody: unknown;
  let lastStatus = 0;
  while (Date.now() < deadline) {
    const response = await fetchWithTimeout(`${apiBaseFromRuntime(runtime)}/api/v1/excel/link/${documentId}`, {
      headers: nodeApiHeaders(runtime)
    }, 20_000);
    const body = await response.json().catch(() => undefined);
    lastBody = body;
    lastStatus = response.status;
    const payload = (body as { payload?: unknown } | undefined)?.payload;
    if (response.ok && typeof payload === "string" && payload) {
      return payload;
    }
    const message = (body as { result?: { message?: unknown } } | undefined)?.result?.message;
    if (response.status !== 400 || !/존재하지 않습니다|생성|파일/i.test(String(message ?? ""))) {
      throw new Error(`정산내역 엑셀 다운로드 링크 요청 실패: ${response.status} ${JSON.stringify(body)}`);
    }
    await sleep(3_000);
  }
  throw new Error(`정산내역 엑셀 파일 생성 완료를 확인하지 못했습니다: ${lastStatus} ${JSON.stringify(lastBody)}`);
}

async function downloadLinkedExcel(
  linkUrl: string,
  testInfo: TestInfo,
  pageDefinition: QaPageDefinition,
  role: string
): Promise<ParsedExcel> {
  const response = await fetchWithTimeout(linkUrl, {}, 30_000);
  if (!response.ok) {
    throw new Error(`정산내역 엑셀 파일 다운로드 실패: ${response.status} ${response.statusText}`);
  }
  const buffer = Buffer.from(await response.arrayBuffer());
  const disposition = response.headers.get("content-disposition") ?? "";
  const fileName = fileNameFromDisposition(disposition) ?? guessDownloadFileNameFromUrl(linkUrl, `settlement-${role}.xlsx`);
  const filePath = testInfo.outputPath(`${safeFilename(pageDefinition.name)}-${role}-${safeFilename(fileName)}`);
  await fs.promises.writeFile(filePath, buffer);
  await testInfo.attach(fileName, {
    path: filePath,
    contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  });
  return { fileName, rows: extractDownloadRows(buffer, fileName) };
}

function findHeaderRow(rows: string[][]): string[] | undefined {
  return rows.find((row) => row.some((cell) => normalizeText(cell) === "구간"));
}

async function clickSearch(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const candidates = [
    page.getByRole("button", { name: "검색", exact: true }),
    page.getByRole("button", { name: "조회", exact: true }),
    page.locator("[data-testid='search-button']")
  ];
  for (const candidate of candidates) {
    const count = await candidate.count().catch(() => 0);
    for (let index = 0; index < count; index += 1) {
      const search = candidate.nth(index);
      const visible = await search.isVisible({ timeout: 500 }).catch(() => false);
      if (!visible) {
        continue;
      }
      await search.click({ force: true, noWaitAfter: true, timeout: 2_000 }).catch(() => undefined);
      await sleep(700);
      return;
    }
  }
}

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

async function attachPageScreenshot(page: Page, testInfo: TestInfo, pageDefinition: QaPageDefinition, role: string, action: string): Promise<void> {
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-${action}.png`, {
    body: await page.screenshot({ fullPage: false }),
    contentType: "image/png"
  });
}

async function attachEvidenceLog(testInfo: TestInfo, pageDefinition: QaPageDefinition, role: string, action: string, lines: string[]): Promise<void> {
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-${action}.txt`, {
    body: `${lines.join("\n")}\n`,
    contentType: "text/plain"
  });
}

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

function redactUrl(value: string): string {
  return value.replace(/tenantId=redacted&]+/g, "tenantId=<redacted>");
}

function maskSensitive(value: string): string {
  return value.replace(/\b(\d{3})\d{5,}(\d{3,4})\b/g, (_match, prefix: string, suffix: string) => `${prefix}***${suffix}`);
}

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

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

function formatKstDate(date: Date): string {
  return formatKstTimestamp(date).slice(0, 8).replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
}

function apiBaseFromRuntime(runtime: RuntimeQaEnv): string {
  if (!runtime.baseUrl) {
    throw new Error("baseUrl이 필요합니다.");
  }
  const url = new URL(runtime.baseUrl);
  url.hostname = url.hostname.replace(/^(admin|store)-/, "api-");
  return url.origin;
}

function nodeApiHeaders(runtime: RuntimeQaEnv, contentType?: string): Record<string, string> {
  const headers: Record<string, string> = {
    accept: "application/json",
    cookie: storageStateCookieHeader(runtime)
  };
  if (contentType) {
    headers["content-type"] = contentType;
  }
  if (runtime.tenantId) {
    headers["x-tenant-id"] = runtime.tenantId;
  }
  return headers;
}

function storageStateCookieHeader(runtime: RuntimeQaEnv): string {
  if (!runtime.storageState) {
    throw new Error("정산내역 엑셀 API 호출에는 storageState가 필요합니다.");
  }
  const raw = JSON.parse(fs.readFileSync(runtime.storageState, "utf8")) as {
    cookies?: Array<{ name?: string; value?: string; domain?: string }>;
  };
  const apiHost = new URL(apiBaseFromRuntime(runtime)).hostname;
  const cookies = raw.cookies?.filter((cookie) => cookie.name && cookie.value && domainMatches(apiHost, cookie.domain ?? "")) ?? [];
  if (!cookies.length) {
    throw new Error(`storageState에서 ${apiHost} API 쿠키를 찾지 못했습니다.`);
  }
  return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}

function domainMatches(host: string, domain: string): boolean {
  const normalized = domain.replace(/^\./, "");
  return host === normalized || host.endsWith(`.${normalized}`);
}

async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs: number): Promise<Response> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    return await fetch(url, { ...init, signal: controller.signal });
  } finally {
    clearTimeout(timeout);
  }
}

function fileNameFromDisposition(disposition: string): string | undefined {
  const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i);
  if (utf8Match?.[1]) {
    return decodeURIComponent(utf8Match[1].replace(/^"|"$/g, ""));
  }
  const asciiMatch = disposition.match(/filename="?([^";]+)"?/i);
  return asciiMatch?.[1] ? decodeURIComponent(asciiMatch[1]) : undefined;
}

function sleep(milliseconds: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
