import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import {
  expect,
  test,
  type Browser,
  type BrowserContext,
  type Page,
  type TestInfo
} from "@playwright/test";
import { loginWithCredentials } from "../auth";
import { loadChecklist, loadIssueMetadata } from "../checklist";
import { getRuntimeQaEnv, type RuntimeQaEnv } from "../env";
import type { QaApplication, QaEnvironment } from "../types";

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

type TargetRole = "ADMIN" | "TA" | "CO" | "BO" | "VE";

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

interface TelegramHistoryRow {
  id: number;
  status: boolean;
  botName?: string;
  purpose?: string;
  purposeName?: string;
  chatRoomName?: string;
  createdAt?: string;
  message?: string;
}

interface TelegramHistoryPage {
  content?: TelegramHistoryRow[];
  totalElements?: number;
}

interface TelegramRoom {
  id: number;
  chatRoomName: string;
  idType?: string;
  botNames?: string[];
  label?: string[];
}

interface TelegramBot {
  label?: string;
  userName?: string;
  botName?: string;
  token?: string;
}

interface RoleResult {
  role: TargetRole;
  account: string;
  userId: string;
  passwordMutationError: string | null;
  passwordHistoryIds: number[];
  twoFactorHistoryIds: number[];
  passwordMessageMatched: boolean;
  twoFactorMessageMatched: boolean;
  passwordRestored: boolean;
  pinRestored: boolean;
}

const ROOM_NAME = "빅스페이먼츠_32ZU1";
const ROOM_CHAT_ID = "-5452****3130";
const BOT_LABEL = "USER_INFORMATION_CHANGE";
const BOT_NAME = "회원 정보 변경 봇";
const EXPECTED_PURPOSE = "RISK_DETECTION";
const SECURITY_GUARD = "QA_4152_ALLOW_SECURITY_MUTATION";
const ROOM_GUARD = "QA_4152_ALLOW_TELEGRAM_ROOM_MUTATION";
const TENANT_ID_FALLBACK = "tenant-id-redacted";
const REGISTRATION_STATE = "/tmp/qa4152-registration.json";
const TARGET_ROLES: TargetRole[] = ["ADMIN", "TA", "CO", "BO", "VE"];

let registrationId: number | undefined;
let cleanupRegistration = false;
let setupEvidence: string[] = [];
let lastMaskSessionAt = 0;

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

