import { expect, test, type Browser, type Locator, type Page, type Request, 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 { QaChecklist, QaEnvironment, QaPageDefinition, QaSubjectAccountDefinition } from "../types";
import { buildPageUrl } from "../url";

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

interface CapturedRequest {
  method: string;
  url: string;
  postData: string;
  resourceType: string;
}

interface CorporationOption {
  text: string;
  value?: string;
  index: number;
}

interface CorporationFilterState {
  control?: Locator;
  isNativeSelect: boolean;
  options: CorporationOption[];
  source: "filter" | "visible-data";
}

interface ScopeSnapshot {
  selected: CorporationOption;
  alternative?: CorporationOption;
  bodyText: string;
  rowText: string;
  requestFingerprint: string;
  requests: CapturedRequest[];
}

interface AccountResultSnapshot {
  bodyText: string;
  rowText: string;
  requestFingerprint: string;
  requests: CapturedRequest[];
}

interface CorporationScopeExpectation {
  selectedCorporation?: string;
  forbiddenCorporation?: string;
}

interface ScopeAccount {
  role: string;
  label: string;
  artifactRole: string;
  runtimeEnv: RuntimeQaEnv;
  expectation: CorporationScopeExpectation;
}

const targetRole = "TA";

function resolveScopeAccounts(checklist: QaChecklist, environment: QaEnvironment): ScopeAccount[] {
  const configuredAccounts = (checklist.subjectAccounts ?? [])
    .filter((account) => account.role.toUpperCase() === targetRole);
  const accounts = configuredAccounts.length > 0
    ? configuredAccounts
    : [{ role: targetRole, label: targetRole, envRole: targetRole }] satisfies QaSubjectAccountDefinition[];

  return accounts.map((account, index) => {
    const runtimeEnv = getRuntimeQaEnv(environment, "admin", account.envRole ?? account.role);
    return {
      role: account.role.toUpperCase(),
      label: account.label,
      artifactRole: safeFilename(`${account.role.toUpperCase()}-${index + 1}-${account.label}`),
      runtimeEnv,
      expectation: {
        selectedCorporation: account.selectedCorporation,
        forbiddenCorporation: account.forbiddenCorporation
      }
    };
  });
}

interface AggregatedVerificationResult {
  failures: string[];
  blockedPages: string[];
  evidence: string[];
}

export function runBiscuitManagementCorporationScopeSuite(options: ScenarioOptions): void {
  const metadata = loadIssueMetadata(options.issueId);
  const checklist = loadChecklist(options.issueId, options.environment);
  const pages = checklist.pages;
  const accounts = resolveScopeAccounts(checklist, options.environment);

  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
      for (const pageDefinition of pages) {
        test(compareAccountsChecklistItem(checklist.checklist, pageDefinition), async ({ browser }, testInfo) => {
          testInfo.setTimeout(90_000);
          const result = await verifyPageResultsSeparatedAcrossAccounts(browser, accounts, pageDefinition, testInfo);

          await attachEvidenceLog(
            testInfo,
            pageDefinition,
            targetRole,
            "account-result-compare",
            result.evidence
          );

          if (result.blockedPages.length > 0) {
            blocked(`테스트 데이터 부족 또는 비교 조건 부족: ${result.blockedPages.join(" / ")}`);
          }
          expect(result.failures, "두 TA 계정의 조회 결과가 동일하게 섞여 보이면 안 됩니다.").toEqual([]);
        });
      }
  });
}

function compareAccountsChecklistItem(items: string[], pageDefinition: QaPageDefinition): string {
  const keywordMap: Array<[RegExp, string]> = [
    [/연결계좌/, "연결계좌관리"],
    [/비스킷카드관리/, "비스킷카드관리"],
    [/신청관리/, "신청관리"],
    [/수수료/, "수수료"],
    [/카드통계/, "카드통계"],
    [/사용관리|사용내역/, "사용"]
  ];
  const pageKeyword = keywordMap.find(([pattern]) => pattern.test(pageDefinition.name))?.[1] ?? pageDefinition.name;
  return items.find((item) => item.includes(pageKeyword) && /동일|섞이지|분리/.test(item)) ??
    `${pageDefinition.name}에서 두 TA 계정의 조회 결과가 서로 섞이지 않는지 확인`;
}

