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

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

interface OffsetRow {
  tenantId?: string;
  tenantName?: string;
  offsetId?: string;
  status?: string;
  departmentName?: string;
  offsetAmount?: number;
  username?: string;
  offsetAt?: string;
}

interface TenantOption {
  id: string;
  name: string;
}

interface OffsetListResult {
  rows: OffsetRow[];
  totalElements: number;
  requestPath: string;
}

interface Profile {
  role: string;
  description: string;
}

const CURRENT_ADMIN: Profile = {
  role: "ADMIN",
  description: "현재 접속 기준 업체/테넌트 ADMIN"
};

const STATUS_OPTIONS = [
  { value: "PENDING", label: "대기중" },
  { value: "AR_DUNNING", label: "채권부과" },
  { value: "DONE", label: "완료" }
];

export function runOffsetManagementSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const offsetPage = findPageDefinition(checklist.pages, "상계처리내역");
  const runtimeEnv = getRuntimeQaEnv(options.environment, "admin", CURRENT_ADMIN.role);
  const blockReason = getRuntimeBlockReason(runtimeEnv);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.describe(`[${CURRENT_ADMIN.role}] role`, () => {
      test.describe(offsetPage.name, () => {
        test.skip(!!blockReason, blockReason);

        test(checklist.checklist[0], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const result = await loadOffsetRows(page, runtimeEnv, {});
            const tenantNames = uniqueValues(result.rows.map((row) => row.tenantName).filter(Boolean));

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "current-company-scope", [
              `${CURRENT_ADMIN.description} 진입 범위 검증`,
              `상계 목록 총 건수: ${result.totalElements}`,
              `현재 접속 기준 업체명 후보: ${tenantNames.join(", ") || "없음"}`,
              ...formatRows(result.rows)
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "current-company-scope");

            expect(result.rows.length, "상계처리내역 목록 데이터가 보여야 합니다.").toBeGreaterThan(0);
            expect(tenantNames.length, "첫 진입 목록은 현재 접속 기준 업체 1개로 고정되어야 합니다.").toBe(1);
          });
        });

        test(checklist.checklist[1], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const result = await loadOffsetRows(page, runtimeEnv, {});
            const tenantNames = tenantNamesFromRows(result.rows);
            const params = new URL(page.url()).searchParams;
            const controls = await inspectCompanyFilterAvailability(page);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "company-fixed", [
              "업체명 고정 표시 검증",
              `화면 URL offsetTenantId: ${params.get("offsetTenantId") ?? "없음"}`,
              `업체/테넌트 선택형 필터 감지: ${controls.selectableCompanyFilter ? "있음" : "없음"}`,
              `목록 업체명: ${tenantNames.join(", ") || "없음"}`,
              ...formatRows(result.rows)
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "company-fixed");

            expect(tenantNames.length, "목록 업체명은 현재 접속한 업체 1개로만 표시되어야 합니다.").toBe(1);
            expect(params.get("offsetTenantId"), "업체명은 선택 필터가 아니므로 offsetTenantId 조회 조건이 없어야 합니다.").toBeNull();
            expect(controls.selectableCompanyFilter, "업체명/테넌트는 사용자가 다른 업체를 선택하는 필터로 노출되면 안 됩니다.").toBe(false);
          });
        });

        test(checklist.checklist[2], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const base = await loadOffsetRows(page, runtimeEnv, {});
            const currentCompanyNames = requireCurrentCompanyNames(base);
            const evidence = [
              "상태 필터별 현재 접속 기준 업체 범위 검증",
              `현재 업체명: ${currentCompanyNames.join(", ")}`
            ];

            for (const status of STATUS_OPTIONS) {
              const result = await loadOffsetRows(page, runtimeEnv, { status: status.value });
              const statusMismatches = result.rows.filter((row) => row.status !== status.label);
              const companyMismatches = outsideCurrentCompanyRows(result.rows, currentCompanyNames);
              evidence.push(
                `${status.label}(${status.value}): rows=${result.rows.length} total=${result.totalElements} statusMismatch=${statusMismatches.length} companyMismatch=${companyMismatches.length}`
              );
              expect(result.rows.length, `${status.label} 상태 검증 데이터가 1건 이상 있어야 합니다.`).toBeGreaterThan(0);
              expect(statusMismatches, `${status.label} 필터 결과에는 ${status.label} 상태만 보여야 합니다.`).toHaveLength(0);
              expect(companyMismatches, `${status.label} 필터 결과는 현재 접속 기준 업체 범위 안이어야 합니다.`).toHaveLength(0);
            }

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "status-filters", evidence);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "status-filters");
          });
        });

        test(checklist.checklist[3], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const base = await loadOffsetRows(page, runtimeEnv, {});
            const currentCompanyNames = requireCurrentCompanyNames(base);
            const target = base.rows.find((row) => row.departmentName);
            if (!target?.departmentName) {
              blocked("부서명 필터 검증에 사용할 부서명이 있는 상계 데이터가 없습니다.");
            }

            const keyword = target.departmentName;
            const result = await loadOffsetRows(page, runtimeEnv, { departmentName: keyword });
            const valueMismatches = result.rows.filter((row) => !row.departmentName?.includes(keyword));
            const companyMismatches = outsideCurrentCompanyRows(result.rows, currentCompanyNames);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "department-filter", [
              `현재 업체명: ${currentCompanyNames.join(", ")}`,
              `검색 부서명: ${keyword}`,
              `조회 건수: ${result.rows.length} / total=${result.totalElements}`,
              `부서명 불일치 건수: ${valueMismatches.length}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`,
              ...formatRows(result.rows)
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "department-filter");

            expect(result.rows.length, "부서명 필터 결과가 1건 이상이어야 합니다.").toBeGreaterThan(0);
            expect(valueMismatches, "부서명 필터 결과는 입력한 부서명 기준이어야 합니다.").toHaveLength(0);
            expect(companyMismatches, "부서명 필터 결과는 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });

        test(checklist.checklist[4], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const base = await loadOffsetRows(page, runtimeEnv, {});
            const currentCompanyNames = requireCurrentCompanyNames(base);
            const target = base.rows.find((row) => row.username);
            if (!target?.username) {
              blocked("처리자 필터 검증에 사용할 처리자가 있는 상계 데이터가 없습니다.");
            }

            const keyword = target.username;
            const result = await loadOffsetRows(page, runtimeEnv, { username: keyword });
            const valueMismatches = result.rows.filter((row) => !row.username?.includes(keyword));
            const companyMismatches = outsideCurrentCompanyRows(result.rows, currentCompanyNames);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "username-filter", [
              `현재 업체명: ${currentCompanyNames.join(", ")}`,
              `검색 처리자: ${keyword}`,
              `조회 건수: ${result.rows.length} / total=${result.totalElements}`,
              `처리자 불일치 건수: ${valueMismatches.length}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`,
              ...formatRows(result.rows)
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "username-filter");

            expect(result.rows.length, "처리자 필터 결과가 1건 이상이어야 합니다.").toBeGreaterThan(0);
            expect(valueMismatches, "처리자 필터 결과는 입력한 처리자 기준이어야 합니다.").toHaveLength(0);
            expect(companyMismatches, "처리자 필터 결과는 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });

        test(checklist.checklist[5], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const base = await loadOffsetRows(page, runtimeEnv, {});
            const currentCompanyNames = requireCurrentCompanyNames(base);
            const candidateDate = findDateForFilter(base.rows);
            if (!candidateDate) {
              blocked("처리일시 기간 필터 검증에 사용할 날짜가 있는 상계 데이터가 없습니다.");
            }

            const result = await loadOffsetRows(page, runtimeEnv, { from: candidateDate, to: candidateDate });
            const dateMismatches = result.rows.filter((row) => toDate(row.offsetAt) !== candidateDate);
            const companyMismatches = outsideCurrentCompanyRows(result.rows, currentCompanyNames);
            const today = formatDate(new Date());

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "date-filter", [
              `현재 업체명: ${currentCompanyNames.join(", ")}`,
              `검증 날짜: ${candidateDate} (${candidateDate === today ? "오늘" : "다른 날짜"})`,
              `조회 건수: ${result.rows.length} / total=${result.totalElements}`,
              `선택 날짜 외 데이터 수: ${dateMismatches.length}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`,
              ...formatRows(result.rows)
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "date-filter");

            expect(result.rows.length, "선택한 처리일시 기간 기준 상계내역 검증 데이터가 있어야 합니다.").toBeGreaterThan(0);
            expect(dateMismatches, "처리일시 기간 필터 결과에는 선택한 기간 데이터만 보여야 합니다.").toHaveLength(0);
            expect(companyMismatches, "처리일시 필터 결과는 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });

        test(checklist.checklist[6], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            await enableHeaderFilter(page);
            const controls = await inspectHeaderFilterControls(page);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "header-filter-controls", [
              "테이블 헤더 필터 노출 검증",
              `업체명/테넌트 헤더 필터: ${controls.tenant ? "있음" : "없음"}`,
              `상태 헤더 필터: ${controls.status ? "있음" : "없음"}`,
              `부서명 헤더 필터: ${controls.departmentName ? "있음" : "없음"}`,
              `처리자 헤더 필터: ${controls.username ? "있음" : "없음"}`
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "header-filter-controls");

            expect(controls.tenant, "업체명/테넌트는 헤더 필터 대상이 아니어야 합니다.").toBe(false);
            expect(controls.status, "상태 컬럼 상단에 헤더 필터가 보여야 합니다.").toBe(true);
            expect(controls.departmentName, "부서명 컬럼 상단에 헤더 필터가 보여야 합니다.").toBe(true);
            expect(controls.username, "처리자 컬럼 상단에 헤더 필터가 보여야 합니다.").toBe(true);
          });
        });

        test(checklist.checklist[7], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv);
            const base = await loadOffsetRows(page, runtimeEnv, {});
            const currentCompanyNames = requireCurrentCompanyNames(base);
            const target = base.rows.find((row) => row.departmentName && row.username && row.offsetAt);
            if (!target?.departmentName || !target.username || !target.offsetAt) {
              blocked("전체 초기화 검증에 사용할 부서명/처리자/처리일시 데이터가 없습니다.");
            }

            const selectedDate = toDate(target.offsetAt);
            await openOffsetPage(page, offsetPage, runtimeEnv, {
              offsetStatus: "PENDING",
              offsetDepartmentName: target.departmentName,
              offsetUsername: target.username,
              from: selectedDate,
              to: selectedDate
            });
            await enableHeaderFilter(page);
            await clickReset(page);
            const params = new URL(page.url()).searchParams;
            const remaining = ["offsetStatus", "offsetDepartmentName", "offsetUsername", "from", "to", "offsetTenantId"].filter((key) => params.has(key));
            const resetResult = await loadOffsetRows(page, runtimeEnv, {});
            const companyMismatches = outsideCurrentCompanyRows(resetResult.rows, currentCompanyNames);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "reset-all-filters", [
              "전체 초기화 후 필터 조건 확인",
              `초기화 전 기준: status=PENDING / department=${target.departmentName} / username=${target.username} / date=${selectedDate}`,
              `남아 있는 필터 키: ${remaining.join(", ") || "없음"}`,
              `초기화 후 조회 건수: ${resetResult.rows.length} / total=${resetResult.totalElements}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`,
              `현재 URL: ${redactRuntimeValues(page.url(), runtimeEnv)}`
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "reset-all-filters");

            expect(remaining, "전체 초기화 후 상단/헤더/처리일시/조회 조건이 모두 해제되어야 합니다.").toHaveLength(0);
            expect(resetResult.rows.length, "초기화 후 현재 접속 기준 업체 목록이 다시 보여야 합니다.").toBeGreaterThan(0);
            expect(companyMismatches, "초기화 후 목록도 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });

        test(checklist.checklist[8], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv, { offsetStatus: "PENDING" });
            const before = await loadOffsetRows(page, runtimeEnv, { status: "PENDING" });
            const currentCompanyNames = requireCurrentCompanyNames(before);
            const target = before.rows.find((row) => row.offsetId);
            if (!target?.offsetId) {
              blocked("상계 처리 상태 변경에 사용할 대기중 데이터가 없습니다.");
            }

            await clickOffsetActionButton(page, "채권처리");
            const after = await loadOffsetRows(page, runtimeEnv, { status: "AR_DUNNING" });
            const changed = after.rows.some((row) => String(row.offsetId) === String(target.offsetId) && row.status === "채권부과");
            const companyMismatches = outsideCurrentCompanyRows(after.rows, currentCompanyNames);

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "action-refresh", [
              "상계 처리 버튼 상태 변경 및 목록 새로고침 검증",
              `대상 행: ${redactId(target.offsetId)} / ${target.tenantName ?? "-"} / ${target.departmentName ?? "-"} / ${target.username ?? "-"}`,
              `변경 후 채권부과 목록에서 대상 확인: ${changed ? "예" : "아니오"}`,
              `변경 후 채권부과 총 건수: ${after.totalElements}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "action-refresh");

            expect(changed, "상계 처리 버튼 클릭 후 변경된 상태가 목록에 반영되어야 합니다.").toBe(true);
            expect(companyMismatches, "상계 처리 후 새로고침 목록도 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });

        test(checklist.checklist[9], async ({ browser }, testInfo) => {
          await withRolePage(browser, runtimeEnv, async (page) => {
            await openOffsetPage(page, offsetPage, runtimeEnv, { offsetStatus: "PENDING" });
            const beforeUrl = page.url();
            const before = await loadOffsetRows(page, runtimeEnv, { status: "PENDING" });
            const currentCompanyNames = requireCurrentCompanyNames(before);
            const target = before.rows.find((row) => row.offsetId && row.departmentName && row.username);
            if (!target?.offsetId || !target.departmentName || !target.username) {
              blocked("필터 적용 상태에서 상계 처리할 대기중 데이터가 없습니다.");
            }

            await openOffsetPage(page, offsetPage, runtimeEnv, {
              offsetStatus: "PENDING",
              offsetDepartmentName: target.departmentName,
              offsetUsername: target.username
            });
            const filteredBefore = await loadOffsetRows(page, runtimeEnv, {
              status: "PENDING",
              departmentName: target.departmentName,
              username: target.username
            });
            if (filteredBefore.rows.length === 0) {
              blocked("상계 처리 전 필터 조건에 맞는 대기중 데이터가 없습니다.");
            }

            await clickOffsetActionButton(page, "채권처리");
            await page.waitForTimeout(700);
            const afterParams = new URL(page.url()).searchParams;
            const pendingAfter = await loadOffsetRows(page, runtimeEnv, {
              status: "PENDING",
              departmentName: target.departmentName,
              username: target.username
            });
            const dunningAfter = await loadOffsetRows(page, runtimeEnv, {
              status: "AR_DUNNING",
              departmentName: target.departmentName,
              username: target.username
            });
            const statusMismatches = pendingAfter.rows.filter((row) => row.status !== "대기중");
            const companyMismatches = outsideCurrentCompanyRows([...pendingAfter.rows, ...dunningAfter.rows], currentCompanyNames);
            const changed = dunningAfter.rows.some((row) => String(row.offsetId) === String(target.offsetId) && row.status === "채권부과");

            await attachEvidenceLog(testInfo, offsetPage, CURRENT_ADMIN.role, "action-preserve-filter", [
              `변경 전 URL: ${redactRuntimeValues(beforeUrl, runtimeEnv)}`,
              `변경 후 URL: ${redactRuntimeValues(page.url(), runtimeEnv)}`,
              `유지된 상태 필터: ${afterParams.get("offsetStatus") ?? "없음"}`,
              `유지된 부서명 필터: ${afterParams.get("offsetDepartmentName") ?? "없음"}`,
              `유지된 처리자 필터: ${afterParams.get("offsetUsername") ?? "없음"}`,
              `변경 후 채권부과 목록에서 대상 확인: ${changed ? "예" : "아니오"}`,
              `필터 유지 후 대기중 조회 건수: ${pendingAfter.rows.length} / total=${pendingAfter.totalElements}`,
              `대기중 외 데이터 수: ${statusMismatches.length}`,
              `현재 업체 밖 데이터 수: ${companyMismatches.length}`
            ]);
            await attachPageScreenshot(page, testInfo, offsetPage, CURRENT_ADMIN.role, "action-preserve-filter");

            expect(afterParams.get("offsetStatus"), "상계 처리 후에도 상태 필터 조건이 URL에 유지되어야 합니다.").toBe("PENDING");
            expect(afterParams.get("offsetDepartmentName"), "상계 처리 후에도 부서명 필터 조건이 URL에 유지되어야 합니다.").toBe(target.departmentName);
            expect(afterParams.get("offsetUsername"), "상계 처리 후에도 처리자 필터 조건이 URL에 유지되어야 합니다.").toBe(target.username);
            expect(changed, "필터 적용 상태의 상계 처리 후 변경된 상태가 반영되어야 합니다.").toBe(true);
            expect(statusMismatches, "상계 처리 후 새로고침된 목록도 현재 필터 조건을 유지해야 합니다.").toHaveLength(0);
            expect(companyMismatches, "상계 처리 후 필터 유지 목록도 현재 접속 기준 업체 범위 안이어야 합니다.").toHaveLength(0);
          });
        });
      });
    });
  });
}

function findPageDefinition(pages: QaPageDefinition[], name: string): QaPageDefinition {
  const pageDefinition = pages.find((page) => page.name === name || page.name.includes(name));
  if (!pageDefinition) {
    throw new Error(`checklist.json pages에 '${name}' 페이지 정의가 필요합니다.`);
  }
  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,
  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 openOffsetPage(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  queryParams: Record<string, string> = {}
): Promise<void> {
  if (!runtimeEnv.storageState) {
    await loginWithCredentials(page, runtimeEnv);
  }

  const targetUrl = new URL(buildPageUrl(runtimeEnv.baseUrl!, pageDefinition, runtimeEnv.tenantId!));
  for (const [key, value] of Object.entries(queryParams)) {
    if (value) {
      targetUrl.searchParams.set(key, value);
    }
  }
  await page.goto(targetUrl.toString());
  await waitForNavigationToSettle(page);

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

  expect(await isLoginPage(page, runtimeEnv), `${pageDefinition.name} 업무 화면 진입 전 로그인 화면을 벗어나야 합니다.`).toBe(false);
  await expect(page.locator("body"), "상계처리내역 화면 본문이 표시되어야 합니다.").toContainText("상계처리내역");
}

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

async function loadTenantOptions(page: Page, runtimeEnv: RuntimeQaEnv): Promise<TenantOption[]> {
  return page.evaluate(async ({ apiBase }) => {
    const response = await fetch(`${apiBase}/api/v1/admin/tenants`, { credentials: "include" });
    const json = await response.json();
    const raw = Array.isArray(json?.payload)
      ? json.payload
      : Array.isArray(json)
        ? json
        : Array.isArray(json?.payload?.content)
          ? json.payload.content
          : [];
    return raw
      .map((item: Record<string, unknown>) => ({
        id: String(item.id ?? item.tenantId ?? ""),
        name: String(item.name ?? item.tenantName ?? item.displayName ?? "")
      }))
      .filter((item: TenantOption) => item.id && item.name);
  }, { apiBase: apiBaseFromRuntime(runtimeEnv) });
}

async function loadOffsetRows(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  filters: Partial<{
    tenantId: string;
    status: string;
    departmentName: string;
    username: string;
    from: string;
    to: string;
  }>
): Promise<OffsetListResult> {
  return page.evaluate(async ({ apiBase, filters: rawFilters }) => {
    const url = new URL(`${apiBase}/api/v1/offset`);
    url.searchParams.set("page", "0");
    url.searchParams.set("size", "50");
    for (const [key, value] of Object.entries(rawFilters)) {
      if (value) {
        url.searchParams.set(key, String(value));
      }
    }

    const response = await fetch(url.toString(), { credentials: "include" });
    const json = await response.json();
    if (!response.ok) {
      throw new Error(`상계처리내역 조회 실패: status=${response.status} message=${json?.result?.message ?? response.statusText}`);
    }
    const payload = json?.payload ?? {};
    return {
      rows: Array.isArray(payload.content) ? payload.content : [],
      totalElements: Number(payload.totalElements ?? 0),
      requestPath: `${url.pathname}?${url.searchParams.toString()}`
    };
  }, { apiBase: apiBaseFromRuntime(runtimeEnv), filters });
}

async function findTenantWithRows(
  page: Page,
  runtimeEnv: RuntimeQaEnv,
  tenants: TenantOption[]
): Promise<TenantOption | undefined> {
  for (const tenant of tenants) {
    const result = await loadOffsetRows(page, runtimeEnv, { tenantId: tenant.id });
    if (result.rows.length > 0) {
      return tenant;
    }
  }
  return undefined;
}

function tenantNamesFromRows(rows: OffsetRow[]): string[] {
  return uniqueValues(rows.map((row) => row.tenantName).filter(isNonEmptyString));
}

function requireCurrentCompanyNames(result: OffsetListResult): string[] {
  const tenantNames = tenantNamesFromRows(result.rows);
  expect(result.rows.length, "현재 접속 기준 업체 범위 검증에 사용할 상계 데이터가 있어야 합니다.").toBeGreaterThan(0);
  expect(tenantNames.length, "현재 접속 기준 업체명은 1개로 식별되어야 합니다.").toBe(1);
  return tenantNames;
}

function outsideCurrentCompanyRows(rows: OffsetRow[], currentCompanyNames: string[]): OffsetRow[] {
  return rows.filter((row) => row.tenantName && !currentCompanyNames.includes(row.tenantName));
}

function findDateForFilter(rows: OffsetRow[]): string | undefined {
  const today = formatDate(new Date());
  const dates = uniqueValues(rows.map((row) => toDate(row.offsetAt)).filter(isNonEmptyString));
  return dates.includes(today) ? today : dates[0];
}

function isNonEmptyString(value: string | undefined): value is string {
  return typeof value === "string" && value.length > 0;
}

async function inspectCompanyFilterAvailability(page: Page): Promise<{ selectableCompanyFilter: boolean }> {
  return page.evaluate(() => {
    function normalizedText(element: Element): string {
      const label = element.getAttribute("aria-label") ?? "";
      const placeholder = element.getAttribute("placeholder") ?? "";
      return `${element.textContent ?? ""} ${label} ${placeholder}`.replace(/\s+/g, " ").trim();
    }

    function isEnabled(element: Element): boolean {
      if (element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true") {
        return false;
      }
      const input = element as HTMLInputElement | HTMLButtonElement;
      return input.disabled !== true;
    }

    const controls = Array.from(document.querySelectorAll("input, button, [role='combobox']"));
    return {
      selectableCompanyFilter: controls.some((element) => {
        const text = normalizedText(element);
        const isCompanyControl = /업체|테넌트/.test(text);
        const isSelectorLike =
          element.getAttribute("role") === "combobox" ||
          element.getAttribute("aria-haspopup") === "listbox" ||
          element.tagName.toLowerCase() === "input";
        return isCompanyControl && isSelectorLike && isEnabled(element);
      })
    };
  });
}

async function enableHeaderFilter(page: Page): Promise<void> {
  const button = page.getByRole("button", { name: "필터" }).first();
  await expect(button, "테이블 헤더 필터 버튼이 보여야 합니다.").toBeVisible();
  await button.click();
  await page.waitForTimeout(500);
}

async function inspectHeaderFilterControls(page: Page): Promise<Record<"tenant" | "status" | "departmentName" | "username", boolean>> {
  return page.evaluate(() => {
    function text(value: Element | null | undefined): string {
      return (value?.textContent ?? "").replace(/\s+/g, " ").trim();
    }

    function visibleControls(): Array<{ text: string; placeholder: string; x: number; y: number }> {
      return Array.from(document.querySelectorAll("input, button, [role='combobox']"))
        .map((element) => {
          const rect = element.getBoundingClientRect();
          return {
            text: text(element),
            placeholder: element.getAttribute("placeholder") ?? element.getAttribute("aria-label") ?? "",
            x: Math.round(rect.x),
            y: Math.round(rect.y)
          };
        })
        .filter((item) => item.y >= 250 && item.y <= 330);
    }

    const controls = visibleControls();
    return {
      tenant: controls.some((item) => /업체|테넌트/.test(item.text) || /업체|테넌트/.test(item.placeholder)),
      status: controls.some((item) => item.text.includes("상태") || item.x < 430),
      departmentName: controls.some((item) => item.placeholder.includes("부서명") || item.text.includes("부서명")),
      username: controls.some((item) => item.placeholder.includes("처리자") || item.text.includes("처리자"))
    };
  });
}

async function clickReset(page: Page): Promise<void> {
  const button = page.getByRole("button", { name: "초기화" }).last();
  await expect(button, "초기화 버튼이 보여야 합니다.").toBeVisible();
  await button.click();
  await waitForNavigationToSettle(page);
}

async function clearOneSearchParam(page: Page, key: string): Promise<void> {
  await page.evaluate((paramKey) => {
    const url = new URL(window.location.href);
    url.searchParams.delete(paramKey);
    window.history.pushState(null, "", url.toString());
    window.dispatchEvent(new PopStateEvent("popstate"));
  }, key);
  await page.waitForTimeout(300);
}

async function setQueryParams(page: Page, params: Record<string, string>): Promise<void> {
  await page.evaluate((nextParams) => {
    const url = new URL(window.location.href);
    for (const [key, value] of Object.entries(nextParams)) {
      url.searchParams.set(key, value);
    }
    window.history.pushState(null, "", url.toString());
    window.dispatchEvent(new PopStateEvent("popstate"));
  }, params);
  await page.waitForTimeout(300);
}

async function waitForOffsetResponse(page: Page) {
  return page.waitForResponse((response) => response.url().includes("/api/v1/offset") && response.request().method() === "GET", {
    timeout: 7_000
  });
}

async function visibleRowTexts(page: Page): Promise<string[]> {
  const rowTexts = await page
    .locator("tbody tr, [role='row']")
    .evaluateAll((rows) =>
      rows
        .map((row) => (row.textContent ?? "").replace(/\s+/g, " ").trim())
        .filter((text) => /(대기중|채권부과|완료)/.test(text) && !/^#?\s*상태\b/.test(text))
        .slice(0, 20)
    )
    .catch(() => []);
  if (rowTexts.length > 0) {
    return rowTexts;
  }

  const bodyText = await page.locator("body").innerText({ timeout: 2_000 }).catch(() => "");
  return bodyText
    .split(/\n+/)
    .map((line) => line.replace(/\s+/g, " ").trim())
    .filter((line) => /\b(대기중|채권부과|완료)\b/.test(line))
    .slice(0, 20);
}

async function clickOffsetActionButton(page: Page, action: "채권처리" | "완료"): Promise<void> {
  const responsePromise = page.waitForResponse(
    (response) => response.url().includes("/api/v1/offset/") && response.request().method() === "POST",
    { timeout: 12_000 }
  ).catch(() => undefined);
  const actionButton = page.getByRole("button", { name: action }).first();
  await expect(actionButton, `${action} 버튼이 화면에 보여야 합니다.`).toBeVisible({ timeout: 7_000 });
  await actionButton.click({ timeout: 7_000 });
  const confirmButton = page.getByRole("button", { name: "처리" });
  await expect(confirmButton, "상계 처리 확인 버튼이 보여야 합니다.").toBeVisible({ timeout: 5_000 });
  await confirmButton.click({ timeout: 5_000 });
  const response = await responsePromise;
  const errorMessage = await page.locator(".swal2-popup").innerText({ timeout: 1_000 }).catch(() => "");
  if (!response && errorMessage) {
    throw new Error(`상계 처리 실패: ${errorMessage.replace(/\s+/g, " ").trim()}`);
  }
  if (!response) {
    throw new Error(`${action} 상태 변경 요청 응답을 감지하지 못했습니다.`);
  }
  if (!response.ok()) {
    const statusText = `${response.status()} ${response.statusText()}`.trim();
    const responseText = await response.text().catch(() => "");
    const message = errorMessage || responseText || "응답 본문 없음";
    throw new Error(`${action} 상태 변경 요청 실패: ${statusText} / ${message.replace(/\s+/g, " ").trim()}`);
  }
  await waitForNavigationToSettle(page);
}

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

async function attachPageScreenshot(
  page: Page,
  testInfo: TestInfo,
  pageDefinition: QaPageDefinition,
  role: string,
  suffix: string
): Promise<void> {
  const screenshotPath = testInfo.outputPath(`${safeFilename(pageDefinition.name)}-${role}-${suffix}.png`);
  await page.screenshot({ path: screenshotPath, fullPage: true });
  await testInfo.attach(`${safeFilename(pageDefinition.name)}-${role}-${suffix}.png`, {
    path: screenshotPath,
    contentType: "image/png"
  });
}

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

function formatRows(rows: OffsetRow[]): string[] {
  return rows.slice(0, 10).map((row, index) =>
    `${index + 1}. ${row.tenantName ?? "-"} / ${row.status ?? "-"} / ${row.departmentName ?? "-"} / ${row.username ?? "-"} / ${row.offsetAt ?? "-"}`
  );
}

function formatDate(date: Date): string {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
}

function toDate(value: string | undefined): string {
  return value?.slice(0, 10) ?? "";
}

function redactRuntimeValues(value: string, runtimeEnv: RuntimeQaEnv): string {
  let output = value;
  for (const sensitive of [runtimeEnv.tenantId, runtimeEnv.username].filter(Boolean)) {
    output = output.replaceAll(String(sensitive), "<redacted>");
  }
  output = output.replace(/tenantId=redacted&]+/g, "tenantId=<redacted>");
  output = output.replace(/offsettenantId=redacted&]+/g, "offsetTenantId=<redacted>");
  return output;
}

function redactId(value: string): string {
  const normalized = String(value);
  if (normalized.length <= 8) {
    return "<redacted>";
  }
  return `${normalized.slice(0, 4)}...${normalized.slice(-4)}`;
}

function uniqueValues<T>(values: T[]): T[] {
  const unique: T[] = [];
  for (const value of values) {
    if (!unique.includes(value)) {
      unique.push(value);
    }
  }
  return unique;
}

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

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