export function runTelegramUserInformationChangeSuite(options: ScenarioOptions): void {
  const checklist = loadChecklist(options.issueId, options.environment);
  const metadata = loadIssueMetadata(options.issueId);
  const tenantId = getRuntimeQaEnv(options.environment, "admin", "ADMIN").tenantId ?? TENANT_ID_FALLBACK;

  test.describe.configure({ mode: "default" });
  test.describe(`[${options.issueId}][${options.environment}] ${metadata.subject}`, () => {
    test.setTimeout(Number(process.env.QA_TEST_TIMEOUT_MS ?? 240_000));

    test.beforeAll(async ({ browser }) => {
      requireMutationGuards();
      const admin = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      ensureRuntime(admin, "ADMIN");

      await withLoggedInContext(browser, admin, admin.password!, admin.twoFactorCode!, async (context) => {
        const bots = await apiGet<ApiEnvelope<TelegramBot[]>>(context, admin, "/v1/telegram/chatBots", tenantId);
        const bot = (bots.payload ?? []).find((item) => item.label === BOT_LABEL);
        expect(bot?.userName, "회원 정보 변경 봇 아이디가 등록되어 있어야 합니다.").toBe("USER_INFORMATION_CHANGE_BOT");
        expect(bot?.token, "텔레그램 그룹 참여 상태 확인에 사용할 봇 토큰이 있어야 합니다.").toBeTruthy();

        const telegram = await context.request.get(
          `https://api.telegram.org/bot${bot!.token}/getChat?chat_id=${ROOM_CHAT_ID}`
        );
        const telegramBody = await telegram.json() as { ok?: boolean; result?: { title?: string; type?: string } };
        expect(telegramBody.ok, "회원 정보 변경 봇이 검증용 텔레그램 그룹에 참여해 있어야 합니다.").toBe(true);
        expect(telegramBody.result?.title).toBe(ROOM_NAME);

        const rooms = await fetchRooms(context, admin, tenantId);
        let room = rooms.find((item) => item.chatRoomName === ROOM_NAME);
        if (!room) {
          const response = await apiRequest(context, admin, "POST", "/v1/telegram", tenantId, {
            tenantId,
            botNames: [BOT_LABEL],
            chatRoomName: ROOM_NAME,
            idType: "TA"
          });
          expect(response.ok, `텔레그램 채팅방 등록 API HTTP ${response.status}`).toBe(true);
          room = (await fetchRooms(context, admin, tenantId)).find((item) => item.chatRoomName === ROOM_NAME);
          cleanupRegistration = true;
        }

        expect(room, "등록 후 회원 정보 변경 알림 채팅방이 조회되어야 합니다.").toBeTruthy();
        expect(room!.label ?? room!.botNames ?? [], "등록된 채팅방에 회원 정보 변경 봇이 연결되어야 합니다.").toContain(BOT_LABEL);
        registrationId = room!.id;
        cleanupRegistration ||= isOwnedRegistrationState(room!.id);
        if (cleanupRegistration) await sleep(5_000);
        setupEvidence = [
          `채팅방: ${ROOM_NAME}`,
          `채팅방 유형: ${telegramBody.result?.type ?? "-"}`,
          `알림봇: ${BOT_NAME} / USER_INFORMATION_CHANGE_BOT`,
          `등록 ID: ${room!.id}`,
          `등록 유형: ${room!.idType ?? "TA"}`,
          "Telegram getChat: 성공",
          "민감한 봇 토큰은 산출물에 기록하지 않음"
        ];
      });
    });

    test.afterAll(async ({ browser }) => {
      if (!cleanupRegistration || !registrationId) return;
      const admin = getRuntimeQaEnv(options.environment, "admin", "ADMIN");
      try {
        await withLoggedInContext(browser, admin, admin.password!, admin.twoFactorCode!, async (context) => {
          const response = await apiRequest(
            context,
            admin,
            "DELETE",
            `/v1/telegram/${registrationId}`,
            tenantId,
            { reason: "QA #4152 완료 후 임시 등록 원복" }
          );
          if (!response.ok) {
            throw new Error(`채팅방 등록 원복 실패 HTTP ${response.status}: ${response.body.slice(0, 300)}`);
          }
        });
        if (fs.existsSync(REGISTRATION_STATE)) fs.unlinkSync(REGISTRATION_STATE);
      } catch (error) {
        console.error(`[QA #4152] 채팅방 등록 원복 실패: ${formatError(error)}`);
        throw error;
      }
    });

    for (const [index, role] of TARGET_ROLES.entries()) {
      test(checklist.checklist[index], async ({ browser }, testInfo) => {
        requireMutationGuards();
        const application: QaApplication = role === "VE" ? "store" : "admin";
        const runtime = getRuntimeQaEnv(options.environment, application, role);
        ensureRuntime(runtime, role);

        const result = await verifyRole(browser, runtime, role, tenantId);
        await attachEvidence(testInfo, role, [...setupEvidence, ...formatRoleEvidence(result)]);
        await attachHistoryScreenshot(browser, options.environment, tenantId, role, testInfo);

        expect(result.passwordMutationError, `${role} 비밀번호 변경 처리가 성공해야 합니다.`).toBeNull();
        expect(result.passwordHistoryIds.length, `${role} 비밀번호 변경/원복 알림은 2건 이상이어야 합니다.`).toBeGreaterThanOrEqual(2);
        expect(result.twoFactorHistoryIds.length, `${role} 2차 인증 초기화/재등록 알림은 2건 이상이어야 합니다.`).toBeGreaterThanOrEqual(2);
        expect(result.passwordMessageMatched, `${role} 비밀번호 변경 메시지에 대상 계정과 변경 유형이 있어야 합니다.`).toBe(true);
        expect(result.twoFactorMessageMatched, `${role} 2차 인증 변경 메시지에 대상 계정과 PIN -> NONE 변경이 있어야 합니다.`).toBe(true);
        expect(result.passwordRestored, `${role} 비밀번호가 원래 값으로 복구되어야 합니다.`).toBe(true);
        expect(result.pinRestored, `${role} PIN이 원래 값으로 복구되어야 합니다.`).toBe(true);
      });
    }
  });
}