async function verifyPageResultsSeparatedAcrossAccounts(
  browser: Browser,
  accounts: ScopeAccount[],
  pageDefinition: QaPageDefinition,
  testInfo: TestInfo
): Promise<AggregatedVerificationResult> {
  const failures: string[] = [];
  const blockedPages: string[] = [];
  const evidence: string[] = [
    `${pageDefinition.name} 화면을 서로 다른 법인 범위의 TA 계정으로 각각 조회해 결과가 동일하게 섞여 보이지 않는지 비교했습니다.`,
    "개별 row의 법인 소속은 직접 판정하지 않고, 동일 조건에서 보이는 목록/요청 기준의 분리 여부를 비교했습니다.",
    "기본 조회에서 데이터가 없으면 초기화/검색을 한 번 더 수행한 뒤 비교했습니다."
  ];
  const snapshots: Array<{ account: ScopeAccount; snapshot: AccountResultSnapshot }> = [];

  if (accounts.length < 2) {
    return {
      failures,
      blockedPages: ["서로 다른 TA 계정 2개가 필요합니다."],
      evidence
    };
  }

  for (const account of accounts) {
    const blockReason = getRuntimeBlockReason(account.runtimeEnv);
    if (blockReason) {
      blockedPages.push(`${account.label}: ${blockReason}`);
      evidence.push(`${account.label}: BLOCKED / ${blockReason}`);
      continue;
    }

    await withRolePage(browser, account.runtimeEnv, async (page) => {
      try {
        const snapshot = await collectAccountResultSnapshot(page, pageDefinition, account.runtimeEnv);
        snapshots.push({ account, snapshot });
        evidence.push(
          `${account.label}: 목록 행 ${countComparableRows(snapshot.rowText)}건, 조회 요청 ${snapshot.requests.length}건`,
          formatRequests(`${pageDefinition.name} / ${account.label} 요청`, snapshot.requests, account.runtimeEnv)
        );
      } catch (error) {
        const message = isBlockedError(error) ? blockedMessage(error) : errorMessage(error);
        blockedPages.push(`${account.label}: ${message}`);
        evidence.push(`${account.label}: ${isBlockedError(error) ? "BLOCKED" : "FAIL"} / ${message}`);
      } finally {
        await attachPageScreenshot(page, testInfo, pageDefinition, account.artifactRole, "account-result-compare");
      }
    });
  }

  if (snapshots.length < 2) {
    blockedPages.push(`${pageDefinition.name}: 비교 가능한 TA 계정 결과가 2개 미만입니다.`);
    return { failures, blockedPages, evidence };
  }

  const [first, second] = snapshots;
  const firstRowCount = countComparableRows(first.snapshot.rowText);
  const secondRowCount = countComparableRows(second.snapshot.rowText);
  if (firstRowCount === 0 && secondRowCount === 0) {
    blockedPages.push(`${pageDefinition.name}: 두 계정 모두 조회 결과가 없어 접근 범위 분리 여부를 비교할 수 없습니다.`);
    return { failures, blockedPages, evidence };
  }

  const firstSignature = accountComparisonSignature(first.snapshot);
  const secondSignature = accountComparisonSignature(second.snapshot);
  const separated = firstSignature !== secondSignature;

  evidence.push(
    `${pageDefinition.name}: ${first.account.label} 행 ${firstRowCount}건 ↔ ${second.account.label} 행 ${secondRowCount}건`,
    `${pageDefinition.name}: 두 계정 조회 결과 ${separated ? "서로 다름" : "동일함"}`
  );

  if (!separated) {
    failures.push(`${pageDefinition.name}: 두 TA 계정의 조회 결과가 동일하게 표시되어 계정별 법인 범위 분리 여부를 확인할 수 없습니다.`);
  }

  return { failures, blockedPages, evidence };
}

async function collectAccountResultSnapshot(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv
): Promise<AccountResultSnapshot> {
  const requests = captureRequests(page, pageDefinition);
  await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
  await expectLoadedBusinessPage(page, pageDefinition);
  await broadenSearchCondition(page, pageDefinition);

  let rowText = await readRowsText(page);
  if (countComparableRows(rowText) === 0) {
    await broadenSearchCondition(page, pageDefinition);
    rowText = await readRowsText(page);
  }

  return {
    bodyText: await readPageSnapshot(page, pageDefinition),
    rowText,
    requestFingerprint: requestFingerprint(requests, pageDefinition),
    requests
  };
}

async function broadenSearchCondition(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const resetButtons = page.getByRole("button", { name: /초기화|Reset/i });
  const resetCount = Math.min(await resetButtons.count().catch(() => 0), 4);
  for (let index = 0; index < resetCount; index += 1) {
    const button = resetButtons.nth(index);
    if (await button.isVisible({ timeout: 300 }).catch(() => false)) {
      await button.click({ timeout: 2_000 }).catch(() => undefined);
      await waitForNavigationToSettle(page);
      break;
    }
  }

  await clickSearchButtonIfVisible(page, pageDefinition);
  await waitForNavigationToSettle(page);
}

function accountComparisonSignature(snapshot: AccountResultSnapshot): string {
  const rowText = normalizeComparableSnapshot(snapshot.rowText);
  if (rowText) {
    return rowText;
  }
  return normalizeComparableSnapshot(snapshot.requestFingerprint);
}

function normalizeComparableSnapshot(value: string): string {
  return normalizeText(value)
    .replace(/총\s*\d+\s*\/\s*\d+건/g, "")
    .replace(/\b\d{2,3}:\d{2}:\d{2}\b/g, "")
    .trim()
    .slice(0, 12_000);
}

function countComparableRows(rowText: string): number {
  return rowText.split("\n").filter((line) => normalizeText(line).length > 0).length;
}

function perPageChecklistItem(items: string[], pageDefinition: QaPageDefinition, account: ScopeAccount): string {
  const keywordMap: Array<[RegExp, string]> = [
    [/연결계좌/, "연결계좌관리"],
    [/비스킷카드관리/, "비스킷카드관리"],
    [/신청관리/, "신청관리"],
    [/수수료/, "수수료"],
    [/카드통계/, "카드통계"],
    [/사용관리|사용내역/, "사용"]
  ];

  const pageKeyword = keywordMap.find(([pattern]) => pattern.test(pageDefinition.name))?.[1] ?? pageDefinition.name;
  const selectedCorporation = account.expectation.selectedCorporation;
  const accountSpecific = selectedCorporation
    ? items.find((item) => item.includes(pageKeyword) && item.includes(selectedCorporation) && !/두 계정|섞이지/.test(item))
    : undefined;
  if (accountSpecific) {
    return accountSpecific;
  }
  return items.find((item) => item.includes(pageKeyword)) ?? `${pageDefinition.name} 선택 법인 기준 데이터 조회`;
}

function crossAccountChecklistItem(items: string[]): string {
  return items.find((item) => /두 계정|섞이지/.test(item)) ??
    "두 TA 계정의 조회 결과가 서로 섞이지 않는지 확인";
}

