// [S162 — 2026-07-30] Nguồn dữ liệu cho cây 4 folder giai đoạn
// (`components/pipeline/PipelineStageFolders.tsx`).
// File MIRROR SHA256 identical giữa 2 app (fe-user ⟂ fe-admin) — sửa 1 bên PHẢI copy sang bên kia.
//
// 🔴 PERMISSION 2 TẦNG (bài S118 / gotcha #82): tầng hiển thị (menu CanRead) và
// tầng API-authz ĐỘC LẬP. Ở đây gate TẦNG HIỂN THỊ **trước khi fetch** — không
// quyền ⇒ `enabled:false` ⇒ KHÔNG gọi API (tránh rác 403 trên mọi gói thầu),
// folder hiện dạng khoá + "Sắp". Query lỗi ⇒ `failed` ⇒ đếm "—", cây KHÔNG vỡ.
//
// ⚠️ Gate theo ĐÚNG tầng của endpoint [F-1 S162]: GĐ2 `/contract-signing-plans`
// có policy per-action `KeHoachKyKet.Read` (ContractSigningPlansController.cs:36)
// và `MenuPermissionHandler.cs:40` so khớp CHÍNH XÁC 1 key — KHÔNG kế thừa,
// KHÔNG OR key con ⇒ gate GĐ2 = đúng key đó (OR `Khkk_List…` sẽ fetch rồi ăn
// 403 khi role thiếu key root). GĐ3/4 `/contracts` chỉ `[Authorize]` trần (#82)
// ⇒ OR key HIỂN-THỊ `Ct_*`/`Hdc_*` vô hại (không có policy per-action để đá).
// Menu-tree fallback quyền cha CHỈ khi menu con KHÔNG có row riêng
// (`GetMyMenuTreeQuery.cs:66`) — seeder TẠO row `Ct_*`/`Khkk_*`/`Hdc_*` riêng
// (DbInitializer.cs:2168-2176/2187/2423) ⇒ thực tế KHÔNG có kế thừa nào ở đây.
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import type { ReactNode } from 'react'
import { api } from '@/lib/api'
import { usePermission } from '@/hooks/usePermission'
import type { PipelineStage } from '@/components/pipeline/PipelineStageFolders'
import { ContractPhaseColor, ContractPhaseLabel, type ContractListItem } from '@/types/contracts'
import {
  KHKK_APPROVAL_GROUPS,
  KHKK_PHASE_BADGE,
  KHKK_PHASE_LABELS,
  KhkkPhase,
  khkkGroupMenuLabel,
  khkkPlanContractIds,
  type KhkkListItemDto,
  type PagedResult,
} from '@/types/khkk'
import type { Paged } from '@/types/master'
import {
  PeDisplayStatusColor,
  PeDisplayStatusLabel,
  getPeDisplayStatus,
  type PeListItem,
} from '@/types/purchaseEvaluation'

// `PagedResult.cs:20` — pageSize kẹp ≤200 (không 400). Index nạp 1 phát rồi
// nối cây client-side; KHÔNG N+1 theo từng gói thầu.
const INDEX_PAGE_SIZE = 200
const INDEX_STALE_MS = 60_000

// `MenuKeys.cs:34` ContractTypeCodes — dùng cho Ct_*_List (GĐ3) và Hdc_* (GĐ4).
const CONTRACT_TYPE_CODES = [
  'ThauPhu', 'GiaoKhoan', 'NhaCungCap', 'DichVu', 'MuaBan', 'NguyenTacNcc', 'NguyenTacDv',
] as const

const PE_MENU_KEYS = ['PurchaseEvaluations', 'Pe_DuyetNcc_List', 'Pe_DuyetNccPhuongAn_List']
// [F-1 S162] = đúng key policy của endpoint GĐ2 (xem ⚠️ đầu file).
const KHKK_MENU_KEYS = ['KeHoachKyKet']
const CONTRACT_MENU_KEYS = ['Contracts', ...CONTRACT_TYPE_CODES.map(c => `Ct_${c}_List`)]
const HARD_COPY_MENU_KEYS = ['HopDongCung', ...CONTRACT_TYPE_CODES.map(c => `Hdc_${c}`)]