async function verifyRole(
  browser: Browser,
  runtime: RuntimeQaEnv,
  role: TargetRole,
  tenantId: string
): Promise<RoleResult> {
  const originalPassword = runtime.password!;
  const originalPin = runtime.twoFactorCode!;
  const temporaryPassword = `Qa4152!${crypto.randomBytes(10).toString("hex")}A9`;
  let userId = "";
  let passwordMutationError: string | null = null;
  let passwordRestored = false;
  let pinRestored = false;
  let temporaryPasswordActive = false;
  let twoFactorReset = false;

  const passwordBefore = await latestHistoryId(browser, runtime.environment, tenantId);
  await withLoggedInContext(browser, runtime, originalPassword, originalPin, async (context) => {
    userId = await fetchCurrentUserId(context, runtime, tenantId);
  });

  try {
    const change = role === "VE"
      ? await withLoggedInContext(browser, runtime, originalPassword, originalPin, (context) =>
          changePassword(context, runtime, tenantId, userId, temporaryPassword))
      : await withAdminContext(browser, runtime.environment, (context, adminRuntime) =>
          changePassword(context, adminRuntime, tenantId, userId, temporaryPassword));
    if (!change.ok) {
      passwordMutationError = `HTTP ${change.status}`;
      passwordRestored = true;
    } else {
      temporaryPasswordActive = true;
    }

    if (!temporaryPasswordActive) {
      await assertFreshLogin(browser, runtime, originalPassword, originalPin);
    } else {
      await assertFreshLogin(browser, runtime, temporaryPassword, originalPin);
    }

    if (!temporaryPasswordActive) {
      // 변경 요청이 거절된 경우에도 2차 인증 검증은 계속 진행한다.
    } else if (role === "VE" || role === "ADMIN") {
      const temporaryContext = await createLoggedInContext(browser, runtime, temporaryPassword, originalPin);
      try {
        const restore = await changePassword(temporaryContext, runtime, tenantId, userId, originalPassword);
        expect(restore.ok, `${role} 비밀번호 원복 API HTTP ${restore.status}: ${restore.body.slice(0, 300)}`).toBe(true);
        temporaryPasswordActive = false;
        passwordRestored = true;
      } finally {
        await temporaryContext.close();
      }
    } else {
      const restore = await withAdminContext(browser, runtime.environment, (context, adminRuntime) =>
        changePassword(context, adminRuntime, tenantId, userId, originalPassword));
      expect(restore.ok, `${role} 비밀번호 원복 API HTTP ${restore.status}: ${restore.body.slice(0, 300)}`).toBe(true);
      temporaryPasswordActive = false;
      passwordRestored = true;
    }
  } finally {
    if (temporaryPasswordActive && userId) {
      const fallback = role === "VE" || role === "ADMIN"
        ? await withLoggedInContext(browser, runtime, temporaryPassword, originalPin, (context) =>
            changePassword(context, runtime, tenantId, userId, originalPassword)).catch(() => undefined)
        : await withAdminContext(browser, runtime.environment, (context, adminRuntime) =>
            changePassword(context, adminRuntime, tenantId, userId, originalPassword)).catch(() => undefined);
      passwordRestored = !!fallback?.ok;
      temporaryPasswordActive = !passwordRestored;
    }
  }

  if (temporaryPasswordActive) {
    throw new Error(`BLOCKED: ${role} 임시 비밀번호를 원래 값으로 복구하지 못했습니다.`);
  }

  await assertFreshLogin(browser, runtime, originalPassword, originalPin);
  const passwordRows = passwordMutationError
    ? []
    : await waitForNewHistoryRows(browser, runtime.environment, tenantId, passwordBefore, 2);
  const passwordMessage = passwordRows[0]
    ? await unmaskHistory(browser, runtime.environment, tenantId, passwordRows[0].id)
    : "";
  const passwordMessageMatched = passwordMessage.includes(runtime.username!) && passwordMessage.includes("비밀번호");

  const twoFactorBefore = await latestHistoryId(browser, runtime.environment, tenantId);
  try {
    await withAdminContext(browser, runtime.environment, async (context, adminRuntime) => {
      const reset = await apiRequest(context, adminRuntime, "POST", `/v1/users/${userId}/reset-2fa`, tenantId);
      expect(reset.ok, `${role} 2차 인증 초기화 API HTTP ${reset.status}: ${reset.body.slice(0, 300)}`).toBe(true);
      twoFactorReset = true;
    });

    await registerPin(browser, runtime, originalPassword, originalPin);
    twoFactorReset = false;
    pinRestored = true;
  } finally {
    if (twoFactorReset) {
      await registerPin(browser, runtime, originalPassword, originalPin).catch(() => undefined);
      pinRestored = await canLogin(browser, runtime, originalPassword, originalPin);
      twoFactorReset = !pinRestored;
    }
  }

  if (twoFactorReset) {
    throw new Error(`BLOCKED: ${role} 2차 인증 초기화 후 PIN을 원래 값으로 재등록하지 못했습니다.`);
  }

  await assertFreshLogin(browser, runtime, originalPassword, originalPin);
  const twoFactorRows = await waitForNewHistoryRows(browser, runtime.environment, tenantId, twoFactorBefore, 2);
  const twoFactorMessage = twoFactorRows[0]
    ? await unmaskHistory(browser, runtime.environment, tenantId, twoFactorRows[0].id)
    : "";
  const twoFactorMessageMatched =
    twoFactorMessage.includes(runtime.username!) &&
    twoFactorMessage.includes("2차 인증 변경") &&
    twoFactorMessage.includes("PIN -> NONE");

  return {
    role,
    account: runtime.username!,
    userId,
    passwordMutationError,
    passwordHistoryIds: passwordRows.map((row) => row.id),
    twoFactorHistoryIds: twoFactorRows.map((row) => row.id),
    passwordMessageMatched,
    twoFactorMessageMatched,
    passwordRestored,
    pinRestored
  };
}