function unselectedHiddenChecklistItem(items: string[]): string {
  return items.find((item) => /secret-redacted.*박재민|secret-redacted.*최인석|선택하지 않은/.test(item)) ??
    "선택하지 않은 법인의 데이터가 목록/상세/통계 화면에 노출되지 않는지 확인";
}

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 collectSelectedCorporationSnapshot(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  expectation: CorporationScopeExpectation = {}
): Promise<ScopeSnapshot> {
  const requests = captureRequests(page, pageDefinition);
  await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
  await expectLoadedBusinessPage(page, pageDefinition);
  const state = await openCorporationFilter(page, pageDefinition);
  const selected = resolveSelectedCorporation(state, pageDefinition, expectation);
  const alternative = resolveAlternativeCorporation(state, selected, pageDefinition, expectation);
  if (state.source === "filter" && shouldSelectFilterOption(state, selected, expectation)) {
    await selectCorporationOption(page, state, selected, pageDefinition);
    await clickSearchButtonIfVisible(page, pageDefinition);
    await waitForNavigationToSettle(page);
  }

  return {
    selected,
    alternative,
    bodyText: await readPageSnapshot(page, pageDefinition),
    rowText: await readRowsText(page),
    requestFingerprint: requestFingerprint(requests, pageDefinition),
    requests
  };
}

async function verifyCorporationChange(
  page: Page,
  pageDefinition: QaPageDefinition,
  runtimeEnv: RuntimeQaEnv,
  expectation: CorporationScopeExpectation = {}
): Promise<{ status: "PASS" | "FAIL" | "BLOCKED"; detail: string; requests: CapturedRequest[] }> {
  const requests = captureRequests(page, pageDefinition);
  await openAuthenticatedTargetPage(page, pageDefinition, runtimeEnv);
  await expectLoadedBusinessPage(page, pageDefinition);
  const state = await openCorporationFilter(page, pageDefinition);
  if (state.source !== "filter") {
    return {
      status: "BLOCKED",
      detail: "화면에 직접 변경 가능한 법인 선택 필터가 없어 현재 목록 범위 증거만 확인했습니다.",
      requests
    };
  }

  const first = resolveSelectedCorporation(state, pageDefinition, expectation);
  const second = resolveAlternativeCorporation(state, first, pageDefinition, expectation);

  if (!second) {
    return {
      status: "BLOCKED",
      detail: `변경 가능한 법인 옵션이 2개 이상 필요합니다. 수집 옵션: ${state.options.map((option) => option.text).join(", ") || "-"}`,
      requests
    };
  }

  await selectCorporationOption(page, state, first, pageDefinition);
  await clickSearchButtonIfVisible(page, pageDefinition);
  await waitForNavigationToSettle(page);
  const before = await readChangeBasis(page, requests, pageDefinition);

  const reopened = await openCorporationFilter(page, pageDefinition);
  await selectCorporationOption(page, reopened, second, pageDefinition);
  await clickSearchButtonIfVisible(page, pageDefinition);
  await waitForNavigationToSettle(page);
  const after = await readChangeBasis(page, requests, pageDefinition);
  const changed = before !== after;

  return {
    status: changed ? "PASS" : "FAIL",
    detail: `선택 ${first.text} -> ${second.text}, 조회 기준 변화 ${changed ? "있음" : "없음"}`,
    requests
  };
}

async function verifyCorporationChangeAcrossAccounts(
  browser: Browser,
  accounts: ScopeAccount[],
  pages: QaPageDefinition[],
  testInfo: TestInfo
): Promise<AggregatedVerificationResult> {
  const failures: string[] = [];
  const blockedPages: string[] = [];
  const evidence: string[] = [
    `${targetRole} 계정 2개로 6개 비스킷관리 화면의 선택 법인 범위가 계정별로 달라지는지 확인했습니다.`
  ];

  for (const pageDefinition of pages) {
    const snapshots: Array<{ account: ScopeAccount; snapshot: ScopeSnapshot }> = [];

    for (const account of accounts) {
      await withRolePage(browser, account.runtimeEnv, async (page) => {
        try {
          const snapshot = await collectSelectedCorporationSnapshot(
            page,
            pageDefinition,
            account.runtimeEnv,
            account.expectation
          );
          snapshots.push({ account, snapshot });
          await attachPageScreenshot(page, testInfo, pageDefinition, account.artifactRole, "corporation-change");
        } catch (error) {
          const message = isBlockedError(error) ? blockedMessage(error) : errorMessage(error);
          evidence.push(`${pageDefinition.name} / ${account.label}: ${isBlockedError(error) ? "BLOCKED" : "FAIL"} / ${message}`);
          if (isBlockedError(error)) {
            blockedPages.push(`${pageDefinition.name} / ${account.label}: ${message}`);
          } else {
            failures.push(`${pageDefinition.name} / ${account.label}: ${message}`);
          }
          await attachPageScreenshot(page, testInfo, pageDefinition, account.artifactRole, "corporation-change");
        }
      });
    }

    if (snapshots.length < 2) {
      blockedPages.push(`${pageDefinition.name}: 비교 가능한 TA 계정 결과가 2개 미만입니다.`);
      continue;
    }

    const [first, second] = snapshots;
    const firstBasis = snapshotComparisonBasis(first.snapshot);
    const secondBasis = snapshotComparisonBasis(second.snapshot);
    const changed = firstBasis !== secondBasis;
    const noEvidenceAccounts = snapshots
      .filter(({ snapshot }) => !hasBusinessEvidence(snapshot))
      .map(({ account }) => account.label);
    const forbiddenHits = snapshots
      .map(({ account, snapshot }) => {
        const forbiddenHit = findForbiddenHit(snapshot, pageDefinition, account.expectation);
        return forbiddenHit ? `${account.label}: ${forbiddenHit}` : undefined;
      })
      .filter((value): value is string => !!value);

    evidence.push(
      `${pageDefinition.name}: ${first.account.label}(${first.snapshot.selected.text}) ↔ ${second.account.label}(${second.snapshot.selected.text}) / 조회 범위 변화 ${changed ? "있음" : "없음"}`,
      ...snapshots.map(({ account, snapshot }) =>
        formatRequests(`${pageDefinition.name} / ${account.label} 요청`, snapshot.requests, account.runtimeEnv)
      )
    );

    if (noEvidenceAccounts.length > 0) {
      blockedPages.push(`${pageDefinition.name}: 판정 근거 부족 계정 ${noEvidenceAccounts.join(", ")}`);
    }
    if (!changed) {
      failures.push(`${pageDefinition.name}: 두 TA 계정의 조회 결과 또는 요청 기준이 구분되지 않았습니다.`);
    }
    if (forbiddenHits.length > 0) {
      failures.push(`${pageDefinition.name}: 선택하지 않은 법인 노출 ${forbiddenHits.join(", ")}`);
    }
  }

  return { failures, blockedPages, evidence };
}