// [K7 S167 — vá-4b] Chuông DEV bắn TỐI ĐA 1 LẦN/phiên (buildStages chạy lại theo từng gói
// thầu ⇒ không có cờ này thì console ngập). Xem chỗ dùng ở GĐ3.
let warnedMissingLineContractIds = false

export type PipelineLinkage = {
  canPe: boolean
  canKhkk: boolean
  canContract: boolean
  canHardCopy: boolean
  khkkByPeId: Map<string, KhkkListItemDto[]>
  contractById: Map<string, ContractListItem>
  khkkFailed: boolean
  contractFailed: boolean
  khkkLoading: boolean
  contractLoading: boolean
  /** ≥1 index chạm trần cửa sổ 200 ⇒ count GĐ2-4 CÓ THỂ thiếu (khai trên cây). */
  indexTruncated: boolean
}

/** Nạp 1 lần index KHKK + HĐ để nối GĐ2/3/4 vào từng gói thầu. */
export function usePipelineLinkage(): PipelineLinkage {
  const { can } = usePermission()
  const canPe = PE_MENU_KEYS.some(k => can(k))
  const canKhkk = KHKK_MENU_KEYS.some(k => can(k))
  const canContract = CONTRACT_MENU_KEYS.some(k => can(k))
  const canHardCopy = HARD_COPY_MENU_KEYS.some(k => can(k))

  // Query key MỚI (không dùng lại `khkk-list` / `hard-copies` / `contracts`):
  // đổi shape cache dùng chung sẽ vỡ trang khác (bài S159).
  const khkkIndex = useQuery({
    queryKey: ['pipeline-khkk-index'],
    // [F-4 S162] giữ cả `total`: chạm trần cửa sổ 200 phải KHAI trên cây
    // (truncated), không để count tụt im lặng.
    queryFn: async () => {
      const d = (
        await api.get<PagedResult<KhkkListItemDto>>('/contract-signing-plans', {
          params: { page: 1, pageSize: INDEX_PAGE_SIZE },
        })
      ).data
      return { items: d.items, total: d.total }
    },
    enabled: canKhkk,
    staleTime: INDEX_STALE_MS,
    retry: 1,
  })

  // GĐ3 và GĐ4 cùng ăn 1 danh sách HĐ (GĐ4 = tập con `hasSealedCopy`).
  const contractIndex = useQuery({
    queryKey: ['pipeline-contract-index'],
    queryFn: async () => {
      const d = (
        await api.get<Paged<ContractListItem>>('/contracts', {
          params: { page: 1, pageSize: INDEX_PAGE_SIZE },
        })
      ).data
      return { items: d.items, total: d.total }
    },
    enabled: canContract || canHardCopy,
    staleTime: INDEX_STALE_MS,
    retry: 1,
  })

  const khkkByPeId = useMemo(() => {
    const map = new Map<string, KhkkListItemDto[]>()
    for (const k of khkkIndex.data?.items ?? []) {
      const bucket = map.get(k.purchaseEvaluationId)
      if (bucket) bucket.push(k)
      else map.set(k.purchaseEvaluationId, [k])
    }
    for (const bucket of map.values()) {
      bucket.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
    }
    return map
  }, [khkkIndex.data])

  const contractById = useMemo(() => {
    const map = new Map<string, ContractListItem>()
    for (const c of contractIndex.data?.items ?? []) map.set(c.id, c)
    return map
  }, [contractIndex.data])

  return {
    canPe,
    canKhkk,
    canContract,
    canHardCopy,
    khkkByPeId,
    contractById,
    khkkFailed: khkkIndex.isError,
    contractFailed: contractIndex.isError,
    // v5: `isLoading = isPending && isFetching` ⇒ query bị `enabled:false` cho
    // false sẵn, không cần chèn thêm điều kiện quyền.
    khkkLoading: khkkIndex.isLoading,
    contractLoading: contractIndex.isLoading,
    indexTruncated:
      (khkkIndex.data ? khkkIndex.data.total > khkkIndex.data.items.length : false) ||
      (contractIndex.data ? contractIndex.data.total > contractIndex.data.items.length : false),
  }
}