async function registerPin(
  browser: Browser,
  runtime: RuntimeQaEnv,
  password: string,
  pin: string
): Promise<void> {
  const context = await browser.newContext();
  const page = await context.newPage();
  try {
    await page.goto(runtime.loginUrl!, { waitUntil: "domcontentloaded", timeout: 60_000 });
    await page.locator(runtime.usernameSelector).first().fill(runtime.username!);
    await page.locator(runtime.passwordSelector).first().fill(password);
    await clickFirstVisible(page.locator(runtime.submitSelector));

    if (runtime.application === "store") {
      await expect(page.getByText(/2차 인증 방식을 선택해주세요/)).toBeVisible({ timeout: 30_000 });
      await page.getByRole("button", { name: /PIN 인증/ }).click();
      await page.getByPlaceholder("PIN 4~8자리").fill(pin);
      await page.getByPlaceholder("PIN 확인").fill(pin);
      const registerResponse = page.waitForResponse(
        (response) => response.request().method() === "POST" && response.url().includes("/api/v1/store/auth/2fa/register"),
        { timeout: 30_000 }
      );
      await page.getByRole("button", { name: "등록", exact: true }).click();
      const response = await registerResponse;
      expect(response.ok(), `VE PIN 재등록 API HTTP ${response.status()}`).toBe(true);
      return;
    }

    await expect(page.getByText("2차 인증 설정", { exact: true })).toBeVisible({ timeout: 30_000 });
    await page.getByText("PIN", { exact: true }).last().click();
    await page.getByRole("button", { name: "다음", exact: true }).click();
    await page.getByPlaceholder("4-8자리 숫자 입력").fill(pin);
    await page.getByPlaceholder("PIN 번호 재입력").fill(pin);
    const registerResponse = page.waitForResponse(
      (response) => response.request().method() === "POST" && response.url().includes("/api/v1/auth/2fa/register"),
      { timeout: 30_000 }
    );
    await page.getByRole("button", { name: "확인", exact: true }).last().click();
    const response = await registerResponse;
    expect(response.ok(), `${runtime.role} PIN 재등록 API HTTP ${response.status()}`).toBe(true);
  } finally {
    await context.close();
  }
}