async function verifyCorporationChangeWithSingleAccount(
  browser: Browser,
  account: ScopeAccount | undefined,
  pages: QaPageDefinition[],
  testInfo: TestInfo
): Promise<AggregatedVerificationResult> {
  if (!account) {
    return {
      failures: [],
      blockedPages: ["검증할 TA 계정 프로필이 없습니다."],
      evidence: ["검증할 TA 계정 프로필이 없습니다."]
    };
  }

  const failures: string[] = [];
  const blockedPages: string[] = [];
  const evidence: string[] = [
    `${account.label} 계정으로 6개 비스킷관리 화면에서 법인 선택 변경 전후 조회 결과 갱신 여부를 확인했습니다.`
  ];

  for (const pageDefinition of pages) {
    await withRolePage(browser, account.runtimeEnv, async (page) => {
      try {
        const result = await verifyCorporationChange(page, pageDefinition, account.runtimeEnv, account.expectation);
        evidence.push(
          `${pageDefinition.name}: ${result.status} / ${result.detail}`,
          formatRequests(`${pageDefinition.name} 요청`, result.requests, account.runtimeEnv)
        );
        await attachPageScreenshot(page, testInfo, pageDefinition, account.artifactRole, "corporation-change");

        if (result.status === "BLOCKED") {
          blockedPages.push(`${pageDefinition.name}: ${result.detail}`);
        }
        if (result.status === "FAIL") {
          failures.push(`${pageDefinition.name}: ${result.detail}`);
        }
      } catch (error) {
        if (!isBlockedError(error)) {
          throw error;
        }
        const message = blockedMessage(error);
        evidence.push(`${pageDefinition.name}: BLOCKED / ${message}`);
        blockedPages.push(`${pageDefinition.name}: ${message}`);
        await attachPageScreenshot(page, testInfo, pageDefinition, account.artifactRole, "corporation-change");
      }
    });
  }

  return { failures, blockedPages, evidence };
}

function snapshotComparisonBasis(snapshot: ScopeSnapshot): string {
  return [
    snapshot.rowText,
    snapshot.requestFingerprint,
    snapshot.bodyText
  ].join("\n---\n").slice(-12_000);
}

function shouldSelectFilterOption(
  state: CorporationFilterState,
  selected: CorporationOption,
  expectation: CorporationScopeExpectation
): boolean {
  if (!expectation.selectedCorporation) {
    return true;
  }
  return state.options.some((option) => optionMatchesText(option, selected.text));
}

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

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

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

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

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

async function expectLoadedBusinessPage(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  await expect(page.locator("body"), `${pageDefinition.name} 화면 본문이 표시되어야 합니다.`).toBeVisible();
  const bodyText = await page.locator("body").innerText({ timeout: 3_000 }).catch(() => "");
  expect(bodyText.trim().length, `${pageDefinition.name} 화면에 확인 가능한 텍스트가 있어야 합니다.`).toBeGreaterThan(0);
  if (/권한이 없습니다|access denied|forbidden/i.test(bodyText)) {
    throw new Error(`${pageDefinition.name} 화면에서 권한이 없습니다 문구가 표시되었습니다.`);
  }
  expect(bodyText, `${pageDefinition.name} 화면이 로그인 또는 오류 화면이면 안 됩니다.`).not.toMatch(/사용자를 찾을 수 없습니다|404|Not Found/i);
}

function captureRequests(page: Page, pageDefinition: QaPageDefinition): CapturedRequest[] {
  const requests: CapturedRequest[] = [];
  page.on("request", (request: Request) => {
    const url = request.url();
    if (/\/(sockjs|webpack|hmr|favicon)|\.(png|jpg|jpeg|gif|svg|woff2?|css|js)(\?|$)/i.test(url)) {
      return;
    }
    if (!isRelevantDataRequest({ method: request.method(), url, postData: request.postData() ?? "", resourceType: request.resourceType() }, pageDefinition)) {
      return;
    }
    requests.push({
      method: request.method(),
      url,
      postData: request.postData() ?? "",
      resourceType: request.resourceType()
    });
  });
  return requests;
}

async function openCorporationFilter(
  page: Page,
  pageDefinition: QaPageDefinition
): Promise<CorporationFilterState> {
  if (!supportsDirectCorporationFilter(pageDefinition)) {
    return visibleDataCorporationState(page, pageDefinition);
  }

  let control = await findCorporationFilterControl(page, pageDefinition);
  if (!control) {
    await openAdvancedSearchIfPresent(page);
    control = await findCorporationFilterControl(page, pageDefinition);
  }
  if (!control) {
    return visibleDataCorporationState(page, pageDefinition);
  }

  const isNativeSelect = await isSelectElement(control);
  if (isNativeSelect) {
    return {
      control,
      isNativeSelect,
      options: await readNativeSelectOptions(control),
      source: "filter"
    };
  }

  await control.scrollIntoViewIfNeeded().catch(() => undefined);
  await control.click({ force: true });
  await control.press("ArrowDown").catch(() => undefined);
  await page.waitForTimeout(500);
  const openOptions = await readOpenCorporationOptions(page, pageDefinition, control);
  return {
    control,
    isNativeSelect,
    options: openOptions.length > 0 ? openOptions : await readCorporationOptionsFromRows(page, pageDefinition),
    source: "filter"
  };
}

