import path from "path";
import { expect, Locator, Page } from "@playwright/test";
import { TIMEOUT } from "../constant/timeout";
import { PW } from "../utils/PW";
import { khoTaiLieuUrl } from "../default-data/khotailieu";

/**
 * Thao tác dùng chung của màn "Kho tài liệu" (`/managed-records`).
 *
 * Tài liệu đi kèm:
 * - KHO-TAI-LIEU.md                          (màn danh sách + modal Bộ hồ sơ)
 * - KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md
 * - KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md
 * - KHO-TAI-LIEU.MODAL-PHAN-QUYEN-NANG-CAO.md
 * - KHO-TAI-LIEU.MODAL-CHON-NGUOI-DUNG.md
 * - KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md      (logic ẩn/hiện theo vai, dựng tiền điều kiện)
 *
 * Khảo sát & kiểm chứng bằng Playwright MCP ngày 2026-07-27 trên sitdev.
 */

const BASE_URL = process.env.BASE_URL!;

/** URL màn danh sách Kho tài liệu */
export const KHO_TAI_LIEU_LIST_URL = `${BASE_URL}${khoTaiLieuUrl}`;

/** Form BHS và modal Tạo tài liệu nạp metadata rất chậm — chờ cứng sau khi mở */
const METADATA_WAIT = 10_000;
/** Modal Thêm thư mục nhẹ hơn một chút */
const FOLDER_MODAL_WAIT = 3_000;
/** Dropdown menu (hover) cần thời gian mount */
const DROPDOWN_WAIT = 2_500;
/**
 * Chờ sau khi lưu (tạo thư mục / tài liệu / lưu phân quyền) **trước khi** đóng modal hoặc
 * điều hướng: toast "Thành công" hiện lên trước khi app ghi xong hẳn và refresh lại bảng —
 * đóng/điều hướng ngay dễ dẫn tới dữ liệu chưa kịp cập nhật ở bước sau.
 */
const SAVE_SETTLE_WAIT = 5_000;

/* -------------------------------------------------------------------------- */
/* Hằng số                                                                     */
/* -------------------------------------------------------------------------- */

/**
 * Mã prefix hệ thống tự sinh cho từng item trong cấu trúc BHS.
 * Dùng làm hậu tố testId ở modal Phân quyền nâng cao (`tbl-row-<code>`,
 * `cell-<field>-row-<code>`) và làm prefix tên hiển thị trong bảng Cấu trúc hồ sơ ("A. Tên").
 * BHS gốc có code rỗng.
 */
export const ITEM_PREFIX = {
  BHS: "",
  A: "A",
  B: "B",
  C: "C",
  D: "D",
  E: "E",
} as const;

/**
 * testId của 5 khối quyền trong tab "Phân quyền" của modal Thêm / Cập nhật thư mục.
 *
 * ✅ Khảo sát lại bằng MCP 2026-07-30: build hiện tại **đã có testId** cho cả 5 khối
 * (`pp-multi-usersRight*`, giống form BHS) → **không dùng index `.nth(i)` nữa**.
 *
 * ⚠️ Khi thư mục còn kế thừa, các khối này chỉ chứa **avatar chỉ đọc**; ô nhập chỉ xuất hiện sau
 * khi "Đặt quyền độc lập".
 */
export const FOLDER_PICKER = {
  OWNER: "pp-multi-usersRightOwner",
  ADD: "pp-multi-usersRightAdd",
  EDIT: "pp-multi-usersRightEdit",
  DOWNLOAD: "pp-multi-usersRightDownload",
  VIEW: "pp-multi-usersRightViewers",
} as const;

/** Nhãn hiển thị của 5 khối quyền, theo testId — dùng cho message assert */
export const FOLDER_PICKER_LABELS: Record<string, string> = {
  [FOLDER_PICKER.OWNER]: "Quyền Owner",
  [FOLDER_PICKER.ADD]: "Quyền Tạo mới",
  [FOLDER_PICKER.EDIT]: "Quyền Cập nhật",
  [FOLDER_PICKER.DOWNLOAD]: "Quyền tải file",
  [FOLDER_PICKER.VIEW]: "Quyền Xem",
};

/** testId các field của modal Thêm / Cập nhật thư mục (khảo sát MCP 2026-07-30) */
export const THU_MUC_FIELD = {
  /** Input **disabled** hiển thị mã prefix tự sinh (`A`, `B`, `A.1`…) — QA gọi là "Index thư mục" */
  INDEX: "txt-ma-thu-muc",
  TEN: "txt-ten-thu-muc",
  CAP_CHA: "tree-sel-thu-muc-cha",
  DO_MAT: "sel-do-mat",
  TRANG_THAI_HIEN_THI: "sel-trang-thai-hien-thi",
  DON_VI_SOAN_THAO: "tree-sel-submissionUnit",
} as const;

/** testId people-picker phân quyền trong modal Tạo mới tài liệu (và form BHS) */
export const DOC_PERM = {
  OWNER: "pp-multi-usersRightOwner",
  ADD: "pp-multi-usersRightAdd",
  EDIT: "pp-multi-usersRightEdit",
  DOWNLOAD: "pp-multi-usersRightDownload",
  VIEW: "pp-multi-usersRightViewers",
} as const;

/** Cột của bảng trong modal Phân quyền nâng cao (dùng cho `checkOPhanQuyen`) */
export const PQ_COL = {
  TITLE: "title",
  KE_THUA: "hasUniquePermission",
  OWNER: "usersRightOwner",
  ADD: "usersRightAdd",
  EDIT: "usersRightEdit",
  DOWNLOAD: "usersRightDownload",
  VIEW: "usersRightViewers",
  /** chỉ có ở tab "Theo người dùng" */
  QUYEN: "permMark",
} as const;

/**
 * Ánh xạ account id → **tên hiển thị** trên app (sitdev, xác minh 2026-07-27).
 *
 * ⚠️ **Ưu tiên `layTenHienThiNguoiDangDangNhap(page)`** — lấy động từ avatar, không sợ tên user bị
 * đổi trong lúc test. Bảng này chỉ dùng khi test **không có `page`** đăng nhập bằng account đó
 * (vd cần tên của người được cấp quyền mà không mở phiên của họ).
 * (Chưa có tên của `ecm02`, `ecm03`, `ecm04`, `ecm09`.)
 */
export const TEN_HIEN_THI: Record<string, string> = {
  ecm01: "Nguyễn Minh Hoàng",
  ecm05: "Đỗ Mạnh Cường",
  ecm06: "Hoàng Văn Mạnh",
  ecm07: "Trần Lê Nguyên",
  ecm08: "Lê Duy Nam",
};

/** 5 cột quyền của bảng Phân quyền nâng cao, theo đúng thứ tự hiển thị */
export const PQ_COT_QUYEN = [
  PQ_COL.OWNER,
  PQ_COL.ADD,
  PQ_COL.EDIT,
  PQ_COL.DOWNLOAD,
  PQ_COL.VIEW,
] as const;

/** Các giá trị hợp lệ của cột Quyền ở tab "Theo người dùng" */
export const VALID_PERM_VALUES = [
  "OWNER",
  "ADD",
  "EDIT",
  "DOWNLOAD",
  "VIEW",
] as const;

/* -------------------------------------------------------------------------- */
/* Locator getter                                                              */
/* -------------------------------------------------------------------------- */

/** Modal đang ở trên cùng (app chồng tới 3 lớp modal) */
export function getRecordModal(page: Page): Locator {
  return page.locator(".ant-modal-content:visible").last();
}

/** Tab-pane đang active (dùng để scope field của tab Cấu trúc hồ sơ / modal thư mục) */
export function getActiveTabPane(page: Page): Locator {
  return page.locator(".ant-tabs-tabpane-active").last();
}

/** Tabpanel đang mở bên trong 1 modal (modal Thêm thư mục giữ cả 2 tabpanel trong DOM) */
function getActiveTabPanelIn(modal: Locator): Locator {
  return modal.locator('div[role="tabpanel"][aria-hidden="false"]');
}

/**
 * Modal Thêm / Cập nhật thư mục **và** modal "Cập nhật quyền tài liệu" — cả hai đều là
 * modal duy nhất trên màn có **tab "Phân quyền"**, bố cục giống hệt nhau
 * (xem KHO-TAI-LIEU.MODAL-CAP-NHAT-QUYEN-TAI-LIEU.md mục 2).
 *
 * ⚠️ Không dùng `getRecordModal` cho modal này: khi confirm dialog (`.ant-modal-confirm`) chồng lên,
 * `.ant-modal-content:visible.last()` trỏ vào confirm dialog chứ không phải modal thư mục.
 * Lọc theo tab "Phân quyền" cũng loại luôn modal BHS phía dưới (tab của nó là Thông tin / Cấu trúc hồ sơ).
 */
export function getThuMucModal(page: Page): Locator {
  return (
    page
      .locator(".ant-modal-content:visible")
      // ⚠️ dùng `has-text` chứ không phải `text-is`: tab này còn kèm badge "Kế thừa" / "Độc lập"
      .filter({ has: page.locator('.ant-tabs-tab:has-text("Phân quyền")') })
      .last()
  );
}

/**
 * 1 trong 5 khối quyền của modal thư mục (tab "Phân quyền" phải đang mở).
 * @param permTestId dùng hằng `FOLDER_PICKER` (`pp-multi-usersRight*`)
 */
export function getPickerPhanQuyenThuMuc(
  page: Page,
  permTestId: string,
  modal: Locator = getThuMucModal(page),
): Locator {
  return getActiveTabPanelIn(modal).getByTestId(permTestId);
}

/**
 * Đọc **danh sách tên người** trong 1 khối quyền của modal thư mục.
 *
 * 🚨 Khối quyền chỉ hiển thị **avatar chữ viết tắt** (vd `HL`), không có tên đầy đủ trong DOM →
 * hàm **hover từng avatar** để mở `.ant-popover` (chứa tên đầy đủ + email) rồi đọc dòng tên.
 * (Khảo sát MCP 2026-07-30: popover có dạng `HL / Đỗ Hà Linh / … / ecm09@yopmail.com`.)
 */
export async function layNguoiTrongKhoiQuyenThuMuc(
  page: Page,
  permTestId: string,
  modal: Locator = getThuMucModal(page),
): Promise<string[]> {
  const avatars = getPickerPhanQuyenThuMuc(page, permTestId, modal).getByTestId(
    "avatar-container",
  );
  const ten: string[] = [];

  for (let i = 0; i < (await avatars.count()); i++) {
    await avatars.nth(i).hover();
    await page.waitForTimeout(DROPDOWN_WAIT);
    const popover = page
      .locator(".ant-popover:not(.ant-popover-hidden)")
      .first();
    if (await popover.isVisible().catch(() => false)) {
      const dong = (await popover.innerText())
        .split("\n")
        .map((d) => d.trim())
        .filter(Boolean);
      // Dòng 0 là chữ viết tắt, dòng 1 là tên đầy đủ
      if (dong[1]) ten.push(dong[1]);
    }
    await page.mouse.move(0, 0);
    await page.waitForTimeout(500);
  }
  return ten;
}

/**
 * Số **chip** (người / nhóm) đang có trong 1 khối quyền của modal thư mục.
 *
 * Mỗi chip là 1 `span.ant-tag` (xem KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md mục 4c). Đếm chip là cách
 * duy nhất kiểm chứng "thêm / bớt được người" khi khối quyền chỉ hiển thị **chữ viết tắt**.
 */
export async function demChipKhoiQuyenThuMuc(
  page: Page,
  permTestId: string,
  modal: Locator = getThuMucModal(page),
): Promise<number> {
  return getPickerPhanQuyenThuMuc(page, permTestId, modal)
    .locator(".ant-tag")
    .count();
}

/**
 * Xoá **chip đầu tiên** của 1 khối quyền trong modal thư mục (nút xoá là `svg` cuối chip).
 *
 * ⚠️ Chỉ xoá được **theo vị trí**, không theo tên: chip chỉ hiển thị chữ viết tắt, tên đầy đủ
 * không có trong DOM (xem `layNguoiTrongKhoiQuyenThuMuc`).
 * ⚠️ Khối quyền chỉ chỉnh sửa được **sau khi** "Đặt quyền độc lập" (mục 4b) — lúc còn kế thừa
 * app không render chip xoá được.
 */
export async function xoaChipDauTienKhoiQuyenThuMuc(
  page: Page,
  permTestId: string,
  modal: Locator = getThuMucModal(page),
) {
  const picker = getPickerPhanQuyenThuMuc(page, permTestId, modal);
  const truoc = await picker.locator(".ant-tag").count();
  expect(truoc, {
    message: `Lỗi: khối "${FOLDER_PICKER_LABELS[permTestId] ?? permTestId}" không có chip nào để xoá`,
  }).toBeGreaterThan(0);

  await picker.locator(".ant-tag svg").first().click({ force: true });
  await expect(picker.locator(".ant-tag"), {
    message: `Lỗi: xoá chip ở khối "${FOLDER_PICKER_LABELS[permTestId] ?? permTestId}" nhưng số chip không giảm`,
  }).toHaveCount(truoc - 1, { timeout: TIMEOUT.CONTROL_LOADING });
}

/** Dropdown menu đang mở (mọi `mni-*` đều lặp ở nhiều dropdown → luôn lấy cái cuối) */
export function getVisibleDropdown(page: Page): Locator {
  return page.locator(".ant-dropdown:visible").last();
}

/** 1 dòng trong bảng tab "Cấu trúc hồ sơ", tìm theo tên item */
export function getCauTrucRow(page: Page, tenItem: string): Locator {
  return getActiveTabPane(page)
    .locator(".ant-table-tbody tr.ant-table-row")
    .filter({ hasText: tenItem })
    .first();
}

/**
 * Nút mở **menu mở rộng của 1 dòng** trong bảng Cấu trúc hồ sơ (`btn-more-action-<id>`).
 *
 * Dùng khi cần kiểm tra sự **tồn tại** của nút này (vd vai không có quyền có thể không có menu),
 * còn muốn mở menu ra thì dùng `openRowActionDropdown` / `openRowActionMenu`.
 */
export function getRowActionTrigger(page: Page, tenItem: string): Locator {
  return getCauTrucRow(page, tenItem).locator(
    '[data-testid^="btn-more-action-"]',
  );
}

/* -------------------------------------------------------------------------- */
/* Helper chung                                                                */
/* -------------------------------------------------------------------------- */

/**
 * Assert toast "Thành công".
 *
 * ⚠️ Gọi NGAY sau click, không `waitForTimeout` trước — toast tự tắt sau vài giây.
 * ⚠️ Nhiều thao tác (tạo thư mục / tài liệu) bắn **2 toast nối tiếp**: `"Đang xử lý"` hiện trước,
 * vài giây sau mới tới `"Thành công"`. Lúc cả hai cùng nằm trong DOM thì `.ant-message-notice`
 * khớp 2 element → assert trần bị **strict-mode violation**, mà toast đầu lại không chứa
 * "Thành công". Vì vậy phải **lọc đúng toast** rồi chờ nó xuất hiện (mặc định chờ tới 60 s —
 * assert theo sự kiện nên không tốn thời gian khi pass).
 */
export async function expectToastThanhCong(
  page: Page,
  label = "",
  timeout: number = TIMEOUT.PAGE_LOADING,
) {
  await expect(getToast(page, "Thành công"), {
    message: `Lỗi: không thấy toast "Thành công"${label ? ` [${label}]` : ""}`,
  }).toBeVisible({ timeout });
}

/**
 * 1 toast cụ thể theo nội dung — dùng khi trên màn có thể có nhiều toast cùng lúc
 * (vd `"Đang xử lý"` + `"Thành công"`).
 */
export function getToast(page: Page, noiDung: string): Locator {
  return page
    .locator(".ant-message-notice")
    .filter({ hasText: noiDung })
    .first();
}

/**
 * Chọn option đầu tiên của 1 dropdown/tree-dropdown đã được scope sẵn theo modal.
 * Dùng thay `PW.inputDropDownList` khi testId bị trùng ở nhiều vùng trên trang.
 */
