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

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

type TargetRole = "ADMIN" | "TA";

interface InstallmentItem {
  id: number;
  status: string;
  vendorBpCode: string;
  vendorName: string;
  startedAt: string;
  totalAmount: number;
  paid: number;
  remainingAmount: number;
  installmentAmount: number;
  canceledAmount: number | null;
  canceledAt: string | null;
}

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

interface PermissionSnapshot {
  loginId: string;
  roleAvailableStatus: number;
  roleAvailableCount: number;
  roleAvailableHasUseInstallment: boolean;
  presetId: number;
  presetStatus: number;
  presetPermissionCount: number;
  presetHasUseInstallment: boolean;
  effectiveStatus: number;
  effectivePermissionCount: number;
  effectiveHasUseInstallment: boolean;
}

const MUTATION_GUARD = "QA_4107_ALLOW_INSTALLMENT_MUTATION";
const TENANT_ID_FALLBACK = "tenant-id-redacted";
const TARGET_VENDOR = {
  id: "tenant-id-redacted",
  code: "BP100008",
  name: "박재민_가맹"
};
const ROLE_AMOUNT: Record<TargetRole, number> = {
  ADMIN: 410_701,
  TA: 410_702
};

test.use({ trace: process.env.QA_PUBLISH_TRACE === "1" ? "on" : "retain-on-failure" });