async function visibleDataCorporationState(
  page: Page,
  pageDefinition: QaPageDefinition
): Promise<CorporationFilterState> {
  const visibleOptions = await readCorporationOptionsFromRows(page, pageDefinition);
  if (visibleOptions.length === 0) {
    blocked(`${pageDefinition.name}에서 법인/업체/테넌트 범위 판정 근거를 찾지 못했습니다.`);
  }
  return {
    isNativeSelect: false,
    options: visibleOptions,
    source: "visible-data"
  };
}

function supportsDirectCorporationFilter(pageDefinition: QaPageDefinition): boolean {
  if (pageDefinition.corporationFilterSelector) {
    return true;
  }
  return /연결계좌관리|법인별\s*수수료|카드통계/.test(pageDefinition.name);
}

async function findCorporationFilterControl(
  page: Page,
  pageDefinition: QaPageDefinition
): Promise<Locator | undefined> {
  const main = page.locator("main, [role='main'], section").first();
  const candidates: Locator[] = [];
  if (pageDefinition.corporationFilterSelector) {
    candidates.push(page.locator(pageDefinition.corporationFilterSelector));
  }
  candidates.push(
    main.getByRole("combobox", { name: /법인명|법인|업체|부서|corporation|company/i }),
    main.getByRole("button", { name: /법인명|법인|업체|부서|corporation|company/i }),
    main.getByLabel(/법인명|법인|업체|부서|corporation|company/i),
    main.locator("input[placeholder*='법인'], input[placeholder*='업체'], input[placeholder*='부서'], input[aria-label*='법인'], input[aria-label*='업체'], input[aria-label*='부서']"),
    main.locator("[data-testid*='corporation' i], [aria-label*='corporation' i], [name*='corporation' i], [id*='corporation' i]"),
    main.locator("[data-testid*='company' i], [aria-label*='company' i], [name*='company' i], [id*='company' i]"),
    main.locator("[data-testid*='department' i], [aria-label*='department' i], [name*='department' i], [id*='department' i]"),
    main.locator("select"),
    main.locator("xpath=.//*[contains(normalize-space(), '법인명') or contains(normalize-space(), '법인') or contains(normalize-space(), '업체') or contains(normalize-space(), '부서')]/ancestor::*[self::label or self::div][1]//*[self::button or self::input or self::select or @role='combobox']")
  );

  for (const candidate of candidates) {
    const count = Math.min(await candidate.count().catch(() => 0), 16);
    for (let index = 0; index < count; index += 1) {
      const current = candidate.nth(index);
      if (await isUsableBusinessControl(current)) {
        return current;
      }
    }
  }

  return undefined;
}

async function openAdvancedSearchIfPresent(page: Page): Promise<void> {
  const button = page.getByRole("button", { name: /상세\s*검색|필터/i }).first();
  if (await button.isVisible({ timeout: 700 }).catch(() => false)) {
    await button.click().catch(() => undefined);
    await waitForNavigationToSettle(page);
  }
}

async function isUsableBusinessControl(locator: Locator): Promise<boolean> {
  if (!(await locator.isVisible({ timeout: 500 }).catch(() => false))) {
    return false;
  }

  const box = await locator.boundingBox().catch(() => null);
  if (!box) {
    return false;
  }

  return box.x >= 230 && box.y >= 60 && box.width > 0 && box.height > 0;
}

async function isSelectElement(locator: Locator): Promise<boolean> {
  return locator.evaluate((element) => element.tagName.toLowerCase() === "select").catch(() => false);
}

async function readNativeSelectOptions(select: Locator): Promise<CorporationOption[]> {
  const options = select.locator("option");
  const count = await options.count();
  const values: CorporationOption[] = [];
  for (let index = 0; index < count; index += 1) {
    const option = options.nth(index);
    const text = normalizeText(await option.innerText().catch(() => ""));
    if (!isBusinessOptionText(text)) {
      continue;
    }
    values.push({
      text,
      value: await option.getAttribute("value").catch(() => undefined) ?? undefined,
      index
    });
  }
  return uniqueOptions(values);
}

async function readOpenCorporationOptions(
  page: Page,
  pageDefinition: QaPageDefinition,
  control: Locator
): Promise<CorporationOption[]> {
  const controlBox = await control.boundingBox().catch(() => null);
  const selector = pageDefinition.corporationOptionSelector ??
    "[role='option'], [role='menuitem'], .ant-select-item-option, .ant-select-item-option-content, .MuiAutocomplete-option, li";
  const optionLocators = page.locator(selector);
  const count = Math.min(await optionLocators.count().catch(() => 0), 100);
  const options: CorporationOption[] = [];

  for (let index = 0; index < count; index += 1) {
    const option = optionLocators.nth(index);
    if (!(await option.isVisible().catch(() => false))) {
      continue;
    }
    const box = await option.boundingBox().catch(() => null);
    if (!isLikelyDropdownOptionBox(box, controlBox)) {
      continue;
    }
    const text = normalizeText(await option.innerText().catch(() => ""));
    if (!isBusinessOptionText(text)) {
      continue;
    }
    options.push({ text, index });
  }

  return uniqueOptions(options);
}

function resolveSelectedCorporation(
  state: CorporationFilterState,
  pageDefinition: QaPageDefinition,
  expectation: CorporationScopeExpectation = {}
): CorporationOption {
  const configured = configuredSelectedCorporation(pageDefinition, expectation);
  if (configured) {
    return findConfiguredOption(state.options, configured) ?? { text: configured, index: 0 };
  }

  const candidate = state.options.find((option) => !/^전체$/.test(option.text)) ?? state.options[0];
  if (!candidate) {
    blocked(`${pageDefinition.name} 법인 선택 옵션을 찾지 못했습니다.`);
  }
  return candidate;
}

