import { expect, test, type Browser, 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 { loadChecklist, loadIssueMetadata } from "../checklist";
import { extractDownloadRows, guessDownloadFileNameFromUrl } from "../downloadText";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaChecklist, QaEnvironment, QaPageDefinition } from "../types";
import { buildPageUrl } from "../url";

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

interface Fixtures {
  excelTargets: {
    corporation: string;
    branch: string;
    merchant: string;
  };
}

interface DepartmentExcelTarget {
  key: keyof Fixtures["excelTargets"];
  page: QaPageDefinition;
  targetName: string;
  downloadMenu: RegExp;
  uploadMenu: RegExp;
  fileNameIncludes: RegExp;
  excelEndpoint: string;
  uploadType: string;
  listEndpoint: string;
  filterParamKey: string;
}

interface ExcelEvidence {
  target: DepartmentExcelTarget;
  fileName: string;
  filePath: string;
  headers: string[];
  rows: string[][];
  businessTypeValue: string;
  uploadText: string;
  targetUploadSuccess: boolean;
  targetUploadMessage: string;
  postUploadText: string;
  documentPageText: string;
}

const ROLE = "ADMIN";

const DEFAULT_FIXTURES: Fixtures = {
  excelTargets: {
    corporation: "박재민_법인",
    branch: "박재민_영업",
    merchant: "박재민_가맹"
  }
};

export function runDepartmentExcelDocumentRetestSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const fixtures = {
    ...DEFAULT_FIXTURES,
    ...((checklist as QaChecklist & { fixtures?: Partial<Fixtures> }).fixtures ?? {}),
    excelTargets: {
      ...DEFAULT_FIXTURES.excelTargets,
      ...((checklist as QaChecklist & { fixtures?: Partial<Fixtures> }).fixtures?.excelTargets ?? {})
    }
  };
  const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", ROLE);
  const blockReason = getRuntimeBlockReason(runtimeEnv);
  const targets: DepartmentExcelTarget[] = [
    {
      key: "corporation",
      page: findPage(checklist.pages, "총판관리"),
      targetName: fixtures.excelTargets.corporation,
      downloadMenu: /법인\s*엑셀\s*다운로드/,
      uploadMenu: /법인\s*일괄\s*등록/,
      fileNameIncludes: /법인|총판|corporation/i,
      excelEndpoint: "corporations",
      uploadType: "corporations",
      listEndpoint: "corporations",
      filterParamKey: "corporationIds"
    },
    {
      key: "branch",
      page: findPage(checklist.pages, "영업점관리"),
      targetName: fixtures.excelTargets.branch,
      downloadMenu: /영업점\s*엑셀\s*다운로드/,
      uploadMenu: /영업점\s*일괄\s*등록/,
      fileNameIncludes: /영업점|branch/i,
      excelEndpoint: "branch-offices",
      uploadType: "branch-offices",
      listEndpoint: "branch-offices",
      filterParamKey: "branchOfficeIds"
    },
    {
      key: "merchant",
      page: findPage(checklist.pages, "가맹점관리"),
      targetName: fixtures.excelTargets.merchant,
      downloadMenu: /가맹점\s*엑셀\s*다운로드/,
      uploadMenu: /가맹점\s*일괄\s*등록/,
      fileNameIncludes: /가맹점|merchant|vendor/i,
      excelEndpoint: "vendors",
      uploadType: "vendors",
      listEndpoint: "vendors",
      filterParamKey: "vendorIds"
    }
  ];
  const evidence = new Map<DepartmentExcelTarget["key"], ExcelEvidence>();

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject} / 문서보관함 엑셀 재QA`, () => {
    test.skip(!!blockReason, blockReason);

    for (const target of targets) {
      test(`${target.page.name}에서 ${target.targetName} 검색 후 문서보관함 엑셀 다운로드 파일을 그대로 업로드`, async ({ browser }, testInfo) => {
        test.setTimeout(240_000);
        const result = await runTargetExcelFlow(browser, runtimeEnv, target, testInfo);
        evidence.set(target.key, result);
        writeEvidence(options.issueId, options.environment, target, result);
        expect(result.headers.join(" "), `${target.page.name} 엑셀에 사업자 구분 컬럼이 있어야 합니다.`).toMatch(/사업자\s*구분|사업자구분/);
        expect(result.businessTypeValue, `${target.page.name} 엑셀의 ${target.targetName} 행에 사업자 구분 선택값이 있어야 합니다.`).not.toBe("");
        expect(result.targetUploadSuccess, `${target.page.name} 엑셀 업로드에서 ${target.targetName} 대상 row가 성공 처리되어야 합니다: ${result.targetUploadMessage}`).toBe(true);
        expect(result.postUploadText, `${target.page.name} 업로드 후 대상 데이터가 목록에 유지되어야 합니다.`).toContain(target.targetName);
      });
    }

    test(checklist.checklist[3], async () => {
      for (const target of targets) {
        const result = evidence.get(target.key) ?? readEvidence(options.issueId, options.environment, target);
        expect(result, `${target.page.name} 엑셀 다운로드/업로드 선행 검증이 완료되어야 합니다.`).toBeTruthy();
        expect(result!.headers.join(" "), `${target.page.name} 엑셀에 사업자 구분 컬럼이 있어야 합니다.`).toMatch(/사업자\s*구분|사업자구분/);
        expect(result!.businessTypeValue, `${target.page.name} 엑셀의 사업자 구분 선택값이 있어야 합니다.`).not.toBe("");
      }
    });

    test(checklist.checklist[4], async () => {
      for (const target of targets) {
        const result = evidence.get(target.key) ?? readEvidence(options.issueId, options.environment, target);
        expect(result, `${target.page.name} 업로드 후 목록 재확인 근거가 있어야 합니다.`).toBeTruthy();
        expect(result!.postUploadText, `${target.page.name} 업로드 완료 후 대상 데이터가 정상 유지되어야 합니다.`).toContain(target.targetName);
      }
    });
  });
}

async function runTargetExcelFlow(
  browser: Browser,
  runtimeEnv: RuntimeQaEnv,
  target: DepartmentExcelTarget,
  testInfo: TestInfo
): Promise<ExcelEvidence> {
  let result!: ExcelEvidence;
  await withRolePage(browser, runtimeEnv, async (page) => {
    await openTargetPage(page, runtimeEnv, target);
    await applyDepartmentSearch(page, target);
    await attachScreenshot(page, testInfo, target, "filtered-list");

    const documentId = await requestExcelByApi(runtimeEnv, target, testInfo);
    const documentPageText = await openDocumentHistory(page, runtimeEnv, target, documentId, testInfo);
    const downloaded = await downloadDocumentExcel(runtimeEnv, documentId, target, testInfo);
    const parsed = parseDepartmentExcel(downloaded.buffer, downloaded.fileName, target);
    const uploadResult = await uploadDownloadedExcel(runtimeEnv, target, downloaded.buffer, downloaded.fileName, testInfo);
    await openTargetPage(page, runtimeEnv, target);
    await applyDepartmentSearch(page, target);
    const postUploadText = normalize(await page.locator("body").innerText({ timeout: 10_000 }));
    await attachScreenshot(page, testInfo, target, "after-upload");
    await attachLog(testInfo, target, "excel-document-retest", [
      `화면: ${target.page.name}`,
      `검색 대상: ${target.targetName}`,
      `문서 ID: ${documentId}`,
      `파일명: ${downloaded.fileName}`,
      `헤더: ${parsed.headers.join(" | ")}`,
      `사업자 구분 선택값: ${parsed.businessTypeValue}`,
      `문서보관함 화면 일부: ${snippet(documentPageText, 2000)}`,
      `업로드 대상 row 성공 여부: ${uploadResult.targetUploadSuccess}`,
      `업로드 대상 row 메시지: ${uploadResult.targetUploadMessage}`,
      `업로드 응답 일부: ${snippet(uploadResult.text, 2500)}`,
      `목록 재확인 일부: ${snippet(postUploadText, 2500)}`
    ]);
    result = {
      target,
      fileName: downloaded.fileName,
      filePath: downloaded.filePath,
      headers: parsed.headers,
      rows: parsed.rows,
      businessTypeValue: parsed.businessTypeValue,
      uploadText: uploadResult.text,
      targetUploadSuccess: uploadResult.targetUploadSuccess,
      targetUploadMessage: uploadResult.targetUploadMessage,
      postUploadText,
      documentPageText
    };
  });
  return result;
}

function evidencePath(issueId: string, environment: QaEnvironment, target: DepartmentExcelTarget): string {
  return path.join(process.cwd(), "qa-results", "issues", issueId, environment, "evidence", `${target.key}.json`);
}

function writeEvidence(issueId: string, environment: QaEnvironment, target: DepartmentExcelTarget, result: ExcelEvidence): void {
  const file = evidencePath(issueId, environment, target);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify({
    fileName: result.fileName,
    filePath: result.filePath,
    headers: result.headers,
    rows: result.rows,
    businessTypeValue: result.businessTypeValue,
    uploadText: result.uploadText,
    targetUploadSuccess: result.targetUploadSuccess,
    targetUploadMessage: result.targetUploadMessage,
    postUploadText: result.postUploadText,
    documentPageText: result.documentPageText
  }, null, 2), "utf8");
}

function readEvidence(issueId: string, environment: QaEnvironment, target: DepartmentExcelTarget): ExcelEvidence | undefined {
  const file = evidencePath(issueId, environment, target);
  if (!fs.existsSync(file)) return undefined;
  const value = JSON.parse(fs.readFileSync(file, "utf8")) as Omit<ExcelEvidence, "target">;
  return { ...value, target };
}

async function requestExcelByApi(
  runtimeEnv: RuntimeQaEnv,
  target: DepartmentExcelTarget,
  testInfo: TestInfo
): Promise<string> {
  const targetId = await resolveTargetDepartmentId(runtimeEnv, target);
  const params = new URLSearchParams({ includeData: "true" });
  params.append(target.filterParamKey, targetId);
  const requestUrl = `${apiBaseFromRuntime(runtimeEnv)}/api/v1/excel/${target.excelEndpoint}?${params.toString()}`;
  const response = await fetchWithTimeout(requestUrl, { method: "POST", headers: nodeApiHeaders(runtimeEnv) }, 30_000);
  const body = await response.json().catch(async () => ({ text: await response.text().catch(() => "") }));
  if (!response.ok) throw new Error(`${target.page.name} 엑셀 생성 요청 실패: ${response.status} ${JSON.stringify(body)}`);
  const documentId = extractDocumentId(body);
  await testInfo.attach(`${safe(target.page.name)}-excel-request.json`, {
    body: JSON.stringify({
      note: "화면 엑셀 메뉴는 작업 탭 캐시의 hidden DOM과 충돌해 자동화 클릭이 불안정하므로, 같은 ADMIN 세션으로 비동기 엑셀 생성 API를 호출하고 문서보관함에서 생성물을 확인합니다.",
      url: redactUrl(requestUrl),
      status: response.status,
      body,
      documentId,
      targetId,
      filterParamKey: target.filterParamKey,
      apiBase: apiBaseFromRuntime(runtimeEnv)
    }, null, 2),
    contentType: "application/json"
  });
  return documentId;
}

async function openDocumentHistory(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  target: DepartmentExcelTarget,
  documentId: string,
  testInfo: TestInfo
): Promise<string> {
  const documentPage = { ...target.page, path: "/document", name: "문서보관함" };
  await openAuthenticated(page, runtimeEnv, buildPageUrl(runtimeEnv.baseUrl!, documentPage, runtimeEnv.tenantId!));
  await expect(page.locator("body"), "문서보관함 화면이 표시되어야 합니다.").toContainText(/문서보관함|파일명|다운로드|파일 생성중/, { timeout: 20_000 });
  const deadline = Date.now() + 120_000;
  let bodyText = "";
  while (Date.now() < deadline) {
    bodyText = normalize(await page.locator("body").innerText({ timeout: 10_000 }).catch(() => ""));
    if (/다운로드/.test(bodyText) && (target.fileNameIncludes.test(bodyText) || !/파일 생성중/.test(bodyText))) break;
    await page.waitForTimeout(3_000);
    await page.reload({ waitUntil: "domcontentloaded" }).catch(() => undefined);
    await settle(page);
  }
  await attachScreenshot(page, testInfo, target, `document-${documentId}`);
  return bodyText;
}

async function downloadDocumentExcel(
  runtimeEnv: RuntimeQaEnv,
  documentId: string,
  target: DepartmentExcelTarget,
  testInfo: TestInfo
): Promise<{ fileName: string; filePath: string; buffer: Buffer }> {
  const link = await waitForExcelLink(runtimeEnv, documentId, 120_000);
  const response = await fetchWithTimeout(link, { headers: { accept: "*/*" } }, 30_000);
  if (!response.ok) throw new Error(`${target.page.name} 문서보관함 엑셀 다운로드 실패: ${response.status} ${redactUrl(link)}`);
  const buffer = Buffer.from(await response.arrayBuffer());
  const fileName = guessDownloadFileNameFromUrl(link, `${safe(target.page.name)}-${target.key}.xlsx`);
  const filePath = testInfo.outputPath(`${safe(target.page.name)}-${safe(fileName)}`);
  await fs.promises.writeFile(filePath, buffer);
  await testInfo.attach(fileName, { path: filePath, contentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" });
  await testInfo.attach(`${safe(target.page.name)}-excel-link.json`, {
    body: JSON.stringify({ documentId, link: redactUrl(link), fileName }, null, 2),
    contentType: "application/json"
  });
  return { fileName, filePath, buffer };
}

function parseDepartmentExcel(buffer: Buffer, fileName: string, target: DepartmentExcelTarget): { headers: string[]; rows: string[][]; businessTypeValue: string } {
  const allRows = extractDownloadRows(buffer, fileName).filter((row) => row.some((cell) => normalize(cell)));
  const headerIndex = allRows.findIndex((row) => row.some((cell) => /사업자\s*구분|사업자구분/.test(normalizeCell(cell))));
  if (headerIndex < 0) throw new Error(`${target.page.name} 엑셀에서 사업자 구분 헤더를 찾지 못했습니다: ${JSON.stringify(allRows.slice(0, 8))}`);
  const headers = allRows[headerIndex];
  const rows = allRows.slice(headerIndex + 1).filter((row) => row.some((cell) => normalize(cell)));
  const businessTypeIndex = headers.findIndex((cell) => /사업자\s*구분|사업자구분/.test(normalizeCell(cell)));
  const nameIndex = headers.findIndex((cell) => /^(법인명|상호명)$/.test(normalizeCell(cell)));
  const targetRow = rows.find((row) => normalize(row[nameIndex]) === target.targetName)
    ?? rows.find((row) => normalize(row.join(" ")).includes(target.targetName));
  if (!targetRow) throw new Error(`${target.page.name} 엑셀에서 ${target.targetName} 대상 행을 찾지 못했습니다.`);
  return { headers, rows, businessTypeValue: normalize(targetRow[businessTypeIndex]) };
}

async function uploadDownloadedExcel(
  runtimeEnv: RuntimeQaEnv,
  target: DepartmentExcelTarget,
  buffer: Buffer,
  fileName: string,
  testInfo: TestInfo
): Promise<{ text: string; targetUploadSuccess: boolean; targetUploadMessage: string }> {
  const uploadUrl = `${apiBaseFromRuntime(runtimeEnv)}/api/v1/excel/upload/${target.uploadType}`;
  const formData = new FormData();
  formData.append(
    "file",
    new Blob([new Uint8Array(buffer)], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }),
    fileName
  );
  const response = await fetchWithTimeout(uploadUrl, {
    method: "POST",
    headers: nodeApiHeaders(runtimeEnv),
    body: formData
  }, 120_000);
  const text = await response.text();
  await testInfo.attach(`${safe(target.page.name)}-excel-upload.json`, {
    body: JSON.stringify({ url: redactUrl(uploadUrl), status: response.status, body: safeJson(text) }, null, 2),
    contentType: "application/json"
  });
  if (!response.ok) throw new Error(`${target.page.name} 엑셀 업로드 실패: ${response.status} ${text.slice(0, 1000)}`);
  const targetResult = extractUploadTargetResult(text, target);
  return {
    text,
    targetUploadSuccess: targetResult.success,
    targetUploadMessage: targetResult.message
  };
}

function extractUploadTargetResult(text: string, target: DepartmentExcelTarget): { success: boolean; message: string } {
  const body = safeJson(text);
  if (!body || typeof body !== "object") return { success: /success|성공|완료/i.test(text), message: snippet(text, 1000) };
  const results = (body as { payload?: { results?: unknown } }).payload?.results;
  if (!Array.isArray(results)) return { success: /success|성공|완료/i.test(text), message: snippet(text, 1000) };
  const targetResult = results.find((item) => {
    if (!item || typeof item !== "object") return false;
    const name = (item as { businessInfo?: { name?: unknown } }).businessInfo?.name;
    return normalize(String(name ?? "")) === target.targetName;
  }) as
    | { isSuccess?: unknown; errorMessage?: unknown; action?: { label?: unknown }; businessInfo?: { name?: unknown } }
    | undefined;
  if (!targetResult) return { success: false, message: `${target.targetName} 대상 row를 업로드 응답에서 찾지 못했습니다.` };
  return {
    success: targetResult.isSuccess === true,
    message: normalize([
      `name=${String(targetResult.businessInfo?.name ?? target.targetName)}`,
      `action=${String(targetResult.action?.label ?? "")}`,
      `error=${String(targetResult.errorMessage ?? "")}`
    ].join(" "))
  };
}

async function resolveTargetDepartmentId(runtimeEnv: RuntimeQaEnv, target: DepartmentExcelTarget): Promise<string> {
  const requestUrl = `${apiBaseFromRuntime(runtimeEnv)}/api/v2/${target.listEndpoint}?size=5000`;
  const response = await fetchWithTimeout(requestUrl, { headers: nodeApiHeaders(runtimeEnv) }, 30_000);
  const text = await response.text();
  if (!response.ok) throw new Error(`${target.page.name} 대상 ID 조회 실패: ${response.status} ${text.slice(0, 1000)}`);
  const body = safeJson(text) as { payload?: unknown };
  const rows = Array.isArray(body?.payload)
    ? body.payload as Array<{ id?: unknown; name?: unknown; code?: unknown }>
    : [];
  const exact = rows.find((row) => normalize(String(row.name ?? "")) === target.targetName);
  if (!exact?.id) {
    const candidates = rows
      .filter((row) => normalize(String(row.name ?? "")).includes(target.targetName))
      .map((row) => `${String(row.name ?? "")}(${String(row.code ?? "")})`)
      .slice(0, 20)
      .join(", ");
    throw new Error(`${target.page.name} 목록 API에서 ${target.targetName} exact match 대상 ID를 찾지 못했습니다. 후보: ${candidates}`);
  }
  return String(exact.id);
}

async function openTargetPage(page: Page, runtimeEnv: RuntimeQaEnv, target: DepartmentExcelTarget): Promise<void> {
  await openAuthenticated(page, runtimeEnv, buildPageUrl(runtimeEnv.baseUrl!, target.page, runtimeEnv.tenantId!));
  await expect(page.locator("body"), `${target.page.name} 화면이 표시되어야 합니다.`).toContainText(new RegExp(target.page.name.replace(/\s+/g, "\\s*")), { timeout: 20_000 });
  await settle(page);
}

async function applyDepartmentSearch(page: Page, target: DepartmentExcelTarget): Promise<void> {
  const initialBody = normalize(await page.locator("body").innerText({ timeout: 10_000 }).catch(() => ""));
  if (initialBody.includes(target.targetName)) return;
  const filled = await fillNamedInput(page, /법인명|영업점명|가맹점명|상호명|업체명|검색어|이름|명/, target.targetName);
  if (!filled) throw new Error(`${target.page.name}에서 ${target.targetName} 검색 입력란을 찾지 못했습니다.`);
  const searchButton = page.getByRole("button", { name: /^검색$/ }).last().or(page.getByRole("button", { name: /^조회$/ }).last());
  await expect(searchButton, `${target.page.name} 검색 버튼이 보여야 합니다.`).toBeVisible({ timeout: 10_000 });
  await searchButton.click({ force: true, noWaitAfter: true });
  await settle(page);
  await expect(page.locator("body"), `${target.page.name} 검색 결과에 ${target.targetName}이 보여야 합니다.`).toContainText(target.targetName, { timeout: 20_000 });
}

async function fillNamedInput(page: Page, label: RegExp, value: string): Promise<boolean> {
  const candidates = [
    page.getByLabel(label).first(),
    page.getByPlaceholder(label).first()
  ];
  for (const candidate of candidates) {
    if (await candidate.isVisible({ timeout: 1_000 }).catch(() => false)) {
      if (!(await candidate.isEditable({ timeout: 500 }).catch(() => false))) continue;
      const filled = await candidate.fill("", { timeout: 3_000 })
        .then(() => candidate.fill(value, { timeout: 3_000 }))
        .then(() => true)
        .catch(() => false);
      if (filled) return true;
    }
  }
  const textboxes = page.locator("input:visible");
  const count = Math.min(await textboxes.count(), 20);
  for (let index = 0; index < count; index += 1) {
    const input = textboxes.nth(index);
    const box = await input.boundingBox().catch(() => null);
    if (!box || box.width < 40 || box.height < 10) continue;
    const containerText = normalize(await input.locator("xpath=ancestor::*[self::div or self::label][1]").innerText({ timeout: 500 }).catch(() => ""));
    const placeholder = await input.getAttribute("placeholder").catch(() => "");
    if (label.test(containerText) || label.test(placeholder ?? "")) {
      if (!(await input.isEditable({ timeout: 500 }).catch(() => false))) continue;
      const filled = await input.fill("", { timeout: 3_000 })
        .then(() => input.fill(value, { timeout: 3_000 }))
        .then(() => true)
        .catch(() => false);
      if (filled) return true;
    }
  }
  return false;
}

async function openAuthenticated(page: Page, runtimeEnv: RuntimeQaEnv, url: string): Promise<void> {
  if (!runtimeEnv.storageState) await loginWithCredentials(page, runtimeEnv);
  await page.goto(url, { waitUntil: "domcontentloaded" });
  await settle(page);
  if (await isLoginPage(page, runtimeEnv)) {
    await loginWithCredentials(page, runtimeEnv);
    await page.goto(url, { waitUntil: "domcontentloaded" });
    await settle(page);
  }
  expect(await isLoginPage(page, runtimeEnv), "업무 화면 진입 전 로그인을 완료해야 합니다.").toBe(false);
}

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, viewport: { width: 1600, height: 1000 } }
    : { acceptDownloads: true, viewport: { width: 1600, height: 1000 } });
  const page = await context.newPage();
  try { await callback(page); } finally { await context.close(); }
}

async function waitForExcelLink(runtimeEnv: RuntimeQaEnv, documentId: string, timeoutMs: number): Promise<string> {
  const apiBase = apiBaseFromRuntime(runtimeEnv);
  const deadline = Date.now() + timeoutMs;
  let last = "";
  while (Date.now() < deadline) {
    const response = await fetchWithTimeout(`${apiBase}/api/v1/excel/link/${documentId}`, { headers: nodeApiHeaders(runtimeEnv) }, 15_000);
    last = await response.text();
    if (response.ok) {
      const body = JSON.parse(last) as { payload?: unknown };
      if (typeof body.payload === "string" && body.payload) return body.payload;
    }
    await new Promise((resolve) => setTimeout(resolve, 3_000));
  }
  throw new Error(`문서보관함 엑셀 링크 생성 대기 초과: ${last.slice(0, 500)}`);
}

function extractDocumentId(body: unknown): string {
  if (!body || typeof body !== "object") throw new Error(`엑셀 생성 응답이 JSON 객체가 아닙니다: ${JSON.stringify(body)}`);
  const value = (body as { payload?: unknown; id?: unknown; documentId?: unknown }).payload
    ?? (body as { id?: unknown }).id ?? (body as { documentId?: unknown }).documentId;
  if (typeof value !== "string" || !value) throw new Error(`엑셀 생성 응답에서 문서 ID를 찾지 못했습니다: ${JSON.stringify(body)}`);
  return value;
}

function findPage(pages: QaPageDefinition[], keyword: string): QaPageDefinition {
  const result = pages.find((item) => item.name.includes(keyword));
  if (!result) throw new Error(`${keyword} 페이지 정의가 필요합니다.`);
  return result;
}

function getRuntimeBlockReason(runtimeEnv: RuntimeQaEnv): string | undefined {
  const prefix = `${runtimeEnv.application}/${runtimeEnv.environment}/${runtimeEnv.role}`;
  if (!runtimeEnv.baseUrl || !runtimeEnv.tenantId) return `${prefix} baseUrl/tenantId 설정이 필요합니다.`;
  if (!runtimeEnv.storageState && (!runtimeEnv.username || !runtimeEnv.password || !runtimeEnv.loginUrl)) return `${prefix} 로그인 설정이 필요합니다.`;
  return undefined;
}

function apiBaseFromRuntime(runtimeEnv: RuntimeQaEnv): string {
  return new URL(runtimeEnv.baseUrl!).origin.replace("admin-", "api-").replace("store-", "api-");
}

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

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 storageStateCookieHeader(runtimeEnv: RuntimeQaEnv): string {
  if (!runtimeEnv.storageState) throw new Error("엑셀 링크 조회에는 role storageState가 필요합니다.");
  const state = JSON.parse(fs.readFileSync(runtimeEnv.storageState, "utf8")) as {
    cookies?: Array<{ name?: string; value?: string; domain?: string }>;
  };
  const host = new URL(apiBaseFromRuntime(runtimeEnv)).hostname;
  const cookies = state.cookies?.filter((cookie) => cookie.name && cookie.value && domainMatches(host, cookie.domain ?? "")) ?? [];
  if (!cookies.length) throw new Error(`storageState에서 ${host} 쿠키를 찾지 못했습니다.`);
  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 settle(page: Page): Promise<void> {
  await page.waitForLoadState("domcontentloaded").catch(() => undefined);
  await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
  await page.waitForTimeout(700);
}

async function attachScreenshot(page: Page, testInfo: TestInfo, target: DepartmentExcelTarget, slug: string): Promise<void> {
  const screenshotPath = testInfo.outputPath(`${safe(target.page.name)}-${target.key}-${slug}.png`);
  await page.screenshot({ path: screenshotPath, fullPage: false });
  await testInfo.attach(path.basename(screenshotPath), { path: screenshotPath, contentType: "image/png" });
  await testInfo.attach(`${safe(target.page.name)}-${target.key}-${slug}-url.txt`, { body: page.url(), contentType: "text/plain" });
}

async function attachLog(testInfo: TestInfo, target: DepartmentExcelTarget, slug: string, lines: string[]): Promise<void> {
  const file = testInfo.outputPath(`${safe(target.page.name)}-${target.key}-${slug}.md`);
  fs.writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
  await testInfo.attach(path.basename(file), { path: file, contentType: "text/markdown" });
}

function normalize(value: string | undefined): string { return String(value ?? "").replace(/\s+/g, " ").trim(); }
function normalizeCell(value: string | undefined): string { return normalize(value).replace(/\s+/g, ""); }
function snippet(value: string, max = 2000): string { const text = normalize(value); return text.length > max ? `${text.slice(0, max)}...` : text; }
function safe(value: string): string { return value.replace(/[^a-zA-Z0-9가-힣_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80); }
function safeJson(value: string): unknown {
  try {
    return JSON.parse(value);
  } catch {
    return value.slice(0, 5000);
  }
}
function redactUrl(value: string): string {
  try {
    const url = new URL(value);
    for (const key of url.searchParams.keys()) if (/tenant|token|key/i.test(key)) url.searchParams.set(key, "<redacted>");
    return url.toString();
  } catch {
    return value;
  }
}