async function fetchCurrentUserId(context: BrowserContext, runtime: RuntimeQaEnv, tenantId: string): Promise<string> {
  const path = runtime.application === "store" ? "/v1/store/users/auth" : "/v1/auth/me";
  const response = await apiGet<ApiEnvelope<Record<string, unknown>>>(context, runtime, path, tenantId);
  const id = response.payload?.userId ?? response.payload?.id;
  if (typeof id !== "string" || !id) {
    throw new Error(`${runtime.role} 로그인 사용자 ID를 조회하지 못했습니다.`);
  }
  return id;
}

async function changePassword(
  context: BrowserContext,
  runtime: RuntimeQaEnv,
  tenantId: string,
  userId: string,
  password: string
): Promise<{ ok: boolean; status: number; body: string }> {
  const path = runtime.application === "store"
    ? `/v1/store/users/${userId}/change-password`
    : `/v1/users/${userId}/change-password`;
  return apiRequest(context, runtime, runtime.application === "store" ? "PATCH" : "POST", path, tenantId, {
    password,
    confirmPassword: password
  });
}

async function latestHistoryId(browser: Browser, environment: QaEnvironment, tenantId: string): Promise<number> {
  return withAdminContext(browser, environment, async (context, runtime) => {
    const rows = await fetchHistory(context, runtime, tenantId);
    return Math.max(0, ...rows.map((row) => Number(row.id || 0)));
  });
}

async function waitForNewHistoryRows(
  browser: Browser,
  environment: QaEnvironment,
  tenantId: string,
  afterId: number,
  minimumCount: number
): Promise<TelegramHistoryRow[]> {
  const deadline = Date.now() + 20_000;
  let latest: TelegramHistoryRow[] = [];
  while (Date.now() < deadline) {
    latest = await withAdminContext(browser, environment, async (context, runtime) => {
      const rows = await fetchHistory(context, runtime, tenantId);
      return rows
        .filter((row) => Number(row.id) > afterId && row.botName === BOT_NAME && row.chatRoomName === ROOM_NAME)
        .sort((a, b) => Number(a.id) - Number(b.id));
    });
    if (latest.length >= minimumCount) {
      expect(latest.every((row) => row.status === true), "텔레그램 발송 상태는 모두 성공이어야 합니다.").toBe(true);
      expect(latest.every((row) => row.purpose === EXPECTED_PURPOSE), "회원 정보 변경 알림 용도는 위험 감지여야 합니다.").toBe(true);
      return latest;
    }
    await sleep(1_000);
  }
  return latest;
}

async function unmaskHistory(
  browser: Browser,
  environment: QaEnvironment,
  tenantId: string,
  historyId: number
): Promise<string> {
  const waitMs = Math.max(0, 5_200 - (Date.now() - lastMaskSessionAt));
  if (waitMs > 0) await sleep(waitMs);

  return withAdminContext(browser, environment, async (context, runtime) => {
    const start = await apiRequest(context, runtime, "POST", "/v1/masking/authentication-sessions", tenantId, {
      targetType: "TELEGRAM_HISTORY",
      targetId: String(historyId)
    });
    lastMaskSessionAt = Date.now();
    expect(start.ok, `마스킹 인증 세션 발급 HTTP ${start.status}: ${start.body.slice(0, 300)}`).toBe(true);
    const parsed = JSON.parse(start.body) as ApiEnvelope<{ authSessionId?: string; id?: string }>;
    const sessionId = parsed.payload?.authSessionId ?? parsed.payload?.id;
    expect(sessionId, "마스킹 인증 세션 ID가 있어야 합니다.").toBeTruthy();

    const verify = await apiRequest(
      context,
      runtime,
      "POST",
      `/v1/masking/authentication-sessions/${sessionId}/verify`,
      tenantId,
      { authenticationNumber: runtime.twoFactorCode }
    );
    expect(verify.ok, `마스킹 인증번호 검증 HTTP ${verify.status}: ${verify.body.slice(0, 300)}`).toBe(true);

    const detail = await apiGet<ApiEnvelope<TelegramHistoryRow>>(
      context,
      runtime,
      `/v1/telegram/history/${historyId}?unmask=true`,
      tenantId
    );
    return detail.payload?.message ?? "";
  });
}