export type BuildStagesOptions = {
  /** GĐ1 tự vẽ (trang Duyệt NCC truyền khối leaf phiếu CŨ vào đây). */
  stage1Content?: ReactNode
  stage1Count?: number
  // ── [S171 — mitigation B1 + M-2, owner chốt panel 1 KHKK = cây] ─────────────
  /**
   * GĐ2 tự vẽ (trang Kế hoạch ký kết truyền khối CARD phiếu của chính nó).
   * 🔴 TRADE-OFF ĐÃ KHAI: `PipelineStageFolders.tsx:285-287` đặt `content` TRƯỚC `groups`
   * trong chuỗi tam-nguyên ⇒ truyền cái này thì 8 ngăn N1-N8 KHÔNG vẽ nữa (im lặng, không
   * lỗi). Chấp nhận được VÌ lối vào nhóm giữ ở `Select` nhóm của khối filter panel 1.
   * ADDITIVE: `PurchaseEvaluationsListPage` không truyền ⇒ trang PE/GĐ3 GIỮ NGUYÊN 8 ngăn.
   */
  stage2Content?: (items: KhkkListItemDto[]) => ReactNode
  /**
   * 🔴 BẮT BUỘC đi kèm `stage2Content` — `stageCount()` (`PipelineStageFolders.tsx:95-97`)
   * đọc `count ?? 0` khi có `content`, thiếu là badge về 0 trong khi folder đầy phiếu.
   * Mặc định (không truyền) = số phiếu của gói, KHÔNG bao giờ để rơi về 0.
   */
  stage2Count?: (items: KhkkListItemDto[]) => number
  /**
   * [M-2] Bấm leaf/card KHKK ⇒ HOST xử lý (chọn vào panel 2 của màn 3-panel) thay vì
   * `navigate('/khkk/:id')` rời trang. KHÔNG truyền ⇒ giữ NGUYÊN hành vi cũ (navigate)
   * cho mọi call-site khác — 0 hồi-quy trang Duyệt NCC / GĐ3.
   */
  onOpenKhkk?: (id: string) => void
  /**
   * [S175 · O6] Mirror ĐỐI XỨNG của `onOpenKhkk` cho leaf GĐ1 (phiếu Duyệt NCC).
   * Anh chốt: *"bấm card PE trong cây ở màn KHKK phải GIỮ NGUYÊN màn KHKK — chỉ view là
   * được, không re-direct về phiếu gốc"*. KHÔNG truyền ⇒ giữ NGUYÊN
   * `navigate('/purchase-evaluations?id=…&expand=1')` ⇒ 5 host còn lại 0 đổi hành vi.
   */
  onOpenPe?: (id: string) => void
  /**
   * [S190 · W2 YC-029] Bộ ba khép kín với `onOpenKhkk` (GĐ2) / `onOpenPe` (GĐ1): leaf GĐ3 (HĐ)
   * cho HOST bắt tại chỗ. Màn `my-contracts` sau hoán-đổi đặt cây ở panel-1 TRÁI ⇒ bấm leaf phải
   * chọn HĐ vào panel-2 (URL ở lại `/my-contracts`), không rời trang.
   * không truyền ⇒ giữ nguyên `navigate('/contracts/:id')` — 6 host còn lại (kể cả fe-admin
   * `ContractsListPage.tsx:265`) 0 đổi hành vi.
   */
  onOpenContract?: (id: string) => void
  /**
   * [S190 · W2 YC-029] Lọc leaf GĐ3 theo `ContractType` (1..7) — nguồn là thanh `Select` loại HĐ
   * ở đầu panel cây `my-contracts` (Q2 nhánh-đã-chọn).
   * không truyền ⇒ giữ nguyên cây đủ MỌI loại HĐ — 6 host còn lại 0 đổi hành vi.
   * 🔴 HĐ nằm NGOÀI cửa sổ index 200 KHÔNG suy được loại ⇒ vẫn hiện dù filter bật; lý-do ghi tại
   * chỗ dùng (`passStage3Type`).
   */
  stage3TypeFilter?: number
}