function resolveAlternativeCorporation(
  state: CorporationFilterState,
  selected: CorporationOption,
  pageDefinition: QaPageDefinition,
  expectation: CorporationScopeExpectation = {}
): CorporationOption | undefined {
  const configured = configuredForbiddenCorporation(pageDefinition, expectation) ?? process.env.QA_3201_ALTERNATIVE_CORPORATION;
  if (configured) {
    return findConfiguredOption(state.options, configured) ?? { text: configured, index: 1 };
  }

  return state.options.find((option) => option.text !== selected.text && !/^전체$/.test(option.text));
}

async function selectCorporationOption(
  page: Page,
  state: CorporationFilterState,
  option: CorporationOption,
  pageDefinition: QaPageDefinition
): Promise<void> {
  if (!state.control) {
    return;
  }

  if (state.isNativeSelect) {
    if (option.value) {
      await state.control.selectOption(option.value);
    } else {
      await state.control.selectOption({ index: option.index });
    }
    return;
  }

  const selector = pageDefinition.corporationOptionSelector ??
    ".MuiPopper-root, .MuiAutocomplete-popper, [role='listbox'] [role='option'], [role='option'], [role='menuitem'], .ant-select-item-option, .ant-select-item-option-content, .MuiAutocomplete-option, li";
  let target = await findVisibleOptionByText(page, selector, option.text);
  if (!target) {
    await state.control.click({ force: true }).catch(() => undefined);
    await state.control.press("ArrowDown").catch(() => undefined);
    await page.waitForTimeout(300);
    target = await findVisibleOptionByText(page, selector, option.text);
  }

  if (target && await target.isVisible({ timeout: 1_000 }).catch(() => false)) {
    await target.click({ force: true, timeout: 3_000 });
    await page.keyboard.press("Escape").catch(() => undefined);
    return;
  }

  const input = await isInputElement(state.control) ? state.control : state.control.locator("input").first();
  if (await input.isVisible({ timeout: 500 }).catch(() => false)) {
    await input.fill(option.text).catch(() => undefined);
    await page.waitForTimeout(500);
    target = await findVisibleOptionByText(page, selector, option.text);
    if (target && await target.isVisible({ timeout: 1_000 }).catch(() => false)) {
      await target.click({ force: true, timeout: 3_000 }).catch(() => undefined);
    } else {
      await page.keyboard.press("Enter").catch(() => undefined);
    }
    await page.keyboard.press("Escape").catch(() => undefined);
    return;
  }

  blocked(`${pageDefinition.name}에서 법인 옵션 '${option.text}'를 선택하지 못했습니다.`);
}

async function isInputElement(locator: Locator): Promise<boolean> {
  return locator.evaluate((element) => element.tagName.toLowerCase() === "input").catch(() => false);
}

async function clickSearchButtonIfVisible(page: Page, pageDefinition: QaPageDefinition): Promise<void> {
  const locators: Locator[] = [];
  if (pageDefinition.searchButtonSelector) {
    locators.push(page.locator(pageDefinition.searchButtonSelector));
  }
  locators.push(
    page.getByRole("button", { name: /조회|검색|적용|Search|Filter/i }),
    page.locator("button[type='submit']")
  );

  for (const locator of locators) {
    const count = Math.min(await locator.count().catch(() => 0), 12);
    for (let index = 0; index < count; index += 1) {
      const button = locator.nth(index);
      if (await button.isVisible({ timeout: 500 }).catch(() => false)) {
        await button.click({ timeout: 3_000 }).catch(() => undefined);
        return;
      }
    }
  }
}

async function readChangeBasis(page: Page, requests: CapturedRequest[], pageDefinition: QaPageDefinition): Promise<string> {
  return [
    await readRowsText(page),
    await readPageSnapshot(page, pageDefinition),
    requestFingerprint(requests, pageDefinition)
  ].join("\n---\n").slice(-12_000);
}

async function readPageSnapshot(page: Page, pageDefinition: QaPageDefinition): Promise<string> {
  const locator = pageDefinition.corporationSummarySelector
    ? page.locator(pageDefinition.corporationSummarySelector)
    : page.locator("main, [role='main'], body").first();
  return normalizeText(await locator.innerText({ timeout: 3_000 }).catch(() => "")).slice(0, 8_000);
}

async function readRowsText(page: Page): Promise<string> {
  const rows = page.locator("[role='row'], tbody tr, .ag-row, .ant-table-row, .MuiDataGrid-row");
  const count = Math.min(await rows.count().catch(() => 0), 80);
  const values: string[] = [];
  for (let index = 0; index < count; index += 1) {
    const text = normalizeText(await rows.nth(index).innerText({ timeout: 500 }).catch(() => ""));
    if (text && !/^선택|No rows|조회된 데이터가 없습니다$/i.test(text)) {
      values.push(text);
    }
  }
  return values.join("\n").slice(0, 8_000);
}