async function fetchHistory(
  context: BrowserContext,
  runtime: RuntimeQaEnv,
  tenantId: string
): Promise<TelegramHistoryRow[]> {
  const data = await apiGet<ApiEnvelope<TelegramHistoryPage>>(
    context,
    runtime,
    `/v1/telegram/history?page=0&size=100&name=${encodeURIComponent(ROOM_NAME)}`,
    tenantId
  );
  return data.payload?.content ?? [];
}

async function fetchRooms(context: BrowserContext, runtime: RuntimeQaEnv, tenantId: string): Promise<TelegramRoom[]> {
  const data = await apiGet<ApiEnvelope<TelegramHistoryPage & { content?: TelegramRoom[] }>>(
    context,
    runtime,
    "/v1/telegram?page=0&size=100",
    tenantId
  );
  return data.payload?.content ?? [];
}

async function apiGet<T>(
  context: BrowserContext,
  runtime: RuntimeQaEnv,
  apiPath: string,
  tenantId: string
): Promise<T> {
  const response = await context.request.get(`${resolveApiBase(runtime)}${apiPath}`, {
    headers: { accept: "application/json", "x-tenant-id": tenantId }
  });
  const body = await response.text();
  if (!response.ok()) throw new Error(`GET ${apiPath} HTTP ${response.status()}: ${body.slice(0, 300)}`);
  return JSON.parse(body) as T;
}

async function apiRequest(
  context: BrowserContext,
  runtime: RuntimeQaEnv,
  method: "POST" | "PATCH" | "DELETE",
  apiPath: string,
  tenantId: string,
  data?: unknown
): Promise<{ ok: boolean; status: number; body: string }> {
  const response = await context.request.fetch(`${resolveApiBase(runtime)}${apiPath}`, {
    method,
    headers: { accept: "application/json", "content-type": "application/json", "x-tenant-id": tenantId },
    data
  });
  return { ok: response.ok(), status: response.status(), body: await response.text() };
}

async function withAdminContext<T>(
  browser: Browser,
  environment: QaEnvironment,
  callback: (context: BrowserContext, runtime: RuntimeQaEnv) => Promise<T>
): Promise<T> {
  const runtime = getRuntimeQaEnv(environment, "admin", "ADMIN");
  ensureRuntime(runtime, "ADMIN");
  return withLoggedInContext(browser, runtime, runtime.password!, runtime.twoFactorCode!, (context) => callback(context, runtime));
}

async function withLoggedInContext<T>(
  browser: Browser,
  runtime: RuntimeQaEnv,
  password: string,
  pin: string,
  callback: (context: BrowserContext, page: Page) => Promise<T>
): Promise<T> {
  const context = await createLoggedInContext(browser, runtime, password, pin);
  const page = context.pages()[0] ?? await context.newPage();
  try {
    return await callback(context, page);
  } finally {
    await context.close();
  }
}

async function createLoggedInContext(
  browser: Browser,
  runtime: RuntimeQaEnv,
  password: string,
  pin: string
): Promise<BrowserContext> {
  const context = await browser.newContext();
  const page = await context.newPage();
  try {
    await loginWithCredentials(page, { ...runtime, password, twoFactorCode: pin });
    return context;
  } catch (error) {
    await context.close();
    throw error;
  }
}

async function assertFreshLogin(browser: Browser, runtime: RuntimeQaEnv, password: string, pin: string): Promise<void> {
  const context = await createLoggedInContext(browser, runtime, password, pin);
  await context.close();
}

async function canLogin(browser: Browser, runtime: RuntimeQaEnv, password: string, pin: string): Promise<boolean> {
  try {
    await assertFreshLogin(browser, runtime, password, pin);
    return true;
  } catch {
    return false;
  }
}