/**
 * Trả `buildStages(peItems)` — dựng 4 folder cho MỘT gói thầu (1 cặp Dự án ×
 * Hạng mục) từ danh sách phiếu PE của gói đó.
 *   GĐ1 = chính các phiếu ấy
 *   GĐ2 = KHKK có `purchaseEvaluationId` thuộc tập phiếu, chia thành 8 NGĂN theo
 *         nhóm duyệt (+ ngăn "(chưa phân nhóm)" khi có phiếu lạc) — K5
 *   GĐ3 = HĐ trong TẬP id của gói — hợp của `pe.contractId` (legacy) và contract-id trên
 *         các DÒNG phiếu KHKK (K7 vá-4b; xem thân hàm)
 *   GĐ4 = đúng các HĐ ấy khi `hasSealedCopy === true`
 */
export function usePipelineStages() {
  const linkage = usePipelineLinkage()
  const navigate = useNavigate()

  const buildStages = (peItems: PeListItem[], opts: BuildStagesOptions = {}): PipelineStage[] => {
    // ── GĐ1 — Duyệt NCC ────────────────────────────────────────────────────
    const stage1: PipelineStage =
      opts.stage1Content !== undefined
        ? { n: 1, leaves: [], content: opts.stage1Content, count: opts.stage1Count ?? peItems.length }
        : {
            n: 1,
            leaves: peItems.map(p => {
              const display = getPeDisplayStatus(p.phase)
              return {
                id: p.id,
                primary: p.maPhieu ?? '(chưa cấp mã)',
                secondary: p.tenGoiThau,
                badgeLabel: PeDisplayStatusLabel[display],
                badgeClass: PeDisplayStatusColor[display],
                // [S175 · O6] Host truyền callback ⇒ xem TẠI CHỖ; không truyền ⇒ navigate
                // như cũ. Ternary y khuôn leaf GĐ2 ở `:233` — 2 giai-đoạn cùng một luật.
                // `expand=1` để mở overlay chi tiết — deep-link từ trang khác
                // chạy được ở mọi bề rộng (panel giữa của trang PE ẩn dưới lg).
                onOpen: opts.onOpenPe
                  ? () => opts.onOpenPe!(p.id)
                  : () => navigate(`/purchase-evaluations?id=${p.id}&expand=1`),
              }
            }),
          }

    // ── GĐ2 — Kế hoạch Hợp đồng (8 ngăn theo NHÓM DUYỆT) ───────────────────
    const khkkItems = linkage.canKhkk ? peItems.flatMap(p => linkage.khkkByPeId.get(p.id) ?? []) : []
    const toKhkkLeaf = (k: KhkkListItemDto) => ({
      id: k.id,
      primary: k.maKeHoach ?? '(chưa cấp mã)',
      secondary: k.peTenGoiThau,
      badgeLabel: KHKK_PHASE_LABELS[k.phase],
      badgeClass: KHKK_PHASE_BADGE[k.phase],
      // [S171 M-2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
      onOpen: opts.onOpenKhkk ? () => opts.onOpenKhkk!(k.id) : () => navigate(`/khkk/${k.id}`),
    })
    const khkkLeaves = khkkItems.map(toKhkkLeaf)

    // [K5 S166 — vá-9] Chia phiếu về ngăn theo `approvalGroup` CỦA CHÍNH PHIẾU.
    // Phiếu thiếu nhóm / nhóm ngoài miền 1..8 KHÔNG được rơi ra ngoài im lặng —
    // gom vào ngăn "(chưa phân nhóm)" để nhìn thấy được mà đi sửa dữ liệu.
    // (`approvalGroup` khai `number` nhưng payload cũ còn trong cache React Query
    //  có thể chưa có field — K2 cũng để `!= null` guard ở 2 trang.)
    const khkkByGroup = new Map<number, KhkkListItemDto[]>()
    const khkkUngrouped: KhkkListItemDto[] = []
    for (const k of khkkItems) {
      const g = k.approvalGroup
      if (typeof g === 'number' && (KHKK_APPROVAL_GROUPS as readonly number[]).includes(g)) {
        const bucket = khkkByGroup.get(g)
        if (bucket) bucket.push(k)
        else khkkByGroup.set(g, [k])
      } else {
        khkkUngrouped.push(k)
      }
    }
    // 🔴 HIỆN ĐỦ 8 NGĂN KỂ CẢ RỖNG — mirror tiền lệ folder giai đoạn chưa có gì
    // (chip "Sắp"): khung nhóm là thứ người dùng cần thấy để biết chỗ nào trống
    // và bấm vào lập phiếu, ẩn đi thì họ phải đoán hệ thống có mấy nhóm.
    const khkkGroups = KHKK_APPROVAL_GROUPS.map(n => ({
      key: `khkk-g${n}`,
      label: khkkGroupMenuLabel(n),
      leaves: (khkkByGroup.get(n) ?? []).map(toKhkkLeaf),
      // Khớp ĐÚNG URL leaf sidebar nhóm n (K4b) ⇒ bấm ngăn hay bấm menu ra cùng
      // một trang, và trang đó tự sáng đúng mục bên trái.
      onOpenAll: () => navigate(`/khkk/list?group=${n}`),
    }))
    // Ngăn "chưa phân nhóm" CHỈ hiện khi thật sự có phiếu rơi vào — khác 8 ngăn
    // trên (8 nhóm là khung cố định của nghiệp vụ, còn đây là chỗ chứa bất thường).
    if (khkkUngrouped.length > 0) {
      khkkGroups.push({
        key: 'khkk-g0',
        label: '(chưa phân nhóm)',
        leaves: khkkUngrouped.map(toKhkkLeaf),
        onOpenAll: () => navigate('/khkk/list'),
      })
    }

    // Bất-biến Σ: mỗi phiếu vào ĐÚNG một ngăn ⇒ tổng các ngăn == tổng folder.
    // Bảo đảm bằng cấu trúc (vòng lặp trên phân hoạch `khkkItems`), dòng dưới chỉ
    // là chuông báo khi ai đó sửa vòng lặp thành lọc-trùng/lọc-sót. Dev-only:
    // người dùng cuối không cần thấy, nhưng người sửa code thì cần.
    const khkkGrouped = khkkGroups.reduce((s, g) => s + g.leaves.length, 0)
    if (import.meta.env.DEV && khkkGrouped !== khkkLeaves.length) {
      console.warn(
        `[pipeline GĐ2] Σ ngăn (${khkkGrouped}) ≠ Σ folder (${khkkLeaves.length}) — có phiếu bị nhân đôi hoặc rơi mất khi chia nhóm.`,
      )
    }

    const stage2: PipelineStage = {
      n: 2,
      leaves: khkkLeaves,
      // [S171 B1] `content` ⇒ thân folder do host vẽ (card phiếu KHKK) và `groups` bị bỏ
      // qua; `count` PHẢI đi cùng, mặc định = số phiếu của gói (không để rơi về 0).
      ...(opts.stage2Content !== undefined
        ? {
            content: opts.stage2Content(khkkItems),
            count: (opts.stage2Count ?? (its => its.length))(khkkItems),
          }
        : { groups: khkkGroups }),
      locked: !linkage.canKhkk,
      failed: linkage.khkkFailed,
      loading: linkage.khkkLoading,
      truncated: linkage.indexTruncated,
    }

    // ── GĐ3 / GĐ4 — HĐ trỏ bởi phiếu (GĐ4 = tập con đã có bản cứng) ─────────
    // HĐ nằm ngoài index (list kẹp 200) vẫn được nêu bằng leaf tối giản thay vì
    // biến mất im lặng — nhưng KHÔNG suy được `hasSealedCopy` ⇒ chỉ vào GĐ3.
    //
    // [K5 S166 — forward-provision B3-A5 · K7 S167 vá-4b ĐÃ CẮM NGUỒN 2] Lấy HĐ của gói
    // bằng một TẬP id gộp từ NHIỀU nguồn, thay vì đọc thẳng một ô `pe.contractId`.
    //
    // 🔴 VÌ SAO CẦN: 1 gói có thể đẻ N hợp đồng (liên danh, và từ K7 là N dòng phiếu
    //    KHKK × mỗi lần bấm "Đưa vào HĐ"), NHƯNG phía PE chỉ ghi ngược HĐ ĐẦU:
    //      `CreateContractFromEvaluationFeatures.cs:143-145`
    //      "Idempotency: pe.ContractId = HĐ ĐẦU … N HĐ liên-danh truy qua changelog"
    //    ⇒ đọc mỗi `pe.contractId` thì HĐ thứ 2 trở đi VÔ HÌNH trên cây (spec cụm-3 vá-4).
    //
    // 2 nguồn (dedupe bằng `Set` — chúng CỐ Ý chồng nhau ở HĐ đầu tiên):
    //   (1) `pe.contractId` — LEGACY, giữ cho phiếu PE cũ đã bắc cầu bằng đường PE→HĐ.
    //   (2) `khkkPlanContractIds(k)` — HĐ bắc cầu từ TỪNG DÒNG phiếu KHKK của gói. K7 ghi
    //       `Line.ContractId` là NGUỒN THẬT, `pe.ContractId ??=` chỉ còn là fallback hiển
    //       thị ⇒ N HĐ / 1 PE hiện ĐỦ, đúng OG-1 "máy rộng, UI hẹp".
    const contractIdsOfPackage = new Set<string>()
    for (const p of peItems) {
      if (p.contractId) contractIdsOfPackage.add(p.contractId)
    }
    for (const k of khkkItems) {
      for (const cid of khkkPlanContractIds(k)) contractIdsOfPackage.add(cid)
    }
    // Nguồn (2) IM khi BE chưa trả field trên LIST payload — mà "im vì chưa nối" trông y
    // hệt "im vì gói chưa có HĐ nào". Nói ra ở DEV thay vì để người sau tưởng cây đã đủ.
    // Chỉ báo khi có phiếu ĐÃ DUYỆT (chỉ phiếu duyệt rồi mới bắc cầu được) mà KHÔNG phiếu
    // nào mang field — `[]` (BE đã trả, gói chưa có HĐ) thì im, đúng như phải thế.
    if (import.meta.env.DEV && !warnedMissingLineContractIds) {
      const approved = khkkItems.filter(k => k.phase === KhkkPhase.DaDuyet)
      if (approved.length > 0 && approved.every(k => k.contractIds == null)) {
        warnedMissingLineContractIds = true
        console.warn(
          '[pipeline GĐ3] Phiếu KHKK đã duyệt nhưng payload list KHÔNG có `contractIds` ⇒ cây ' +
          'chỉ thấy HĐ qua `pe.contractId` (HĐ ĐẦU). API cũ chưa deploy hoặc cache cũ — xem ghi ' +
          'chú `types/khkk.ts` (K7 vá-4b, BE `…ListItemDto:75`).',
        )
      }
    }

    const stage3Leaves: PipelineStage['leaves'] = []
    const stage4Leaves: PipelineStage['leaves'] = []
    // `Set` đã khử trùng ⇒ không cần biến `seen` riêng như trước.
    for (const contractId of contractIdsOfPackage) {
      const c = linkage.contractById.get(contractId)
      // Tên gói thầu để làm dòng phụ khi HĐ nằm ngoài cửa sổ index 200. [K7 vá-4b] tra CẢ
      // 2 nguồn: HĐ đến từ dòng phiếu KHKK thì KHÔNG phiếu PE nào trỏ tới nó ⇒ chỉ tra
      // `peItems` sẽ rơi về `null` và dòng phụ trống trơn.
      const fallbackName =
        peItems.find(p => p.contractId === contractId)?.tenGoiThau
        ?? khkkItems.find(k => khkkPlanContractIds(k).includes(contractId))?.peTenGoiThau
        ?? null
      // [S190 W2] Vế lọc loại HĐ — CHỈ chạy khi host bật `stage3TypeFilter` (opt-in).
      // 🔴 QUYẾT ĐỊNH "THÀ THỪA CÒN HƠN THIẾU": `c` vắng = HĐ ngoài cửa sổ index 200 (xem
      // `primary` ngay dưới) ⇒ KHÔNG có `c.type` để phân loại. Ẩn nó đi khi filter bật là làm
      // HĐ biến mất IM LẶNG — đúng cái bệnh F-3 S162 đã vá bằng nhãn "(ngoài chỉ mục 200)".
      // Vậy nên `!c` được cho QUA: người dùng thấy thừa 1 leaf còn hơn mất trắng 1 HĐ.
      const passStage3Type = !opts.stage3TypeFilter || !c || c.type === opts.stage3TypeFilter
      if (linkage.canContract && passStage3Type) {
        stage3Leaves.push({
          id: contractId,
          // [F-3 S162] c vắng = HĐ NGOÀI cửa sổ index 200 — đừng dán nhãn
          // "(chưa cấp mã)" (trộn 2 nghĩa khác nhau).
          primary: c ? (c.maHopDong ?? '(chưa cấp mã)') : '(ngoài chỉ mục 200 — mở để xem)',
          secondary: c?.tenHopDong ?? fallbackName,
          badgeLabel: c ? ContractPhaseLabel[c.phase] : null,
          badgeClass: c ? ContractPhaseColor[c.phase] : null,
          // [S190 W2] Host truyền callback ⇒ chọn tại chỗ; không truyền ⇒ navigate như cũ.
          // Ternary y khuôn leaf GĐ1 `:228-230` / GĐ2 `:244` — 3 giai-đoạn cùng một luật.
          onOpen: opts.onOpenContract
            ? () => opts.onOpenContract!(contractId)
            : () => navigate(`/contracts/${contractId}`),
        })
      }
      if (linkage.canHardCopy && c?.hasSealedCopy) {
        stage4Leaves.push({
          id: contractId,
          primary: c.maHopDong ?? '(chưa cấp mã)',
          secondary: c.tenHopDong,
          badgeLabel: 'Đã lưu bản cứng',
          badgeClass: 'bg-emerald-100 text-emerald-800',
          // Trang "Hợp đồng cứng" đọc `?type=` từ URL (HardCopiesPage.tsx:64-67)
          // ⇒ mở đúng nhóm loại HĐ của bộ này.
          onOpen: () => navigate(`/hard-copies?type=${c.type}`),
        })
      }
    }

    const stage3: PipelineStage = {
      n: 3,
      leaves: stage3Leaves,
      locked: !linkage.canContract,
      failed: linkage.contractFailed,
      loading: linkage.contractLoading,
      truncated: linkage.indexTruncated,
    }
    const stage4: PipelineStage = {
      n: 4,
      leaves: stage4Leaves,
      locked: !linkage.canHardCopy,
      failed: linkage.contractFailed,
      loading: linkage.contractLoading,
      truncated: linkage.indexTruncated,
    }

    return [stage1, stage2, stage3, stage4]
  }

  return { ...linkage, buildStages }
}