export function runDailyCollectionCancellationSuite(options: ScenarioOptions): void {
  const checklist = loadChecklist(options.issueId, options.environment);
  const metadata = loadIssueMetadata(options.issueId);
  const installmentPage = requirePage(checklist.pages, "수납관리");
  const adminRuntime = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
  const tenantId = adminRuntime.tenantId ?? TENANT_ID_FALLBACK;
  const targetRoles = (checklist.roles ?? []).filter(isTargetRole);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 180_000));

    for (const [index, role] of targetRoles.entries()) {
      test(`[${role}] role / ${checklist.checklist[index]}`, async ({ browser }, testInfo) => {
        if (process.env[MUTATION_GUARD] !== "1") {
          throw new Error(`BLOCKED: 일수금 생성·취소 검증은 ${MUTATION_GUARD}=1 설정이 필요합니다.`);
        }

        const runtime = getRuntimeQaEnv(options.environment, "admin", role);
        ensureRuntime(adminRuntime, "ADMIN");
        ensureRuntime(runtime, role);
        const dateRange = koreaCurrentMonthRange();
        let fixtureId: number | undefined;

        try {
          const permissionSnapshot = await readPermissionSnapshot(browser, adminRuntime, runtime, tenantId);
          await attachText(testInfo, `${role.toLowerCase()}-installment-permission.md`, [
            `로그인 계정: ${permissionSnapshot.loginId}`,
            `역할: ${role}`,
            `TA 역할 허용 권한 조회: HTTP ${permissionSnapshot.roleAvailableStatus}`,
            `TA 역할 허용 권한 USE_INSTALLMENT: ${permissionSnapshot.roleAvailableHasUseInstallment ? "ON" : "OFF"}`,
            `TA 역할 허용 권한 수: ${permissionSnapshot.roleAvailableCount}`,
            `ta 프리셋 ID: ${permissionSnapshot.presetId}`,
            `ta 프리셋 기능 권한 조회: HTTP ${permissionSnapshot.presetStatus}`,
            `ta 프리셋 USE_INSTALLMENT: ${permissionSnapshot.presetHasUseInstallment ? "ON" : "OFF"}`,
            `ta 프리셋 기능 권한 수: ${permissionSnapshot.presetPermissionCount}`,
            `TA 유효 권한 조회: HTTP ${permissionSnapshot.effectiveStatus}`,
            `TA 유효 권한 USE_INSTALLMENT: ${permissionSnapshot.effectiveHasUseInstallment ? "ON" : "OFF"}`,
            `TA 유효 권한 수: ${permissionSnapshot.effectivePermissionCount}`
          ]);

          fixtureId = await withRolePage(browser, adminRuntime, async (page) => {
            await openAuthenticatedUrl(
              page,
              adminRuntime,
              installmentListUrl(adminRuntime, installmentPage, tenantId, dateRange)
            );
            return createFixture(page.context().request, adminRuntime, role, dateRange.today);
          });

          await withRolePage(browser, runtime, async (page) => {
            await openAuthenticatedUrl(
              page,
              runtime,
              installmentListUrl(runtime, installmentPage, tenantId, dateRange)
            );
            const searchRequestUrl = await applyCurrentMonthInProgressFilters(page, dateRange);
            const row = installmentRow(page, fixtureId!);
            await expect(row, `${role} 수납관리의 QA 전용 진행중 일수금`).toBeVisible({ timeout: 30_000 });
            await expect(row).toContainText(TARGET_VENDOR.name);
            await expect(row).toContainText(formatNumber(ROLE_AMOUNT[role]));
            await expect(row).toContainText("진행중");
            await attachScreenshot(page, testInfo, `${role.toLowerCase()}-installment-in-progress-current-month.png`);

            const cancelButton = row.getByRole("button", { name: "취소", exact: true });
            await expect(cancelButton, `${role} 일수금 취소 버튼`).toBeVisible();
            await cancelButton.click();

            const confirm = page.locator(".swal2-popup").filter({ hasText: "일수금을 취소하시겠습니까?" }).last();
            await expect(confirm, `${role} 일수금 취소 확인창`).toBeVisible({ timeout: 10_000 });
            await expect(confirm).toContainText("취소 후에는 되돌릴 수 없습니다.");

            const cancelResponse = page.waitForResponse(
              (response) => response.request().method() === "POST" && response.url().includes(`/api/v1/installments/${fixtureId}/cancel`),
              { timeout: 30_000 }
            );
            await confirm.locator(".swal2-confirm").click();
            const response = await cancelResponse;
            const responseBody = await response.text();
            await attachText(testInfo, `${role.toLowerCase()}-installment-cancel-response.md`, [
              `역할: ${role}`,
              `일수금 ID: ${fixtureId}`,
              `취소 API: POST /api/v1/installments/${fixtureId}/cancel`,
              `HTTP status: ${response.status()}`,
              `응답: ${responseBody || "(empty)"}`
            ]);
            if (!response.ok()) {
              await attachScreenshot(page, testInfo, `${role.toLowerCase()}-installment-cancel-api-error.png`);
            }
            expect(
              response.ok(),
              `${role} 일수금 취소 API status=${response.status()} body=${snippet(responseBody)}`
            ).toBe(true);
            await expect(page.getByText("일수금이 취소되었습니다.", { exact: true }).last()).toBeVisible({ timeout: 10_000 });

            const canceled = await waitForCanceled(page.context().request, runtime, fixtureId!);
            expect(canceled.status).toBe("CANCELED");
            expect(Number(canceled.canceledAmount)).toBe(ROLE_AMOUNT[role]);
            expect(Number(canceled.remainingAmount)).toBe(0);
            expect(canceled.canceledAt).toBeTruthy();

            await expect(row).toContainText("취소", { timeout: 20_000 });
            await expect(row).toContainText(formatNumber(ROLE_AMOUNT[role]));
            await expect(row.getByRole("button", { name: "취소", exact: true })).toHaveCount(0);

            const detailButton = row.locator("svg.lucide-book-open, svg[data-lucide='book-open'], svg[class*='book-open']").first();
            await expect(detailButton, `${role} 일수금 상세 버튼`).toBeVisible();
            await detailButton.click({ force: true });
            const detail = page.locator("[role='dialog']").filter({ hasText: "일수금 상세" }).last();
            await expect(detail, `${role} 취소된 일수금 상세`).toBeVisible({ timeout: 20_000 });
            await expect(detail).toContainText("취소 금액");
            await expect(detail).toContainText(`${formatNumber(ROLE_AMOUNT[role])}원`);
            await expect(detail).toContainText("남은 금액");
            await expect(detail).toContainText("0원");
            await expect(detail.getByText("취소", { exact: true }).last()).toBeVisible();
            await attachLocatorScreenshot(
              detail,
              testInfo,
              `${role.toLowerCase()}-installment-canceled-detail.png`
            );
            await closeDialog(page, detail);

            await page.reload({ waitUntil: "domcontentloaded" });
            await page.waitForTimeout(1_500);
            const persistedRow = installmentRow(page, fixtureId!);
            await expect(persistedRow, `${role} 새로고침 후 취소 일수금`).toBeVisible({ timeout: 30_000 });
            await expect(persistedRow).toContainText("취소");
            await expect(persistedRow).toContainText(formatNumber(ROLE_AMOUNT[role]));
            await expect(persistedRow.getByRole("button", { name: "취소", exact: true })).toHaveCount(0);

            await attachText(testInfo, `${role.toLowerCase()}-installment-cancel.md`, [
              `역할: ${role}`,
              `가맹점: ${TARGET_VENDOR.code}/${TARGET_VENDOR.name}`,
              `일수금 ID: ${fixtureId}`,
              `조회 기간: ${dateRange.from} ~ ${dateRange.to} (당월)`,
              "조회 상태: 진행중",
              `조회 API: ${searchRequestUrl}`,
              `총액: ${formatNumber(ROLE_AMOUNT[role])}원`,
              `취소 API: POST /api/v1/installments/${fixtureId}/cancel -> ${response.status()}`,
              `취소 상태: ${canceled.status}`,
              `취소 금액: ${formatNumber(Number(canceled.canceledAmount))}원`,
              `남은 금액: ${formatNumber(Number(canceled.remainingAmount))}원`,
              `취소 일시: ${canceled.canceledAt}`,
              "새로고침 후 상태: 취소 유지",
              "취소 후 재취소 버튼: 미노출"
            ]);
            await attachScreenshot(page, testInfo, `${role.toLowerCase()}-installment-canceled.png`);
          });
        } finally {
          if (fixtureId !== undefined) {
            await cleanupActiveFixture(browser, adminRuntime, fixtureId);
          }
        }
      });
    }
  });
}