async function attachHistoryScreenshot(
  browser: Browser,
  environment: QaEnvironment,
  tenantId: string,
  role: TargetRole,
  testInfo: TestInfo
): Promise<void> {
  await withAdminContext(browser, environment, async (_context, runtime) => {
    const page = _context.pages()[0] ?? await _context.newPage();
    const url = new URL("/business/telegram-history", runtime.baseUrl!);
    url.searchParams.set("tenantId", tenantId);
    url.searchParams.set("name", ROOM_NAME);
    await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: 60_000 });
    await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
    await page.waitForTimeout(800);
    const screenshot = testInfo.outputPath(`${role.toLowerCase()}-telegram-history.png`);
    await page.screenshot({ path: screenshot, fullPage: true });
    await testInfo.attach(`${role.toLowerCase()}-telegram-history.png`, { path: screenshot, contentType: "image/png" });
  });
}

async function attachEvidence(testInfo: TestInfo, role: TargetRole, lines: string[]): Promise<void> {
  const evidencePath = testInfo.outputPath(`${role.toLowerCase()}-user-information-change.md`);
  fs.writeFileSync(evidencePath, `${lines.join("\n")}\n`, "utf8");
  await testInfo.attach(path.basename(evidencePath), { path: evidencePath, contentType: "text/markdown" });
}

function formatRoleEvidence(result: RoleResult): string[] {
  return [
    `검증 역할: ${result.role}`,
    `대상 계정: ${result.account}`,
    `비밀번호 변경 처리: ${result.passwordMutationError ?? "성공"}`,
    `비밀번호 변경 알림 이력 ID: ${result.passwordHistoryIds.join(", ")}`,
    `비밀번호 메시지 대상/유형 일치: ${result.passwordMessageMatched ? "예" : "아니오"}`,
    `2차 인증 변경 알림 이력 ID: ${result.twoFactorHistoryIds.join(", ")}`,
    `2차 인증 메시지 대상/PIN -> NONE 일치: ${result.twoFactorMessageMatched ? "예" : "아니오"}`,
    `비밀번호 원복 및 기존 정보 재로그인: ${result.passwordRestored ? "성공" : "실패"}`,
    `PIN 재등록 및 기존 정보 재로그인: ${result.pinRestored ? "성공" : "실패"}`,
    "텔레그램 발송 상태: 모두 성공",
    "비밀번호, PIN, 봇 토큰은 산출물에 기록하지 않음"
  ];
}

function requireMutationGuards(): void {
  if (process.env[SECURITY_GUARD] !== "1") {
    throw new Error(`BLOCKED: 계정 보안정보 임시 변경 검증은 ${SECURITY_GUARD}=1 설정이 필요합니다.`);
  }
  if (process.env[ROOM_GUARD] !== "1") {
    throw new Error(`BLOCKED: 텔레그램 채팅방 임시 등록 검증은 ${ROOM_GUARD}=1 설정이 필요합니다.`);
  }
}

function ensureRuntime(runtime: RuntimeQaEnv, role: string): void {
  if (!runtime.baseUrl || !runtime.loginUrl || !runtime.username || !runtime.password || !runtime.twoFactorCode) {
    throw new Error(`BLOCKED: ${role} 로그인 환경 변수가 완전하지 않습니다.`);
  }
}

function isOwnedRegistrationState(id: number): boolean {
  if (!fs.existsSync(REGISTRATION_STATE)) return false;
  try {
    const state = JSON.parse(fs.readFileSync(REGISTRATION_STATE, "utf8")) as { created?: boolean; id?: number; roomName?: string };
    return state.created === true && state.id === id && state.roomName === ROOM_NAME;
  } catch {
    return false;
  }
}

function resolveApiBase(runtime: RuntimeQaEnv): string {
  const url = new URL(runtime.baseUrl!);
  url.hostname = url.hostname.replace(/^admin-/, "api-").replace(/^store-/, "api-");
  url.pathname = "/api";
  url.search = "";
  url.hash = "";
  return url.toString().replace(/\/$/, "");
}

async function clickFirstVisible(locator: ReturnType<Page["locator"]>): Promise<void> {
  const count = await locator.count();
  for (let index = 0; index < count; index += 1) {
    const candidate = locator.nth(index);
    if (await candidate.isVisible().catch(() => false)) {
      await candidate.click();
      return;
    }
  }
  await locator.first().click();
}

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

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