async function readCorporationOptionsFromRows(
  page: Page,
  pageDefinition: QaPageDefinition
): Promise<CorporationOption[]> {
  const headerIndex = await findScopeColumnIndex(page, pageDefinition);
  const rows = page.locator("[role='row'], tbody tr, .ag-row, .ant-table-row, .MuiDataGrid-row");
  const count = Math.min(await rows.count().catch(() => 0), 80);
  const options: CorporationOption[] = [];

  for (let index = 0; index < count; index += 1) {
    const row = rows.nth(index);
    if (await row.locator("[role='columnheader'], th").count().catch(() => 0) > 0) {
      continue;
    }
    const rowText = normalizeText(await row.innerText({ timeout: 500 }).catch(() => ""));
    if (!rowText || /^#\s*작업/.test(rowText) || /조회된 데이터가 없습니다|내역이 없습니다|총\s*\d+\s*\/\s*\d+건/.test(rowText)) {
      continue;
    }

    const cells = await readRowCells(row);
    const explicitCandidate = explicitScopeCellText(cells, pageDefinition);
    const candidate = explicitCandidate ?? (headerIndex >= 0 && headerIndex < cells.length
      ? cells[headerIndex]
      : parseLeadingScopeText(cells, rowText, pageDefinition));

    if (isBusinessOptionText(candidate)) {
      options.push({ text: candidate, index });
    }
  }

  return uniqueOptions(options).slice(0, 20);
}

function explicitScopeCellText(cells: string[], pageDefinition: QaPageDefinition): string | undefined {
  if (/연결계좌관리/.test(pageDefinition.name)) {
    return cells[2] || cells[1];
  }
  if (/비스킷카드관리/.test(pageDefinition.name)) {
    return cells[11] || cells[10];
  }
  if (/법인별\s*수수료/.test(pageDefinition.name)) {
    return cells[2] || cells[1];
  }
  if (/카드통계/.test(pageDefinition.name)) {
    return cells[0];
  }
  if (/사용관리|사용내역/.test(pageDefinition.name)) {
    return cells[4];
  }
  return undefined;
}

async function findScopeColumnIndex(page: Page, pageDefinition: QaPageDefinition): Promise<number> {
  const headers = page.locator("[role='columnheader'], th, .ag-header-cell, .ant-table-cell, .MuiDataGrid-columnHeader");
  const count = Math.min(await headers.count().catch(() => 0), 80);
  const labels: string[] = [];
  for (let index = 0; index < count; index += 1) {
    const header = headers.nth(index);
    const box = await header.boundingBox().catch(() => null);
    if (box && box.x < 230) {
      continue;
    }
    const text = normalizeText(await header.innerText({ timeout: 500 }).catch(() => ""));
    if (text) {
      labels.push(text);
    }
  }

  const preferred = scopeColumnPattern(pageDefinition);
  const found = labels.findIndex((label) => preferred.test(label));
  return found;
}

function scopeColumnPattern(pageDefinition: QaPageDefinition): RegExp {
  if (/비스킷카드관리/.test(pageDefinition.name)) {
    return /업체|법인명|법인|테넌트/;
  }
  if (/사용관리|사용내역/.test(pageDefinition.name)) {
    return /테넌트|업체|법인명|법인/;
  }
  if (/신청관리/.test(pageDefinition.name)) {
    return /법인명|법인|업체|테넌트|부서/;
  }
  return /법인명|법인|업체|테넌트/;
}

async function readRowCells(row: Locator): Promise<string[]> {
  const cells = row.locator("[role='gridcell'], td, .ag-cell, .ant-table-cell, .MuiDataGrid-cell");
  const count = Math.min(await cells.count().catch(() => 0), 80);
  const values: string[] = [];
  for (let index = 0; index < count; index += 1) {
    const text = normalizeText(await cells.nth(index).innerText({ timeout: 300 }).catch(() => ""));
    values.push(text);
  }
  return values;
}

function parseLeadingScopeText(
  cells: string[],
  rowText: string,
  pageDefinition: QaPageDefinition
): string {
  const usableCells = cells.filter((cell) => isBusinessOptionText(cell));
  if (usableCells.length > 0) {
    return usableCells[0];
  }

  const parts = rowText.split(/\s+/).filter(Boolean);
  const offset = /^\d+$/.test(parts[0] ?? "") ? 1 : 0;
  if (/카드통계/.test(pageDefinition.name)) {
    return parts.slice(offset, offset + 2).join(" ");
  }
  return parts[offset] ?? "";
}

async function findVisibleOptionByText(
  page: Page,
  selector: string,
  text: string
): Promise<Locator | undefined> {
  const candidates = page.locator(selector);
  const count = Math.min(await candidates.count().catch(() => 0), 120);
  for (let index = 0; index < count; index += 1) {
    const candidate = candidates.nth(index);
    if (!(await candidate.isVisible({ timeout: 150 }).catch(() => false))) {
      continue;
    }
    const box = await candidate.boundingBox().catch(() => null);
    if (box && box.x < 230) {
      continue;
    }
    const candidateText = normalizeText(await candidate.innerText({ timeout: 300 }).catch(() => ""));
    if (candidateText.includes(text)) {
      return candidate;
    }
  }
  return undefined;
}

function findForbiddenHit(
  snapshot: ScopeSnapshot,
  pageDefinition: QaPageDefinition,
  expectation: CorporationScopeExpectation = {}
): string | undefined {
  const configured = configuredForbiddenCorporation(pageDefinition, expectation);
  const forbidden = configured ?? snapshot.alternative?.text;
  if (!forbidden || forbidden.length < 2) {
    return undefined;
  }

  const source = snapshot.rowText.trim() ? snapshot.rowText : snapshot.bodyText;
  return source.includes(forbidden) ? forbidden : undefined;
}

function configuredSelectedCorporation(
  pageDefinition: QaPageDefinition,
  expectation: CorporationScopeExpectation = {}
): string | undefined {
  return expectation.selectedCorporation ??
    process.env[`QA_3201_${envKey(pageDefinition.name)}_SELECTED_CORPORATION`] ??
    process.env.QA_3201_SELECTED_CORPORATION;
}

function configuredForbiddenCorporation(
  pageDefinition: QaPageDefinition,
  expectation: CorporationScopeExpectation = {}
): string | undefined {
  return expectation.forbiddenCorporation ??
    process.env[`QA_3201_${envKey(pageDefinition.name)}_FORBIDDEN_CORPORATION`] ??
    process.env.QA_3201_FORBIDDEN_CORPORATION;
}