async function readPermissionSnapshot(
  browser: Browser,
  adminRuntime: RuntimeQaEnv,
  runtime: RuntimeQaEnv,
  tenantId: string
): Promise<PermissionSnapshot> {
  const adminState = await withRolePage(browser, adminRuntime, async (page) => {
    await ensureAuthenticatedPage(page, adminRuntime);
    const request = page.context().request;
    const apiBase = resolveApiBase(adminRuntime);

    const roleUrl = new URL(`${apiBase}/api/v1/admin/permission-available`);
    roleUrl.searchParams.set("role", "TA");
    roleUrl.searchParams.set("tenantId", tenantId);
    const roleResponse = await request.get(roleUrl.toString());
    const roleText = await roleResponse.text();
    const roleBody = parseJson<ApiEnvelope<string[]>>(roleText);
    if (!roleResponse.ok() || !Array.isArray(roleBody?.payload)) {
      throw new Error(`BLOCKED: TA 역할 허용 권한 조회 실패 status=${roleResponse.status()} body=${snippet(roleText)}`);
    }

    const presetListUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets`);
    presetListUrl.searchParams.set("role", "TA");
    presetListUrl.searchParams.set("tenantId", tenantId);
    const presetListResponse = await request.get(presetListUrl.toString());
    const presetListText = await presetListResponse.text();
    const presetListBody = parseJson<ApiEnvelope<Array<{ id: number; name: string }>>>(presetListText);
    const preset = presetListBody?.payload?.find((item) => item.name === "ta");
    if (!presetListResponse.ok() || !preset) {
      throw new Error(`BLOCKED: TA/ta 프리셋 조회 실패 status=${presetListResponse.status()} body=${snippet(presetListText)}`);
    }

    const presetUrl = new URL(`${apiBase}/api/v1/admin/role-permission-presets/${preset.id}`);
    presetUrl.searchParams.set("tenantId", tenantId);
    presetUrl.searchParams.set("type", "TASK");
    const presetResponse = await request.get(presetUrl.toString());
    const presetText = await presetResponse.text();
    const presetBody = parseJson<ApiEnvelope<unknown>>(presetText);
    if (!presetResponse.ok() || !presetBody?.payload) {
      throw new Error(`BLOCKED: TA/ta 프리셋 기능 권한 조회 실패 status=${presetResponse.status()} body=${snippet(presetText)}`);
    }
    const presetPermissionIds = extractPermissionIds(presetBody.payload);

    return {
      roleAvailableStatus: roleResponse.status(),
      roleAvailableIds: roleBody.payload,
      presetId: preset.id,
      presetStatus: presetResponse.status(),
      presetPermissionIds
    };
  });

  const effectiveState = await withRolePage(browser, runtime, async (page) => {
    await ensureAuthenticatedPage(page, runtime);
    const response = await page.context().request.get(`${resolveApiBase(runtime)}/api/v1/auth/me`, {
      headers: apiHeaders(runtime)
    });
    const text = await response.text();
    const body = parseJson<ApiEnvelope<{ loginId?: string; permissions?: string[] }>>(text);
    if (!response.ok() || !body?.payload) {
      throw new Error(`BLOCKED: TA 유효 권한 조회 실패 status=${response.status()} body=${snippet(text)}`);
    }
    return {
      status: response.status(),
      loginId: body.payload.loginId ?? "(unknown)",
      permissionIds: body.payload.permissions ?? []
    };
  });

  return {
    loginId: effectiveState.loginId,
    roleAvailableStatus: adminState.roleAvailableStatus,
    roleAvailableCount: adminState.roleAvailableIds.length,
    roleAvailableHasUseInstallment: adminState.roleAvailableIds.includes("USE_INSTALLMENT"),
    presetId: adminState.presetId,
    presetStatus: adminState.presetStatus,
    presetPermissionCount: adminState.presetPermissionIds.length,
    presetHasUseInstallment: adminState.presetPermissionIds.includes("USE_INSTALLMENT"),
    effectiveStatus: effectiveState.status,
    effectivePermissionCount: effectiveState.permissionIds.length,
    effectiveHasUseInstallment: effectiveState.permissionIds.includes("USE_INSTALLMENT")
  };
}

function extractPermissionIds(value: unknown): string[] {
  const ids = new Set<string>();
  const visit = (current: unknown): void => {
    if (Array.isArray(current)) {
      current.forEach(visit);
      return;
    }
    if (!current || typeof current !== "object") return;
    const record = current as Record<string, unknown>;
    if (typeof record.id === "string") ids.add(record.id);
    Object.values(record).forEach(visit);
  };
  visit(value);
  return [...ids];
}

function isTargetRole(role: string): role is TargetRole {
  return role === "ADMIN" || role === "TA";
}

async function createFixture(
  request: APIRequestContext,
  runtime: RuntimeQaEnv,
  role: TargetRole,
  startedAt: string
): Promise<number> {
  const response = await request.post(`${resolveApiBase(runtime)}/api/v1/installments`, {
    headers: apiHeaders(runtime),
    data: {
      vendorId: TARGET_VENDOR.id,
      startedAt,
      totalAmount: ROLE_AMOUNT[role],
      installmentAmountType: "FIXED_AMOUNT",
      installmentAmount: 4_107,
      installmentRate: null,
      holidayInstallmentCarryOverEnabled: false
    }
  });
  const text = await response.text();
  const body = parseJson<ApiEnvelope<InstallmentItem | number>>(text);
  if (!response.ok()) {
    throw new Error(`BLOCKED: ${role} QA 전용 일수금 생성 실패 status=${response.status()} body=${snippet(text)}`);
  }

  const directId = typeof body?.payload === "number" ? body.payload : body?.payload?.id;
  if (directId) return directId;

  const rows = await readInstallments(request, runtime, startedAt);
  const fixture = rows.find(
    (item) => item.vendorBpCode === TARGET_VENDOR.code &&
      Number(item.totalAmount) === ROLE_AMOUNT[role] &&
      item.status === "IN_PROGRESS"
  );
  if (!fixture) {
    throw new Error(`BLOCKED: ${role} 생성 응답에서 일수금 ID를 확인하지 못했습니다. body=${snippet(text)}`);
  }
  return fixture.id;
}

async function readInstallments(
  request: APIRequestContext,
  runtime: RuntimeQaEnv,
  date: string
): Promise<InstallmentItem[]> {
  const url = new URL("/api/v1/installments", resolveApiBase(runtime));
  url.searchParams.set("page", "0");
  url.searchParams.set("size", "100");
  url.searchParams.set("from", date);
  url.searchParams.set("to", date);
  const response = await request.get(url.toString(), { headers: apiHeaders(runtime) });
  const text = await response.text();
  const body = parseJson<ApiEnvelope<{ content?: InstallmentItem[] }>>(text);
  if (!response.ok()) {
    throw new Error(`BLOCKED: ${runtime.role} 일수금 조회 실패 status=${response.status()} body=${snippet(text)}`);
  }
  return body?.payload?.content ?? [];
}

async function waitForCanceled(
  request: APIRequestContext,
  runtime: RuntimeQaEnv,
  installmentId: number
): Promise<InstallmentItem> {
  const deadline = Date.now() + 20_000;
  while (Date.now() < deadline) {
    const response = await request.get(`${resolveApiBase(runtime)}/api/v1/installments/${installmentId}?page=0&size=20`, {
      headers: apiHeaders(runtime)
    });
    const text = await response.text();
    const body = parseJson<ApiEnvelope<InstallmentItem>>(text);
    if (response.ok() && body?.payload?.status === "CANCELED") return body.payload;
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
  throw new Error(`${runtime.role} 일수금 ID ${installmentId} 취소 상태 반영을 확인하지 못했습니다.`);
}

async function cleanupActiveFixture(browser: Browser, runtime: RuntimeQaEnv, installmentId: number): Promise<void> {
  await withRolePage(browser, runtime, async (page) => {
    await ensureAuthenticatedPage(page, runtime);
    const response = await page.context().request.get(
      `${resolveApiBase(runtime)}/api/v1/installments/${installmentId}?page=0&size=1`,
      { headers: apiHeaders(runtime) }
    );
    const body = parseJson<ApiEnvelope<InstallmentItem>>(await response.text());
    if (response.ok() && body?.payload?.status === "IN_PROGRESS") {
      await page.context().request.post(`${resolveApiBase(runtime)}/api/v1/installments/${installmentId}/cancel`, {
        headers: apiHeaders(runtime)
      });
    }
  }).catch(() => undefined);
}

function installmentListUrl(
  runtime: RuntimeQaEnv,
  pageDefinition: QaPageDefinition,
  tenantId: string,
  dateRange: { from: string; to: string }
): string {
  const url = new URL(buildPageUrl(requireBaseUrl(runtime), pageDefinition, tenantId));
  url.searchParams.set("from", dateRange.from);
  url.searchParams.set("to", dateRange.to);
  return url.toString();
}

async function applyCurrentMonthInProgressFilters(
  page: Page,
  dateRange: { from: string; to: string }
): Promise<string> {
  const currentMonth = page.locator("div:visible").filter({ hasText: /^당월$/ }).last();
  await expect(currentMonth, "당월 프리셋").toBeVisible({ timeout: 10_000 });
  await currentMonth.click();

  const dateInputs = page.locator("input[type='date']:visible");
  await expect(dateInputs.nth(0), "당월 조회 시작일").toHaveValue(dateRange.from);
  await expect(dateInputs.nth(1), "당월 조회 종료일").toHaveValue(dateRange.to);

  const statusSelect = page.getByRole("combobox").first();
  await statusSelect.click();
  await page.getByRole("option", { name: "진행중", exact: true }).click();
  await expect(statusSelect, "진행중 상태 필터").toContainText("진행중");

  const searchResponse = page.waitForResponse(
    (response) => response.request().method() === "GET" && response.url().includes("/api/v1/installments"),
    { timeout: 30_000 }
  );
  await page.getByRole("button", { name: "검색", exact: true }).click();
  const response = await searchResponse;
  expect(response.ok(), `당월·진행중 일수금 조회 API status=${response.status()}`).toBe(true);
  return response.url();
}

function installmentRow(page: Page, installmentId: number): Locator {
  return page.locator(`[role='row'][data-id='${installmentId}']:visible`).last();
}

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

async function withRolePage<T>(
  browser: Browser,
  runtime: RuntimeQaEnv,
  callback: (page: Page) => Promise<T>
): Promise<T> {
  const context = await browser.newContext({
    ...(runtime.storageState ? { storageState: runtime.storageState } : {}),
    viewport: { width: 1680, height: 1000 }
  });
  const page = await context.newPage();
  try {
    return await callback(page);
  } finally {
    await context.close();
  }
}

async function ensureAuthenticatedPage(page: Page, runtime: RuntimeQaEnv): Promise<void> {
  const url = new URL(requireBaseUrl(runtime));
  if (runtime.tenantId) url.searchParams.set("tenantId", runtime.tenantId);
  await openAuthenticatedUrl(page, runtime, url.toString());
}

async function openAuthenticatedUrl(page: Page, runtime: RuntimeQaEnv, url: string): Promise<void> {
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
  await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
  if (await isLoginPage(page, runtime)) {
    await loginWithCredentials(page, runtime);
    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 });
    await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
  }
  expect(await isLoginPage(page, runtime), `${runtime.role} 로그인 상태`).toBe(false);
  await page.waitForTimeout(800);
}

function requirePage(pages: QaPageDefinition[], name: string): QaPageDefinition {
  const page = pages.find((candidate) => candidate.name === name);
  if (!page) throw new Error(`checklist.json pages에 '${name}' 정의가 필요합니다.`);
  return page;
}

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

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

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

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

function koreaCurrentMonthRange(): { from: string; to: string; today: string } {
  const today = new Intl.DateTimeFormat("en-CA", {
    timeZone: "Asia/Seoul",
    year: "numeric",
    month: "2-digit",
    day: "2-digit"
  }).format(new Date());
  const [year, month] = today.split("-").map(Number);
  const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
  return {
    from: `${today.slice(0, 7)}-01`,
    to: `${today.slice(0, 7)}-${String(lastDay).padStart(2, "0")}`,
    today
  };
}

function parseJson<T>(value: string): T | undefined {
  try {
    return JSON.parse(value) as T;
  } catch {
    return undefined;
  }
}

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

function snippet(value: string, limit = 800): string {
  return normalizeText(value).slice(0, limit);
}

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

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

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

async function attachLocatorScreenshot(locator: Locator, testInfo: TestInfo, fileName: string): Promise<void> {
  const outputPath = testInfo.outputPath(fileName);
  await locator.screenshot({ path: outputPath });
  await testInfo.attach(fileName, { path: outputPath, contentType: "image/png" });
}