export async function pickFirstOption(page: Page, control: Locator) {
  await control.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await page.keyboard.press("ArrowDown");
  await page.waitForTimeout(500);
  await page.keyboard.press("Enter");
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Chọn 1 option **theo nhãn** của 1 select đã scope sẵn (`sel-do-mat`, `sel-trang-thai-hien-thi`…).
 * Danh sách option của các select này ngắn nên không cần gõ search.
 */
export async function chonOptionSelect(
  page: Page,
  select: Locator,
  nhan: string,
) {
  await select.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  const option = page
    .locator(".ant-select-dropdown:visible")
    .last()
    .locator(".ant-select-item-option-content")
    .filter({ hasText: new RegExp(`^\\s*${nhan}\\s*$`) })
    .first();
  await expect(option, {
    message: `Lỗi: dropdown không có option "${nhan}"`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await option.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  await expect(select.locator(".ant-select-selection-item"), {
    message: `Lỗi: select không nhận giá trị "${nhan}"`,
  }).toHaveText(nhan, { timeout: TIMEOUT.VALIDATE_WAITING });
}

/**
 * Điền 1 people-picker đã scope sẵn (kể cả picker không có testId).
 * - `clearFirst`: xoá hết chip đang có (chip kế thừa) trước khi thêm.
 * - Sau khi Enter luôn nhấn Escape vì gợi ý còn sót có thể che nút Xác nhận / field kế tiếp.
 */
async function fillPeoplePicker(
  page: Page,
  picker: Locator,
  accounts: string,
  clearFirst = false,
) {
  if (clearFirst) {
    // Mỗi chip là 1 `.ant-tag`, nút xoá là `svg` cuối chip (kiểm chứng 2026-07-27)
    let guard = 0;
    while ((await picker.locator(".ant-tag").count()) > 0 && guard++ < 20) {
      await picker.locator(".ant-tag svg").first().click({ force: true });
      await page.waitForTimeout(600);
    }
  }
  const input = picker.locator(".ant-select-selection-search-input");
  for (const account of accounts.split(",").map((a) => a.trim())) {
    if (!account) continue;
    await input.click();
    await input.pressSequentially(account, { delay: 150 });
    await page.waitForTimeout(5000); // chờ kết quả search
    await page.keyboard.press("Enter");
    await page.waitForTimeout(1000);
    await page.keyboard.press("Escape");
    await page.waitForTimeout(300);
  }
}

/* -------------------------------------------------------------------------- */
/* Điều hướng                                                                  */
/* -------------------------------------------------------------------------- */

/** Mở màn danh sách Kho tài liệu và chờ init */
export async function openDanhSachKhoTaiLieu(page: Page) {
  await page.goto(KHO_TAI_LIEU_LIST_URL);
  await page.waitForTimeout(TIMEOUT.HARD_WAITING);
}

/**
 * Mở lại 1 Bộ hồ sơ / Tài liệu theo URL `?itemId=...`.
 *
 * ⚠️ BẮT BUỘC đi 2 bước qua màn danh sách: app là SPA hash-route, `page.goto()` khi chỉ đổi
 * phần query sau dấu `#` KHÔNG khiến app render lại — modal cũ nằm nguyên trên màn và mọi
 * `.ant-modal-content:visible.last()` sau đó sẽ trỏ nhầm. (Kiểm chứng 2026-07-27.)
 *
 * Xem KHO-TAI-LIEU.md mục 4.0.
 */
export async function openBoHoSo(page: Page, recordUrl: string) {
  await openDanhSachKhoTaiLieu(page);
  await page.goto(recordUrl);
  await page.waitForTimeout(TIMEOUT.HARD_WAITING);
  await expect(getRecordModal(page).getByTestId("lbl-modal-title"), {
    message: `Lỗi: không mở được phiếu ${recordUrl}`,
  }).toBeVisible({ timeout: TIMEOUT.PAGE_LOADING });
}

/**
 * Lấy **tên hiển thị của user đang đăng nhập** trên `page` — hover avatar ở header màn danh sách
 * rồi đọc dropdown thông tin user.
 *
 * 📌 Luôn dùng hàm này thay vì hard-code tên: tên user **có thể bị đổi trong lúc test**, mà bảng
 * quyền/avatar trong app chỉ hiển thị tên (không hiển thị account id).
 *
 * Khảo sát 2026-07-30: hover `[data-testid="avatar-container"]` → mở `.ant-dropdown` với nội dung
 * theo dòng: `MH` (viết tắt) / `Nguyễn Minh Hoàng` (tên hiển thị) / `ecm01@yopmail.com` /
 * `Ngôn ngữ` / `Tiếng Việt` / `Đăng xuất`. Hàm lấy **dòng ngay trước dòng chứa email**, nên không
 * phụ thuộc thứ tự tuyệt đối.
 *
 * ⚠️ Hàm **điều hướng về màn danh sách** (nơi chỉ có đúng 1 `avatar-container`; trong modal Phân
 * quyền nâng cao testId này lặp ở từng ô quyền) → gọi ở đầu test hoặc giữa các bước, đừng gọi khi
 * đang mở modal cần giữ nguyên.
 */
export async function layTenHienThiNguoiDangDangNhap(
  page: Page,
): Promise<string> {
  await openDanhSachKhoTaiLieu(page);

  const avatar = page.getByTestId("avatar-container").first();
  await expect(avatar, {
    message: "Lỗi: không thấy avatar user ở header màn danh sách",
  }).toBeVisible({ timeout: TIMEOUT.PAGE_LOADING });
  await avatar.hover();
  await page.waitForTimeout(DROPDOWN_WAIT);

  const noiDung = await getVisibleDropdown(page).innerText();
  await page.mouse.move(0, 0); // rời chuột để dropdown đóng lại
  await page.waitForTimeout(500);

  const dong = noiDung
    .split("\n")
    .map((d) => d.trim())
    .filter(Boolean);
  const iEmail = dong.findIndex((d) => d.includes("@"));
  const ten = iEmail > 0 ? dong[iEmail - 1] : dong[1];

  expect(ten, {
    message: `Lỗi: không đọc được tên hiển thị từ dropdown avatar. Nội dung dropdown: [${dong.join(" | ")}]`,
  }).toBeTruthy();
  return ten;
}

/**
 * 1 đối tượng được phân quyền (người dùng hoặc nhóm) mà case cần assert trong bảng quyền.
 *
 * @property account account id (vd `"ecm07"`) hoặc **tên nhóm** (vd `GROUP.ECM06`) — dùng để điền
 *   vào people-picker.
 * @property page phiên đăng nhập **của chính người đó** (inject fixture cùng tên) → dùng để lấy
 *   **tên hiển thị động**. Nhóm người dùng không đăng nhập được nên bỏ trống.
 */
export type DoiTuongPhanQuyen = { account: string; page?: Page };

/**
 * **Tên hiển thị** của 1 đối tượng phân quyền để assert trong bảng quyền / ô avatar.
 *
 * - Có `page` → lấy động bằng `layTenHienThiNguoiDangDangNhap` (khuyến nghị: tên user có thể bị đổi
 *   trong lúc test).
 * - Không có `page` (**nhóm người dùng**) → dùng luôn `account`, vì bảng quyền hiển thị đúng tên nhóm.
 */
export async function layTenDoiTuongPhanQuyen(
  doiTuong: DoiTuongPhanQuyen,
): Promise<string> {
  return doiTuong.page
    ? layTenHienThiNguoiDangDangNhap(doiTuong.page)
    : doiTuong.account;
}

/** Vào tab "Cấu trúc hồ sơ" của BHS đang mở */
export async function openTabCauTrucHoSo(page: Page) {
  await getRecordModal(page).getByTestId("lbl-tab-cauTrucHoSo").click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/** Vào tab "Thông tin" — bắt buộc trước khi chỉnh sửa field của BHS */
export async function openTabThongTin(page: Page) {
  await getRecordModal(page).getByTestId("lbl-tab-thongTin").click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/* -------------------------------------------------------------------------- */
/* 1. Tạo mới Bộ hồ sơ                                                         */
/* -------------------------------------------------------------------------- */

export type CreateBoHoSoOptions = {
  /** Gán quyền ngay khi tạo, vd `[{ testId: DOC_PERM.VIEW, account: "ecm05" }]` */
  peoplePickers?: { testId: string; account: string }[];
  /** Field tuỳ chọn điền thêm — route theo prefix testId qua `PW.batchInput` */
  fields?: { testId: string; value: string; extraLocator?: string }[];
  /** Đọc lại giá trị các dropdown NGAY TRONG FORM trước khi Lưu (vd option hệ thống tự chọn) */
  readFields?: string[];
};

/**
 * Tạo mới 1 Bộ hồ sơ hoàn chỉnh và trả về `recordUrl` (`/managed-records?itemId=<id>`).
 *
 * Bên trong: mở màn danh sách → `btn-create-hstl` → chờ metadata → điền toàn bộ field bắt buộc
 * (Tên, Thư mục lưu trữ, Công ty, Đơn vị sở hữu, Loại hồ sơ, Dự án nếu hiện, Tình trạng cập nhật)
 * → people-picker (nếu có) → `btn-save` → chờ URL có `itemId`.
 *
 * Xem KHO-TAI-LIEU.md mục 3.
 */
export async function createBoHoSo(
  page: Page,
  pw: PW,
  tenHoSo: string,
  opts: CreateBoHoSoOptions = {},
): Promise<string> {
  await openDanhSachKhoTaiLieu(page);
  await pw.clickButton("btn-create-hstl");

  await expect(page.getByTestId("lbl-modal-title"), {
    message: 'Lỗi: không mở được modal "Tạo mới bộ hồ sơ"',
  }).toHaveText("Tạo mới bộ hồ sơ", { timeout: TIMEOUT.ACTION_LOADING });
  await page.waitForTimeout(METADATA_WAIT); // form nạp danh mục + field động rất chậm

  // --- field bắt buộc ---
  await pw.inputText("txt-tenHoSo", tenHoSo);
  await pw.inputTreeDropDown("tree-sel-mucLuuTru");
  await pw.inputDropDownList("sel-companyInvestor");
  await pw.inputTreeDropDown("tree-sel-owner-department");
  await pw.inputDropDownList("sel-loaiBoHoSo", "AUTO-TEST-TYPE");

  // Loại hồ sơ dạng "dự án" → hiện thêm `sel-duAn` (cũng bắt buộc)
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  if (
    await page
      .getByTestId("sel-duAn")
      .isVisible()
      .catch(() => false)
  ) {
    await pw.inputDropDownList("sel-duAn");
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }

  await pw.inputDropDownList("sel-hardCopyStatus");

  // --- field tuỳ chọn ---
  if (opts.fields?.length) await pw.batchInput(opts.fields);

  for (const { testId, account } of opts.peoplePickers ?? []) {
    await pw.inputPeoplePicker(testId, account);
    await page.keyboard.press("Escape");
    await page.waitForTimeout(500);
  }

  // --- đọc lại giá trị trước khi Lưu (một số field hệ thống tự chọn option đầu tiên) ---
  const values: Record<string, string> = {};
  for (const testId of opts.readFields ?? []) {
    values[testId] = (await pw.getValue(testId)) ?? "";
  }

  await pw.clickButton("btn-save");

  // Lưu thành công: URL đổi sang ?itemId=... và modal chuyển thành màn chi tiết
  await page.waitForURL(/itemId=/, { timeout: TIMEOUT.PAGE_LOADING });
  await page.waitForTimeout(TIMEOUT.ACTION_LOADING);
  await expect(getRecordModal(page).getByTestId("lbl-tenHoSo"), {
    message: `Lỗi: tạo BHS "${tenHoSo}" không thành công`,
  }).toContainText(tenHoSo, { timeout: TIMEOUT.ACTION_LOADING });

  const recordUrl = page.url();
  (
    createBoHoSo as unknown as { lastValues: Record<string, string> }
  ).lastValues = values;
  return recordUrl;
}

/**
 * Giá trị các field đã đọc ở lần `createBoHoSo` gần nhất (`opts.readFields`).
 * Ví dụ: `createBoHoSo(..., { readFields: ["sel-loaiBoHoSo"] })` rồi
 * `getLastCreateValues()["sel-loaiBoHoSo"]`.
 */
export function getLastCreateValues(): Record<string, string> {
  return (
    (createBoHoSo as unknown as { lastValues?: Record<string, string> })
      .lastValues ?? {}
  );
}

/**
 * Chuyển BHS từ "Khai báo" sang "Hoạt động" (có confirm dialog).
 * Truyền `recordUrl` để tự mở lại phiếu trước khi thao tác.
 *
 * Xem KHO-TAI-LIEU.md mục 4.2.
 */
export async function chuyenHoatDong(page: Page, recordUrl?: string) {
  if (recordUrl) await openBoHoSo(page, recordUrl);

  const modal = getRecordModal(page);
  await modal.getByTestId("btn-chuyen-hoat-dong").click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  await page
    .locator(".ant-modal-confirm-btns")
    .getByRole("button", { name: "Chuyển hoạt động" })
    .click();

  await expect(getRecordModal(page).getByTestId("lbl-modal-title"), {
    message: 'Lỗi: BHS không chuyển sang trạng thái "Hoạt động"',
  }).toContainText("Hoạt động", { timeout: TIMEOUT.ACTION_LOADING });
}

/* -------------------------------------------------------------------------- */
/* 2. Menu "..." và menu hành động của dòng                                     */
/* -------------------------------------------------------------------------- */

/**
 * Hover `btn-more` để mở dropdown.
 * ⚠️ Khi đang ở tab "Cấu trúc hồ sơ" có TỚI 2 phần tử `btn-more` (header BHS + header bảng)
 * → phải chỉ rõ `scope`.
 *
 * @param scope "record" = menu của BHS/Tài liệu (mặc định) | "cau-truc" = menu của bảng Cấu trúc
 */
export async function openMoreMenu(
  page: Page,
  scope: "record" | "cau-truc" = "record",
) {
  const trigger =
    scope === "record"
      ? getRecordModal(page).getByTestId("btn-more").first()
      : getActiveTabPane(page).getByTestId("btn-more");
  await trigger.hover();
  await page.waitForTimeout(DROPDOWN_WAIT);
  await expect(getVisibleDropdown(page), {
    message: `Lỗi: dropdown btn-more [${scope}] không mở`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Mở dropdown của nút "Tạo mới" ở tab Cấu trúc hồ sơ (Thư mục / Tài liệu / Tạo hồ sơ liên quan)
 * và trả về locator của dropdown đang mở (để assert các mục bên trong).
 *
 * ⚠️ Mũi tên dropdown chưa có testId → dùng `.ant-dropdown-trigger` ĐẦU TIÊN trong tab-pane
 * (phần tử thứ 2 là `btn-more`).
 */
export async function openTaoMoiDropdown(page: Page): Promise<Locator> {
  await getActiveTabPane(page).locator(".ant-dropdown-trigger").first().hover();
  await page.waitForTimeout(DROPDOWN_WAIT);

  const dropdown = getVisibleDropdown(page);
  await expect(dropdown, {
    message: 'Lỗi: dropdown của nút "Tạo mới" (tab Cấu trúc hồ sơ) không mở',
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  return dropdown;
}

/**
 * Hover 1 dòng trong bảng Cấu trúc hồ sơ → mở menu hành động (`btn-more-action-<id>`)
 * → click 1 mục menu.
 *
 * @param mniTestId vd `"mni-them-tai-lieu"`, `"mni-cap-nhat"`, `"mni-xoa"`…
 *
 * Xem KHO-TAI-LIEU.md mục 4.4.
 */
export async function openRowActionMenu(
  page: Page,
  tenItem: string,
  mniTestId: string,
) {
  const dropdown = await openRowActionDropdown(page, tenItem);
  await dropdown.getByTestId(mniTestId).click();
}

/**
 * Hover 1 dòng trong bảng Cấu trúc hồ sơ → mở **menu mở rộng của dòng**
 * (`btn-more-action-<id>`) và trả về locator dropdown đang mở — **không click mục nào**.
 *
 * Dùng khi cần assert nội dung menu (vd case kiểm tra hiển thị nút "Thêm thư mục");
 * muốn click luôn 1 mục thì dùng `openRowActionMenu`.
 */
export async function openRowActionDropdown(
  page: Page,
  tenItem: string,
): Promise<Locator> {
  const row = getCauTrucRow(page, tenItem);
  await expect(row, {
    message: `Lỗi: không thấy dòng "${tenItem}" trong bảng Cấu trúc hồ sơ`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  await row.hover();
  await page.waitForTimeout(1000);
  await getRowActionTrigger(page, tenItem).hover();
  await page.waitForTimeout(DROPDOWN_WAIT);

  const dropdown = getVisibleDropdown(page);
  await expect(dropdown, {
    message: `Lỗi: menu mở rộng của dòng "${tenItem}" không mở`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  return dropdown;
}

/**
 * **Tên hiển thị của mọi dòng** đang có trong bảng tab "Cấu trúc hồ sơ"
 * (dạng `"<mã prefix>. <tên>"`, vd `"A. Thư mục 1"`, `"A.1 Tài liệu 1"`).
 *
 * Cây ở tab này **expand sẵn** nên danh sách trả về gồm cả item con
 * (`KHO-TAI-LIEU.md` mục 4.4). Dùng để assert 1 item **không còn** trong bảng — assert kiểu này
 * cần đọc cả danh sách để đưa vào message lỗi, chứ chỉ `toHaveCount(0)` thì không biết đang có gì.
 */
export async function layTenCacDongCauTruc(page: Page): Promise<string[]> {
  const ten = await getActiveTabPane(page)
    .locator('[data-testid^="lnk-ten-ho-so-tai-lieu-"]')
    .allInnerTexts();
  return ten.map((t) => t.trim()).filter(Boolean);
}

/**
 * Nội dung confirm dialog khi xoá 1 item ở bảng "Cấu trúc hồ sơ" — app dùng **2 dialog khác nhau**
 * tuỳ item có dữ liệu bên trong hay không.
 */
export const XOA_CONFIRM = {
  /**
   * Item **rỗng** (thư mục không có gì bên trong / tài liệu): dialog `"Xóa"` với nút
   * `Hủy bỏ` (`btn-xoa-huy`) / `Xác nhận` (`btn-xoa-xac-nhan`) — khảo sát MCP 2026-07-27,
   * đo lại DOM 2026-08-14 (xem `KHO-TAI-LIEU.md` mục 4.4).
   */
  RONG: "Bạn có chắc chắn muốn xóa?",
  /**
   * Thư mục **có Thư mục/Tài liệu bên trong**: app hiện dialog cảnh báo xoá lan xuống, nút xác nhận
   * có testId `btn-xoa-xac-nhan`.
   *
   * ⚠️ **Chưa khảo sát bằng MCP** — nội dung + testId do người dùng cung cấp (2026-08-05).
   */
  CO_DU_LIEU_BEN_TRONG:
    "Thao tác này sẽ xóa tất cả dữ liệu Thư mục/Tài liệu bên trong! Bạn có chắc chắn thực hiện Xóa?",
} as const;

/**
 * testId nút **Xác nhận** của dialog xoá.
 *
 * ✅ Khảo sát MCP 2026-08-14: dialog xoá item **rỗng** cũng dùng đúng testId này
 * (trước đây tài liệu ghi "chưa thấy testId" cho trường hợp rỗng).
 * ⚠️ Dialog xoá thư mục **có dữ liệu bên trong** vẫn chưa khảo sát
 * (xem `XOA_CONFIRM.CO_DU_LIEU_BEN_TRONG`).
 */
export const BTN_XOA_XAC_NHAN = "btn-xoa-xac-nhan";

/** testId nút **Hủy bỏ** của dialog xoá (khảo sát MCP 2026-08-14) */
export const BTN_XOA_HUY = "btn-xoa-huy";

/** testId của chính dialog xác nhận xoá (khảo sát MCP 2026-08-14) */
export const MDL_XOA_XAC_NHAN = "mdl-xoa-xac-nhan";

/**
 * Toast app bắn ra sau khi xác nhận xoá 1 item ở bảng "Cấu trúc hồ sơ"
 * (`.ant-message-success`, khảo sát MCP 2026-08-14 — sống ~3 s rồi tự tắt).
 */
export const TOAST_XOA = "Xóa thành công";

/**
 * Xoá 1 item (thư mục / tài liệu) từ bảng tab **"Cấu trúc hồ sơ"**: menu dòng → `mni-xoa` →
 * xác nhận dialog. Trả về **nội dung dialog** đã đọc được (để case assert thêm nếu cần).
 *
 * BHS phải đang mở sẵn ở tab "Cấu trúc hồ sơ" (`openBoHoSo` + `openTabCauTrucHoSo`).
 *
 * Cách bấm nút xác nhận: ưu tiên **testId `btn-xoa-xac-nhan`** (dialog xoá thư mục có dữ liệu bên
 * trong), không có thì rơi về nút theo nhãn `Xác nhận` ở `.ant-modal-confirm-btns` (dialog xoá item
 * rỗng — `KHO-TAI-LIEU.md` mục 4.4). Cả 2 chỗ lệch mong đợi đều báo bằng **`expect.soft`** để thao
 * tác xoá vẫn chạy tiếp mà kết quả test vẫn ghi nhận sai lệch.
 *
 * Toast: app bắn `"Xóa thành công"` (`TOAST_XOA`, khảo sát MCP 2026-08-14). Hàm vẫn **chỉ chờ
 * "mềm"** — toast sống ~3 s nên rất dễ lỡ nếu máy chạy chậm; tín hiệu chắc chắn là **dòng biến mất
 * khỏi bảng**, việc assert đó thuộc case (bảng tự refresh sau khi xoá, không cần mở lại BHS).
 *
 * @param opts.noiDungMongDoi Nội dung mong đợi của `.ant-modal-confirm-content`
 *   (hằng `XOA_CONFIRM`). Lệch → `expect.soft` báo lỗi nhưng **vẫn xoá tiếp**.
 */
export async function xoaItemCauTruc(
  page: Page,
  tenItem: string,
  opts: { noiDungMongDoi?: string } = {},
): Promise<string> {
  await openRowActionMenu(page, tenItem, "mni-xoa");

  const dialog = page.locator(".ant-modal-confirm:visible").last();
  await expect(dialog, {
    message: `Lỗi: bấm "Xóa" ở dòng "${tenItem}" nhưng không thấy dialog xác nhận`,
  }).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });

  // ⚠️ Không `innerText()` trần: config không set `actionTimeout` nên đọc 1 element KHÔNG tồn tại
  // sẽ chờ tới khi test timeout (`tests/README.md` mục 6.7) → chờ có element trước, có timeout rõ ràng
  const oNoiDung = dialog.locator(".ant-modal-confirm-content");
  const coNoiDung = await oNoiDung
    .first()
    .waitFor({ state: "attached", timeout: TIMEOUT.CONTROL_LOADING })
    .then(() => true)
    .catch(() => false);
  const noiDung = coNoiDung
    ? (await oNoiDung.first().innerText()).replace(/\s+/g, " ").trim()
    : "";

  if (opts.noiDungMongDoi) {
    expect
      .soft(noiDung, {
        message:
          `Cảnh báo: nội dung dialog xoá "${tenItem}" không đúng mong đợi.\n` +
          `  mong đợi: "${opts.noiDungMongDoi}"\n  thực tế:  "${noiDung}"`,
      })
      .toBe(opts.noiDungMongDoi);
  }

  const nutTheoTestId = dialog.getByTestId(BTN_XOA_XAC_NHAN);
  const coTestId = (await nutTheoTestId.count()) > 0;
  if (opts.noiDungMongDoi === XOA_CONFIRM.CO_DU_LIEU_BEN_TRONG) {
    expect
      .soft(coTestId, {
        message: `Cảnh báo: dialog xoá "${tenItem}" không có nút testId "${BTN_XOA_XAC_NHAN}" → bấm theo nhãn "Xác nhận"`,
      })
      .toBe(true);
  }

  const nutXacNhan = coTestId
    ? nutTheoTestId
    : dialog
        .locator(".ant-modal-confirm-btns")
        .getByRole("button", { name: /^Xác nhận/ });
  await expect(nutXacNhan, {
    message: `Lỗi: dialog xoá "${tenItem}" không có nút xác nhận nào bấm được`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await nutXacNhan.click();

  // Toast `"Xóa thành công"` chỉ sống ~3 s → chờ "mềm", không fail vì lỡ mất toast
  await getToast(page, TOAST_XOA)
    .waitFor({ state: "visible", timeout: TIMEOUT.ACTION_LOADING })
    .catch(() => {});
  await expect(dialog, {
    message: `Lỗi: đã bấm xác nhận nhưng dialog xoá "${tenItem}" không đóng`,
  }).toBeHidden({ timeout: TIMEOUT.ACTION_LOADING });
  // Chờ app ghi xong + refresh bảng trước khi assert / điều hướng
  await page.waitForTimeout(SAVE_SETTLE_WAIT);

  return noiDung;
}

/* -------------------------------------------------------------------------- */
/* 3. Tạo thư mục                                                              */
/* -------------------------------------------------------------------------- */

export type CreateThuMucOptions = {
  /** Tạo thư mục con bên trong 1 thư mục đã có (khớp theo tên) */
  parentName?: string;
  /** Bật "Đặt quyền độc lập" (tách khỏi kế thừa) */
  quyenDocLap?: boolean;
  /** Gán quyền — chỉ có tác dụng khi `quyenDocLap = true` */
  perm?: {
    account: string;
    /**
     * Dùng hằng `FOLDER_PICKER` (testId `pp-multi-usersRight*`) — truyền 1 chuỗi để gán vào
     * đúng 1 trường, hoặc 1 mảng (vd `Object.values(FOLDER_PICKER)`) để gán cùng account vào
     * **nhiều trường quyền cùng lúc**.
     */
    permTestId: string | readonly string[];
    /** Xoá hết chip kế thừa trong picker đó trước khi thêm */
    clearInherited?: boolean;
  };
  /**
   * Đặt **Độ mật** khác giá trị kế thừa từ cấp cha: `"Thường"` | `"Mật"` | `"Tuyệt mật"`
   * (khảo sát MCP 2026-07-30).
   */
  doMat?: string;
  /** Đặt **Trạng thái hiển thị** khác giá trị kế thừa: `"Public"` | `"Private"` */
  trangThaiHienThi?: string;
  /** Bỏ qua bước tự điền "Đơn vị soạn thảo" (nếu BHS không bắt buộc field này) */
  skipDonViSoanThao?: boolean;
  /**
   * `false` → **không** mở lại BHS trước khi tạo (dùng luôn màn đang mở ở tab "Cấu trúc hồ sơ").
   * Xem `openModalThemThuMuc` — tiết kiệm ~37 s mỗi lần gọi khi tạo nhiều thư mục liên tiếp.
   */
  moLaiBoHoSo?: boolean;
};

/**
 * Mở modal **"Thêm thư mục"** (không điền gì, không lưu) và trả về locator của modal.
 *
 * Tự mở lại BHS theo `recordUrl` → vào tab "Cấu trúc hồ sơ" → mở menu tương ứng:
 * - `parentName` rỗng → dropdown nút "Tạo mới" → `mni-them-thu-muc` (thư mục ở gốc BHS)
 * - có `parentName` → menu mở rộng của dòng thư mục đó → `mni-them-thu-muc` (thư mục con)
 *
 * Dùng cho case chỉ **quan sát** modal (vd kiểm tra giá trị mặc định của field); muốn tạo hẳn
 * thư mục thì dùng `createThuMuc`.
 *
 * ⚡ `moLaiBoHoSo: false` → **bỏ qua** bước mở lại BHS + vào tab "Cấu trúc hồ sơ", dùng luôn màn
 * đang mở. Tiết kiệm ~37 s mỗi lần gọi (`openBoHoSo` ~22 s + `openTabCauTrucHoSo` 15 s), hợp lý khi
 * gọi liên tiếp nhiều lần vì sau khi lưu, modal thư mục **tự đóng** và app trả về đúng tab
 * "Cấu trúc hồ sơ" của BHS (KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md mục 5).
 *
 * ⚠️ Chỉ dùng khi **chắc chắn** BHS đang mở sẵn ở tab "Cấu trúc hồ sơ" (vừa tạo xong 1 thư mục,
 * hoặc vừa đóng modal thư mục bằng "Hủy"). Nếu màn đang ở chỗ khác thì để mặc định (`true`).
 *
 * Xem KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md mục 1.
 */
export async function openModalThemThuMuc(
  page: Page,
  recordUrl: string,
  opts: { parentName?: string; moLaiBoHoSo?: boolean } = {},
): Promise<Locator> {
  if (opts.moLaiBoHoSo ?? true) {
    await openBoHoSo(page, recordUrl);
    await openTabCauTrucHoSo(page);
  }

  if (opts.parentName) {
    await openRowActionMenu(page, opts.parentName, "mni-them-thu-muc");
  } else {
    await openTaoMoiDropdown(page);
    await getVisibleDropdown(page).getByTestId("mni-them-thu-muc").click();
  }

  const modal = getRecordModal(page);
  await expect(modal.getByTestId("lbl-modal-title"), {
    message: 'Lỗi: không mở được modal "Thêm thư mục"',
  }).toHaveText("Thêm thư mục", { timeout: TIMEOUT.ACTION_LOADING });
  await page.waitForTimeout(FOLDER_MODAL_WAIT); // modal nạp metadata

  return modal;
}

/**
 * Ô **"Index thư mục"** trong modal Thêm / Cập nhật thư mục — input **disabled** hiển thị mã prefix
 * hệ thống tự sinh (`A`, `B`, `A.1`…), nằm chung ô "Tên" với input nhập tên.
 *
 * ✅ Khảo sát MCP 2026-07-30: đã có testId **`txt-ma-thu-muc`** (input `disabled`).
 */
export function getIndexThuMuc(page: Page, modal?: Locator): Locator {
  return (modal ?? getThuMucModal(page)).getByTestId(THU_MUC_FIELD.INDEX);
}

/**
 * Ô select **"Thư mục/Hồ sơ cấp cha"** của modal thư mục — **tree-select** `tree-sel-thu-muc-cha`
 * (khảo sát MCP 2026-07-30).
 */
export function getSelectCapCha(page: Page, modal?: Locator): Locator {
  return (modal ?? getThuMucModal(page)).getByTestId(THU_MUC_FIELD.CAP_CHA);
}

/**
 * Đổi **Thư mục/Hồ sơ cấp cha** trong modal Thêm / Cập nhật thư mục.
 *
 * Khảo sát MCP 2026-07-30:
 * - testId `tree-sel-thu-muc-cha`, dropdown là **tree-select** → option là `.ant-select-tree-title`
 *   với text `"<mã>. <tên>"` (vd `"B. TM aba"`).
 * - Dropdown **chỉ liệt kê thư mục** (tài liệu không xuất hiện) và **không có mục "gốc BHS"**;
 *   mở modal từ gốc BHS thì ô này rỗng.
 * - Đổi cấp cha xong app **tự cập nhật**: Chỉ mục thành `<mã cha>.<n>`, Độ mật + Trạng thái hiển thị
 *   **kế thừa từ cha**, khối phân quyền đổi theo quyền của cha.
 * - Không khớp option nào → ném lỗi kèm **danh sách option đang hiển thị**.
 */
export async function doiThuMucCapCha(
  page: Page,
  tenCha: string,
  modal?: Locator,
) {
  const select = getSelectCapCha(page, modal);
  await expect(select, {
    message: 'Lỗi: không thấy ô "Thư mục/Hồ sơ cấp cha" trong modal thư mục',
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });

  await select.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  const dropdown = page.locator(".ant-select-dropdown:visible").last();
  const option = dropdown
    .locator(".ant-select-tree-title")
    .filter({ hasText: tenCha })
    .first();

  try {
    await expect(option).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });
  } catch {
    const dangCo = await dropdown
      .locator(".ant-select-tree-title")
      .allInnerTexts()
      .catch(() => []);
    throw new Error(
      `Lỗi: dropdown "Thư mục/Hồ sơ cấp cha" không có option "${tenCha}". ` +
        `Option đang hiển thị: [${dangCo.map((t) => t.trim()).join(" | ")}]`,
    );
  }

  await option.click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING); // app cập nhật lại các field phụ thuộc cấp cha
  await page.keyboard.press("Escape"); // đóng dropdown còn sót, tránh che field khác
  await page.waitForTimeout(500);
}

/**
 * Đọc giá trị đang chọn của 1 select trong modal thư mục theo **testId**
 * (`THU_MUC_FIELD.DO_MAT`, `THU_MUC_FIELD.TRANG_THAI_HIEN_THI`…). Trả `""` nếu select rỗng.
 */
export async function docGiaTriSelectThuMuc(
  modal: Locator,
  testId: string,
): Promise<string> {
  const item = modal
    .getByTestId(testId)
    .locator(".ant-select-selection-item")
    .first();
  if ((await item.count()) === 0) return "";
  return (await item.innerText()).trim();
}

/**
 * Đọc danh sách option **chọn được** của 1 select trong modal thư mục (mở dropdown → đọc → Escape).
 *
 * Bỏ qua option bị `.ant-select-item-option-disabled`: cái test quan tâm là người dùng **thật sự
 * chọn được** giá trị nào, nên option hiện ra mà bấm không được thì coi như không có.
 *
 * Dùng cho case kiểm tra app có giới hạn danh sách giá trị theo ngữ cảnh hay không
 * (vd Độ mật của thư mục con bị chặn theo Độ mật thư mục cha — case 182).
 */
export async function layDanhSachOptionSelectThuMuc(
  page: Page,
  select: Locator,
): Promise<string[]> {
  await select.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  const dropdown = page.locator(".ant-select-dropdown:visible").last();
  await expect(dropdown.locator(".ant-select-item-option").first(), {
    message: "Lỗi: mở dropdown nhưng không thấy option nào",
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });

  const options = await dropdown
    .locator(
      ".ant-select-item-option:not(.ant-select-item-option-disabled) .ant-select-item-option-content",
    )
    .allInnerTexts();

  await page.keyboard.press("Escape");
  await page.waitForTimeout(500);

  return options.map((t) => t.trim()).filter(Boolean);
}

/**
 * Đóng modal Thêm / Cập nhật thư mục bằng nút **"Hủy"** ở footer, **nếu nó đang mở**.
 *
 * Dùng sau khi chỉ quan sát modal (không lưu), hoặc sau 1 thao tác lưu **bị app chặn** — lúc đó
 * modal vẫn nằm trên màn và sẽ che mọi thao tác kế tiếp.
 *
 * Khớp nhãn bằng regex `/^Hủy/` để đúng với cả `"Hủy"` lẫn `"Hủy bỏ"` mà không chạm `"Xác nhận"`.
 */
export async function dongModalThuMucNeuDangMo(page: Page) {
  const modal = getThuMucModal(page);
  if (!(await modal.isVisible().catch(() => false))) return;

  await modal.getByRole("button", { name: /^Hủy/ }).first().click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Đọc nội dung tab **"Phân quyền"** của modal thư mục rồi **quay lại tab "Thông tin"**.
 *
 * Dùng để so sánh khối "đối tượng phân quyền" trước/sau 1 thao tác (vd đổi thư mục cấp cha →
 * quyền kế thừa phải đổi theo cha mới). Khi thư mục đang kế thừa, tab này chỉ là nhãn + avatar
 * quyền kế thừa (chỉ đọc) — xem MODAL-TAO-THU-MUC.md mục 4a.
 */
export async function docNoiDungTabPhanQuyenThuMuc(
  page: Page,
  modal?: Locator,
): Promise<string> {
  const m = modal ?? getThuMucModal(page);
  await openTabThuMuc(page, "Phân quyền", m);
  const noiDung = (await getActiveTabPanelIn(m).innerText()).trim();
  await openTabThuMuc(page, "Thông tin", m);
  return noiDung;
}

/**
 * Tạo 1 thư mục trong tab "Cấu trúc hồ sơ" của BHS.
 * Tự mở lại BHS theo `recordUrl` trước khi thao tác (reset DOM).
 *
 * Xem KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md.
 */
export async function createThuMuc(
  page: Page,
  _pw: PW,
  recordUrl: string,
  tenThuMuc: string,
  opts: CreateThuMucOptions = {},
) {
  const modal = await openModalThemThuMuc(page, recordUrl, {
    parentName: opts.parentName,
    moLaiBoHoSo: opts.moLaiBoHoSo,
  });

  // --- tab Phân quyền (nếu cần) ---
  if (opts.quyenDocLap) {
    await openTabThuMuc(page, "Phân quyền", modal);
    await datQuyenDocLap(page, modal);

    if (opts.perm) {
      const permTestIds = Array.isArray(opts.perm.permTestId)
        ? opts.perm.permTestId
        : [opts.perm.permTestId as string];
      for (const permTestId of permTestIds) {
        const picker = getPickerPhanQuyenThuMuc(page, permTestId, modal);
        await fillPeoplePicker(
          page,
          picker,
          opts.perm.account,
          opts.perm.clearInherited ?? false,
        );
      }
    }

    // quay lại tab Thông tin để điền tên
    await openTabThuMuc(page, "Thông tin", modal);
  }

  // --- tab Thông tin ---
  const pane = getActiveTabPanelIn(modal);
  await pane.getByTestId(THU_MUC_FIELD.TEN).fill(tenThuMuc);

  // Độ mật / Trạng thái hiển thị: mặc định kế thừa từ cấp cha, chỉ chọn khi case yêu cầu khác
  if (opts.doMat) {
    await chonOptionSelect(
      page,
      pane.getByTestId(THU_MUC_FIELD.DO_MAT),
      opts.doMat,
    );
  }
  if (opts.trangThaiHienThi) {
    await chonOptionSelect(
      page,
      pane.getByTestId(THU_MUC_FIELD.TRANG_THAI_HIEN_THI),
      opts.trangThaiHienThi,
    );
  }

  // "Đơn vị soạn thảo" bắt buộc tuỳ cấu hình Cấu trúc hồ sơ (không có icon-required)
  if (!opts.skipDonViSoanThao) {
    const donViSoanThao = pane.getByTestId(THU_MUC_FIELD.DON_VI_SOAN_THAO);
    if (await donViSoanThao.isVisible().catch(() => false)) {
      await pickFirstOption(page, donViSoanThao);
    }
  }

  await luuModalThuMuc(page, modal, `Tạo thư mục ${tenThuMuc}`);
}

/* -------------------------------------------------------------------------- */
/* 3b. Màn cập nhật thư mục + tab "Phân quyền" của modal thư mục                */
/* -------------------------------------------------------------------------- */

/**
 * Bấm **Xác nhận** ở footer modal Thêm / Cập nhật thư mục rồi chờ lưu xong.
 *
 * Nút nằm ở footer modal (ngoài tabpanel) → scope theo modal, không scope theo tabpanel.
 * Lưu xong app bắn 2 toast nối tiếp `"Đang xử lý"` → `"Thành công"` (xem KHO-TAI-LIEU.md mục 9.8).
 */
export async function luuModalThuMuc(
  page: Page,
  modal: Locator = getThuMucModal(page),
  label = "",
) {
  await modal.getByRole("button", { name: /Xác nhận/ }).click();
  await expectToastThanhCong(page, label);
  // Chờ app ghi xong + refresh bảng trước khi đóng modal / điều hướng
  await page.waitForTimeout(SAVE_SETTLE_WAIT);
}

/**
 * Thêm 1 người dùng / nhóm vào các trường quyền của modal thư mục (**không** xoá người
 * đang có, kể cả chip kế thừa). Tab "Phân quyền" phải đang mở và thư mục phải đã
 * "Đặt quyền độc lập" — lúc còn kế thừa app không render ô nhập nào.
 *
 * @param account       account id hoặc tên nhóm người dùng
 * @param permTestIds các trường cần thêm, dùng hằng `FOLDER_PICKER`; mặc định **cả 5 trường**
 */
export async function themQuyenTrongModalThuMuc(
  page: Page,
  account: string,
  permTestIds: readonly string[] = Object.values(FOLDER_PICKER),
  modal: Locator = getThuMucModal(page),
) {
  for (const permTestId of permTestIds) {
    await fillPeoplePicker(
      page,
      getPickerPhanQuyenThuMuc(page, permTestId, modal),
      account,
    );
  }
}

/**
 * Bấm "Khôi phục kế thừa" (ngược lại `datQuyenDocLap`) trong modal thư mục / modal
 * "Cập nhật quyền tài liệu", rồi xác nhận dialog.
 *
 * ⚠️ Thao tác này **xoá toàn bộ quyền riêng** và thay bằng quyền của cha (đúng như nội dung
 * dialog cảnh báo). Không có toast — thay đổi chỉ ở UI, phải bấm "Xác nhận" ở footer mới lưu.
 * Xem KHO-TAI-LIEU.MODAL-CAP-NHAT-QUYEN-TAI-LIEU.md mục 4b.
 */
export async function khoiPhucKeThuaTrongModal(
  page: Page,
  modal: Locator = getThuMucModal(page),
) {
  await modal
    .locator(".ant-alert-action")
    .getByRole("button", { name: "Khôi phục kế thừa" })
    .click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await confirmDialog(page, "Xác nhận");

  await expect(
    modal.locator(".ant-tabs-tab").filter({ hasText: "Phân quyền" }),
    {
      message: "Lỗi: badge tab Phân quyền không đổi sang 'Kế thừa'",
    },
  ).toContainText("Kế thừa", { timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Mở màn **chi tiết 1 Tài liệu** từ tab "Cấu trúc hồ sơ" của BHS (click vào tên tài liệu).
 *
 * 🚨 Modal chi tiết TL **chồng lên** modal BHS (không thay thế) và `page.url()` đổi sang
 * `itemId` của tài liệu → luôn giữ `recordUrl` của BHS để quay lại.
 * Xem KHO-TAI-LIEU.MODAL-CAP-NHAT-QUYEN-TAI-LIEU.md mục 1.
 *
 * 🚨 Tài liệu **đã có file mềm** thì app còn tự mở thêm **modal xem trước file** (PDF viewer) chồng
 * lên trên nữa → assert phải dùng `getModalTaiLieu` (lọc modal có `tab-digitizedATM`), **không**
 * dùng `getRecordModal` (`.last()` sẽ trỏ vào lớp xem trước và `lbl-tenHoSo` không tồn tại ở đó).
 * Kiểm chứng 2026-08-05 — xem KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md mục 1.
 */
export async function openChiTietTaiLieu(
  page: Page,
  recordUrl: string,
  tenTaiLieu: string,
) {
  await openBoHoSo(page, recordUrl);
  await openTabCauTrucHoSo(page);

  await getActiveTabPane(page)
    .locator('[data-testid^="lnk-ten-ho-so-tai-lieu-"]')
    .filter({ hasText: tenTaiLieu })
    .first()
    .click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  await expect(getModalTaiLieu(page).getByTestId("lbl-tenHoSo"), {
    message: `Lỗi: không mở được màn chi tiết tài liệu "${tenTaiLieu}"`,
  }).toContainText(tenTaiLieu, { timeout: TIMEOUT.ACTION_LOADING });
}

/**
 * Mở modal **"Cập nhật quyền tài liệu"** từ màn chi tiết TL đang mở
 * (menu `btn-more` → "Kiểm tra phân quyền").
 *
 * Modal chi tiết TL chỉ có 1 `btn-more` nên `openMoreMenu(page, "record")` là an toàn.
 * Xem KHO-TAI-LIEU.MODAL-CAP-NHAT-QUYEN-TAI-LIEU.md.
 */
export async function openCapNhatQuyenTaiLieu(page: Page) {
  await openMoreMenu(page, "record");
  await getVisibleDropdown(page).getByTestId("btn-kiem-tra-phan-quyen").click();

  await expect(getRecordModal(page).getByTestId("lbl-modal-title"), {
    message: 'Lỗi: không mở được modal "Cập nhật quyền tài liệu"',
  }).toContainText("Cập nhật quyền tài liệu", {
    timeout: TIMEOUT.PAGE_LOADING,
  });
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/** Chuyển tab trong modal Thêm / Cập nhật thư mục */
export async function openTabThuMuc(
  page: Page,
  tab: "Thông tin" | "Phân quyền",
  modal: Locator = getThuMucModal(page),
) {
  await modal.locator(".ant-tabs-tab").filter({ hasText: tab }).click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Mở màn hình **cập nhật Thư mục** từ bảng Cấu trúc hồ sơ (menu hành động của dòng → "Cập nhật").
 *
 * 🚨 `mni-cap-nhat` đổi `page.url()` sang `itemId` của thư mục → luôn truyền `recordUrl` của BHS
 * và giữ lại biến đó để quay về sau (xem KHO-TAI-LIEU.md mục 4.4).
 */
export async function openManCapNhatThuMuc(
  page: Page,
  recordUrl: string,
  tenThuMuc: string,
) {
  await openBoHoSo(page, recordUrl);
  await openTabCauTrucHoSo(page);
  await openRowActionMenu(page, tenThuMuc, "mni-cap-nhat");
  await page.waitForTimeout(FOLDER_MODAL_WAIT); // màn cập nhật nạp metadata giống modal Thêm thư mục

  // Tiêu đề modal: "Cập nhật thư mục <tên thư mục>"
  await expect(getThuMucModal(page), {
    message: `Lỗi: không mở được màn cập nhật thư mục "${tenThuMuc}"`,
  }).toContainText(`Cập nhật thư mục ${tenThuMuc}`, {
    timeout: TIMEOUT.ACTION_LOADING,
  });
}

/**
 * Bấm "Đặt quyền độc lập" trong tab Phân quyền của modal thư mục rồi xác nhận dialog.
 * Tab "Phân quyền" phải đang mở (gọi `openTabThuMuc(page, "Phân quyền")` trước).
 *
 * Sau khi Xác nhận: badge tab đổi `Kế thừa` → `Độc lập`, alert đổi sang "Khôi phục kế thừa",
 * 5 people-picker trở thành chỉnh sửa được.
 * Xem KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md mục 4b.
 */
export async function datQuyenDocLap(
  page: Page,
  modal: Locator = getThuMucModal(page),
) {
  await modal
    .locator(".ant-alert-action")
    .getByRole("button", { name: "Đặt quyền độc lập" })
    .click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  // Confirm dialog "Đặt quyền độc lập?" chồng lên trên
  await confirmDialog(page, "Xác nhận");

  await expect(
    modal.locator(".ant-tabs-tab").filter({ hasText: "Phân quyền" }),
    {
      message: "Lỗi: badge tab Phân quyền không đổi sang 'Độc lập'",
    },
  ).toContainText("Độc lập", { timeout: TIMEOUT.CONTROL_LOADING });
}

/** Assert badge trạng thái của tab "Phân quyền" trong modal thư mục */
export async function checkBadgeTabPhanQuyen(
  page: Page,
  expected: "Kế thừa" | "Độc lập",
  modal: Locator = getThuMucModal(page),
) {
  await expect(
    modal.locator(".ant-tabs-tab").filter({ hasText: "Phân quyền" }),
    {
      message: `Lỗi: badge tab Phân quyền không phải "${expected}"`,
    },
  ).toContainText(expected, { timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Assert tab Phân quyền đang ở trạng thái **kế thừa / chỉ đọc**.
 *
 * Khi thư mục còn kế thừa, app KHÔNG render ô nhập nào: 5 dòng "Quyền Owner / Tạo mới / Cập nhật /
 * Tải file / Xem" chỉ là nhãn + avatar quyền kế thừa (rỗng nếu không ai có quyền đó), kèm chú thích
 * `"* Hiển thị quyền đang được kế thừa từ thư mục cha (chỉ đọc)"`.
 */
export async function expectPickerPhanQuyenChiDoc(
  page: Page,
  modal: Locator = getThuMucModal(page),
) {
  await expect(
    getActiveTabPanelIn(modal).locator(".ant-select-selection-search-input"),
    {
      message:
        "Lỗi: tab Phân quyền vẫn có ô nhập dù thư mục đang kế thừa quyền (phải chỉ đọc)",
    },
  ).toHaveCount(0, { timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Kiểm chứng 1 people-picker **thật sự nhập được**: click vào ô → gõ từ khoá → gợi ý hiện ra
 * → xoá sạch chữ vừa gõ bằng Backspace.
 *
 * 🚨 KHÔNG dùng `toBeEditable()` trên ô search khi chưa click: AntD (rc-select) luôn gắn
 * `readonly` cho `input.ant-select-selection-search-input` lúc select **đang đóng**, kể cả khi
 * trường hoàn toàn bình thường. Trường bị khoá thật thì input có `disabled` + wrapper có class
 * `ant-select-disabled`. Vì vậy tín hiệu tin cậy là: click vào gõ được và **có gợi ý trả về**.
 *
 * ⚠️ Không nhấn Enter (sẽ thêm chip, đổi dữ liệu). Xoá bằng Backspace **đúng số ký tự đang có** —
 * Backspace khi ô đã rỗng sẽ xoá mất chip quyền kế thừa.
 */
export async function expectPickerNhapDuoc(
  page: Page,
  picker: Locator,
  keyword = "ecm02",
  label = "",
) {
  const nhan = label ? ` "${label}"` : "";
  const input = picker.locator(".ant-select-selection-search-input");
  const soChipTruoc = await picker.locator(".ant-tag").count();

  await input.click();
  await expect(input, {
    message: `Lỗi: ô nhập${nhan} vẫn readonly sau khi click → không chỉnh sửa được`,
  }).toBeEditable({ timeout: TIMEOUT.CONTROL_LOADING });

  await input.pressSequentially(keyword, { delay: 150 });
  await expect(input, {
    message: `Lỗi: gõ "${keyword}" vào ô${nhan} nhưng ô không nhận ký tự nào`,
  }).toHaveValue(keyword, { timeout: TIMEOUT.CONTROL_LOADING });

  // Gợi ý người dùng nằm ở dropdown gắn vào body (không nằm trong modal)
  await expect(
    page
      .locator(".ant-select-dropdown:visible")
      .last()
      .locator(".ant-select-item-option")
      .first(),
    {
      message: `Lỗi: gõ "${keyword}" vào ô${nhan} nhưng không thấy gợi ý nào`,
    },
  ).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  // Xoá đúng số ký tự còn trong ô, có guard để không backspace lố sang chip
  let guard = 0;
  while ((await input.inputValue()).length > 0 && guard++ <= keyword.length) {
    await page.keyboard.press("Backspace");
    await page.waitForTimeout(200);
  }
  await expect(input, {
    message: `Lỗi: không xoá hết được từ khoá vừa gõ ở ô${nhan}`,
  }).toHaveValue("", { timeout: TIMEOUT.CONTROL_LOADING });

  await page.keyboard.press("Escape"); // đóng gợi ý, tránh che field/nút phía sau
  await page.waitForTimeout(500);

  await expect(picker.locator(".ant-tag"), {
    message: `Lỗi: số chip quyền của ô${nhan} bị thay đổi sau khi kiểm tra nhập liệu`,
  }).toHaveCount(soChipTruoc);
}

/**
 * Assert 5 trường quyền (Owner / Tạo mới / Cập nhật / Tải file / Xem) của modal thư mục đã
 * **enable chỉnh sửa** — chỉ xảy ra sau khi "Đặt quyền độc lập" (trước đó không có ô nhập nào).
 *
 * Kiểm chứng bằng thao tác thật trên từng ô (`expectPickerNhapDuoc`): gõ `keyword` → có gợi ý →
 * xoá lại bằng Backspace, không để lại thay đổi nào.
 */
export async function expectPickerPhanQuyenChinhSuaDuoc(
  page: Page,
  modal: Locator = getThuMucModal(page),
  keyword = "ecm02",
) {
  for (const permTestId of Object.values(FOLDER_PICKER)) {
    const picker = getPickerPhanQuyenThuMuc(page, permTestId, modal);
    await expect(picker, {
      message: `Lỗi: không thấy khối "${FOLDER_PICKER_LABELS[permTestId]}" trong tab Phân quyền`,
    }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });

    await expectPickerNhapDuoc(
      page,
      picker,
      keyword,
      FOLDER_PICKER_LABELS[permTestId],
    );
  }
}

/* -------------------------------------------------------------------------- */
/* 4. Tạo tài liệu                                                             */
/* -------------------------------------------------------------------------- */

export type CreateTaiLieuOptions = {
  /** Đặt tài liệu vào 1 thư mục (khớp theo tên; option hiển thị có prefix nên dùng contains) */
  folderName?: string;
  /**
   * `true` → mở modal từ **menu mở rộng của dòng thư mục** `folderName` (`mni-them-tai-lieu`)
   * thay vì nút "Tạo mới" của toolbar. Lúc đó `sel-folder-storage` **đã được app điền sẵn**
   * thư mục đó nên hàm bỏ qua bước chọn thư mục (KHO-TAI-LIEU.md mục 4.4).
   *
   * Cần cho vai **chỉ có quyền tại 1 thư mục con**: vai đó không có `btn-tao-moi` ở toolbar
   * (`KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md` mục 2) nên chỉ tạo được qua menu dòng.
   */
  moTuMenuDongThuMuc?: boolean;
  /** Tick "Break quyền riêng" để tách khỏi kế thừa */
  quyenRieng?: boolean;
  /** Gán quyền — 1 hoặc nhiều */
  perm?: {
    account: string;
    /** Dùng hằng DOC_PERM */
    permTestId: string;
    clearInherited?: boolean;
  }[];
  /**
   * Đọc lại giá trị các field **ngay trong modal trước khi Lưu** — dùng cho field mà hàm để app /
   * chính nó tự chọn option đầu tiên (vd `sel-loai-tai-lieu`), sau này cần so lại với giá trị
   * đã lưu. Giá trị trả về theo `Record<testId, giá trị>`.
   */
  readFields?: string[];
};

/**
 * Tạo 1 tài liệu trong tab "Cấu trúc hồ sơ" của BHS. Trả về giá trị các field khai báo ở
 * `opts.readFields` (đọc ngay trước khi Lưu), rỗng nếu không khai báo.
 *
 * 🚨 Sau khi lưu, `page.url()` bị thay bằng URL của TÀI LIỆU vừa tạo — hàm này tự mở lại
 * `recordUrl` ở cuối để trả trạng thái về BHS.
 *
 * Xem KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md.
 */
export async function createTaiLieu(
  page: Page,
  _pw: PW,
  recordUrl: string,
  tenTaiLieu: string,
  opts: CreateTaiLieuOptions = {},
): Promise<Record<string, string>> {
  await openBoHoSo(page, recordUrl);
  await openTabCauTrucHoSo(page);

  if (opts.moTuMenuDongThuMuc) {
    if (!opts.folderName) {
      throw new Error(
        "Lỗi dùng hàm: `moTuMenuDongThuMuc` cần kèm `folderName` (tên thư mục để mở menu dòng)",
      );
    }
    await openRowActionMenu(page, opts.folderName, "mni-them-tai-lieu");
  } else {
    await openTaoMoiDropdown(page);
    await getVisibleDropdown(page).getByTestId("mni-them-tai-lieu").click();
  }

  const modal = getRecordModal(page);
  await expect(modal.getByTestId("txt-ten-tai-lieu"), {
    message: 'Lỗi: không mở được modal "Tạo mới tài liệu"',
  }).toBeVisible({ timeout: TIMEOUT.PAGE_LOADING });
  await page.waitForTimeout(METADATA_WAIT);

  // ⚠️ scope theo modal: nhiều testId trùng với panel lọc / form BHS phía sau
  await modal.getByTestId("txt-ten-tai-lieu").fill(tenTaiLieu);
  await pickFirstOption(page, modal.getByTestId("sel-loai-tai-lieu"));

  if (opts.folderName && !opts.moTuMenuDongThuMuc) {
    const folderSelect = modal.getByTestId("sel-folder-storage");
    await folderSelect.click();
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
    // Option hiển thị có prefix mã ("A. Tên thư mục") → khớp contains
    await page
      .locator(`.ant-select-tree-title:has-text("${opts.folderName}")`)
      .first()
      .click();
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }

  if (opts.quyenRieng) {
    await modal.locator("input#basic_hasUniquePermission").check();
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }

  for (const perm of opts.perm ?? []) {
    await fillPeoplePicker(
      page,
      modal.getByTestId(perm.permTestId),
      perm.account,
      perm.clearInherited ?? false,
    );
  }

  // Đọc lại giá trị field trước khi Lưu (vd option "Loại tài liệu" hàm tự chọn)
  const values: Record<string, string> = {};
  for (const testId of opts.readFields ?? []) {
    values[testId] = await docGiaTriTruongTaiLieu(modal, testId);
  }

  await modal.getByTestId("btn-save").click();
  await expectToastThanhCong(page, `Tạo tài liệu ${tenTaiLieu}`);
  // Chờ app ghi xong + refresh bảng trước khi điều hướng
  await page.waitForTimeout(SAVE_SETTLE_WAIT);

  // URL giờ trỏ sang tài liệu vừa tạo → trả về BHS
  await openBoHoSo(page, recordUrl);

  return values;
}

/**
 * Đọc **giá trị đang hiển thị** của 1 field trong modal Tạo mới / chi tiết Tài liệu (hoặc BHS),
 * theo testId. Trả `""` khi field không có trong DOM hoặc đang rỗng.
 *
 * Hàm tự nhận dạng cách đọc vì cùng 1 testId có thể render khác nhau ở modal tạo mới và ở
 * màn chi tiết sau khi lưu (một số field bị khoá thành text — xem KHO-TAI-LIEU.md mục 4.5):
 *
 * 1. Select (`sel-*`, `tree-sel-*`) → text của `.ant-select-selection-item`
 * 2. `input` / `textarea` (testId gắn ngay trên control, hoặc trên wrapper) → `inputValue()`
 * 3. Còn lại (field đã bị khoá, render thành text) → `innerText()`
 *
 * @param scope modal cần đọc — luôn scope theo modal vì nhiều testId của màn này bị trùng
 *              (`KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md` mục 2)
 */
export async function docGiaTriTruongTaiLieu(
  scope: Locator,
  testId: string,
): Promise<string> {
  const el = scope.getByTestId(testId).first();
  if ((await el.count()) === 0) return "";

  const selectionItem = el.locator(".ant-select-selection-item").first();
  if ((await selectionItem.count()) > 0) {
    return (await selectionItem.innerText()).trim();
  }

  const tagName = (await el.evaluate((e) => e.tagName)).toLowerCase();
  if (tagName === "input" || tagName === "textarea") {
    return (await el.inputValue()).trim();
  }

  const control = el.locator("input, textarea").first();
  if ((await control.count()) > 0) return (await control.inputValue()).trim();

  return (await el.innerText()).trim();
}

/**
 * **Tình trạng** của BHS / Tài liệu đang mở, đọc từ `lbl-modal-title`.
 *
 * Sau khi lưu, tiêu đề modal có dạng `"<Mã hồ sơ|Mã tài liệu>\n<Tình trạng>"` (vd
 * `"ATT-001/TL001\nKhai báo"` — KHO-TAI-LIEU.md mục 3d, MODAL-TAO-TAI-LIEU.md mục 6) → hàm lấy
 * **dòng cuối** nên không phụ thuộc độ dài mã.
 */
export async function layTinhTrangTuTieuDe(page: Page): Promise<string> {
  const title = getRecordModal(page).getByTestId("lbl-modal-title");
  await expect(title, {
    message: "Lỗi: không thấy tiêu đề modal để đọc Tình trạng",
  }).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });

  const dong = (await title.innerText())
    .split("\n")
    .map((d) => d.trim())
    .filter(Boolean);
  return dong[dong.length - 1] ?? "";
}

/* -------------------------------------------------------------------------- */
/* 5. Modal "Phân quyền nâng cao"                                              */
/* -------------------------------------------------------------------------- */

/**
 * Mở modal "Phân quyền nâng cao" từ menu `btn-more` của BHS (hoặc của Tài liệu đang mở).
 * Truyền `recordUrl` để tự mở lại phiếu trước.
 *
 * Xem KHO-TAI-LIEU.MODAL-PHAN-QUYEN-NANG-CAO.md.
 */
export async function openPhanQuyenNangCao(page: Page, recordUrl?: string) {
  if (recordUrl) await openBoHoSo(page, recordUrl);

  await openMoreMenu(page, "record");
  await getVisibleDropdown(page).getByTestId("btn-kiem-tra-phan-quyen").click();

  await expect(getRecordModal(page).getByTestId("lbl-modal-title"), {
    message: 'Lỗi: không mở được modal "Phân quyền nâng cao"',
  }).toHaveText("Phân quyền nâng cao", { timeout: TIMEOUT.PAGE_LOADING });
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/** Chuyển tab trong modal Phân quyền nâng cao */
export async function switchTabPhanQuyen(
  page: Page,
  tab: "permission" | "user",
) {
  await getRecordModal(page)
    .getByTestId(`tab-phan-quyen-nang-cao-${tab}`)
    .click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/**
 * Điền people-picker "Người dùng/Nhóm" của tab "Theo người dùng".
 * Picker này chưa có testId — bám class `cssPeoplePickerPermissionTabModal`.
 */
export async function fillUserPickerPhanQuyen(
  page: Page,
  account: string,
  clearFirst = true,
) {
  const picker = getRecordModal(page).locator(
    ".cssPeoplePickerPermissionTabModal.people-picker",
  );
  await fillPeoplePicker(page, picker, account, clearFirst);
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/** Search trong modal Phân quyền nâng cao (mỗi tab có ô search riêng) */
export async function searchTrongPhanQuyen(
  page: Page,
  keyword: string,
  tab: "permission" | "user" = "permission",
) {
  const testId =
    tab === "permission"
      ? "input-search-tab-theo-phan-quyen"
      : "input-search-tab-theo-nguoi-dung";
  const input = getRecordModal(page).getByTestId(testId);
  await input.fill(keyword);
  await input.press("Enter");
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/**
 * Tick 1 option của dropdown lọc trong tab "Theo người dùng" rồi đóng dropdown.
 * Gọi lại lần nữa với cùng option → bỏ tick.
 *
 * @param label   "Quyền" | "Phân loại"
 * @param option  "VIEW"|"EDIT"|"ADD"|"DOWNLOAD"|"OWNER" hoặc "Kế thừa"|"Quyền riêng tư"
 */
export async function chonBoLocPhanQuyen(
  page: Page,
  label: "Quyền" | "Phân loại",
  option: string,
) {
  // ⚠️ 2 dropdown dùng chung class → bắt buộc filter theo text
  await getRecordModal(page)
    .locator("div.cssDropdownSelectField")
    .filter({ hasText: label })
    .first()
    .click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  await page
    .locator("label.ant-checkbox-wrapper:visible")
    .filter({ hasText: new RegExp(`^${option}$`) })
    .first()
    .click();
  await page.keyboard.press("Escape");
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/**
 * Expand 1 thư mục trong bảng Phân quyền nâng cao.
 *
 * ⚠️ Khác bảng "Cấu trúc hồ sơ" (expand sẵn), bảng này là CÂY — item con ẩn tới khi expand cha.
 * Trigger là `span` thứ 2 trong `td` đầu (span[0] = indent, span[1] = chevron).
 */
export async function expandItemPhanQuyen(page: Page, code: string) {
  await getRecordModal(page)
    .getByTestId(`tbl-row-${code}`)
    .locator("td")
    .first()
    .locator("span")
    .nth(1)
    .click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);
}

/**
 * **Đảm bảo** 1 thư mục trong bảng Phân quyền nâng cao đang được expand (idempotent).
 *
 * 🚨 Dùng hàm này thay `expandItemPhanQuyen` mỗi khi sắp đọc/thao tác dòng con:
 * `expandItemPhanQuyen` là **toggle** — gọi lúc đang mở sẽ thu gọn lại.
 *
 * Vì sao cần: mở rồi đóng modal con (`ngatKeThua`, `khoiPhucKeThua`,
 * `themQuyenChoNguoiDung`…) làm bảng **re-mount toàn bộ dòng** → trạng thái expand bị reset,
 * dòng con biến mất khỏi DOM. (Kiểm chứng 2026-07-29: gắn attribute vào 1 dòng, mở + đóng
 * modal con → attribute mất.)
 */
export async function moItemPhanQuyen(page: Page, code: string) {
  if (!code) return; // dòng BHS gốc luôn hiển thị, không có chevron
  const dongCon = getRecordModal(page).locator(
    `[data-testid^="tbl-row-${code}."]`,
  );
  if ((await dongCon.count()) > 0) return; // đã mở sẵn

  await expandItemPhanQuyen(page, code);
  await expect(dongCon.first(), {
    message: `Lỗi: expand item "${code}" nhưng không thấy dòng con nào`,
  }).toBeAttached({ timeout: TIMEOUT.DATA_LOADING });
}

/** Mở modal con "Phân quyền cấu trúc bộ hồ sơ" bằng cách click tên item */
export async function openModalPhanQuyenItem(page: Page, code: string) {
  await getRecordModal(page).getByTestId(`cell-title-row-${code}`).click();
  await expect(getRecordModal(page).getByTestId("lbl-modal-title"), {
    message: `Lỗi: không mở được modal con cho item "${code}"`,
  }).toHaveText("Phân quyền cấu trúc bộ hồ sơ", {
    timeout: TIMEOUT.ACTION_LOADING,
  });
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/** Xác nhận 1 `.ant-modal-confirm` đang mở */
async function confirmDialog(
  page: Page,
  tenNut: "Xác nhận" | "Chuyển hoạt động",
) {
  await page
    .locator(".ant-modal-confirm-btns")
    .getByRole("button", { name: tenNut })
    .click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Ngắt kế thừa quyền của 1 item (thư mục / tài liệu) qua modal Phân quyền nâng cao.
 * Modal Phân quyền nâng cao phải đang mở.
 *
 * ⚠️ Thao tác này KHÔNG có toast. Cột "Kế thừa" ở bảng cha chỉ cập nhật SAU KHI đóng modal con —
 * hàm đã tự đóng nên gọi xong có thể assert ngay bằng `checkKeThua(page, code, "Quyền riêng tư")`.
 */
export async function ngatKeThua(page: Page, code: string) {
  await openModalPhanQuyenItem(page, code);
  const modalCon = getRecordModal(page);
  await modalCon.getByRole("button", { name: "Ngắt kế thừa" }).click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await confirmDialog(page, "Xác nhận");
  await expect(
    getRecordModal(page).getByRole("button", { name: "Kế thừa quyền" }),
    { message: `Lỗi: item "${code}" không chuyển sang trạng thái quyền riêng` },
  ).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  await getRecordModal(page).getByTestId("btn-close-modal").click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/** Khôi phục kế thừa quyền của 1 item (ngược lại `ngatKeThua`) */
export async function khoiPhucKeThua(page: Page, code: string) {
  await openModalPhanQuyenItem(page, code);
  const modalCon = getRecordModal(page);
  await modalCon.getByRole("button", { name: "Kế thừa quyền" }).click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await confirmDialog(page, "Xác nhận");
  await expect(
    getRecordModal(page).getByRole("button", { name: "Ngắt kế thừa" }),
    { message: `Lỗi: item "${code}" không quay về trạng thái kế thừa` },
  ).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  await getRecordModal(page).getByTestId("btn-close-modal").click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Cấp quyền TRỰC TIẾP cho 1 người trên 1 item, qua modal con → nút "Thêm".
 * Modal Phân quyền nâng cao phải đang mở.
 *
 * Đây là cách duy nhất sửa quyền của BHS sau khi đã tạo (khối "Phân quyền truy cập" trên màn
 * chi tiết bị app ẩn — xem KHO-TAI-LIEU.md mục 4.6).
 * Quyền có hiệu lực NGAY (đã kiểm chứng), không phải gửi yêu cầu chờ duyệt.
 */
export async function themQuyenChoNguoiDung(
  page: Page,
  code: string,
  account: string,
  quyen: (typeof VALID_PERM_VALUES)[number],
  noiDung = "Cấp quyền tự động",
) {
  await openModalPhanQuyenItem(page, code);
  await getRecordModal(page).getByRole("button", { name: "Thêm" }).click();
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  const modalThem = getRecordModal(page);
  // Field "Quyền" và "Người nhận" chưa có testId → bám placeholder / class
  await modalThem
    .locator(".ant-select")
    .filter({ hasText: "Chọn quyền" })
    .first()
    .click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await page
    .locator(".ant-select-dropdown:visible")
    .last()
    .locator(".ant-select-item-option-content")
    .filter({ hasText: new RegExp(`^${quyen}$`) })
    .first()
    .click();
  await page.waitForTimeout(1000);

  await fillPeoplePicker(
    page,
    modalThem.locator(".people-picker").first(),
    account,
  );
  await modalThem
    .locator('textarea[placeholder="Nhập nội dung"]')
    .fill(noiDung);
  await modalThem.getByRole("button", { name: "Gửi" }).click();
  await expectToastThanhCong(page, `Cấp quyền ${quyen} cho ${account}`);

  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await getRecordModal(page).getByTestId("btn-close-modal").click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * Assert user KHÔNG mở được BHS: toast "Bạn không có quyền xem!" và không có modal nào.
 *
 * ⚠️ Toast chỉ tồn tại ~5 giây kể từ lúc điều hướng → gọi hàm này NGAY sau `page.goto(recordUrl)`,
 * không `waitForTimeout` dài ở giữa.
 * ⚠️ Nhớ: vai không phải Owner chỉ mở được BHS ở trạng thái "Hoạt động"
 * (KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md mục 1).
 */
export async function expectKhongCoQuyenXem(page: Page, label = "") {
  // Lọc theo nội dung: trên màn có thể còn toast khác ("Đang xử lý"…) → tránh strict-mode violation
  await expect(getToast(page, "Bạn không có quyền xem!"), {
    message: `Lỗi: không thấy toast "Bạn không có quyền xem!"${label ? ` [${label}]` : ""}`,
  }).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  await expect(page.locator(".ant-modal-content:visible"), {
    message: `Lỗi: modal chi tiết vẫn mở dù user không có quyền${label ? ` [${label}]` : ""}`,
  }).toHaveCount(0);
}

/**
 * Mở BHS bằng URL nhưng KHÔNG assert modal mở được — dùng cho case kiểm tra vai không có quyền.
 * Sau khi gọi, dùng `expectKhongCoQuyenXem(page)` để assert.
 */
export async function tryOpenBoHoSo(page: Page, recordUrl: string) {
  await openDanhSachKhoTaiLieu(page);
  await page.goto(recordUrl);
}

/**
 * Assert nội dung 1 ô trong bảng Phân quyền nâng cao.
 *
 * ⚠️ `code` là MÃ PREFIX của item (`ITEM_PREFIX.BHS` = "", `"A"`, `"B"`…), không phải tên.
 * Dùng match testId chính xác vì `cell-title-row-` là tiền tố của `cell-title-row-A`.
 */
export async function checkOPhanQuyen(
  page: Page,
  code: string,
  cot: string,
  expected: string,
  label = "",
) {
  await expect(getRecordModal(page).getByTestId(`cell-${cot}-row-${code}`), {
    message: `Lỗi: ô [${cot}] của item "${code}" không chứa "${expected}"${label ? ` [${label}]` : ""}`,
  }).toContainText(expected, { timeout: TIMEOUT.DATA_LOADING });
}

/**
 * Lấy **mã prefix** (`code`) của 1 item trong bảng Phân quyền nâng cao theo tên hiển thị.
 *
 * Dùng thay vì hard-code `"A"` / `"A.1"`: mã do hệ thống sinh theo thứ tự item nên không
 * chắc chắn khi test tạo nhiều item.
 *
 * ⚠️ Bảng là CÂY — dòng của item con **chưa có trong DOM** tới khi expand cha
 * (gọi `expandItemPhanQuyen` trước).
 */
export async function getCodeCuaItemPhanQuyen(
  page: Page,
  tenItem: string,
): Promise<string> {
  const row = getRecordModal(page)
    .locator('[data-testid^="tbl-row-"]')
    .filter({ hasText: tenItem })
    .first();
  await expect(row, {
    message: `Lỗi: không thấy dòng "${tenItem}" trong bảng Phân quyền nâng cao (item con thì phải expand cha trước)`,
  }).toBeAttached({ timeout: TIMEOUT.DATA_LOADING });

  const testId = (await row.getAttribute("data-testid")) ?? "";
  return testId.replace(/^tbl-row-/, "");
}

/**
 * Assert 1 người dùng / nhóm có mặt trong **1 ô quyền** của bảng Phân quyền nâng cao.
 *
 * 🚨 Ô quyền chỉ hiển thị được vài avatar đầu, số còn lại gom vào nút **`+n`** — tên của những
 * người bị gom KHÔNG có trong DOM tới khi bấm nút đó. Vì vậy đừng dùng `checkOPhanQuyen` trần
 * cho tên người: hàm này tự bấm `+n` rồi mới assert.
 *
 * ⚠️ Ô quyền hiển thị **tên hiển thị**, không phải account id — dùng hằng `TEN_HIEN_THI`.
 */
export async function checkNguoiTrongOQuyen(
  page: Page,
  code: string,
  cot: string,
  tenHienThi: string,
  label = "",
) {
  const nhan = `${label ? ` [${label}]` : ""}`;
  const cell = getRecordModal(page).getByTestId(`cell-${cot}-row-${code}`);
  await expect(cell, {
    message: `Lỗi: không thấy ô [${cot}] của item "${code}"${nhan}`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  // Đang hiện sẵn thì xong luôn, không cần mở rộng.
  // Dùng innerText (chỉ text đang render) — người bị gom KHÔNG có trong DOM nên không pass giả.
  if (((await cell.innerText()) || "").includes(tenHienThi)) return;

  // Chưa thấy tên → người này (nếu có quyền) đang bị gom vào nút "+ n người khác"
  const nutMoRong = getNutMoRongOQuyen(cell);
  await expect(nutMoRong, {
    message: `Lỗi: ô [${cot}] của item "${code}" không có "${tenHienThi}", cũng không có nút "+ n người khác" để mở rộng${nhan}`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await nutMoRong.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  // App mở rộng NGAY TRONG Ô (không popover) → assert lại chính ô đó
  await expect(cell, {
    message: `Lỗi: mở rộng ô [${cot}] của item "${code}" nhưng vẫn không thấy "${tenHienThi}"${nhan}`,
  }).toContainText(tenHienThi, { timeout: TIMEOUT.DATA_LOADING });
}

/**
 * Đọc **danh sách tên** trong 1 ô quyền của bảng Phân quyền nâng cao.
 *
 * Tự bấm nút `"+ n người khác"` (nếu có) để app render nốt những người bị gom, rồi đọc từng
 * `[data-testid="avatar-container"]`. Trả về mảng tên hiển thị đã trim, rỗng nếu không ai có quyền.
 */
export async function getNguoiTrongOQuyen(
  page: Page,
  code: string,
  cot: string,
): Promise<string[]> {
  const cell = getRecordModal(page).getByTestId(`cell-${cot}-row-${code}`);
  await expect(cell, {
    message: `Lỗi: không thấy ô [${cot}] của item "${code}"`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  const nutMoRong = getNutMoRongOQuyen(cell);
  if (await nutMoRong.isVisible().catch(() => false)) {
    await nutMoRong.click();
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }

  const ten = await cell
    .locator('[data-testid="avatar-container"]')
    .allInnerTexts();
  return ten.map((t) => t.trim()).filter(Boolean);
}

/** Quyền của 1 item, đọc từ bảng Phân quyền nâng cao: mỗi cột → danh sách tên (đã sort) */
export type Quyen5Cot = Record<string, string[]>;

/**
 * Đọc quyền của 1 item ở **cả 5 cột** → dùng để so sánh 2 item, hoặc snapshot trước/sau
 * một thao tác (vd kiểm tra item con "giữ nguyên phân quyền").
 */
export async function getQuyen5Cot(
  page: Page,
  code: string,
): Promise<Quyen5Cot> {
  const ketQua: Quyen5Cot = {};
  for (const cot of PQ_COT_QUYEN) {
    // sort vì thứ tự avatar trong ô không đảm bảo
    ketQua[cot] = (await getNguoiTrongOQuyen(page, code, cot)).sort();
  }
  return ketQua;
}

/** In gọn 1 `Quyen5Cot` để đưa vào message lỗi */
function moTaQuyen(quyen: Quyen5Cot): string {
  return PQ_COT_QUYEN.map(
    (cot) => `${cot}=[${(quyen[cot] ?? []).join(", ")}]`,
  ).join(" ");
}

/**
 * Assert quyền của 1 item **giống hệt** quyền của item cha ở cả 5 cột — dùng cho case kiểm tra
 * item con đang kế thừa có được áp đúng quyền của thư mục cha hay không.
 */
export async function checkQuyenGiongItemCha(
  page: Page,
  codeCha: string,
  codeCon: string,
  tenCha = "",
  tenCon = "",
) {
  const cha = await getQuyen5Cot(page, codeCha);
  const con = await getQuyen5Cot(page, codeCon);
  expect(con, {
    message:
      `Lỗi: quyền của item con "${tenCon || codeCon}" không giống item cha ` +
      `"${tenCha || codeCha}"\n  cha: ${moTaQuyen(cha)}\n  con: ${moTaQuyen(con)}`,
  }).toEqual(cha);
}

/**
 * Assert quyền của 1 item **không đổi** so với snapshot đọc trước đó (`getQuyen5Cot`) —
 * dùng cho case item con có quyền riêng tư thì không bị quyền của thư mục cha ghi đè.
 */
export async function checkQuyen5CotNhuCu(
  page: Page,
  code: string,
  truoc: Quyen5Cot,
  label = "",
) {
  const sau = await getQuyen5Cot(page, code);
  expect(sau, {
    message:
      `Lỗi: quyền của item "${label || code}" đã bị thay đổi\n` +
      `  trước: ${moTaQuyen(truoc)}\n  sau:   ${moTaQuyen(sau)}`,
  }).toEqual(truoc);
}

/**
 * Nút mở rộng của 1 ô quyền — text dạng `"+ 1 người khác"`.
 *
 * ❌ Chưa có `data-testid` (xem KHO-TAI-LIEU.TODO-DEV.md). DOM thật (2026-07-28):
 * `div[class*="_expand__others"]`, class là CSS-module có hash (`_expand__others_k9vgr_348`)
 * nên chỉ match theo **tiền tố class**, kèm fallback theo text để hash đổi vẫn chạy.
 */
function getNutMoRongOQuyen(cell: Locator): Locator {
  return cell
    .locator('[class*="_expand__others"]')
    .or(cell.getByText(/\+\s*\d+\s*người khác/))
    .first();
}

/**
 * Assert 1 người dùng / nhóm có mặt ở **cả 5 cột quyền** (Owner, Tạo mới, Cập nhật,
 * Tải file, Xem) của 1 item trong bảng Phân quyền nâng cao.
 * Tự bấm nút `+n` khi ô bị gom bớt avatar (xem `checkNguoiTrongOQuyen`).
 */
export async function checkNguoiCoQuyenO5Cot(
  page: Page,
  code: string,
  tenHienThi: string,
  label = "",
) {
  for (const cot of PQ_COT_QUYEN) {
    await checkNguoiTrongOQuyen(page, code, cot, tenHienThi, label);
  }
}

/** Assert cột "Kế thừa": `"Kế thừa"` hoặc `"Quyền riêng tư"` */
export async function checkKeThua(
  page: Page,
  code: string,
  expected: "Kế thừa" | "Quyền riêng tư",
  label = "",
) {
  await expect(
    getRecordModal(page).getByTestId(`cell-hasUniquePermission-row-${code}`),
    {
      message: `Lỗi: cột Kế thừa của item "${code}" khác "${expected}"${label ? ` [${label}]` : ""}`,
    },
  ).toHaveText(expected, { timeout: TIMEOUT.DATA_LOADING });
}

/**
 * Assert cột "Quyền" ở tab "Theo người dùng": chứa ĐỦ các quyền mong đợi và
 * không chứa token nào ngoài `VALID_PERM_VALUES`.
 */
export async function checkQuyenTheoNguoiDung(
  page: Page,
  code: string,
  expectedPerms: string[],
  label = "",
) {
  const cell = getRecordModal(page).getByTestId(`cell-permMark-row-${code}`);
  await expect(cell, {
    message: `Lỗi: không thấy ô Quyền của item "${code}"${label ? ` [${label}]` : ""}`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  const text = ((await cell.innerText()) || "").trim();
  const tokens = text.split(/\s+/).filter(Boolean);

  for (const perm of expectedPerms) {
    expect(tokens, {
      message: `Lỗi: item "${code}" thiếu quyền ${perm} (đang là "${text}")${label ? ` [${label}]` : ""}`,
    }).toContain(perm);
  }
  for (const token of tokens) {
    expect(VALID_PERM_VALUES as readonly string[], {
      message: `Lỗi: item "${code}" có giá trị quyền lạ "${token}"${label ? ` [${label}]` : ""}`,
    }).toContain(token);
  }
}

/* -------------------------------------------------------------------------- */
/* 6. Màn chi tiết Tài liệu — tab "Tài liệu số" (upload file)                   */
/*    Xem KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md                                      */
/* -------------------------------------------------------------------------- */

/** Kho file mẫu dùng để upload — `src/sample-files/` */
export const SAMPLE_FILES_DIR = path.resolve(__dirname, "../sample-files");

/**
 * File mẫu có sẵn trong `src/sample-files/`.
 *
 * 🚨 `accept` của uploader **không có `.doc` và `.xls`** (khảo sát 2026-08-04, xem
 * KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md mục 4) → chọn 2 file đó thì app **im lặng bỏ qua**,
 * không toast, không dòng mới. Chỉ dùng `PDF` / `DOCX` / `XLSX`.
 */
export const SAMPLE_FILE = {
  PDF: "file-sample.pdf",
  DOCX: "file-sample.docx",
  XLSX: "file_example_XLSX.xlsx",
  /** ⚠️ ngoài `accept` — app bỏ qua, chỉ dùng cho case kiểm tra định dạng không hợp lệ */
  DOC_KHONG_HO_TRO: "file-sample.doc",
  /** ⚠️ ngoài `accept` — như trên */
  XLS_KHONG_HO_TRO: "file_example_XLS.xls",
} as const;

/** Đường dẫn tuyệt đối tới 1 file mẫu (dùng hằng `SAMPLE_FILE`) */
export function sampleFile(ten: string): string {
  return path.join(SAMPLE_FILES_DIR, ten);
}

/**
 * Lựa chọn khi app hỏi *"Danh sách tài liệu trùng. Có tiếp tục upload các file dưới không?"* —
 * giá trị là **testId của nút** (khảo sát DOM 2026-08-05).
 *
 * | Hằng              | testId                    | Nhãn                      |
 * | ----------------- | ------------------------- | ------------------------- |
 * | `TOAN_BO`         | `btn-upload-tat-ca`       | Toàn bộ (primary)         |
 * | `CHI_KHONG_TRUNG` | `btn-upload-khong-trung`  | Chỉ tài liệu không trùng  |
 * | `KHONG`           | `btn-huy-upload`          | Không                     |
 */
export const LUA_CHON_TRUNG = {
  TOAN_BO: "btn-upload-tat-ca",
  CHI_KHONG_TRUNG: "btn-upload-khong-trung",
  KHONG: "btn-huy-upload",
} as const;
export type LuaChonTrung = (typeof LUA_CHON_TRUNG)[keyof typeof LUA_CHON_TRUNG];

/** Nhãn hiển thị của 3 nút dialog trùng — dùng cho message assert */
export const LUA_CHON_TRUNG_LABELS: Record<string, string> = {
  [LUA_CHON_TRUNG.TOAN_BO]: "Toàn bộ",
  [LUA_CHON_TRUNG.CHI_KHONG_TRUNG]: "Chỉ tài liệu không trùng",
  [LUA_CHON_TRUNG.KHONG]: "Không",
};

/**
 * Modal **chi tiết Tài liệu** = modal đang hiển thị **có tab `tab-digitizedATM`**.
 *
 * 🚨 Không dùng `getRecordModal` ở màn này: app chồng tới 3 lớp modal
 * (BHS → chi tiết TL → **modal xem trước file** không có `lbl-modal-title`), nên
 * `.ant-modal-content:visible.last()` trỏ vào lớp xem trước.
 * (Kiểm chứng 2026-08-04 — xem KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md mục 1.)
 */
export function getModalTaiLieu(page: Page): Locator {
  return page
    .locator(".ant-modal-content:visible")
    .filter({ has: page.getByTestId("tab-digitizedATM") })
    .last();
}

/** Tab-pane đang mở của modal chi tiết Tài liệu (modal giữ cả 3 pane trong DOM) */
export function getPaneTaiLieu(page: Page): Locator {
  return getModalTaiLieu(page).locator(
    'div[role="tabpanel"][aria-hidden="false"]',
  );
}

/**
 * Bảo đảm tab **"Tài liệu số"** của màn chi tiết Tài liệu đang mở (idempotent).
 *
 * Vì sao cần: tab mặc định **phụ thuộc trạng thái tài liệu** — tài liệu **vừa tạo** mở ở
 * `tab-general` ("Thông tin"), tài liệu **mở lại mà đã có file** mở sẵn ở `tab-digitizedATM`
 * (đo 2026-08-04). Upload khi đang ở tab "Thông tin" **không có tác dụng**.
 *
 * 🚨 3 tab là `input[type=radio]` **ẩn** → phải click `label` bọc ngoài, và dùng `force`
 * vì lớp modal xem trước file có thể phủ lên (click thường bị timeout "element is not visible").
 */
export async function openTabTaiLieuSo(page: Page) {
  const modal = getModalTaiLieu(page);
  await expect(modal.getByTestId("tab-digitizedATM"), {
    message:
      'Lỗi: không thấy tab "Tài liệu số" — có phải đang ở màn chi tiết Tài liệu?',
  }).toBeAttached({ timeout: TIMEOUT.ACTION_LOADING });

  const label = modal
    .getByTestId("tab-digitizedATM")
    .locator("xpath=ancestor::label[1]");
  if (!((await label.getAttribute("class")) ?? "").includes("checked")) {
    await label.click({ force: true, timeout: TIMEOUT.CONTROL_LOADING });
  }
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  await expect(
    getPaneTaiLieu(page).getByTestId("sec-upload-tai-lieu-dinh-kem"),
    {
      message: 'Lỗi: tab "Tài liệu số" không mở được (không thấy khối upload)',
    },
  ).toBeAttached({ timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Bảo đảm tab **"Thông tin"** (`tab-general`) của màn chi tiết Tài liệu đang mở (idempotent) —
 * đây là tab chứa form thuộc tính tài liệu (Tên tài liệu, Loại tài liệu…), tức nơi **sửa** dữ liệu
 * của tài liệu đã lưu.
 *
 * Vì sao cần: tab mặc định **phụ thuộc trạng thái tài liệu** (KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md mục 2)
 * — tài liệu vừa tạo mở ở `tab-general`, tài liệu mở lại mà **đã có file** lại mở ở
 * `tab-digitizedATM` → không gọi hàm này thì thao tác sửa field có thể chạy trên tab sai.
 *
 * 🚨 Cùng cạm bẫy như `openTabTaiLieuSo`: 3 tab là `input[type=radio]` **ẩn** → phải click `label`
 * bọc ngoài, kèm `force` vì modal xem trước file có thể phủ lên.
 *
 * ⚠️ Assert cuối chỉ kiểm `txt-ten-tai-lieu` **có trong DOM của modal chi tiết TL** — cách app render
 * field này sau khi lưu (input sửa được hay text đã khoá như một số field của BHS,
 * KHO-TAI-LIEU.md mục 4.5) **chưa được khảo sát**.
 */
export async function openTabThongTinTaiLieu(page: Page) {
  const modal = getModalTaiLieu(page);
  await expect(modal.getByTestId("tab-general"), {
    message:
      'Lỗi: không thấy tab "Thông tin" — có phải đang ở màn chi tiết Tài liệu?',
  }).toBeAttached({ timeout: TIMEOUT.ACTION_LOADING });

  const label = modal
    .getByTestId("tab-general")
    .locator("xpath=ancestor::label[1]");
  if (!((await label.getAttribute("class")) ?? "").includes("checked")) {
    await label.click({ force: true, timeout: TIMEOUT.CONTROL_LOADING });
  }
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  await expect(modal.getByTestId("txt-ten-tai-lieu"), {
    message:
      'Lỗi: tab "Thông tin" của tài liệu không mở được (không thấy trường "Tên tài liệu")',
  }).toBeAttached({ timeout: TIMEOUT.CONTROL_LOADING });
}

/**
 * Dialog **"Danh sách tài liệu trùng. Có tiếp tục upload các file dưới không?"** — app bật lên khi
 * file sắp upload trùng với file mềm đã có trong hệ thống.
 *
 * Khảo sát DOM 2026-08-05: là `.ant-modal-content` **thường** (KHÔNG phải `.ant-modal-confirm`),
 * bên trong có bảng **`tbl-file-trung`** (cột `Chỉ mục` | `Mã tài liệu` | `Tên tài liệu chứa file
 * mềm` | `Tên file mềm`) và 3 nút `btn-upload-tat-ca` / `btn-upload-khong-trung` / `btn-huy-upload`.
 * → lọc theo `tbl-file-trung` cho chắc (không bám chuỗi tiêu đề).
 */
export function getDialogTaiLieuTrung(page: Page): Locator {
  return page
    .locator(".ant-modal-content:visible")
    .filter({ has: page.getByTestId("tbl-file-trung") })
    .last();
}

/**
 * Upload file vào tab **"Tài liệu số"** của tài liệu đang mở, và trả về danh sách **tên tệp**
 * đã upload.
 *
 * Bên trong: bảo đảm đang ở tab "Tài liệu số" → `setInputFiles` vào `input[type=file]` của
 * **pane đang active** → xử lý dialog "Danh sách tài liệu trùng" (nếu có) → chờ toast
 * `"Tải tài liệu lên thành công."` → assert từng tệp đã có dòng trong bảng.
 *
 * 🚨 Các cạm bẫy đã kiểm chứng (KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md mục 4–6):
 * - `file-upload-tai-lieu` là **div nút "Tải lên"**, click nó **không mở `filechooser`**
 *   (đã thử, timeout) → chỉ upload được bằng `setInputFiles`.
 * - Modal chi tiết TL có **2** `input[type=file]` (1 ở pane ẩn) → phải scope theo pane active,
 *   không dùng `.first()` theo modal/trang. Input `accept=null` ở modal BHS bên dưới cũng dễ bắt nhầm.
 * - File **trùng** (đã có trong hệ thống) → app **dừng** ở bước `check-duplicate` và chờ lựa chọn
 *   ở dialog `tbl-file-trung`; không chọn thì **không có toast, không có dòng mới**.
 *   `opts.luaChonTrung` mặc định `TOAN_BO` để vẫn upload.
 * - Chọn `TOAN_BO` với file **trùng tên**: app upload thật (log có `azure-put` + `save-metadata`),
 *   toast hiện sau ~4 s, nhưng **số dòng trong bảng KHÔNG tăng** — file cũ bị **thay thế**.
 *   Vì vậy hàm chỉ assert "có dòng mang tên tệp đó", **không** assert số dòng tăng.
 * - Định dạng ngoài `accept` (`.doc`, `.xls`) → app **im lặng bỏ qua** (không toast, không lỗi).
 * - 🚨 **Không set lại ĐÚNG cùng 1 file vào cùng 1 input hai lần trong 1 phiên**: lần thứ hai
 *   `setInputFiles` không bắn `change` nên app không chạy gì (đã gặp: dialog không hiện, không
 *   toast). Cần upload lại đúng file đó thì **mở lại phiếu** (`openChiTietTaiLieu`) trước.
 *
 * @param duongDan 1 file hoặc mảng file (uploader có `multiple`, đã kiểm chứng 2 file/lượt).
 *   Dùng `sampleFile(SAMPLE_FILE.PDF)` cho file mẫu trong `src/sample-files/`.
 * @param opts.luaChonTrung Nút chọn ở dialog trùng (hằng `LUA_CHON_TRUNG`) — mặc định `TOAN_BO`.
 *   `KHONG` → không upload gì, hàm **không** chờ toast và trả về `[]`.
 * @param opts.expectKhongTrung Dùng cho case **"upload lần đầu"**: dialog trùng xuất hiện là **fail**
 *   (hàm đóng dialog rồi ném lỗi kèm nội dung bảng `tbl-file-trung`), vì file lẽ ra chưa có trong
 *   hệ thống. Kèm theo phải truyền file **nội dung độc nhất** (file mẫu dùng lại sẽ bị coi là trùng).
 */
export async function uploadFileTaiLieu(
  page: Page,
  duongDan: string | string[],
  opts: { luaChonTrung?: LuaChonTrung; expectKhongTrung?: boolean } = {},
): Promise<string[]> {
  const files = Array.isArray(duongDan) ? duongDan : [duongDan];
  const tenTep = files.map((f) => path.basename(f));
  const luaChon = opts.luaChonTrung ?? LUA_CHON_TRUNG.TOAN_BO;

  await openTabTaiLieuSo(page);

  await getPaneTaiLieu(page)
    .locator('input[type="file"]')
    .first()
    .setInputFiles(files, { timeout: TIMEOUT.ACTION_LOADING });

  // Dialog "tài liệu trùng" chỉ hiện khi có file trùng → chờ ngắn rồi bỏ qua nếu không có
  const dialog = getDialogTaiLieuTrung(page);
  const coDialog = await dialog
    .waitFor({ state: "visible", timeout: TIMEOUT.CONTROL_LOADING })
    .then(() => true)
    .catch(() => false);

  if (coDialog) {
    // Case "upload lần đầu" cần chắc chắn file chưa có trong hệ thống → dialog trùng là FAIL
    if (opts.expectKhongTrung) {
      const dsTrung = await dialog
        .getByTestId("tbl-file-trung")
        .innerText()
        .then((t) => t.replace(/\s+/g, " ").slice(0, 300))
        .catch(() => "");
      await dialog
        .getByTestId(LUA_CHON_TRUNG.KHONG)
        .click()
        .catch(() => {}); // đóng dialog để không chặn bước sau
      throw new Error(
        `Lỗi: file [${tenTep.join(", ")}] đã tồn tại trong hệ thống nên app bật dialog ` +
          `"Danh sách tài liệu trùng" — đây không còn là lần upload đầu tiên. ` +
          `Bảng file trùng: ${dsTrung}`,
      );
    }

    await dialog.getByTestId(luaChon).click();
    if (luaChon === LUA_CHON_TRUNG.KHONG) {
      await expect(dialog, {
        message:
          'Lỗi: bấm "Không" ở dialog tài liệu trùng nhưng dialog không đóng',
      }).toBeHidden({ timeout: TIMEOUT.CONTROL_LOADING });
      return [];
    }
  }

  // Toast "Tải tài liệu lên thành công." CÓ thật nhưng KHÔNG đáng tin để assert: nó sống ~4 s và
  // ngay sau khi upload xong app còn tự mở modal xem trước file vừa upload → đã gặp lần upload
  // thành công (log app có `azure-put` + `save-metadata`, dòng file đã vào bảng) mà vẫn không bắt
  // được toast. Vì vậy chỉ chờ "mềm", không fail vì thiếu toast.
  await getToast(page, "Tải tài liệu lên thành công")
    .waitFor({ state: "visible", timeout: TIMEOUT.ACTION_LOADING })
    .catch(() => {});

  // Tín hiệu chắc chắn: bảng có dòng mang đúng tên tệp (bảng tự cập nhật, không cần mở lại phiếu)
  for (const ten of tenTep) {
    await expect(getDongFileTaiLieu(page, ten), {
      message: `Lỗi: upload xong nhưng không thấy dòng file "${ten}" trong bảng "Tài liệu số"`,
    }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });
  }

  return tenTep;
}

/** Dòng của 1 file trong bảng `tbl-danh-sach-file-dinh-kem`, tìm theo **tên tệp** */
export function getDongFileTaiLieu(page: Page, tenTep: string): Locator {
  return getPaneTaiLieu(page)
    .locator(".ant-table-tbody tr.ant-table-row")
    .filter({ hasText: tenTep })
    .first();
}

/** Danh sách **tên tệp** đang hiển thị trong bảng "Tài liệu số" (rỗng nếu chưa có file) */
export async function layDanhSachTenTep(page: Page): Promise<string[]> {
  const ten = await getPaneTaiLieu(page)
    .locator('[data-testid^="lbl-ten-tep-row-"]')
    .allInnerTexts();
  return ten.map((t) => t.trim()).filter(Boolean);
}

/**
 * Đọc thông tin 1 dòng file theo tên tệp: dung lượng, thời gian upload, người upload, trạng thái
 * OCR toàn văn. (Giá trị thật đo được: `139.44 KB`, `04/08/2026`, `HL Đỗ Hà Linh`, `Đã bóc tách`.)
 */
export async function docThongTinFile(
  page: Page,
  tenTep: string,
): Promise<{
  dungLuong: string;
  thoiGianUpload: string;
  nguoiUpload: string;
  ocr: string;
}> {
  const dong = getDongFileTaiLieu(page, tenTep);
  await expect(dong, {
    message: `Lỗi: không thấy dòng file "${tenTep}" trong bảng "Tài liệu số"`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  const doc = async (prefix: string) => {
    const el = dong.locator(`[data-testid^="${prefix}"]`).first();
    return (await el.count()) > 0 ? (await el.innerText()).trim() : "";
  };
  return {
    dungLuong: await doc("lbl-dung-luong-row-"),
    thoiGianUpload: await doc("lbl-ngay-upload-row-"),
    nguoiUpload: (await doc("lbl-nguoi-upload-row-")).replace(/\s+/g, " "),
    ocr: await doc("tag-ocr-status-row-"),
  };
}

/**
 * Mở menu `"..."` của 1 dòng file và trả về dropdown đang mở.
 *
 * 🚨 `btn-menu-row-<uuid>` là 1 **div phủ kín ô cuối dòng** (`absolute inset-0`) và **chỉ visible
 * khi hover dòng** → bắt buộc `row.hover()` trước, hover thẳng nút sẽ timeout
 * ("element is not visible").
 *
 * ⚠️ **Các mục trong menu chưa khảo sát được** (lần thử bị chặn ở bước upload) — tài liệu cũ ghi
 * `mni-tai-xuong`, `mni-cap-nhat-thuoc-tinh`, `mni-quan-ly-phien-ban`,
 * `mni-xem-noi-dung-boc-tach`, `mni-xoa` nhưng **chưa xác minh trên build này**.
 */
export async function openMenuDongFile(
  page: Page,
  tenTep: string,
): Promise<Locator> {
  const dong = getDongFileTaiLieu(page, tenTep);
  await expect(dong, {
    message: `Lỗi: không thấy dòng file "${tenTep}" để mở menu`,
  }).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });

  await dong.hover();
  await page.waitForTimeout(1_000);
  await dong.locator('[data-testid^="btn-menu-row-"]').first().hover();
  await page.waitForTimeout(DROPDOWN_WAIT);

  const dropdown = getVisibleDropdown(page);
  await expect(dropdown, {
    message: `Lỗi: menu "..." của file "${tenTep}" không mở`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  return dropdown;
}

/* -------------------------------------------------------------------------- */
/* 7. Nhập cấu trúc hồ sơ từ file Excel (tab Cấu trúc hồ sơ → "..." → Nhập excel) */
/* -------------------------------------------------------------------------- */

/** Kho file Excel mẫu dùng để **nhập cấu trúc hồ sơ** — `src/template-files/` */
export const TEMPLATE_FILES_DIR = path.resolve(__dirname, "../template-files");

/**
 * File Excel mẫu có sẵn trong `src/template-files/` (khác `src/sample-files/` — chỗ đó là file mềm
 * để upload vào tab "Tài liệu số").
 */
export const TEMPLATE_FILE = {
  /** Cấu trúc mẫu 3A: 9 thư mục + 11 tài liệu (dòng 4–23 của `Sheet1`) */
  CAU_TRUC_3A: "template3A.xlsx",
} as const;

/** Đường dẫn tuyệt đối tới 1 file Excel mẫu (dùng hằng `TEMPLATE_FILE`) */
export function templateFile(ten: string): string {
  return path.join(TEMPLATE_FILES_DIR, ten);
}

/**
 * Modal **"Nhập excel"** = modal đang hiển thị **không phải** modal Bộ hồ sơ.
 *
 * Modal BHS luôn có tab `lbl-tab-cauTrucHoSo`, modal nhập excel mở **chồng lên** nó → lọc bỏ modal
 * BHS rồi lấy lớp trên cùng. Cách này bền hơn `getRecordModal` khi app còn chồng thêm lớp khác.
 *
 * ⚠️ **CHƯA KHẢO SÁT BẰNG MCP** — xem JSDoc của {@link nhapCauTrucTuExcel}.
 */
export function getModalNhapExcel(page: Page): Locator {
  return page
    .locator(".ant-modal-content:visible")
    .filter({ hasNot: page.getByTestId("lbl-tab-cauTrucHoSo") })
    .last();
}

/**
 * Nhập cấu trúc hồ sơ (thư mục + tài liệu) vào 1 BHS bằng **file Excel**:
 * tab "Cấu trúc hồ sơ" → `btn-more` của bảng → `mni-nhap-tu-excel` → chọn file →
 * **"Tiếp theo"** → **"Cập nhật"** → (bấm **"Tiếp tục"** nếu app bật thêm modal xác nhận —
 * xem {@link bamTiepTucNeuCo}) → chờ toast `"Tạo mới thành công"`.
 *
 * 🚨 **TOÀN BỘ MODAL "Nhập excel" CHƯA ĐƯỢC KHẢO SÁT BẰNG MCP** (viết 2026-08-14 theo mô tả thao
 * tác của người dùng, không chạy khảo sát). Chỉ 2 mốc đầu là đã khảo sát và có testId thật:
 * `btn-more` của bảng Cấu trúc hồ sơ và mục menu `mni-nhap-tu-excel` (`KHO-TAI-LIEU.md` mục 4.4).
 * Bên trong modal, hàm **không đoán testId** mà bám các dấu hiệu chung của AntD:
 * - ô chọn file: `input[type="file"]` **bên trong modal** (uploader AntD luôn render input này;
 *   `setInputFiles` là cách upload đã kiểm chứng ở tab "Tài liệu số" — click nút không mở
 *   `filechooser`).
 * - 2 nút bấm: khớp theo **nhãn** `"Tiếp theo"` / `"Cập nhật"` (`getByRole("button")`).
 *
 * → Chạy thật mà hỏng ở bước nào thì **khảo sát lại bằng `/khao-sat-man-hinh`** rồi thay bằng
 * testId thật + bổ sung mô tả modal vào `KHO-TAI-LIEU.md`; đừng vá bằng selector đoán thêm.
 *
 * ⚠️ Không assert kết quả nhập ở đây (số thư mục/tài liệu sinh ra) — đó là **kỳ vọng của case**.
 *
 * @param opts.recordUrl Có truyền → tự `openBoHoSo` + vào tab "Cấu trúc hồ sơ" trước
 *   (mở lại BHS phải đi 2 bước — mục 4.0). Bỏ trống → dùng luôn màn đang mở ở tab đó.
 * @param opts.toastMongDoi Nội dung toast chờ sau khi bấm "Cập nhật" — mặc định
 *   `"Tạo mới thành công"`. ✅ **Đã kiểm chứng trên DOM thật** (trace lần chạy 2026-08-14 10:49):
 *   toast là `.ant-message-notice` > `.ant-message-custom-content.ant-message-success` >
 *   `span` với text **`"Tạo mới thành công!"`** (có dấu `!`) → `getToast` lọc theo **chuỗi con** nên
 *   vẫn khớp. Lần chạy trước đó không thấy toast **không phải** do sai chuỗi mà do modal
 *   **"Tiếp tục"** chặn luồng — nay đã xử lý bằng {@link bamTiepTucNeuCo}.
 */
export async function nhapCauTrucTuExcel(
  page: Page,
  duongDanFile: string,
  opts: { recordUrl?: string; toastMongDoi?: string } = {},
) {
  const toastMongDoi = opts.toastMongDoi ?? "Tạo mới thành công";

  if (opts.recordUrl) {
    await openBoHoSo(page, opts.recordUrl);
    await openTabCauTrucHoSo(page);
  }

  // --- mở modal: "..." của bảng Cấu trúc hồ sơ → "Nhập excel" ---
  await openMoreMenu(page, "cau-truc");
  await getVisibleDropdown(page).getByTestId("mni-nhap-tu-excel").click();

  const modal = getModalNhapExcel(page);
  await expect(modal, {
    message:
      'Lỗi: bấm "Nhập excel" nhưng không thấy modal nào mở lên ' +
      "(modal này chưa khảo sát MCP — xem JSDoc nhapCauTrucTuExcel)",
  }).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  await page.waitForTimeout(FOLDER_MODAL_WAIT);

  // --- chọn file Excel ---
  const input = modal.locator('input[type="file"]').first();
  const coInput = await input
    .waitFor({ state: "attached", timeout: TIMEOUT.ACTION_LOADING })
    .then(() => true)
    .catch(() => false);
  expect(coInput, {
    message:
      `Lỗi: modal "Nhập excel" không có \`input[type=file]\` để đặt file "${path.basename(duongDanFile)}". ` +
      "Uploader của màn này chưa được khảo sát → chạy /khao-sat-man-hinh để lấy cách chọn file thật.",
  }).toBe(true);
  await input.setInputFiles(duongDanFile, { timeout: TIMEOUT.ACTION_LOADING });
  // App đọc file + dựng bảng xem trước cấu trúc → chờ dữ liệu load
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  // --- "Tiếp theo" → "Cập nhật" (2 nút của modal, khớp theo nhãn vì chưa có testId) ---
  await clickNutTheoNhan(page, getModalNhapExcel(page), "Tiếp theo");
  await page.waitForTimeout(TIMEOUT.DATA_LOADING);

  // 🚨 Bắt đầu "rình" toast TRƯỚC khi bấm "Cập nhật" — xem JSDoc `rinhToast`: toast AntD chỉ sống
  // ~3 s, mọi `waitForTimeout` chen giữa click và assert đều có thể nuốt mất nó.
  const daThayToast = rinhToast(page, toastMongDoi, TIMEOUT.PAGE_LOADING);

  await clickNutTheoNhan(page, getModalNhapExcel(page), "Cập nhật", {
    choSauClick: false,
  });

  // Sau "Cập nhật" app CÓ THỂ bật thêm 1 modal xác nhận có nút "Tiếp tục" → bấm nếu có
  await bamTiepTucNeuCo(page, daThayToast);

  // Chốt kết quả bằng cái đã rình được từ trước, KHÔNG assert lại trạng thái hiện tại
  const thayToast = await daThayToast;
  if (!thayToast) {
    const toastDangCo = await page
      .locator(".ant-message-notice")
      .allInnerTexts()
      .catch(() => []);
    expect(thayToast, {
      message:
        `Lỗi: nhập excel xong nhưng không thấy toast "${toastMongDoi}" trong suốt ` +
        `${TIMEOUT.PAGE_LOADING / 1000}s kể từ trước lúc bấm "Cập nhật". ` +
        `Toast đang hiển thị lúc kiểm tra: [${toastDangCo.map((t) => t.replace(/\s+/g, " ").trim()).join(" | ") || "không có"}]`,
    }).toBe(true);
  }

  // Chờ app ghi xong + refresh bảng Cấu trúc hồ sơ trước khi assert / điều hướng
  await page.waitForTimeout(SAVE_SETTLE_WAIT);
}

/**
 * **Rình 1 toast từ trước khi thao tác** — trả về `Promise<boolean>` (thấy / không thấy trong
 * `timeout`), **không** ném lỗi.
 *
 * 🚨 Vì sao cần: toast AntD của app sống **~3 s** (`.ant-message` mặc định), đúng bằng
 * `TIMEOUT.CONTROL_LOADING`. Cách viết thông thường — click → `waitForTimeout` → `expect(toast)` —
 * có thể **assert vào lúc toast đã tắt**: người xem video thấy toast hiện rõ, còn Playwright báo
 * `element(s) not found`. (Cạm bẫy này `KHO-TAI-LIEU.md` mục 9.8 đã nêu: "assert NGAY sau click,
 * không `waitForTimeout` trước".)
 *
 * Gọi hàm **trước** khi bấm nút, rồi `await` kết quả sau khi làm xong các bước phụ (đóng modal xác
 * nhận…) → không còn khe hở nào giữa lúc toast hiện và lúc bắt đầu chờ.
 */
export function rinhToast(
  page: Page,
  noiDung: string,
  timeout: number = TIMEOUT.PAGE_LOADING,
): Promise<boolean> {
  return getToast(page, noiDung)
    .waitFor({ state: "visible", timeout })
    .then(() => true)
    .catch(() => false);
}

/**
 * Sau khi bấm "Cập nhật" ở modal "Nhập excel", app **có thể** bật thêm 1 modal xác nhận có nút
 * **"Tiếp tục"** (vd cảnh báo file có dòng lỗi / dữ liệu sẽ bị ghi đè). Hàm bấm nút đó **nếu có**;
 * không có thì không làm gì và để bước sau chờ toast như bình thường.
 *
 * Cách chờ: **đua** giữa 2 tín hiệu — nút "Tiếp tục" hiện lên **hoặc** `daThayToast` (promise đang
 * rình toast kết quả, bắt đầu từ **trước** lúc bấm "Cập nhật") kết thúc. Không đua như vậy thì mỗi
 * lần app *không* bật modal đều phải chờ hết timeout mới đi tiếp.
 *
 * Lặp tối đa 3 lần phòng khi app hỏi nhiều bước liên tiếp; mỗi vòng chỉ chờ nhanh
 * (`CONTROL_LOADING`) vì lúc đó modal trước đã đóng nên modal kế tiếp phải hiện gần như tức thì.
 *
 * ⚠️ Modal này **chưa khảo sát bằng MCP** — chưa biết tiêu đề/nội dung/testId, chỉ khớp theo **nhãn
 * nút** `"Tiếp tục"` trong phạm vi các modal đang mở.
 */
async function bamTiepTucNeuCo(page: Page, daThayToast: Promise<boolean>) {
  const nutTiepTuc = () =>
    page
      .locator(".ant-modal-content:visible, .ant-modal-confirm:visible")
      .getByRole("button", { name: /^\s*Tiếp tục\s*$/ })
      .first();

  for (let vong = 0; vong < 3; vong++) {
    // Vòng đầu chờ dài (app đang xử lý file); các vòng sau modal kế tiếp phải hiện ngay
    const timeout = vong === 0 ? TIMEOUT.PAGE_LOADING : TIMEOUT.CONTROL_LOADING;
    await Promise.race([
      nutTiepTuc()
        .waitFor({ state: "visible", timeout })
        .catch(() => {}),
      daThayToast,
    ]);

    const nut = nutTiepTuc();
    if (!(await nut.isVisible().catch(() => false))) return;
    await nut.click();
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }
}

/**
 * Click 1 nút của modal **theo nhãn hiển thị** (dùng khi modal chưa có testId).
 * Khớp nhãn bằng regex neo 2 đầu để `"Cập nhật"` không dính `"Cập nhật quyền..."`.
 *
 * @param opts.choSauClick Mặc định `true` — chờ `CONTROL_LOADING` sau khi click cho app kịp render.
 *   🚨 Đặt `false` khi **ngay sau click sẽ có toast cần bắt**: toast chỉ sống ~3 s, đúng bằng
 *   `CONTROL_LOADING`, chờ cứng ở đây là cách chắc chắn nhất để **bỏ lỡ** nó (xem `rinhToast`).
 */
async function clickNutTheoNhan(
  page: Page,
  modal: Locator,
  nhan: string,
  opts: { choSauClick?: boolean } = {},
) {
  const nut = modal
    .getByRole("button", { name: new RegExp(`^\\s*${nhan}\\s*$`) })
    .first();
  try {
    await expect(nut).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  } catch {
    const dangCo = await modal
      .getByRole("button")
      .allInnerTexts()
      .catch(() => []);
    throw new Error(
      `Lỗi: không thấy nút "${nhan}" trong modal. ` +
        `Nút đang có: [${dangCo.map((t) => t.replace(/\s+/g, " ").trim()).join(" | ")}]`,
    );
  }
  await nut.click();
  if (opts.choSauClick ?? true)
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
}

/**
 * **Mở rộng toàn bộ cây** trong bảng "Cấu trúc hồ sơ" — bấm hết các nút expand đang thu gọn, lặp
 * đến khi không còn nút nào (mỗi lần mở lại lộ ra nút của cấp sâu hơn).
 *
 * 🚨 Vì sao cần: `KHO-TAI-LIEU.md` mục 4.4 ghi "cây ở tab này expand sẵn" — **chỉ đúng với cấu trúc
 * 2 cấp**. Đo trên DOM thật (trace lần chạy 2026-08-14, BHS nhập từ `template3A.xlsx`): bảng chỉ
 * hiện `A. Thư mục gốc A`, `A.1`, `A.2`, `A.3` — tài liệu ở cấp sâu hơn **không** hiển thị. Muốn
 * assert "có tài liệu được tạo" thì phải mở cây trước.
 *
 * ⚠️ Nút expand dùng class AntD chuẩn `.ant-table-row-expand-icon-collapsed` (**chưa khảo sát riêng
 * trên bảng này**). Không có nút nào → hàm không làm gì, coi như cây đã mở hết.
 */
export async function moRongToanBoCayCauTruc(page: Page, soVongToiDa = 6) {
  const pane = getActiveTabPane(page);
  for (let vong = 0; vong < soVongToiDa; vong++) {
    const nutMo = pane.locator(".ant-table-row-expand-icon-collapsed");
    const soNut = await nutMo.count();
    if (soNut === 0) return;

    // Click lần lượt: mỗi lần mở, bảng render lại nên phải lấy lại locator theo index 0
    for (let i = 0; i < soNut; i++) {
      const nut = pane.locator(".ant-table-row-expand-icon-collapsed").first();
      if ((await nut.count()) === 0) break;
      await nut.click({ timeout: TIMEOUT.CONTROL_LOADING }).catch(() => {});
      await page.waitForTimeout(1_000);
    }
    await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  }
}

/**
 * Tên hiển thị của **mọi dòng trong bảng Cấu trúc hồ sơ, gộp cả các trang phân trang**.
 *
 * Khác {@link layTenCacDongCauTruc} (chỉ đọc trang đang hiển thị): bảng mặc định `15 / trang`
 * (`KHO-TAI-LIEU.md` mục 2) nên cấu trúc nhập từ Excel (hàng chục item) bị tràn sang trang sau —
 * assert trên 1 trang sẽ báo thiếu item một cách sai.
 *
 * ⚠️ Cách duyệt trang (`li.ant-pagination-item-<n>`) là selector AntD chuẩn của dự án
 * (`tests/README.md` mục 5) nhưng **chưa khảo sát riêng trên bảng Cấu trúc hồ sơ**. Không có
 * `.ant-pagination` → coi như 1 trang.
 */
export async function layTenCacDongCauTrucMoiTrang(
  page: Page,
): Promise<string[]> {
  const pagination = getActiveTabPane(page).locator(".ant-pagination").first();
  if ((await pagination.count()) === 0) return layTenCacDongCauTruc(page);

  const soTrang = await pagination.locator("li.ant-pagination-item").count();
  if (soTrang <= 1) return layTenCacDongCauTruc(page);

  const ten: string[] = [];
  for (let i = 1; i <= soTrang; i++) {
    const nutTrang = pagination.locator(`li.ant-pagination-item-${i}`);
    if ((await nutTrang.count()) === 0) continue;
    const dangMo = ((await nutTrang.getAttribute("class")) ?? "").includes(
      "ant-pagination-item-active",
    );
    if (!dangMo) {
      await nutTrang.click();
      await page.waitForTimeout(TIMEOUT.DATA_LOADING);
    }
    ten.push(...(await layTenCacDongCauTruc(page)));
  }
  return ten;
}

/* -------------------------------------------------------------------------- */
/* 8. Nhân bản item trong Cấu trúc hồ sơ                                       */
/* -------------------------------------------------------------------------- */

/** Toast app bắn khi nhân bản xong (nội dung do QA cung cấp 2026-08-14 — chưa khảo sát MCP) */
export const TOAST_NHAN_BAN = "Nhân bản hoàn thành";

/** Tiền tố app tự thêm vào tên bản sao (do QA cung cấp 2026-08-14 — chưa khảo sát MCP) */
export const PREFIX_BAN_SAO = "Copy of ";

/**
 * **Nhân bản 1 thư mục** từ bảng "Cấu trúc hồ sơ": menu dòng → `mni-nhan-ban` → chờ toast
 * `"Nhân bản hoàn thành"` → app mở **màn cập nhật của thư mục bản sao**. Trả về **tên đang nằm
 * trong ô "Tên"** của màn cập nhật đó (để case assert tiền tố `"Copy of "`).
 *
 * BHS phải đang mở sẵn ở tab "Cấu trúc hồ sơ" (`openBoHoSo` + `openTabCauTrucHoSo`).
 * Hàm **dừng lại khi modal còn mở** — case tự assert tên rồi gọi {@link luuModalThuMuc} để xác nhận
 * (hoặc {@link dongModalThuMucNeuDangMo} nếu không muốn lưu).
 *
 * 🚨 Giống `mni-cap-nhat`, thao tác này **đổi `page.url()`** sang `itemId` của bản sao
 * (`KHO-TAI-LIEU.md` mục 4.4) → luôn giữ `recordUrl` của BHS để quay lại sau.
 *
 * 🚨 Toast được **rình từ trước khi click** ({@link rinhToast}): toast AntD chỉ sống ~3 s, mà giữa
 * click và assert còn phải chờ modal cập nhật render (`KHO-TAI-LIEU.md` mục 9.8).
 *
 * ⚠️ **Chưa khảo sát bằng MCP**: mục menu `mni-nhan-ban` mới chỉ thấy **tên trong dropdown**
 * (`KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md` mục 7); nội dung toast, việc app mở màn cập nhật và tiền tố
 * `"Copy of "` đều do QA mô tả. Chạy thật lệch mô tả → khảo sát lại bằng `/khao-sat-man-hinh`,
 * đừng vá bằng selector đoán.
 */
export async function nhanBanThuMuc(
  page: Page,
  tenThuMuc: string,
  opts: { toastMongDoi?: string } = {},
): Promise<string> {
  const toastMongDoi = opts.toastMongDoi ?? TOAST_NHAN_BAN;

  // Bắt đầu chờ toast TRƯỚC khi bấm — sau click còn phải chờ modal cập nhật render
  const daThayToast = rinhToast(page, toastMongDoi, TIMEOUT.PAGE_LOADING);

  await openRowActionMenu(page, tenThuMuc, "mni-nhan-ban");

  // Màn cập nhật của bản sao (cùng bố cục modal "Cập nhật thư mục" — MODAL-TAO-THU-MUC.md mục 6b)
  const modal = getThuMucModal(page);
  await expect(modal, {
    message:
      `Lỗi: bấm "Nhân bản" ở thư mục "${tenThuMuc}" nhưng không thấy màn cập nhật của thư mục ` +
      'được nhân bản mở lên (modal có tab "Phân quyền")',
  }).toBeVisible({ timeout: TIMEOUT.PAGE_LOADING });
  await page.waitForTimeout(FOLDER_MODAL_WAIT); // modal nạp metadata

  const thayToast = await daThayToast;
  if (!thayToast) {
    const toastDangCo = await page
      .locator(".ant-message-notice")
      .allInnerTexts()
      .catch(() => []);
    expect(thayToast, {
      message:
        `Lỗi: nhân bản thư mục "${tenThuMuc}" nhưng không thấy toast "${toastMongDoi}" trong ` +
        `${TIMEOUT.PAGE_LOADING / 1000}s kể từ trước lúc bấm. ` +
        `Toast đang hiển thị lúc kiểm tra: [${toastDangCo.map((t) => t.replace(/\s+/g, " ").trim()).join(" | ") || "không có"}]`,
    }).toBe(true);
  }

  const oTen = modal.getByTestId(THU_MUC_FIELD.TEN).first();
  await expect(oTen, {
    message: `Lỗi: màn cập nhật của bản sao không có ô "Tên" (${THU_MUC_FIELD.TEN})`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  return (await oTen.inputValue()).trim();
}

/** 1 dòng của bảng "Cấu trúc hồ sơ", đã tách mã prefix khỏi tên hiển thị */
export type DongCauTruc = {
  /** Mã prefix hệ thống sinh: `"A"`, `"B"`, `"A.1"`… */
  ma: string;
  /** Tên thư mục / tài liệu (phần sau mã) */
  ten: string;
  /** Nguyên văn text của dòng, vd `"A. Thư mục 1"` */
  hienThi: string;
};

/**
 * Tách **mã prefix** và **tên** từ text hiển thị của các dòng bảng "Cấu trúc hồ sơ"
 * (lấy bằng {@link layTenCacDongCauTruc} / {@link layTenCacDongCauTrucMoiTrang}).
 *
 * Cần cho các assert về **quan hệ cha–con**: bảng là cây phẳng, item con chỉ nhận ra được qua mã
 * (`A.1` nằm trong `A`) chứ không có thuộc tính cha trong DOM.
 *
 * ⚠️ Dấu phân cách giữa mã và tên **không nhất quán** giữa các cấp: `KHO-TAI-LIEU.md` mục 4.4 ghi
 * cấp 0 hiển thị `"A. Thư mục 1"` (có dấu chấm) còn cấp 1 hiển thị `"A.1 Tài liệu 1"` (không có) →
 * hàm cắt ở **khoảng trắng đầu tiên** rồi bỏ dấu chấm cuối của mã. Dòng không khớp dạng đó → trả
 * `ma: ""` và giữ nguyên `ten = hienThi` (để message lỗi của case vẫn đọc được).
 */
export function phanTichDongCauTruc(danhSachHienThi: string[]): DongCauTruc[] {
  return danhSachHienThi.map((hienThi) => {
    const text = hienThi.replace(/\s+/g, " ").trim();
    const khop = /^([A-Za-z0-9.]+)\s+(.*)$/.exec(text);
    if (!khop) return { ma: "", ten: text, hienThi: text };
    return {
      ma: khop[1].replace(/\.$/, ""),
      ten: khop[2].trim(),
      hienThi: text,
    };
  });
}

/**
 * **Nhân bản 1 tài liệu** từ bảng "Cấu trúc hồ sơ": menu dòng → `mni-nhan-ban` → chờ toast
 * `"Nhân bản hoàn thành"`.
 *
 * BHS phải đang mở sẵn ở tab "Cấu trúc hồ sơ" (`openBoHoSo` + `openTabCauTrucHoSo`).
 *
 * 🚨 **Khác {@link nhanBanThuMuc}**: với thư mục, QA khẳng định app mở **màn cập nhật của bản sao**
 * và phải bấm Xác nhận; với **tài liệu thì chưa có mô tả nào** (2026-08-14). Vì vậy hàm xử lý **mềm**:
 * - App mở màn chi tiết/cập nhật của bản sao (modal có `tab-digitizedATM`) → đọc tên trong ô
 *   `txt-ten-tai-lieu`, bấm **`btn-save`** để chốt rồi chờ toast `"Thành công"` (chờ mềm).
 *   Bấm lưu chứ không đóng modal: nếu app coi bản sao là bản nháp chờ xác nhận, đóng modal sẽ **mất**
 *   bản sao — còn lưu lại 1 bản đã ghi sẵn thì vô hại.
 * - Không có modal nào mở → không làm gì, trả `""`; case tự đọc tên bản sao **từ bảng Cấu trúc hồ sơ**
 *   sau khi mở lại BHS.
 *
 * @returns tên đọc được trên màn cập nhật của bản sao, hoặc `""` nếu app không mở màn nào.
 *
 * ⚠️ **Chưa khảo sát bằng MCP** — `mni-nhan-ban` của dòng tài liệu mới chỉ thấy tên trong dropdown
 * (`KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md` mục 8). Nội dung toast do QA mô tả.
 */
export async function nhanBanTaiLieu(
  page: Page,
  tenTaiLieu: string,
  opts: { toastMongDoi?: string } = {},
): Promise<string> {
  const toastMongDoi = opts.toastMongDoi ?? TOAST_NHAN_BAN;

  const daThayToast = rinhToast(page, toastMongDoi, TIMEOUT.PAGE_LOADING);
  await openRowActionMenu(page, tenTaiLieu, "mni-nhan-ban");

  const thayToast = await daThayToast;
  if (!thayToast) {
    const toastDangCo = await page
      .locator(".ant-message-notice")
      .allInnerTexts()
      .catch(() => []);
    expect(thayToast, {
      message:
        `Lỗi: nhân bản tài liệu "${tenTaiLieu}" nhưng không thấy toast "${toastMongDoi}" trong ` +
        `${TIMEOUT.PAGE_LOADING / 1000}s kể từ trước lúc bấm. ` +
        `Toast đang hiển thị lúc kiểm tra: [${toastDangCo.map((t) => t.replace(/\s+/g, " ").trim()).join(" | ") || "không có"}]`,
    }).toBe(true);
  }

  // App CÓ THỂ mở màn chi tiết/cập nhật của bản sao — chưa khảo sát nên chỉ xử lý nếu thấy
  await page.waitForTimeout(FOLDER_MODAL_WAIT);
  const modal = getModalTaiLieu(page);
  if (!(await modal.isVisible().catch(() => false))) return "";

  const oTen = modal.getByTestId("txt-ten-tai-lieu").first();
  const ten = (await oTen.count())
    ? (await oTen.inputValue().catch(() => "")).trim()
    : "";

  await modal.getByTestId("btn-save").click();
  await getToast(page, "Thành công")
    .waitFor({ state: "visible", timeout: TIMEOUT.ACTION_LOADING })
    .catch(() => {});
  await page.waitForTimeout(SAVE_SETTLE_WAIT);

  return ten;
}

/* -------------------------------------------------------------------------- */
/* 9. Tạo shortcut cho tài liệu                                                */
/* -------------------------------------------------------------------------- */

/** Toast app bắn khi tạo shortcut xong (nội dung do QA cung cấp 2026-08-14 — chưa khảo sát MCP) */
export const TOAST_TAO_SHORTCUT = "Tạo shortcut thành công";

/** Tiền tố tên shortcut app tự điền sẵn (do QA cung cấp 2026-08-14 — chưa khảo sát MCP) */
export const PREFIX_SHORTCUT = "Shortcut of ";

/** Placeholder ô "Tên shortcut" — modal chưa có `data-testid` nào nên đây là mốc nhận dạng duy nhất */
const PLACEHOLDER_TEN_SHORTCUT = "Nhập tên shortcut";

/**
 * Modal **"Tạo mới Shortcut"** — lọc theo ô nhập tên (placeholder `"Nhập tên shortcut"`).
 *
 * ⚠️ Không dùng `getRecordModal`: modal này chồng lên modal BHS, mà `.ant-modal-content:visible`
 * `.last()` còn có thể trỏ nhầm sang confirm dialog / modal xem trước file.
 * ⚠️ Toàn bộ modal **không có `data-testid`** (QA mô tả 2026-08-14) → mọi locator bên trong bám
 * placeholder + class AntD. Có testId rồi thì thay lại.
 */
export function getModalShortcut(page: Page): Locator {
  return page
    .locator(".ant-modal-content:visible")
    .filter({
      has: page.locator(`input[placeholder="${PLACEHOLDER_TEN_SHORTCUT}"]`),
    })
    .last();
}

/**
 * Chọn 1 option của select **có search phía server** khi select không có testId (đã scope sẵn):
 * click → gõ từ khoá vào `.ant-select-selection-search-input` → chọn option khớp.
 *
 * Khác {@link chonOptionSelect} (danh sách ngắn, khớp **exact**): ở đây danh sách dài, app chỉ nạp
 * ~10 option đầu nên **bắt buộc gõ search** (`KHO-TAI-LIEU.md` mục 3e), và option hiển thị có thể
 * kèm mã / hậu tố nên khớp theo **chuỗi con**.
 */
async function chonSelectTheoTuKhoa(
  page: Page,
  select: Locator,
  tuKhoa: string,
  nhanSelect: string,
) {
  await expect(select, {
    message: `Lỗi: không thấy ô "${nhanSelect}"`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });

  await select.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await select
    .locator(".ant-select-selection-search-input")
    .pressSequentially(tuKhoa, { delay: 100 });

  const dropdown = page.locator(".ant-select-dropdown:visible").last();
  const option = dropdown
    .locator(".ant-select-item-option-content")
    .filter({ hasText: tuKhoa })
    .first();
  try {
    // Assert theo sự kiện: search chạy ở server nên có thể chậm, nhưng khớp rồi là đi tiếp ngay
    await expect(option).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });
  } catch {
    const dangCo = await dropdown
      .locator(".ant-select-item-option-content")
      .allInnerTexts()
      .catch(() => []);
    throw new Error(
      `Lỗi: ô "${nhanSelect}" không tìm được option nào chứa "${tuKhoa}". ` +
        `Option đang hiển thị: [${dangCo.map((t) => t.trim()).join(" | ") || "không có"}]`,
    );
  }

  await option.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await expect(select.locator(".ant-select-selection-item"), {
    message: `Lỗi: ô "${nhanSelect}" không nhận giá trị chứa "${tuKhoa}"`,
  }).toContainText(tuKhoa, { timeout: TIMEOUT.VALIDATE_WAITING });
}

export type TaoShortcutOptions = {
  /** Đổi tên shortcut (bỏ trống → giữ nguyên tên app điền sẵn `"Shortcut of <tên gốc>"`) */
  tenShortcut?: string;
  /**
   * Item cần chọn ở **tree-select vị trí lưu** (ô cuối modal) — mặc định chính
   * `tenBoHoSoDich`, tức đặt shortcut ở **gốc** BHS đích. Truyền tên thư mục để đặt vào thư mục con.
   */
  tenViTri?: string;
  /** Nội dung toast mong đợi (mặc định {@link TOAST_TAO_SHORTCUT}) */
  toastMongDoi?: string;
};

/**
 * **Tạo shortcut của 1 tài liệu sang Bộ hồ sơ khác** từ bảng "Cấu trúc hồ sơ":
 * menu dòng → `mni-tao-shortcut` → điền modal "Tạo mới Shortcut" → "Lưu lại" → chờ toast
 * `"Tạo shortcut thành công"`.
 *
 * BHS **nguồn** phải đang mở sẵn ở tab "Cấu trúc hồ sơ" (`openBoHoSo` + `openTabCauTrucHoSo`).
 *
 * 3 ô của modal (theo thứ tự hiển thị, xem `KHO-TAI-LIEU.md` mục 10):
 * 1. **Tên shortcut** — app điền sẵn `"Shortcut of <tên tài liệu gốc>"`
 * 2. **Bộ hồ sơ đích** — select có search ở server → gõ `tenBoHoSoDich` rồi chọn
 * 3. **Vị trí lưu** — tree-select, chọn item tên `opts.tenViTri ?? tenBoHoSoDich`
 *
 * @returns **tên app điền sẵn** ở ô tên (đọc trước khi ghi đè) — để case assert quy tắc
 *   `"Shortcut of " + <tên gốc>`. Tên thực tế đã lưu là `opts.tenShortcut ?? <giá trị trả về>`.
 *
 * 🚨 Toast được **rình từ trước khi bấm "Lưu lại"** ({@link rinhToast}): toast AntD chỉ sống ~3 s
 * (`KHO-TAI-LIEU.md` mục 9.8).
 *
 * ⚠️ **Chưa khảo sát bằng MCP**: mới chỉ thấy mục `mni-tao-shortcut` trong menu dòng tài liệu
 * (`KHO-TAI-LIEU.md` mục 4.4). Cấu trúc modal, nhãn nút "Lưu lại" và nội dung toast đều do QA mô tả
 * (2026-08-14) và modal **không có `data-testid` nào**. Chạy thật lệch mô tả → khảo sát lại bằng
 * `/khao-sat-man-hinh` rồi thay bằng testId thật, đừng vá thêm selector đoán.
 */
export async function taoShortcutTaiLieu(
  page: Page,
  tenTaiLieu: string,
  tenBoHoSoDich: string,
  opts: TaoShortcutOptions = {},
): Promise<string> {
  const toastMongDoi = opts.toastMongDoi ?? TOAST_TAO_SHORTCUT;
  const tenViTri = opts.tenViTri ?? tenBoHoSoDich;

  await openRowActionMenu(page, tenTaiLieu, "mni-tao-shortcut");

  const modal = getModalShortcut(page);
  await expect(modal, {
    message:
      `Lỗi: bấm "Tạo shortcut" ở tài liệu "${tenTaiLieu}" nhưng không thấy modal "Tạo mới Shortcut" ` +
      `(modal có ô nhập placeholder "${PLACEHOLDER_TEN_SHORTCUT}")`,
  }).toBeVisible({ timeout: TIMEOUT.PAGE_LOADING });
  await page.waitForTimeout(FOLDER_MODAL_WAIT); // modal nạp danh mục BHS

  // --- 1. Tên shortcut (app điền sẵn) ---
  const oTen = modal
    .locator(`input[placeholder="${PLACEHOLDER_TEN_SHORTCUT}"]`)
    .first();
  const tenMacDinh = (await oTen.inputValue()).trim();
  if (opts.tenShortcut) {
    await oTen.fill(opts.tenShortcut);
    await page.waitForTimeout(500);
  }

  // --- 2. Bộ hồ sơ đích --- (select thường; tree-select bị loại bằng `:not(.ant-tree-select)`)
  await chonSelectTheoTuKhoa(
    page,
    modal.locator(".ant-select:not(.ant-tree-select)").first(),
    tenBoHoSoDich,
    "Bộ hồ sơ đích",
  );
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING); // app nạp cây vị trí của BHS vừa chọn

  // --- 3. Vị trí lưu (tree-select) ---
  const treeSelect = modal.locator(".ant-tree-select").first();
  await expect(treeSelect, {
    message: 'Lỗi: modal "Tạo mới Shortcut" không có ô tree-select vị trí lưu',
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await treeSelect.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);

  const treeDropdown = page.locator(".ant-select-dropdown:visible").last();
  const treeOption = treeDropdown
    .locator(".ant-select-tree-title")
    .filter({ hasText: tenViTri })
    .first();
  try {
    await expect(treeOption).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });
  } catch {
    const dangCo = await treeDropdown
      .locator(".ant-select-tree-title")
      .allInnerTexts()
      .catch(() => []);
    throw new Error(
      `Lỗi: ô vị trí lưu của modal "Tạo mới Shortcut" không có item "${tenViTri}". ` +
        `Item đang hiển thị: [${dangCo.map((t) => t.trim()).join(" | ") || "không có"}]`,
    );
  }
  await treeOption.click();
  await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
  await page.keyboard.press("Escape"); // đóng dropdown còn sót, tránh che nút Lưu lại
  await page.waitForTimeout(500);

  // --- Lưu lại --- (rình toast TRƯỚC khi bấm: toast chỉ sống ~3 s)
  const daThayToast = rinhToast(page, toastMongDoi, TIMEOUT.PAGE_LOADING);
  const nutSave = modal.getByTestId("btn-save");
  if ((await nutSave.count()) > 0) {
    await nutSave.first().click();
  } else {
    await clickNutTheoNhan(page, modal, "Lưu lại", { choSauClick: false });
  }

  const thayToast = await daThayToast;
  if (!thayToast) {
    const toastDangCo = await page
      .locator(".ant-message-notice")
      .allInnerTexts()
      .catch(() => []);
    expect(thayToast, {
      message:
        `Lỗi: tạo shortcut cho tài liệu "${tenTaiLieu}" sang BHS "${tenBoHoSoDich}" nhưng không thấy ` +
        `toast "${toastMongDoi}" trong ${TIMEOUT.PAGE_LOADING / 1000}s kể từ trước lúc bấm "Lưu lại". ` +
        `Toast đang hiển thị lúc kiểm tra: [${toastDangCo.map((t) => t.replace(/\s+/g, " ").trim()).join(" | ") || "không có"}]`,
    }).toBe(true);
  }

  // Chờ app ghi xong trước khi điều hướng sang BHS đích để assert
  await page.waitForTimeout(SAVE_SETTLE_WAIT);

  return tenMacDinh;
}

/* -------------------------------------------------------------------------- */
/* 12. Modal "Khôi phục Cấu trúc hồ sơ" (Quản lý cấu trúc đã xóa)              */
/*     → KHO-TAI-LIEU.MODAL-KHOI-PHUC-CAU-TRUC-DA-XOA.md                       */
/* -------------------------------------------------------------------------- */

/** Tiêu đề (`lbl-modal-title`) của modal "Quản lý cấu trúc đã xóa" */
export const MODAL_CAU_TRUC_DA_XOA_TITLE = "Khôi phục Cấu trúc hồ sơ";

/** Text hiển thị trong bảng khi BHS chưa xoá item nào */
export const EMPTY_CAU_TRUC_DA_XOA = "Chưa có dữ liệu";

/** 1 dòng trong bảng "Khôi phục Cấu trúc hồ sơ" */
export type CauTrucDaXoa = {
  /** Cột "Danh sách" — **tên item**, không kèm mã prefix (`A.`, `G.`…) */
  ten: string;
  /** Cột "Thời gian xóa", dạng `HH:mm dd/MM/yyyy` */
  thoiGianXoa: string;
  /** Cột "Người thực hiện" — tên hiển thị + chức danh */
  nguoiThucHien: string;
  /** Cột "Vị trí", vd `"Cấu trúc Hồ sơ ATT-067-HĐSL"` */
  viTri: string;
};

/**
 * Locator modal **"Khôi phục Cấu trúc hồ sơ"**.
 *
 * ⚠️ Modal này mở **đè lên** modal BHS và cả hai đều có `lbl-modal-title` → không dùng
 * `.ant-modal-content:visible.last()` trần mà **lọc theo tiêu đề**. Hai modal là **anh em** trong
 * DOM (mỗi cái 1 `.ant-modal-root`, không lồng nhau) nên lọc theo text là an toàn.
 */
export function getModalCauTrucDaXoa(page: Page): Locator {
  return page
    .locator(".ant-modal-content:visible")
    .filter({ hasText: MODAL_CAU_TRUC_DA_XOA_TITLE })
    .last();
}

/**
 * Mở modal "Quản lý cấu trúc đã xóa": hover `btn-more` của **header BHS** → click
 * `btn-quan-ly-cau-truc-da-xoa`. Trả về locator modal đã mở.
 *
 * BHS phải đang mở sẵn (`openBoHoSo`). Mục này **chỉ có ở menu của BHS** — menu `btn-more` của
 * bảng Cấu trúc hồ sơ không có (`KHO-TAI-LIEU.MODAL-KHOI-PHUC-CAU-TRUC-DA-XOA.md` mục 1).
 */
export async function openQuanLyCauTrucDaXoa(page: Page): Promise<Locator> {
  await openMoreMenu(page, "record");

  const mucMenu = getVisibleDropdown(page).getByTestId(
    "btn-quan-ly-cau-truc-da-xoa",
  );
  await expect(mucMenu, {
    message:
      'Lỗi: menu "..." của BHS không có mục "Quản lý cấu trúc đã xóa" (btn-quan-ly-cau-truc-da-xoa)',
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await mucMenu.click();

  const modal = getModalCauTrucDaXoa(page);
  await expect(modal, {
    message: `Lỗi: không mở được modal "${MODAL_CAU_TRUC_DA_XOA_TITLE}"`,
  }).toBeVisible({ timeout: TIMEOUT.ACTION_LOADING });
  // Bảng nạp dữ liệu sau khi modal hiện
  await page.waitForTimeout(DROPDOWN_WAIT);
  return modal;
}

/**
 * Đọc **mọi dòng** trong bảng "Khôi phục Cấu trúc hồ sơ".
 * Danh sách rỗng → trả mảng rỗng (bảng hiện `EMPTY_CAU_TRUC_DA_XOA`).
 */
export async function layDanhSachCauTrucDaXoa(
  page: Page,
  modal?: Locator,
): Promise<CauTrucDaXoa[]> {
  const m = modal ?? getModalCauTrucDaXoa(page);
  const rows = m.locator("tr.ant-table-row");
  const soDong = await rows.count();

  const ketQua: CauTrucDaXoa[] = [];
  for (let i = 0; i < soDong; i++) {
    const o = rows.nth(i).locator("td");
    const doc = async (index: number) =>
      (await o.nth(index).innerText()).replace(/\s+/g, " ").trim();
    ketQua.push({
      ten: await doc(1),
      thoiGianXoa: await doc(2),
      nguoiThucHien: await doc(3),
      viTri: await doc(4),
    });
  }
  return ketQua;
}

/**
 * Tick checkbox của 1 item trong bảng "Khôi phục Cấu trúc hồ sơ".
 *
 * 🚨 Checkbox **chỉ hiện khi hover dòng** (`span.ant-checkbox` có `display:none` lúc không hover,
 * bounding box `0×0` → click thẳng sẽ timeout). Hàm hover dòng trước rồi mới click.
 */
export async function tickItemCauTrucDaXoa(
  page: Page,
  tenItem: string,
  modal?: Locator,
) {
  const m = modal ?? getModalCauTrucDaXoa(page);
  const row = m
    .locator("tr.ant-table-row")
    .filter({ hasText: tenItem })
    .first();

  try {
    await expect(row).toBeVisible({ timeout: TIMEOUT.DATA_LOADING });
  } catch {
    const dangCo = await layDanhSachCauTrucDaXoa(page, m);
    throw new Error(
      `Lỗi: danh sách cấu trúc đã xoá không có item "${tenItem}". ` +
        `Đang có: [${dangCo.map((d) => d.ten).join(" | ") || EMPTY_CAU_TRUC_DA_XOA}]`,
    );
  }

  await row.hover();
  await page.waitForTimeout(500);
  const checkbox = row.locator("span.ant-checkbox");
  await expect(checkbox, {
    message: `Lỗi: dòng "${tenItem}" không hiện checkbox sau khi hover`,
  }).toBeVisible({ timeout: TIMEOUT.CONTROL_LOADING });
  await checkbox.click();

  await expect(row, {
    message: `Lỗi: tick checkbox nhưng dòng "${tenItem}" không ở trạng thái được chọn`,
  }).toHaveClass(/ant-table-row-selected/, {
    timeout: TIMEOUT.CONTROL_LOADING,
  });
}

/**
 * Locator nút **"Khôi phục"** ở footer modal (không có testId → bám nhãn).
 * Nút **`disabled` khi chưa tick dòng nào** — dùng khi case cần assert trạng thái đó.
 */
export function getNutKhoiPhuc(page: Page, modal?: Locator): Locator {
  const m = modal ?? getModalCauTrucDaXoa(page);
  return m.getByRole("button", { name: "Khôi phục", exact: true });
}

/**
 * Khôi phục 1 hoặc nhiều item đã xoá của BHS đang mở: mở modal "Quản lý cấu trúc đã xóa" →
 * tick từng item → bấm "Khôi phục" → chờ modal tự đóng.
 *
 * ⚠️ App **không có dialog xác nhận** và **không bắn toast** khi khôi phục (đo MCP 2026-08-14,
 * rình toast bằng MutationObserver từ trước lúc bấm) → tín hiệu duy nhất là **modal đóng**;
 * việc assert item xuất hiện lại trong bảng Cấu trúc hồ sơ thuộc về case
 * (bảng tự refresh, không cần mở lại BHS).
 *
 * @param opts.moModal `false` khi modal đã mở sẵn (mặc định `true` — tự mở từ `btn-more`)
 */
export async function khoiPhucCauTrucDaXoa(
  page: Page,
  tenItem: string | string[],
  opts: { moModal?: boolean } = {},
) {
  const danhSach = Array.isArray(tenItem) ? tenItem : [tenItem];
  const modal =
    opts.moModal === false
      ? getModalCauTrucDaXoa(page)
      : await openQuanLyCauTrucDaXoa(page);

  for (const ten of danhSach) {
    await tickItemCauTrucDaXoa(page, ten, modal);
  }

  const nut = getNutKhoiPhuc(page, modal);
  await expect(nut, {
    message: `Lỗi: nút "Khôi phục" vẫn disabled sau khi tick [${danhSach.join(" | ")}]`,
  }).toBeEnabled({ timeout: TIMEOUT.CONTROL_LOADING });
  await nut.click();

  await expect(modal, {
    message: `Lỗi: đã bấm "Khôi phục" [${danhSach.join(" | ")}] nhưng modal không đóng`,
  }).toBeHidden({ timeout: TIMEOUT.ACTION_LOADING });
  // Chờ app ghi xong + bảng Cấu trúc hồ sơ refresh trước khi assert
  await page.waitForTimeout(SAVE_SETTLE_WAIT);
}

/** Đóng modal "Khôi phục Cấu trúc hồ sơ" bằng nút "Đóng" — **nếu** còn mở */
export async function dongModalCauTrucDaXoa(page: Page, modal?: Locator) {
  const m = modal ?? getModalCauTrucDaXoa(page);
  if (!(await m.isVisible().catch(() => false))) return;

  await m.getByRole("button", { name: "Đóng", exact: true }).click();
  await expect(m, {
    message: `Lỗi: bấm "Đóng" nhưng modal "${MODAL_CAU_TRUC_DA_XOA_TITLE}" không đóng`,
  }).toBeHidden({ timeout: TIMEOUT.CONTROL_LOADING });
}