function findConfiguredOption(options: CorporationOption[], text: string): CorporationOption | undefined {
  return options.find((option) => optionMatchesText(option, text));
}

function optionMatchesText(option: CorporationOption, text: string): boolean {
  return option.text === text || option.text.includes(text) || text.includes(option.text);
}

function hasBusinessEvidence(snapshot: ScopeSnapshot): boolean {
  return snapshot.rowText.length > 0 || snapshot.requestFingerprint.length > 0 || snapshot.bodyText.length > 100;
}

function requestFingerprint(requests: CapturedRequest[], pageDefinition: QaPageDefinition): string {
  return requests
    .filter((request) => isRelevantDataRequest(request, pageDefinition))
    .map((request) => `${request.method} ${safeRequestPath(request.url)} ${request.postData}`)
    .join("\n")
    .slice(-8_000);
}

function isRelevantDataRequest(request: CapturedRequest, pageDefinition: QaPageDefinition): boolean {
  const text = `${request.url}\n${request.postData}`;
  if (pageDefinition.dataRequestUrlPattern && new RegExp(pageDefinition.dataRequestUrlPattern, "i").test(text)) {
    return true;
  }
  return /biscuit|card|account|corporation|company|commission|fee|statistics|usage|application|신청|계좌|통계|수수료/i.test(text);
}

function safeRequestPath(rawUrl: string): string {
  try {
    const url = new URL(rawUrl);
    return `${url.pathname}${url.search}`;
  } catch {
    return rawUrl;
  }
}

function formatRequests(
  title: string,
  requests: CapturedRequest[],
  runtimeEnv: RuntimeQaEnv
): string {
  const lines = requests.slice(-20).map((request, index) => {
    const postData = request.postData ? ` body=${redactRuntimeValues(request.postData, runtimeEnv).slice(0, 500)}` : "";
    return `${index + 1}. ${request.method} ${redactRuntimeValues(request.url, runtimeEnv)}${postData}`;
  });
  return [`${title}: ${requests.length}건`, ...lines].join("\n");
}

async function attachEvidenceLog(
  testInfo: TestInfo,
  pageDefinition: Pick<QaPageDefinition, "name" | "path">,
  role: string,
  slug: string,
  lines: string[]
): Promise<void> {
  const evidencePath = testInfo.outputPath(`${safeFilename(pageDefinition.name)}-${role}-${slug}.md`);
  fs.writeFileSync(evidencePath, `${lines.join("\n")}\n`, "utf8");
  await testInfo.attach(path.basename(evidencePath), {
    path: evidencePath,
    contentType: "text/markdown"
  });
}

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

function isBusinessOptionText(text: string): boolean {
  if (!text || text.length > 60) {
    return false;
  }

  if (/^#?$|^\d+$|^\d+[,.]?\d*원$|^\d{4}-\d{2}-\d{2}/.test(text)) {
    return false;
  }

  if (/^일\s+(사용|충전)\s+(총계|평균)/.test(text)) {
    return false;
  }

  if (/^(.{2,24})\s+\1$/.test(text)) {
    return false;
  }

  if (/검색|조회|초기화|닫기|취소|확인|선택하세요|전체선택|다운로드|범례|^\s*[-–]\s*$/.test(text)) {
    return false;
  }

  if (/^(홈|전체|작업|법인|법인명|업체|업체명|부서|부서명|영업점|가맹점|테넌트|비스킷관리|거래관리|계좌관리|매출집계|업체관리|계정관리|사용자 프리셋 관리|역할별 사용 가능 권한 관리|고객센터|개발자 관리|리스크관리|실험실)$/.test(text)) {
    return false;
  }

  if (/^(거래|결제|보류|차감|단말기|계좌|매출|업체|계정|고객센터|개발자|리스크|실험실|공지|문의|정책|권한)/.test(text) && /관리|내역|집계|설정|통계|로그/.test(text)) {
    return false;
  }

  return true;
}

function isLikelyDropdownOptionBox(
  box: { x: number; y: number; width: number; height: number } | null,
  controlBox: { x: number; y: number; width: number; height: number } | null
): boolean {
  if (!box || box.width <= 0 || box.height <= 0) {
    return false;
  }

  if (box.x < 230) {
    return false;
  }

  if (!controlBox) {
    return true;
  }

  const minX = Math.max(230, controlBox.x - 80);
  const minY = Math.max(50, controlBox.y - 20);
  const maxY = controlBox.y + 600;
  return box.x >= minX && box.y >= minY && box.y <= maxY;
}

function uniqueOptions(options: CorporationOption[]): CorporationOption[] {
  const seen = new Set<string>();
  const unique: CorporationOption[] = [];
  for (const option of options) {
    const key = option.text;
    if (seen.has(key)) {
      continue;
    }
    seen.add(key);
    unique.push(option);
  }
  return unique;
}

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

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

function envKey(value: string): string {
  return value.toUpperCase().replace(/[^A-Z0-9가-힣]+/g, "_");
}

function redactRuntimeValues(value: string, runtimeEnv: RuntimeQaEnv): string {
  let redacted = value;
  if (runtimeEnv.tenantId) {
    redacted = redacted.replaceAll(runtimeEnv.tenantId, "tenant-id-redacted");
  }
  if (runtimeEnv.username) {
    redacted = redacted.replaceAll(runtimeEnv.username, "username-redacted");
  }
  return redacted.replace(/tenantId=redacted&#\s\\"'<>),}\]]+/gi, "tenantId=redacted");
}

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

function isBlockedError(error: unknown): boolean {
  return error instanceof Error && error.message.startsWith("BLOCKED:");
}

function blockedMessage(error: unknown): string {
  return error instanceof Error ? error.message.replace(/^BLOCKED:\s*/, "") : String(error);
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}
