import { useEffect, useMemo, useState } from 'react'
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import axios from 'axios'
import {
  Alert,
  Badge,
  Button,
  Checkbox,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
  EmptyState,
  IconButton,
  LoadingBlock,
  Modal,
  QueryErrorState,
  SearchInput,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
  TextInput,
} from '@/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, CaretLeftIcon, CaretRightIcon, DotsThreeVerticalIcon, GitBranchIcon } from '@phosphor-icons/react'
import {
  useAudit,
  useChangelog,
  useDictActiveSchemaDraft,
  useDictionariesDeleted,
  useDictionaryDetail,
  useDictPendingDrafts,
  useRecordRaw,
  useRecords,
  useScheduledSummary,
  type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useDeleteDictionary, useRestoreDictionary, useFormIdempotencyKey, useSubmitDraft } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import { useIsAdmin } from '@/auth/usePermissions'
import { cn } from '@/lib/utils'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { RecordDrawer } from '@/components/record/RecordDrawer'
import { ConflictDiffModal } from '@/components/record/ConflictDiffModal'
import { WorkflowBanner } from '@/components/editor/WorkflowBanner'
import { EditorInfoPanel } from '@/components/editor/EditorInfoPanel'
import { ExportModal } from '@/components/editor/ExportModal'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
import { ChangelogDiffModal } from '@/components/changelog/ChangelogDiffModal'
import { kindBadgeVariant, kindIcon, kindLabel, isVersioningKind } from '@/components/changelog/kind-meta'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import { UserCell } from '@/lib/useUserDisplay'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
import { useDictTableState, applySort } from '@/lib/useDictTableState'
import { ColumnVisibilityMenu, type ColumnOption } from '@/components/dict/ColumnVisibilityMenu'
import { SortableHeaderCell } from '@/components/dict/SortableHeaderCell'

/**
 * URL search params для filter state — share-friendly, persist между F5.
 * - q: search query (free text)
 * - scopes: csv subset of PUBLIC/INTERNAL/RESTRICTED. Empty/missing = все.
 * - bbox: AOI как CSV "west,south,east,north". Polygon AOI в URL не
 *   сериализуем — слишком длинно (50+ точек), оставляем в local state.
 *   Если bbox задан — initial AOI установится в bbox-режим при mount'е.
 */
type DictSearch = {
  q?: string
  scopes?: string
  bbox?: string
  /** Time-travel: ISO datetime "просмотр на момент X". Если задан — backend
   *  findActiveAt(at) возвращает active version на этот момент вместо now(). */
  at?: string
  /** Active editor tab (Stage 3.4 handoff design):
   *  - records (default): table + filters + edit modal
   *  - relations: dependents graph (incoming FK refs + outgoing schema FKs)
   *  - fields: schema properties grid
   *  - json: raw schema JSON viewer
   *  - events: audit log filtered к этому dict
   *  - history: timeline записей changes
   *  Backward-compat: старый ?view=hub аутенитично remap'нется в ?tab=relations. */
  tab?: 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'
}

const SCOPE_VALUES: ReadonlySet<DataScope> = new Set(['PUBLIC', 'INTERNAL', 'RESTRICTED'])

const validateSearch = (raw: Record<string, unknown>): DictSearch => {
  const out: DictSearch = {}
  if (typeof raw.q === 'string' && raw.q.length > 0) out.q = raw.q
  if (typeof raw.scopes === 'string') {
    const valid = raw.scopes
      .split(',')
      .map((s) => s.trim().toUpperCase())
      .filter((s): s is DataScope => SCOPE_VALUES.has(s as DataScope))
    if (valid.length > 0) out.scopes = valid.join(',')
  }
  if (typeof raw.bbox === 'string' && /^-?\d+(\.\d+)?(,-?\d+(\.\d+)?){3}$/.test(raw.bbox)) {
    out.bbox = raw.bbox
  }
  // ISO datetime check: 2026-05-08T12:00:00Z или 2026-05-08T12:00:00+03:00.
  if (typeof raw.at === 'string' && !isNaN(Date.parse(raw.at))) {
    out.at = raw.at
  }
  // Tab param + backward-compat для старого ?view=hub.
  const TABS: ReadonlySet<DictSearch['tab']> = new Set([
    'records', 'relations', 'fields', 'json', 'events', 'history',
  ])
  if (typeof raw.tab === 'string' && TABS.has(raw.tab as DictSearch['tab'])) {
    out.tab = raw.tab as DictSearch['tab']
  } else if (raw.view === 'hub') {
    out.tab = 'relations'
  }
  return out
}

export const Route = createFileRoute('/dictionaries/$name')({
  component: DictionaryDetail,
  validateSearch,
})

type EditState =
  | { kind: 'closed' }
  | { kind: 'create' }
  | { kind: 'edit'; record: FlattenedRecord }
  | { kind: 'close-confirm'; record: FlattenedRecord }

function DictionaryDetail() {
  const { name } = Route.useParams()
  const urlSearch = Route.useSearch()
  const navigate = useNavigate({ from: Route.fullPath })
  const { t } = useTranslation()
  const detailQuery = useDictionaryDetail(name)
  const canMutate = useCanMutate()
  // Phase 1c: show «Create draft» button only когда approvalRequired=true
  // и нет active draft'а (иначе banner предложит open existing). Live (без
  // approval flag) словари редактируются напрямую через EditorDialog —
  // создавать draft не нужно.
  const activeSchemaDraftQuery = useDictActiveSchemaDraft(name)
  const shouldOfferCreateDraft =
    detailQuery.data?.approvalRequired === true &&
    !activeSchemaDraftQuery.data

  // AOI: bbox из URL → bbox-AOI rendered initially; polygon AOI живёт
  // только в local state (не сериализуем в URL — слишком длинно).
  // setAoi записывает в URL bbox только для bbox-mode (полигон не жмём в URL).
  const [aoi, setAoi] = useState<AoiResult | null>(() => bboxToAoi(urlSearch.bbox))

  // Time-travel state — keep ISO string in URL так чтобы user мог share link
  // "посмотри dict на 2025-12-31T00:00:00Z". Empty/undefined = now().
  const timeTravelAt: string | undefined = urlSearch.at
  const setTimeTravelAt = (next: string | undefined) => {
    void navigate({
      search: (prev) => ({ ...prev, at: next || undefined }),
      replace: true,
    })
  }

  const filter: RecordsFilter | undefined =
    aoi || timeTravelAt
      ? {
          ...(aoi?.kind === 'bbox' ? { bbox: aoi.bboxCsv } : {}),
          ...(aoi?.kind === 'polygon' ? { polygon: aoi.geojson } : {}),
          ...(timeTravelAt ? { at: timeTravelAt } : {}),
        }
      : undefined
  const recordsResult = useRecords(name, 'PUBLIC,INTERNAL,RESTRICTED', filter)
  /**
   * Scheduled records hint (post-Phase-B-2 UX fix). Когда records list пустой
   * на момент now / time-travel, но в БД есть записи с validFrom > now,
   * показываем «Запланировано N, ближайшая на DD.MM [Перейти к этой дате]».
   * Fixes user feedback: «создал запись на 18.05 — её не видно».
   *
   * <p>Fetch unconditional (cheap single-aggregate query). Frontend сам решает
   * показывать hint или нет по {@code count > 0}.
   */
  const scheduledSummary = useScheduledSummary(name, 'PUBLIC,INTERNAL,RESTRICTED')
  /**
   * Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
   * "На review" в каждой строке records table. Polled 30s. Bypass'нем если
   * dict не approval-required (нет смысла грузить).
   */
  // Гость (anonymous) — drafts polling требует JWT (401 каждые 30s в console).
  // Disable если не авторизован.
  const pendingDraftsQuery = useDictPendingDrafts(
    canMutate && detailQuery.data?.approvalRequired ? name : undefined,
  )
  const pendingByKey = useMemo(() => {
    const set = new Set<string>()
    for (const d of pendingDraftsQuery.data ?? []) set.add(d.businessKey)
    return set
  }, [pendingDraftsQuery.data])
  /**
   * Set<businessKey> для записей имеющих scheduled future version. Используется
   * рендерить «Запланировано» badge per row в records table — user сразу видит
   * у каких записей есть upcoming изменения.
   */
  const scheduledByKey = useMemo(() => {
    const set = new Set<string>()
    for (const bk of scheduledSummary.data?.scheduledBusinessKeys ?? []) set.add(bk)
    return set
  }, [scheduledSummary.data?.scheduledBusinessKeys])
  const createMut = useCreateRecord(name)
  const updateMut = useUpdateRecord(name)
  // Approval Workflow v2 (D2=A): когда dict отмечен approvalRequired=true,
  // backend rejects direct CRUD с 409 draft_required. Используем submit-draft
  // путь автоматически — UX same form, но POST в /records/drafts вместо
  // /records. Maker подаёт proposal, reviewer approve'нёт → запись применится.
  const submitDraftMut = useSubmitDraft(name)
  const approvalRequired = detailQuery.data?.approvalRequired === true
  // Per-form idempotency key. Stable между ререндерами для одной сессии edit
  // modal'а: двойной submit (Enter + click) не создаст 2 record'а — backend
  // deduplicate'ит по тому же ключу. Reset на onSuccess.
  const [recordIdempotencyKey, resetRecordIdempotencyKey] = useFormIdempotencyKey()
  const closeMut = useCloseRecord(name)
  const bulkCloseMut = useBulkCloseRecords(name)
  const bulkExportMut = useBulkExportRecords(name)
  // Delete dict mutation — admin-only через UI button в InfoPanel.
  const deleteMut = useDeleteDictionary()
  const isAdmin = useIsAdmin()
  const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
  const [deleteError, setDeleteError] = useState<string | null>(null)

  const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
  const [closeReason, setCloseReason] = useState('')
  // Dirty-field count surfaced by SchemaDrivenForm → shown в RecordDrawer footer.
  const [recordDirtyCount, setRecordDirtyCount] = useState(0)
  // Drawer toolbar state per handoff Screen 3 line 279 (search + onlyFilled + counter).
  const [drawerSearch, setDrawerSearch] = useState('')
  const [drawerOnlyFilled, setDrawerOnlyFilled] = useState(false)
  const [drawerCount, setDrawerCount] = useState<{ visible: number; total: number }>({
    visible: 0,
    total: 0,
  })
  const [schemaEditOpen, setSchemaEditOpen] = useState(false)
  const [createDraftOpen, setCreateDraftOpen] = useState(false)
  const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
  const [aoiOpen, setAoiOpen] = useState(false)
  const [timeTravelOpen, setTimeTravelOpen] = useState(false)
  const [exportOpen, setExportOpen] = useState(false)
  /** 409 save conflict: capture local payload + businessKey для diff modal.
   *  Открывается из createMut/updateMut onError когда status===409. */
  const [conflict, setConflict] = useState<{
    payload: CreateRecordRequest
    businessKey: string
  } | null>(null)
  /** Phase 3 dict-relationships-v2: cascade dialog state. Открывается из 409
   *  на close, или явным action из record menu. */
  const [cascadeKey, setCascadeKey] = useState<string | undefined>(undefined)

  // Multi-select state. Set<businessKey> — стабильное id'шник. При изменении
  // records data (mutation success / refetch / filter change) — selection
  // не очищаем автоматически, но выкидываем те ключи которых больше нет
  // в видимом фильтре (избегаем "невидимых" выбранных строк).
  const [selection, setSelection] = useState<Set<string>>(new Set())
  const [bulkOpen, setBulkOpen] = useState(false)
  const [bulkReason, setBulkReason] = useState('')
  const [bulkResult, setBulkResult] = useState<BulkCloseResponse | null>(null)

  // Search & scope filter — URL-derived. Empty/missing = все scope активны.
  const search = urlSearch.q ?? ''
  const scopeFilter = useMemo<Set<DataScope>>(
    () => new Set(urlSearch.scopes ? (urlSearch.scopes.split(',') as DataScope[]) : []),
    [urlSearch.scopes],
  )

  const setSearch = (next: string) => {
    void navigate({
      search: (prev) => ({ ...prev, q: next.length > 0 ? next : undefined }),
      replace: true,
    })
  }

  // toggleScope удалён вместе с scope chips в records toolbar
  // (заменены на RecordColumnFilters status/operator dropdowns).
  // scopeFilter URL param остаётся read-only — backwards-compat shared links.

  const handleAoiApply = (result: AoiResult) => {
    setAoi(result)
    // bbox в URL для share; polygon оставляем в memory.
    void navigate({
      search: (prev) => ({
        ...prev,
        bbox: result.kind === 'bbox' ? result.bboxCsv : undefined,
      }),
      replace: true,
    })
  }

  const handleAoiClear = () => {
    setAoi(null)
    void navigate({
      search: (prev) => ({ ...prev, bbox: undefined }),
      replace: true,
    })
  }

  const handleClearFilters = () => {
    setColumnFilters({})
    void navigate({
      search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
      replace: true,
    })
  }

  // Уникальные validFrom timestamps для timeline marks. Используем raw
  // recordsResult (не filtered) чтобы видеть все версии независимо от scope/q
  // фильтров. PLUS scheduled upcoming validFroms — user feedback: «на таймтревел
  // отметки нужны где записи есть или меняются, а не просто +30 и ищи потом».
  // После !220 расширили range до +30 дней, теперь добавляем future record
  // validFroms как marks чтобы slider показывал «вот тут запись появится».
  const recordTimestamps = useMemo(() => {
    const set = new Set<number>()
    for (const r of recordsResult.data ?? []) {
      const ts = new Date(r.validFrom).getTime()
      if (!Number.isNaN(ts)) set.add(ts)
    }
    for (const iso of scheduledSummary.data?.upcomingValidFroms ?? []) {
      const ts = new Date(iso).getTime()
      if (!Number.isNaN(ts)) set.add(ts)
    }
    return Array.from(set).sort((a, b) => a - b)
  }, [recordsResult.data, scheduledSummary.data?.upcomingValidFroms])

  // Column filters — per-column value match (e.g. status='operational',
  // operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
  // Replaces прошлый scope chips filter в records toolbar per user feedback.
  const [columnFilters, setColumnFilters] = useState<Record<string, string>>({})

  const filteredRecords = useMemo(() => {
    const raw = recordsResult.data ?? []
    if (raw.length === 0) return raw
    const q = search.trim().toLowerCase()
    const scopes = scopeFilter
    const colKeys = Object.entries(columnFilters)
    return raw.filter((r) => {
      if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
      // Column filters — exact match по data[field].
      for (const [field, value] of colKeys) {
        if (!value) continue
        const v = (r.data as Record<string, unknown>)[field]
        if (String(v ?? '') !== value) return false
      }
      if (q.length === 0) return true
      // Search по businessKey + JSON.stringify(data) — простой,
      // покрывает локализованные поля, code, описания. Stringify
      // O(n) в memory, для 100 записей мгновенно.
      if (r.businessKey.toLowerCase().includes(q)) return true
      const haystack = JSON.stringify(r.data).toLowerCase()
      return haystack.includes(q)
    })
  }, [recordsResult.data, search, scopeFilter, columnFilters])

  const filtersActive =
    search.length > 0 ||
    scopeFilter.size > 0 ||
    Object.values(columnFilters).some(Boolean)
  const filteredCount = filteredRecords.length

  // Client-side pagination — типичный словарь до 100 записей, server-side
  // пейджинг overkill. Page 0-indexed. PAGE_SIZE = 50 — достаточно чтобы
  // увидеть все основные dictionaries без листания.
  const PAGE_SIZE = 50
  const [page, setPage] = useState(0)
  const totalPages = Math.max(1, Math.ceil(filteredCount / PAGE_SIZE))

  // При изменении filter — на page 0 (иначе page может оказаться вне диапазона)
  useEffect(() => {
    setPage(0)
  }, [search, scopeFilter, aoi])

  // First-visit defaults: с info-dense schemas (10+ scalar полей) полная
  // таблица не помещается в viewport → H-scroll. Hide хвост по умолчанию
  // — показываем businessKey, name, scope, validFrom + первые 2 extra
  // (обычно самые важные ключи). Юзер открывает menu Колонки и сам
  // добавляет нужные — выбор persisted в localStorage и при следующем
  // визите fallback уже не применяется.
  const defaultHiddenExtras = useMemo<string[]>(() => {
    const props = detailQuery.data?.schemaJson?.properties ?? {}
    const eligible: string[] = []
    for (const [key, prop] of Object.entries(props)) {
      if (key === 'name' || key === 'code' || key === 'businessKey') continue
      if (Boolean(prop['x-localized'])) continue
      const t = prop.type
      if (t === 'array' || t === 'object') continue
      eligible.push(key)
    }
    return eligible.slice(2) // первые 2 видны, остальные default-hidden
  }, [detailQuery.data?.schemaJson])

  // Table state — sort (cycle none→asc→desc) + hidden columns persisted в
  // localStorage per dict через useDictTableState.
  const dictTable = useDictTableState(name, defaultHiddenExtras)

  // Getter для applySort — извлекает значение по col key из record.
  // Изолирован чтобы sort работал унифицировано для всех типов колонок.
  const recordColValue = useMemo(
    () => (r: FlattenedRecord, col: string): unknown => {
      if (col === 'businessKey') return r.businessKey
      if (col === 'name') {
        return recordDisplayName(
          r.data,
          detailQuery.data?.defaultLocale ?? 'ru-RU',
        )
      }
      if (col === 'scope') return r.dataScope
      if (col === 'validFrom') return r.validFrom
      return r.data?.[col]
    },
    [detailQuery.data?.defaultLocale],
  )

  // Sort применяем к filteredRecords ДО pagination — иначе sort внутри
  // страницы (например asc) показал бы только N сортированных значений из
  // пула N+M, остальные на следующих страницах. Sort scope = filtered set.
  const sortedRecords = useMemo(
    () => applySort(filteredRecords, dictTable.sort, recordColValue),
    [filteredRecords, dictTable.sort, recordColValue],
  )

  const visibleRecords = useMemo(
    () => sortedRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
    [sortedRecords, page],
  )

  // Каноничный список видимых ключей — для select-all и для очистки selection
  // от ключей которые больше не видны (filter изменился, запись закрылась).
  // С client-side pagination "видимые" = только текущая страница, чтобы
  // select-all чекбокс трогал ровно то что юзер видит на экране.
  const visibleKeys = useMemo(
    () => visibleRecords.map((r) => r.businessKey),
    [visibleRecords],
  )

  // Когда видимый набор сократился — выкидываем из selection невидимые ключи.
  // Иначе bulk close может пытаться закрыть запись которую юзер уже не видит,
  // что нарушает "WYSIWYG" принцип multi-select toolbar'а.
  useEffect(() => {
    setSelection((prev) => {
      if (prev.size === 0) return prev
      const visibleSet = new Set(visibleKeys)
      const next = new Set<string>()
      for (const k of prev) if (visibleSet.has(k)) next.add(k)
      return next.size === prev.size ? prev : next
    })
  }, [visibleKeys])

  const allVisibleSelected =
    visibleKeys.length > 0 && visibleKeys.every((k) => selection.has(k))
  const someVisibleSelected =
    !allVisibleSelected && visibleKeys.some((k) => selection.has(k))

  // Stage 3.9: Radix Checkbox принимает {@code checked="indeterminate"} как
  // tri-state значение declaratively. Старый useEffect+ref hack (для нативного
  // input.indeterminate JS-only prop) больше не нужен.
  const selectAllChecked: boolean | 'indeterminate' = someVisibleSelected
    ? 'indeterminate'
    : allVisibleSelected

  // === Keyboard shortcuts per handoff Screen 2 line 438 ===
  // j/k — move row cursor up/down
  // Enter — open drawer для cursor row
  // Esc — close drawer (handled Radix natively)
  // n — open new-record drawer
  // / — focus search (handled TopBar ⌘K shortcut; tab-style / focuses search)
  //
  // Cursor — index в visibleRecords. -1 когда нет focus (initial state).
  const [rowCursor, setRowCursor] = useState(-1)
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      const target = e.target as HTMLElement
      // Skip когда юзер в textarea/input — не мешаем typing.
      if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return
      if (target && target.isContentEditable) return
      // Skip когда drawer / modal открыт (focus inside Radix portal).
      if (edit.kind !== 'closed') return
      // Skip если другие routes — ловим только на /dictionaries/$name тут.

      if (e.key === 'j' || e.key === 'ArrowDown') {
        e.preventDefault()
        setRowCursor((c) => Math.min(visibleRecords.length - 1, c + 1))
      } else if (e.key === 'k' || e.key === 'ArrowUp') {
        e.preventDefault()
        setRowCursor((c) => Math.max(0, c - 1))
      } else if (e.key === 'Enter' && rowCursor >= 0 && rowCursor < visibleRecords.length) {
        e.preventDefault()
        setEdit({ kind: 'edit', record: visibleRecords[rowCursor] })
      } else if (e.key === 'n' && canMutate) {
        e.preventDefault()
        setEdit({ kind: 'create' })
      }
    }
    window.addEventListener('keydown', handler)
    return () => window.removeEventListener('keydown', handler)
  }, [visibleRecords, rowCursor, edit.kind, canMutate])

  // Reset cursor когда visibleRecords меняется (filter, page).
  useEffect(() => {
    setRowCursor(-1)
  }, [page, search, scopeFilter, aoi])

  const toggleSelect = (key: string) => {
    setSelection((prev) => {
      const next = new Set(prev)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      return next
    })
  }

  const toggleSelectAll = () => {
    setSelection((prev) => {
      if (visibleKeys.every((k) => prev.has(k))) {
        // Все видимые выбраны → снимаем именно их (не трогаем невидимые,
        // если такие были; на практике их не должно быть благодаря useEffect)
        const next = new Set(prev)
        for (const k of visibleKeys) next.delete(k)
        return next
      }
      const next = new Set(prev)
      for (const k of visibleKeys) next.add(k)
      return next
    })
  }

  const clearSelection = () => setSelection(new Set())

  const openBulkClose = () => {
    setBulkResult(null)
    setBulkReason('')
    setBulkOpen(true)
  }

  // handleBulkExport больше не нужен — bulk export через InfoPanel "Экспорт..."
  // → ExportModal автоматически pre-picks scope=selected когда selection непуст.

  const [bulkDraftRunning, setBulkDraftRunning] = useState(false)
  const handleBulkClose = async () => {
    if (selection.size === 0) return
    if (approvalRequired) {
      // Approval Workflow v2: на approval-required dict bulk-close ходит
      // не через POST /records/_bulk_close (вернёт 409 draft_required для
      // каждого), а через N отдельных draft submissions (operation=CLOSE).
      // Backend сейчас не имеет _bulk endpoint для drafts, делаем Promise.all
      // на клиенте — N drafts создаются параллельно, результат собирается
      // в тот же BulkCloseResponse shape.
      setBulkDraftRunning(true)
      const targets = Array.from(selection)
      const result: BulkCloseResponse = { closed: [], skipped: [], errors: [] }
      await Promise.all(
        targets.map(async (bk) => {
          try {
            await submitDraftMut.mutateAsync({
              businessKey: bk,
              operation: 'CLOSE',
              comment: bulkReason || null,
            })
            // 'closed' семантически здесь = 'submitted as CLOSE draft'.
            // Frontend label показывает «отправлено на ревью» когда approvalRequired.
            result.closed.push(bk)
          } catch (e: unknown) {
            const code =
              (e as { response?: { data?: { code?: string } } })?.response
                ?.data?.code ??
              (e as Error)?.message ??
              'unknown'
            // draft_already_pending — этот key уже на ревью, skip (не error).
            if (code === 'draft_already_pending') {
              result.skipped.push({ businessKey: bk, reason: code })
            } else {
              result.errors.push({ businessKey: bk, reason: code })
            }
          }
        }),
      )
      setBulkResult(result)
      setSelection((prev) => {
        const next = new Set(prev)
        for (const k of result.closed) next.delete(k)
        return next
      })
      setBulkDraftRunning(false)
      return
    }
    bulkCloseMut.mutate(
      {
        businessKeys: Array.from(selection),
        reason: bulkReason || undefined,
      },
      {
        onSuccess: (res) => {
          setBulkResult(res)
          // Снимаем selection с успешно закрытых; оставляем skipped/errors
          // чтобы юзер видел что ещё не сделано.
          setSelection((prev) => {
            const next = new Set(prev)
            for (const k of res.closed) next.delete(k)
            return next
          })
        },
      },
    )
  }

  // Schema-driven extra columns: ВСЕ scalar non-localized поля schema'ы
  // редактируемые в drawer'е — те же что и в edit-form. Юзер сам через
  // ColumnVisibilityMenu выбирает что показывать (стейт persisted в
  // localStorage per dict).
  //
  // Skip: name/code/businessKey (primary identifier уже отдельной колонкой),
  // localized (требует locale-aware рендер, не подходит для compact pill),
  // array/object (рендеримся как [object Object] — бесполезно в строке).
  //
  // Cap 30 — soft защита от absurd schemas. На практике справочники ЦУОД
  // имеют 5-15 полей, drawer показывает их все — таблица теперь
  // паритетна. FK маркеры (x-references) подсвечиваются accent цветом
  // в строке row, остальные — нейтральный ink pill.
  const extraColumns = useMemo<{ key: string; label: string; isFk: boolean }[]>(() => {
    const props = detailQuery.data?.schemaJson?.properties ?? {}
    const interesting: { key: string; label: string; isFk: boolean }[] = []
    for (const [key, prop] of Object.entries(props)) {
      if (interesting.length >= 30) break
      if (key === 'name' || key === 'code' || key === 'businessKey') continue
      if (Boolean(prop['x-localized'])) continue
      const type = prop.type
      // Array / object не рендерятся в pill — String([1,2,3]) = "1,2,3"
      // ОК, но String({...}) = "[object Object]". Пропускаем оба.
      if (type === 'array' || type === 'object') continue
      const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references'])
      interesting.push({ key, label: humanize(key), isFk })
    }
    return interesting
  }, [detailQuery.data?.schemaJson])

  // Column registry: pool из которого юзер выбирает что показывать
  // (см. ColumnVisibilityMenu в toolbar). businessKey alwaysVisible
  // (primary identifier, без него nothing makes sense). Остальные —
  // toggleable. Stored в localStorage через useDictTableState (объявлен
  // выше, рядом с sortedRecords).
  const columnOptions = useMemo<ColumnOption[]>(() => {
    const opts: ColumnOption[] = [
      { key: 'businessKey', label: t('dict.col.businessKey'), alwaysVisible: true },
      { key: 'name', label: t('dict.col.name', { defaultValue: 'name / code' }) },
      ...extraColumns.map((c) => ({ key: c.key, label: c.label })),
      { key: 'scope', label: t('dict.col.scope') },
      { key: 'validFrom', label: t('dict.col.validFrom') },
    ]
    return opts
  }, [extraColumns, t])

  const editingKey = edit.kind === 'edit' ? edit.record.businessKey : undefined
  const rawRecordQuery = useRecordRaw(name, editingKey)

  const handleSubmit = (req: CreateRecordRequest) => {
    // Fill validFrom at submit (review #5): defaultValues в RHF берётся
    // ТОЛЬКО при mount. Если user открыл modal в 12:00 и submit в 12:05 —
    // `validFrom` в req это значение из 12:00. Backend check `validFrom >
    // existingFrom` может fail если existing запись свежее. Submit-time
    // refresh гарантирует что validFrom не устарел между mount и submit.
    const submitReq: CreateRecordRequest = {
      ...req,
      validFrom: req.validFrom && req.validFrom.length > 0 ? req.validFrom : nowIsoLocal(),
    }
    // 409 conflict handler — handoff line 444. Detect optimistic-lock
    // mismatch and open ConflictDiffModal (diff editor's payload vs server
    // current state). Other 409 codes (cascade, draft_required) обрабатываются
    // отдельно — здесь только generic conflict ("entity changed concurrently").
    const handleSaveError = (err: unknown, bk: string) => {
      const status = (err as { response?: { status?: number } })?.response?.status
      const code = (err as { response?: { data?: { code?: string } } })
        ?.response?.data?.code
      if (status !== 409) return
      // Specific 409 codes use their own dialogs — skip those.
      if (
        code === 'x_references_blocked_by_dependents' ||
        code === 'x_references_cascade_required' ||
        code === 'draft_required'
      ) {
        return
      }
      // Generic 409 = optimistic-lock conflict. Open diff modal.
      setConflict({ payload: submitReq, businessKey: bk })
    }
    if (edit.kind === 'create') {
      if (approvalRequired) {
        // Approval Workflow v2: POST /records/drafts вместо /records. Backend
        // создаёт draft со status=PENDING, после approve reviewer'ом запись
        // материализуется в live table. UX same form, разный endpoint.
        submitDraftMut.mutate(
          {
            businessKey: submitReq.businessKey ?? '',
            operation: 'CREATE',
            data: submitReq.data,
            geometryWkt: submitReq.geometryWkt ?? null,
            validFrom: submitReq.validFrom ?? null,
            validTo: submitReq.validTo ?? null,
          },
          {
            onSuccess: () => {
              setEdit({ kind: 'closed' })
              resetRecordIdempotencyKey()
            },
            onError: (err) => handleSaveError(err, submitReq.businessKey ?? ''),
          },
        )
        return
      }
      createMut.mutate(
        { payload: submitReq, idempotencyKey: recordIdempotencyKey },
        {
          onSuccess: () => {
            setEdit({ kind: 'closed' })
            resetRecordIdempotencyKey()
          },
          onError: (err) => {
            // Create rarely conflicts (no prior record to lock), но если backend
            // вернёт 409 по уникальному ключу — businessKey возьмём из payload.
            handleSaveError(err, submitReq.businessKey ?? '')
          },
        },
      )
      return
    }
    if (edit.kind === 'edit') {
      if (approvalRequired) {
        // Approval Workflow v2: update тоже через draft (UPDATE proposal).
        submitDraftMut.mutate(
          {
            businessKey: edit.record.businessKey,
            operation: 'UPDATE',
            data: submitReq.data,
            geometryWkt: submitReq.geometryWkt ?? null,
            validFrom: submitReq.validFrom ?? null,
            validTo: submitReq.validTo ?? null,
          },
          {
            onSuccess: () => {
              setEdit({ kind: 'closed' })
              resetRecordIdempotencyKey()
            },
            onError: (err) => handleSaveError(err, edit.record.businessKey),
          },
        )
        return
      }
      updateMut.mutate(
        {
          businessKey: edit.record.businessKey,
          payload: submitReq,
          idempotencyKey: recordIdempotencyKey,
        },
        {
          onSuccess: () => {
            setEdit({ kind: 'closed' })
            resetRecordIdempotencyKey()
          },
          onError: (err) => handleSaveError(err, edit.record.businessKey),
        },
      )
    }
  }

  const handleClose = () => {
    if (edit.kind !== 'close-confirm') return
    const targetKey = edit.record.businessKey
    if (approvalRequired) {
      // Approval Workflow v2: close тоже через draft (operation=CLOSE).
      // Maker подаёт CLOSE proposal, reviewer'у достаточно approve и запись
      // закроется автоматически через recordService.applyApprovedClose.
      submitDraftMut.mutate(
        {
          businessKey: targetKey,
          operation: 'CLOSE',
          comment: closeReason || null,
        },
        {
          onSuccess: () => {
            setEdit({ kind: 'closed' })
            setCloseReason('')
          },
        },
      )
      return
    }
    closeMut.mutate(
      { businessKey: targetKey, reason: closeReason || undefined },
      {
        onSuccess: () => {
          setEdit({ kind: 'closed' })
          setCloseReason('')
        },
        onError: (err: unknown) => {
          // Phase 3 dict-relationships-v2: 409 с cascade-related code
          // открывает CascadeConfirmDialog. Иначе error inline в close-confirm.
          const status =
            (err as { response?: { status?: number } })?.response?.status
          const code = (err as { response?: { data?: { code?: string } } })
            ?.response?.data?.code
          if (
            status === 409 &&
            (code === 'x_references_blocked_by_dependents' ||
              code === 'x_references_cascade_required')
          ) {
            // Reset closeMut.error до открытия cascade dialog'а — иначе stale
            // 409 mesg может проскочить в открытом cascade UI пока user
            // ещё не сделал retry (review #18).
            closeMut.reset()
            setEdit({ kind: 'closed' })
            setCloseReason('')
            setCascadeKey(targetKey)
          }
        },
      },
    )
  }

  // Approval Workflow v2: когда dict approval-required — мы делаем submit
  // через submitDraftMut, поэтому activeMutation должен указывать на него,
  // иначе spinner / error из createMut/updateMut выйдут несинхронно.
  const activeMutation = (() => {
    if (edit.kind === 'create') return approvalRequired ? submitDraftMut : createMut
    if (edit.kind === 'edit') return approvalRequired ? submitDraftMut : updateMut
    return null
  })()
  const serverError = serverErrorMessage(activeMutation?.error)

  const totalRecords = recordsResult.data?.length ?? 0
  const scope = detailQuery.data?.scope

  // Geo capability detection — для скрытия AOI button на non-geo dicts.
  // Эвристика по schema properties: property name matches geom/location/
  // bbox/polygon/coordinates, OR format=geojson. Не идеально (false negatives
  // если backend хранит geometry separately в записи без schema mention),
  // но в practice все geo-dicts имеют geometry property в schemaJson.
  const hasGeoRecords = useMemo(() => {
    const props = detailQuery.data?.schemaJson?.properties
    if (!props) return false
    for (const [key, p] of Object.entries(props)) {
      if (/geom|location|bbox|polygon|coordinates|geo/i.test(key)) return true
      if (p.format === 'geojson' || p.format === 'wkt') return true
    }
    return false
  }, [detailQuery.data?.schemaJson])

  // Full-page loader пока ОБА query (detail + records) не готовы. Иначе
  // юзер видит partial render — узкая колонка без extra columns, info panel
  // ещё не появилась, бар нагружается частями. Per user: "вот такого не
  // должно быть, лучше лоадер".
  if (detailQuery.isLoading || (!detailQuery.data && !detailQuery.error)) {
    return (
      <div className="min-h-[60vh] flex items-center justify-center">
        <LoadingBlock size="lg" label={t('loading')} />
      </div>
    )
  }
  if (detailQuery.error) {
    return (
      <DictionaryErrorOrSoftDeletedCard
        name={name}
        error={detailQuery.error}
        isAdmin={isAdmin}
        onRetry={() => detailQuery.refetch()}
      />
    )
  }

  return (
    <div className="space-y-6">
      {scope && (
        <div
          className={`-mt-4 sm:-mt-6 -mx-4 sm:-mx-6 mb-2 h-1 border-t-4 ${SCOPE_BORDER_TOP[scope]}`}
          aria-hidden="true"
        />
      )}
      {/* PageHeader removed per redesign: title/version вынесены в TopBar
        * breadcrumb (см. components/layout/TopBar.tsx). Actions перенесены
        * в InfoPanel (Time-travel, AOI) + tab toolbar (Schema, +Запись). */}

      {/* 3-col layout per handoff prototype design/compact.html:
          [Sidebar (220px global)] [InfoPanel 220px] [main content]
          Info panel — sticky слева с dict metadata + relations.
          На <lg viewport schedule переключается в stacked (info above content). */}
      {/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
        * gap-3 — closer panels per user feedback ("ближе расположены").
        * QA bug #1: 1fr → minmax(0,1fr) — без minmax(0,…) grid track имеет
        *   implicit min-content (auto), что позволяет records table вырасти
        *   шире viewport. После закрытия Radix Dialog снимается body padding-
        *   right (~15px), 1fr пересчитывается и ломает layout (исчезает
        *   первая колонка под overflow-hidden card). minmax(0,1fr) фиксирует
        *   нижнюю границу 0 → overflow-x-auto в Table primitive работает. */}
      <div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] gap-3 mt-2">
        {detailQuery.data && (
          <EditorInfoPanel
            detail={detailQuery.data}
            recordCount={totalRecords}
            banner={
              // WorkflowBanner живёт в InfoPanel per user feedback ("плашки
              // пусть в левой панели будут") — освобождает horizontal space
              // editor'а и concentrates status info в одном месте.
              <WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
            }
            actions={{
              onTimeTravel: () => setTimeTravelOpen((v) => !v),
              timeTravelActive: Boolean(timeTravelAt),
              // AOI button показываем только если у этого dict есть geo data —
              // хотя бы одна record имеет geometryWkt. На non-geo dicts кнопка
              // лишняя. Per user feedback: "aoi там где нет полей гео лишний".
              // Edge case: если active aoi уже set → показываем чтобы юзер мог clear.
              onAoi: hasGeoRecords || aoi ? () => setAoiOpen(true) : undefined,
              aoiActive: Boolean(aoi),
              // Открываем ExportModal вместо immediate triggera — пользователь
              // выбирает формат/scope/columns/encoding/delimiter.
              onExport: () => setExportOpen(true),
              exportPending: bulkExportMut.isPending,
              // Удалить справочник — только админ. Cascade delete на
              // records/drafts/audit (FK CASCADE migration 0030).
              // Confirmation modal проверяет dependents через backend
              // и предлагает force option если нужно.
              onDelete: isAdmin ? () => setDeleteConfirmOpen(true) : undefined,
              deletePending: deleteMut.isPending,
            }}
          />
        )}

        <div className="min-w-0 space-y-4">
          {/* WorkflowBanner перенесён в InfoPanel banner slot — освобождает
            * horizontal space здесь, concentrates все status notices в leftpanel. */}

          {/* Main editor panel — tab bar + toolbar + tab content в bordered card
            * per redesign prototype. Visual containment отделяет editor от
            * page chrome.
            * QA bug #1: overflow-hidden убран — клиппил records table при ширине
            *   контента больше колонки. Table primitive сам имеет overflow-x-auto;
            *   tabs ниже получают свой scroll контейнер. Card rounding продолжает
            *   работать через border-radius на самом div'е. */}
          <div className="rounded-lg border border-line bg-surface">

          {/* Editor tabs per handoff Stage 3.4 + redesign:
            * - counts в labels (Записи 127, Связи 6, Поля 12)
            * - right-aligned actions: Schema edit + +Запись per редизайн */}
          {detailQuery.data && (
        <EditorTabBar
          active={urlSearch.tab ?? 'records'}
          onChange={(tab) =>
            void navigate({
              search: (prev) => ({
                ...prev,
                tab: tab === 'records' ? undefined : tab,
                // QA UX-003: сбросить records-tab фильтры (search/q + column filters
                // + scopes) когда пользователь перешёл на другую вкладку. Иначе
                // search:'абв' оставался применённым после возврата на records.
                q: undefined,
                scopes: undefined,
              }),
              replace: true,
            })
          }
          counts={{
            records: totalRecords,
            relations: (detailQuery.data ? extractOutgoingFkCount(detailQuery.data.schemaJson) : 0),
            fields: Object.keys(detailQuery.data?.schemaJson?.properties ?? {}).length,
          }}
          actions={
            canMutate ? (
              <>
                {/* approval-required dict без active draft: предлагаем
                    создать черновик. Direct edit через GearIcon оставляем
                    для админов, но primary path — через workflow. */}
                {shouldOfferCreateDraft && (
                  <IconButton
                    type="button"
                    label={t('createDraft.openButton', {
                      defaultValue: 'Создать черновик схемы',
                    })}
                    variant="default"
                    icon={<GitBranchIcon weight="regular" />}
                    disabled={!detailQuery.data}
                    onClick={() => setCreateDraftOpen(true)}
                  />
                )}
                <IconButton
                  type="button"
                  label={t('schema.action.editSchema')}
                  variant="default"
                  icon={<GearIcon weight="regular" />}
                  disabled={!detailQuery.data}
                  onClick={() => setSchemaEditOpen(true)}
                />
                <Button
                  type="button"
                  variant="primary"
                  size="sm"
                  leftIcon={<PlusIcon weight="bold" size={14} />}
                  disabled={!detailQuery.data}
                  onClick={() => setEdit({ kind: 'create' })}
                  className="whitespace-nowrap"
                >
                  {t('dict.action.create')}
                </Button>
              </>
            ) : undefined
          }
        />
      )}

      {/* Relations tab — incoming FK + outgoing schema FK. */}
      {urlSearch.tab === 'relations' && detailQuery.data && (
        <DictionaryHubView detail={detailQuery.data} />
      )}

      {/* Fields tab — schema properties grid. */}
      {urlSearch.tab === 'fields' && detailQuery.data && (
        <FieldsTabContent detail={detailQuery.data} />
      )}

      {/* JSON tab — raw schema viewer (read-only). */}
      {urlSearch.tab === 'json' && detailQuery.data && (
        <JsonTabContent detail={detailQuery.data} />
      )}

      {/* Events tab — link to /audit с pre-filled dict filter. */}
      {urlSearch.tab === 'events' && (
        <EventsTabContent dictName={name} />
      )}

      {/* History tab — schema versions timeline. HEAD row из current detail;
          backend changelog endpoint pending для исторических versions. */}
      {urlSearch.tab === 'history' && (
        <HistoryTabContent detail={detailQuery.data} />
      )}

      {/* DictionaryDependentsPanel inline-card удалён — reverse FK rows
        * теперь живут в EditorInfoPanel ← используют (rich rows с field path
        * + active count + onClose policy chip). Один source of truth. */}

      {aoi && (
        <div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-accent/30 bg-accent/4 text-body">
          <span className=" text-mono text-ink">
            {aoi.kind === 'bbox'
              ? t('aoi.activeBbox', { value: aoi.bboxCsv })
              : t('aoi.activePolygon', {
                  points: aoi.geojson.coordinates[0]?.length ?? 0,
                })}
          </span>
          <button
            type="button"
            className="text-cell text-accent hover:underline"
            onClick={handleAoiClear}
          >
            {t('aoi.clear')}
          </button>
        </div>
      )}

      {/* === Records tab content (default, also fallback if no ?tab) === */}
      {(!urlSearch.tab || urlSearch.tab === 'records') && (
      <>

      {/* Time-travel modal — only mount когда open=true. Modal внутри
          использует Radix Dialog который portals content при open. Но props
          like `marks={recordTimestamps}` пересоздаются на каждый render
          родителя (new array reference) — это нормально для уже mounted
          компонента, но при always-mounted причиняет нестабильность store.
          Pattern: lazy mount только если open. */}
      {timeTravelOpen && detailQuery.data && (
        <TimeTravelModal
          open
          onClose={() => setTimeTravelOpen(false)}
          detail={detailQuery.data}
          value={timeTravelAt}
          onApply={(iso) => setTimeTravelAt(iso)}
          onClear={() => setTimeTravelAt(undefined)}
          marks={recordTimestamps}
          totalRecords={totalRecords}
        />
      )}

      {/* Active state — ambient banner если ?at= задан, даже если picker закрыт. */}
      {timeTravelAt && !timeTravelOpen && (
        <div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-warn bg-warn-bg text-body">
          <ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
          <span className=" text-mono text-ink">
            {t('timeTravel.viewing', {
              date: new Date(timeTravelAt).toLocaleString(),
            })}
          </span>
          <button
            type="button"
            className="text-cell text-accent hover:underline ml-auto"
            onClick={() => setTimeTravelAt(undefined)}
          >
            {t('timeTravel.clear')}
          </button>
        </div>
      )}

      {recordsResult.isLoading && <LoadingBlock size="md" label={t('loading')} />}

      {recordsResult.error && (
        <QueryErrorState
          error={recordsResult.error}
          onRetry={() => recordsResult.refetch()}
        />
      )}

      {recordsResult.data && recordsResult.data.length === 0 && (
        <EmptyState
          title={t('dict.empty')}
          description={
            scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
              ? t('dict.scheduled.hint', {
                  count: scheduledSummary.data.count,
                  date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
                })
              : undefined
          }
          action={
            scheduledSummary.data && scheduledSummary.data.count > 0 && scheduledSummary.data.nearestValidFrom
              ? (
                <button
                  type="button"
                  onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
                  className="text-cell text-accent hover:underline"
                >
                  {t('dict.scheduled.cta')}
                </button>
              )
              : undefined
          }
        />
      )}

      {recordsResult.data && recordsResult.data.length > 0
        && scheduledSummary.data && scheduledSummary.data.count > 0
        && scheduledSummary.data.nearestValidFrom && (
        // Info banner для non-empty listing когда есть scheduled records.
        // User feedback: «нету отметки что есть запись в будущем» — table
        // shows только currently active, юзер не видит upcoming. Banner
        // duplicates EmptyState hint в non-empty case + CTA jump-to-date.
        <div className="flex items-center gap-3 px-3 py-2 mb-2 rounded-sm border border-warn bg-warn-bg text-body">
          <ClockCounterClockwiseIcon weight="bold" size={16} className="text-warn shrink-0" />
          <span className="text-cell text-ink">
            {t('dict.scheduled.hint', {
              count: scheduledSummary.data.count,
              date: new Date(scheduledSummary.data.nearestValidFrom).toLocaleDateString(),
            })}
          </span>
          <button
            type="button"
            onClick={() => setTimeTravelAt(scheduledSummary.data!.nearestValidFrom!)}
            className="text-cell text-accent hover:underline ml-auto"
          >
            {t('dict.scheduled.cta')}
          </button>
        </div>
      )}

      {recordsResult.data && recordsResult.data.length > 0 && (
        <>
          {/* Records toolbar — attached к table panel (tinted top section,
            * border-b separator от table body). User feedback: "бар таблицы
            * не должен быть оторван от нее" — applied к catalog в MR 88,
            * теперь и в editor records tab. */}
          <div className="flex flex-wrap items-center gap-3 bg-surface-2 border-b border-line px-3 py-2">
            <div className="flex-1 min-w-[240px] max-w-md">
              <SearchInput
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder={t('dict.filter.searchPlaceholder')}
                aria-label={t('dict.filter.searchPlaceholder')}
              />
            </div>
            {/* Column filter dropdowns per redesign — auto-detect filterable
              * columns (status, enum-typed fields). User feedback: "фильтр в
              * справочнике по статусу скорее нужен чем по scope". Scope chips
              * убраны — scope viewable в SCOPE column, less useful as filter. */}
            <RecordColumnFilters
              schema={detailQuery.data?.schemaJson}
              records={recordsResult.data ?? []}
              value={columnFilters}
              onChange={setColumnFilters}
            />
            {filtersActive && (
              <div className="flex items-center gap-2 text-cell text-ink-2">
                <span>
                  {t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
                </span>
                <button
                  type="button"
                  onClick={handleClearFilters}
                  className="text-accent hover:underline"
                >
                  {t('dict.filter.clear')}
                </button>
              </div>
            )}
            {/* Column visibility menu — persisted в localStorage per dict.
              * Скрывает любую non-essential колонку (всё кроме businessKey). */}
            <div className="ml-auto">
              <ColumnVisibilityMenu
                columns={columnOptions}
                hidden={dictTable.hidden}
                onToggle={dictTable.toggleHidden}
                onShowAll={() => dictTable.setHidden([])}
              />
            </div>
          </div>

          {filteredRecords.length === 0 ? (
            <EmptyState title={t('dict.filter.noMatches')} />
          ) : (
            <>
              {canMutate && selection.size > 0 && (
                <BulkSelectionToolbar
                  count={selection.size}
                  totalVisible={filteredCount}
                  onBulkClose={openBulkClose}
                  onClear={clearSelection}
                />
              )}
              {/* No <Panel> wrap — outer editor panel handles border. Direct
                * table → continuous visual flow toolbar→table в one card.
                * TableHead sticky top: при page V-scroll header не уезжает,
                * sort/aria всегда видны. z-10 чтобы над cells. bg-surface
                * чтобы строки под header не просвечивали при scroll. */}
                <Table>
                  <TableHead className="sticky top-0 z-10 bg-surface">
                    <TableRow>
                      {canMutate && (
                        <TableHeaderCell>
                          <Checkbox
                            aria-label={t('dict.bulk.selectAll')}
                            checked={selectAllChecked}
                            onChange={toggleSelectAll}
                          />
                        </TableHeaderCell>
                      )}
                      <SortableHeaderCell
                        colKey="businessKey"
                        sort={dictTable.sort}
                        onSort={dictTable.toggleSort}
                      >
                        {t('dict.col.businessKey')}
                      </SortableHeaderCell>
                      {!dictTable.isHidden('name') && (
                        <SortableHeaderCell
                          colKey="name"
                          hideBelow="md"
                          sort={dictTable.sort}
                          onSort={dictTable.toggleSort}
                        >
                          {t('dict.col.name', { defaultValue: 'name / code' })}
                        </SortableHeaderCell>
                      )}
                      {extraColumns
                        .filter((c) => !dictTable.isHidden(c.key))
                        .map((c) => (
                          <SortableHeaderCell
                            key={c.key}
                            colKey={c.key}
                            hideBelow="lg"
                            sort={dictTable.sort}
                            onSort={dictTable.toggleSort}
                          >
                            {c.label}
                          </SortableHeaderCell>
                        ))}
                      {!dictTable.isHidden('scope') && (
                        <SortableHeaderCell
                          colKey="scope"
                          hideBelow="sm"
                          sort={dictTable.sort}
                          onSort={dictTable.toggleSort}
                        >
                          {t('dict.col.scope')}
                        </SortableHeaderCell>
                      )}
                      {!dictTable.isHidden('validFrom') && (
                        <SortableHeaderCell
                          colKey="validFrom"
                          hideBelow="lg"
                          sort={dictTable.sort}
                          onSort={dictTable.toggleSort}
                        >
                          {t('dict.col.validFrom')}
                        </SortableHeaderCell>
                      )}
                      {/* Actions column — без подписи per redesign prototype:
                        * 3-dot trigger открывает menu с History / Edit / Close.
                        * Aria-label на cell хедере для screen readers (visual: пусто). */}
                      <TableHeaderCell align="right" aria-label={t('dict.col.actions')}>
                        <span className="sr-only">{t('dict.col.actions')}</span>
                      </TableHeaderCell>
                    </TableRow>
                  </TableHead>
                  <TableBody>
                  {visibleRecords.map((r, idx) => (
                <TableRow
                  key={r.id}
                  data-selected={selection.has(r.businessKey)}
                  // Row-wide click → open edit drawer (только для canMutate).
                  // Anonymous users — read-only, click no-op чтобы не путать.
                  // Bubbling: nested buttons (checkbox, action icons) делают
                  // stopPropagation чтобы они не открывали drawer случайно.
                  onClick={canMutate ? () => setEdit({ kind: 'edit', record: r }) : undefined}
                  className={[
                    canMutate ? 'cursor-pointer' : '',
                    rowCursor === idx ? 'bg-accent-bg/40 outline outline-1 outline-accent/30' : '',
                  ].filter(Boolean).join(' ') || undefined}
                >
                  {canMutate && (
                    <TableCell onClick={(e) => e.stopPropagation()}>
                      <Checkbox
                        aria-label={`${t('dict.bulk.selectRow')} ${r.businessKey}`}
                        checked={selection.has(r.businessKey)}
                        onChange={() => toggleSelect(r.businessKey)}
                      />
                    </TableCell>
                  )}
                  <TableCell>
                    <div className="flex items-center gap-1.5">
                      {/* Business key — accent color per prototype (mono accent,
                        * primary identifier для каждой row). */}
                      <span className="text-mono text-accent">{r.businessKey}</span>
                      {pendingByKey.has(r.businessKey) ? (
                        <span title={t('dict.pendingReview.tooltip')}>
                          <Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
                        </span>
                      ) : null}
                      {/* «Scheduled» badge: у этой записи есть future version
                        * (validFrom > now). User видит в общем списке какие
                        * записи скоро изменятся. CTA для перехода к этой дате
                        * — banner выше уже линкует на nearestValidFrom. */}
                      {scheduledByKey.has(r.businessKey) ? (
                        <span title={t('dict.scheduled.row.tooltip', {
                          defaultValue: 'У записи есть запланированная будущая версия',
                        })}>
                          <Badge variant="info">
                            {t('dict.scheduled.row.badge', { defaultValue: 'Запланировано' })}
                          </Badge>
                        </span>
                      ) : null}
                    </div>
                  </TableCell>
                  {!dictTable.isHidden('name') && (
                    <TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
                      {recordDisplayName(
                        r.data,
                        detailQuery.data?.defaultLocale ?? 'ru-RU',
                      )}
                    </TableCell>
                  )}
                  {extraColumns
                    .filter((c) => !dictTable.isHidden(c.key))
                    .map((c) => {
                      const v = r.data?.[c.key]
                      if (v == null || v === '') {
                        return (
                          <TableCell key={c.key} hideBelow="lg">
                            <span className="text-cell text-mute/70">—</span>
                          </TableCell>
                        )
                      }
                      const text = String(v)
                      return (
                        <TableCell key={c.key} hideBelow="lg">
                          <span
                            className={[
                              'inline-block px-1.5 py-0.5 rounded-sm font-mono text-cell',
                              c.isFk
                                ? 'bg-accent/8 text-accent'
                                : 'bg-ink/8 text-ink',
                            ].join(' ')}
                          >
                            {text}
                          </span>
                        </TableCell>
                      )
                    })}
                  {!dictTable.isHidden('scope') && (
                    <TableCell hideBelow="sm">
                      <Badge variant="info">{r.dataScope}</Badge>
                    </TableCell>
                  )}
                  {!dictTable.isHidden('validFrom') && (
                    <TableCell hideBelow="lg">
                      <span className="text-cell text-mute">
                        {new Date(r.validFrom).toLocaleDateString()}
                      </span>
                    </TableCell>
                  )}
                  <TableCell align="right" onClick={(e) => e.stopPropagation()}>
                    <div className="flex items-center justify-end">
                      {/* 3-dot DropdownMenu per redesign prototype. Заменяет
                        * inline icon trio (history / edit / close). Trigger:
                        * 36px touch target (tablet-safe). Items: History,
                        * Edit, Close (Edit/Close — только для canMutate). */}
                      <DropdownMenu>
                        <DropdownMenuTrigger asChild>
                          <button
                            type="button"
                            aria-label={t('dict.col.actions')}
                            className="size-9 inline-flex items-center justify-center rounded-md text-mute hover:text-ink hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
                          >
                            <DotsThreeVerticalIcon weight="bold" size={18} />
                          </button>
                        </DropdownMenuTrigger>
                        <DropdownMenuContent align="end" className="min-w-[180px]">
                          <DropdownMenuItem onSelect={() => setHistoryKey(r.businessKey)}>
                            <ClockCounterClockwiseIcon weight="regular" size={16} className="mr-2 text-ink-2" />
                            {t('history.title')}
                          </DropdownMenuItem>
                          {canMutate && (
                            <>
                              <DropdownMenuItem onSelect={() => setEdit({ kind: 'edit', record: r })}>
                                <PencilSimpleIcon weight="regular" size={16} className="mr-2 text-ink-2" />
                                {t('dict.action.edit')}
                              </DropdownMenuItem>
                              <DropdownMenuSeparator />
                              <DropdownMenuItem
                                onSelect={() => setEdit({ kind: 'close-confirm', record: r })}
                                className="text-mars focus:text-mars focus:bg-mars/8"
                              >
                                <XCircleIcon weight="regular" size={16} className="mr-2" />
                                {t('dict.action.close')}
                              </DropdownMenuItem>
                            </>
                          )}
                        </DropdownMenuContent>
                      </DropdownMenu>
                    </div>
                  </TableCell>
                </TableRow>
                  ))}
                  </TableBody>
                </Table>
              {totalPages > 1 && (
                // Sticky bottom: pagination всегда в viewport при V-scroll
                // длинной таблицы — не надо листать вниз чтобы перейти на
                // page 2. bg-surface + border-top чтобы визуально отделить
                // от scrolling tbody.
                <div className="sticky bottom-0 z-10 bg-surface border-t border-line">
                  <RecordsPagination
                    page={page}
                    totalPages={totalPages}
                    totalCount={filteredCount}
                    pageSize={PAGE_SIZE}
                    onPrev={() => setPage((p) => Math.max(0, p - 1))}
                    onNext={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
                  />
                </div>
              )}
            </>
          )}
        </>
      )}

      </>
      )}
      {/* === end records tab content === */}

          </div>
          {/* === end editor main panel === */}
        </div>
      </div>
      {/* === end 3-col grid === */}

      <RecordDrawer
        open={edit.kind === 'create' || edit.kind === 'edit'}
        onClose={() => {
          setEdit({ kind: 'closed' })
          setRecordDirtyCount(0)
          setDrawerSearch('')
          setDrawerOnlyFilled(false)
        }}
        caption={
          edit.kind === 'edit'
            ? approvalRequired
              ? t('drawer.captionEditDraft', {
                  dict: name,
                  defaultValue: `${name} — proposal на изменение (через ревью)`,
                })
              : t('drawer.captionEdit', { dict: name })
            : approvalRequired
            ? t('drawer.captionCreateDraft', {
                dict: name,
                defaultValue: `${name} — proposal на создание (через ревью)`,
              })
            : t('drawer.captionCreate', { dict: name })
        }
        recordId={edit.kind === 'edit' ? edit.record.businessKey : undefined}
        formId="record-form"
        dirtyCount={recordDirtyCount}
        isPending={Boolean(activeMutation?.isPending)}
        // Toolbar показывается только в edit mode где filters имеют смысл.
        // Create mode — все поля пустые, "только заполненные" даст empty list.
        toolbar={
          edit.kind === 'edit'
            ? {
                search: drawerSearch,
                onSearchChange: setDrawerSearch,
                onlyFilled: drawerOnlyFilled,
                onOnlyFilledChange: setDrawerOnlyFilled,
                counterVisible: drawerCount.visible,
                counterTotal: drawerCount.total,
              }
            : undefined
        }
        onDelete={
          edit.kind === 'edit'
            ? () => setEdit({ kind: 'close-confirm', record: edit.record })
            : undefined
        }
        onHistory={
          edit.kind === 'edit'
            ? () => setHistoryKey(edit.record.businessKey)
            : undefined
        }
      >
        {detailQuery.data && edit.kind === 'create' && (
          <SchemaDrivenForm
            schema={detailQuery.data.schemaJson}
            supportedLocales={detailQuery.data.supportedLocales}
            defaultLocale={detailQuery.data.defaultLocale}
            mode="create"
            isPending={Boolean(activeMutation?.isPending)}
            serverError={serverError}
            // Prefill validFrom = now для consistent UX с edit mode.
            // Без этого create form тоже показывает пустое «Действует с».
            defaultValues={{ validFrom: nowIsoLocal() }}
            onSubmit={handleSubmit}
            onCancel={() => setEdit({ kind: 'closed' })}
            formId="record-form"
            layout="sections"
            searchFilter={drawerSearch}
            onlyFilled={drawerOnlyFilled}
            onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
            onDirtyChange={setRecordDirtyCount}
          />
        )}

        {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.isLoading && (
          <LoadingBlock size="md" label={t('loading')} />
        )}

        {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
          <QueryErrorState
            error={rawRecordQuery.error}
            onRetry={() => rawRecordQuery.refetch()}
          />
        )}

        {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.data && (
          <SchemaDrivenForm
            schema={detailQuery.data.schemaJson}
            supportedLocales={detailQuery.data.supportedLocales}
            defaultLocale={detailQuery.data.defaultLocale}
            mode="edit"
            isPending={Boolean(activeMutation?.isPending)}
            serverError={serverError}
            defaultValues={{
              businessKey: rawRecordQuery.data.businessKey,
              data: rawRecordQuery.data.data,
              // Edit в bitemporal model = «создаём новую версию с этого момента,
              // старая закрывается». Prefill nowIsoLocal() чтобы юзер видел
              // конкретную дату («сейчас» как точка отсчёта), а не пустое поле.
              // User feedback: "Действует с показывает ничего, а даты заданы" —
              // empty field выглядит как баг. handleSubmit всё равно подставит
              // свежий nowIsoLocal() если поле не тронуто (review #5 защита от
              // stale значения если drawer долго открыт).
              // validTo пустой = бессрочно (или до следующей правки).
              validFrom: nowIsoLocal(),
              validTo: '',
            }}
            onSubmit={handleSubmit}
            onCancel={() => setEdit({ kind: 'closed' })}
            formId="record-form"
            layout="sections"
            searchFilter={drawerSearch}
            onlyFilled={drawerOnlyFilled}
            onCounterChange={(visible, total) => setDrawerCount({ visible, total })}
            onDirtyChange={setRecordDirtyCount}
          />
        )}
      </RecordDrawer>

      <Modal
        isOpen={edit.kind === 'close-confirm'}
        onClose={() => {
          setEdit({ kind: 'closed' })
          setCloseReason('')
        }}
        title={t('dict.confirmClose.title')}
        maxWidth="max-w-md"
      >
        {edit.kind === 'close-confirm' && (
          <div className="space-y-4">
            <p className="text-body text-ink">
              {t('dict.confirmClose.body', { key: edit.record.businessKey })}
            </p>
            {approvalRequired && (
              <Alert variant="info" title={t('dict.confirmClose.approvalTitle', {
                defaultValue: 'Закрытие пройдёт через ревью',
              })}>
                {t('dict.confirmClose.approvalBody', {
                  defaultValue: 'Этот справочник требует maker-checker approval. Будет создан CLOSE-draft, запись закроется после approve от reviewer\'а.',
                })}
              </Alert>
            )}
            <TextInput
              label={t('dict.confirmClose.reason')}
              placeholder={t('dict.confirmClose.reasonPlaceholder')}
              value={closeReason}
              onChange={(e) => setCloseReason(e.target.value)}
            />
            {serverErrorMessage(
              approvalRequired ? submitDraftMut.error : closeMut.error,
            ) && (
              <Alert variant="error">
                {serverErrorMessage(
                  approvalRequired ? submitDraftMut.error : closeMut.error,
                )}
              </Alert>
            )}
            <div className="flex justify-end gap-2 pt-2 border-t border-line">
              <Button
                type="button"
                variant="ghost"
                disabled={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
                onClick={() => setEdit({ kind: 'closed' })}
              >
                {t('form.cancel')}
              </Button>
              <Button
                type="button"
                variant="danger"
                loading={approvalRequired ? submitDraftMut.isPending : closeMut.isPending}
                onClick={handleClose}
              >
                {approvalRequired
                  ? t('dict.action.closeAsDraft', {
                      defaultValue: 'Отправить на ревью',
                    })
                  : t('dict.action.close')}
              </Button>
            </div>
          </div>
        )}
      </Modal>

      {detailQuery.data && (
        <DictionaryEditorDialog
          open={schemaEditOpen}
          mode={{ kind: 'edit', existing: detailQuery.data }}
          onClose={() => setSchemaEditOpen(false)}
          onSuccess={() => setSchemaEditOpen(false)}
        />
      )}

      {detailQuery.data && (
        <CreateSchemaDraftModal
          open={createDraftOpen}
          onClose={() => setCreateDraftOpen(false)}
          detail={detailQuery.data}
        />
      )}

      <RecordHistoryDrawer
        open={Boolean(historyKey)}
        onClose={() => setHistoryKey(undefined)}
        dictionaryName={name}
        businessKey={historyKey}
        // Compare: opens the conflict diff modal сравнивает selected version
        // данные с текущим server state. Re-use ConflictDiffModal — оно
        // exactly показывает "что было vs что есть" diff.
        onCompare={canMutate ? (versionData) => {
          if (!historyKey) return
          const fakePayload: CreateRecordRequest = {
            businessKey: historyKey,
            data: versionData as Record<string, unknown>,
            validFrom: undefined,
            validTo: undefined,
          }
          setConflict({ payload: fakePayload, businessKey: historyKey })
        } : undefined}
        // Revert: submit update mutation с old version's data как payload.
        // Backend создаст новую active version с этими data — старая history
        // сохраняется (bitemporal). 409 на conflict перехватываем как при
        // обычном edit.
        onRevert={canMutate ? (versionData, version) => {
          if (!historyKey) return
          const confirmed = window.confirm(
            t('history.revertConfirm', { v: version, defaultValue: `Откатить к v${version}? Создастся новая активная версия.` }) as string
          )
          if (!confirmed) return
          updateMut.mutate({
            businessKey: historyKey,
            payload: {
              businessKey: historyKey,
              data: versionData as Record<string, unknown>,
              validFrom: undefined,
              validTo: undefined,
            },
            idempotencyKey: recordIdempotencyKey,
          }, {
            onSuccess: () => {
              setHistoryKey(undefined)
              resetRecordIdempotencyKey()
            },
          })
        } : undefined}
      />

      <CascadeConfirmDialog
        open={Boolean(cascadeKey)}
        onClose={() => setCascadeKey(undefined)}
        onSuccess={() => {
          setCascadeKey(undefined)
          recordsResult.refetch()
        }}
        dictionaryName={name}
        businessKey={cascadeKey}
      />

      <AoiPickerDialog
        isOpen={aoiOpen}
        onClose={() => setAoiOpen(false)}
        onApply={handleAoiApply}
        initial={aoi?.geojson ?? null}
      />

      {/* Conflict diff modal — opens on 409 generic conflict (optimistic lock).
        * Diff: editor payload vs server current state (refetched). Three actions:
        *   - Отмена: close modal, keep editor (user retry/edit manually)
        *   - Загрузить серверное: discard local, open editor с server snapshot
        *   - Перезаписать: re-submit with current payload (will overwrite if no
        *     new conflict — backend has no force-overwrite endpoint поэтому
        *     просто retry; если опять 409 — modal снова откроется). */}
      {conflict && (
        <ConflictDiffModal
          open
          onClose={() => setConflict(null)}
          localPayload={conflict.payload}
          dictionaryName={name}
          businessKey={conflict.businessKey}
          onReloadServer={() => {
            // Discard local edits, refetch records → пользователь увидит
            // актуальное состояние. Закрываем editor; user может открыть
            // запись ещё раз для редактирования с правильной базы.
            setConflict(null)
            setEdit({ kind: 'closed' })
            resetRecordIdempotencyKey()
            void recordsResult.refetch()
          }}
          onOverwrite={() => {
            // Re-submit с current payload. Backend re-evaluates; если record
            // изменился ещё раз → новый 409, modal откроется заново.
            const payload = conflict.payload
            const bk = conflict.businessKey
            setConflict(null)
            if (edit.kind === 'edit') {
              updateMut.mutate(
                { businessKey: bk, payload, idempotencyKey: recordIdempotencyKey },
                {
                  onSuccess: () => {
                    setEdit({ kind: 'closed' })
                    resetRecordIdempotencyKey()
                  },
                  onError: (err) => {
                    const status = (err as { response?: { status?: number } })
                      ?.response?.status
                    if (status === 409) {
                      // Re-open diff modal с свежим server state.
                      setConflict({ payload, businessKey: bk })
                    }
                  },
                },
              )
            }
          }}
        />
      )}

      {/* Export modal — only mount when open. Same lazy pattern as Time-travel. */}
      {exportOpen && detailQuery.data && (
        <ExportModal
          open
          onClose={() => setExportOpen(false)}
          detail={detailQuery.data}
          totalCount={totalRecords}
          filteredRecords={filteredRecords}
          selection={selection}
          onExportCsv={(keys) => bulkExportMut.mutate(keys)}
          exportPending={bulkExportMut.isPending}
        />
      )}

      <Modal
        isOpen={bulkOpen}
        onClose={() => {
          setBulkOpen(false)
          setBulkResult(null)
          setBulkReason('')
        }}
        title={t('dict.bulk.confirmTitle')}
        maxWidth="max-w-lg"
      >
        <BulkCloseDialog
          count={selection.size}
          reason={bulkReason}
          onReasonChange={setBulkReason}
          onSubmit={handleBulkClose}
          onClose={() => {
            setBulkOpen(false)
            setBulkResult(null)
            setBulkReason('')
          }}
          isPending={approvalRequired ? bulkDraftRunning : bulkCloseMut.isPending}
          serverError={serverErrorMessage(
            approvalRequired ? submitDraftMut.error : bulkCloseMut.error,
          )}
          result={bulkResult}
          approvalMode={approvalRequired}
        />
      </Modal>

      {/* Delete dict confirmation modal — admin-only. Cascade на
        * records + drafts + schema_drafts через FK CASCADE (migration 0030).
        * Backend проверяет incoming dependents через lineage и rejектит с
        * 409 has_dependents если другой dict ссылается через x-references.
        * UI показывает error text + предлагает force option. */}
      <Modal
        isOpen={deleteConfirmOpen}
        onClose={() => {
          if (deleteMut.isPending) return
          setDeleteConfirmOpen(false)
          setDeleteError(null)
        }}
        title={t('dict.delete.title', { defaultValue: 'Удалить справочник?' })}
        maxWidth="max-w-lg"
      >
        <div className="space-y-4">
          <p className="text-cell text-ink-2">
            {t('dict.delete.warning', {
              defaultValue:
                'Будут удалены: ВСЕ записи справочника, drafts, schema-drafts, история версий. Эта операция необратима.',
            })}
          </p>
          <div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-cell">
            <strong>{name}</strong>
            {detailQuery.data?.displayName ? ` — ${detailQuery.data.displayName}` : null}
          </div>
          {deleteError && (
            <div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-cell text-destructive whitespace-pre-line">
              {deleteError}
            </div>
          )}
          <div className="flex justify-end gap-2 pt-2">
            <button
              type="button"
              className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
              onClick={() => {
                setDeleteConfirmOpen(false)
                setDeleteError(null)
              }}
              disabled={deleteMut.isPending}
            >
              {t('common.cancel', { defaultValue: 'Отмена' })}
            </button>
            <button
              type="button"
              className="h-9 px-4 rounded-md bg-destructive text-destructive-foreground text-cell font-medium hover:bg-destructive/85 disabled:opacity-50 disabled:cursor-not-allowed"
              onClick={() => {
                setDeleteError(null)
                deleteMut.mutate(
                  { name },
                  {
                    onSuccess: () => {
                      setDeleteConfirmOpen(false)
                      navigate({ to: '/dictionaries' })
                    },
                    onError: (err) => {
                      const code = (err as { response?: { data?: { code?: string } } })?.response
                        ?.data?.code
                      const msg = (err as { response?: { data?: { message?: string } } })?.response
                        ?.data?.message
                      if (code === 'has_dependents') {
                        setDeleteError(
                          (msg ?? 'Есть зависимые справочники.') +
                            '\n\n' +
                            t('dict.delete.forceHint', {
                              defaultValue:
                                'Можно повторить с force=true — но зависимые справочники будут ссылаться на удалённое (FK validation сломается).',
                            }),
                        )
                      } else {
                        setDeleteError(msg ?? String(err))
                      }
                    },
                  },
                )
              }}
              disabled={deleteMut.isPending}
            >
              {deleteMut.isPending
                ? t('dict.delete.pending', { defaultValue: 'Удаляю…' })
                : t('dict.delete.confirm', { defaultValue: 'Удалить безвозвратно' })}
            </button>
            {deleteError?.includes('has_dependents') ||
            (deleteError &&
              deleteError.toLowerCase().includes('зависимы')) ? (
              <button
                type="button"
                className="h-9 px-4 rounded-md border border-destructive/40 bg-surface text-destructive text-cell hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed"
                onClick={() => {
                  setDeleteError(null)
                  deleteMut.mutate(
                    { name, force: true },
                    {
                      onSuccess: () => {
                        setDeleteConfirmOpen(false)
                        navigate({ to: '/dictionaries' })
                      },
                      onError: (err) => {
                        const msg = (err as { response?: { data?: { message?: string } } })
                          ?.response?.data?.message
                        setDeleteError(msg ?? String(err))
                      },
                    },
                  )
                }}
                disabled={deleteMut.isPending}
              >
                {t('dict.delete.force', { defaultValue: 'Удалить force (всё равно)' })}
              </button>
            ) : null}
          </div>
        </div>
      </Modal>
    </div>
  )
}

type BulkSelectionToolbarProps = {
  count: number
  totalVisible: number
  onBulkClose: () => void
  onClear: () => void
}

/**
 * BulkSelectionToolbar — sticky bar появляется когда selection.size > 0.
 *
 * <p>Per user feedback: "Экспорт CSV в bulk bar лишний — повтор Экспорт
 * кнопки в InfoPanel". Bulk export достижим через InfoPanel "↓ Экспорт..."
 * → ExportModal с scope=selected (модал автоматически выбирает selected
 * если selection непустой). Чтобы не дублировать, в bulk bar оставляем
 * только destructive action — Закрыть выбранные.
 */
function BulkSelectionToolbar({
  count,
  totalVisible,
  onBulkClose,
  onClear,
}: BulkSelectionToolbarProps) {
  const { t } = useTranslation()
  return (
    <div
      className={[
        'sticky top-0 z-10 -mx-1 flex items-center justify-between gap-3',
        'bg-accent text-white rounded-md shadow-sm px-4 py-2',
      ].join(' ')}
    >
      <div className="flex items-center gap-3">
        <span className="text-body font-medium tabular-nums">
          {t('dict.bulk.selectedCount', { count, total: totalVisible })}
        </span>
        <button
          type="button"
          onClick={onClear}
          className="inline-flex items-center gap-1 text-cell underline-offset-2 hover:underline opacity-80 hover:opacity-100"
        >
          <XIcon size={12} weight="bold" />
          {t('dict.bulk.clearSelection')}
        </button>
      </div>
      <div className="flex items-center gap-2">
        <Button
          type="button"
          variant="danger"
          leftIcon={<TrashIcon weight="bold" size={14} />}
          onClick={onBulkClose}
        >
          {t('dict.bulk.closeAction')}
        </Button>
      </div>
    </div>
  )
}

type RecordsPaginationProps = {
  page: number
  totalPages: number
  totalCount: number
  pageSize: number
  onPrev: () => void
  onNext: () => void
}

function RecordsPagination({
  page,
  totalPages,
  totalCount,
  pageSize,
  onPrev,
  onNext,
}: RecordsPaginationProps) {
  const { t } = useTranslation()
  const from = page * pageSize + 1
  const to = Math.min((page + 1) * pageSize, totalCount)
  return (
    <div className="flex items-center justify-between flex-wrap gap-3 px-2">
      <span className="text-cell text-ink-2 tabular-nums">
        {t('dict.page.range', { from, to, total: totalCount })}
      </span>
      <div className="flex items-center gap-3 text-body">
        <Button
          type="button"
          variant="secondary"
          leftIcon={<CaretLeftIcon weight="bold" size={14} />}
          onClick={onPrev}
          disabled={page <= 0}
        >
          {t('dict.page.prev')}
        </Button>
        <span className="tabular-nums text-cell text-ink-2">
          {t('dict.page.of', { cur: page + 1, total: totalPages })}
        </span>
        <Button
          type="button"
          variant="secondary"
          rightIcon={<CaretRightIcon weight="bold" size={14} />}
          onClick={onNext}
          disabled={page + 1 >= totalPages}
        >
          {t('dict.page.next')}
        </Button>
      </div>
    </div>
  )
}

type BulkCloseDialogProps = {
  count: number
  reason: string
  onReasonChange: (s: string) => void
  onSubmit: () => void
  onClose: () => void
  isPending: boolean
  serverError: string | null
  result: BulkCloseResponse | null
  /** Approval Workflow v2: на approval-required dict bulk-close шлёт N drafts
   *  CLOSE-операции вместо прямого закрытия. UI меняет copy + summary semantics. */
  approvalMode?: boolean
}

function BulkCloseDialog({
  count,
  reason,
  onReasonChange,
  onSubmit,
  onClose,
  isPending,
  serverError,
  result,
  approvalMode = false,
}: BulkCloseDialogProps) {
  const { t } = useTranslation()

  // После успеха — показываем сводку; вход на confirm форму прячем
  if (result) {
    return (
      <div className="space-y-4">
        <div className="space-y-2">
          {result.closed.length > 0 && (
            <div className="flex items-center gap-2 text-body text-soyuz-green">
              <CheckCircleIcon size={18} weight="fill" />
              <span>{t('dict.bulk.resultClosed', { count: result.closed.length })}</span>
            </div>
          )}
          {result.skipped.length > 0 && (
            <div className="flex items-center gap-2 text-body text-saturn">
              <WarningIcon size={18} weight="fill" />
              <span>{t('dict.bulk.resultSkipped', { count: result.skipped.length })}</span>
            </div>
          )}
          {result.errors.length > 0 && (
            <div className="flex items-center gap-2 text-body text-mars">
              <XCircleIcon size={18} weight="fill" />
              <span>{t('dict.bulk.resultErrors', { count: result.errors.length })}</span>
            </div>
          )}
        </div>

        {(result.skipped.length > 0 || result.errors.length > 0) && (
          <div className="border border-line rounded p-3 max-h-48 overflow-auto bg-line/30">
            <ul className="text-mono space-y-1">
              {result.skipped.map((s) => (
                <li key={`s-${s.businessKey}`} className="text-saturn">
                  · {s.businessKey} — {s.reason}
                </li>
              ))}
              {result.errors.map((e) => (
                <li key={`e-${e.businessKey}`} className="text-mars">
                  ! {e.businessKey} — {e.reason}
                </li>
              ))}
            </ul>
          </div>
        )}

        <div className="flex justify-end pt-2 border-t border-line">
          <Button type="button" variant="primary" onClick={onClose}>
            {t('dict.bulk.dismiss')}
          </Button>
        </div>
      </div>
    )
  }

  return (
    <div className="space-y-4">
      <p className="text-body text-ink">{t('dict.bulk.confirmBody', { count })}</p>
      {approvalMode && (
        <Alert
          variant="info"
          title={t('dict.bulk.approvalTitle', {
            defaultValue: 'Закрытие пройдёт через ревью',
          })}
        >
          {t('dict.bulk.approvalBody', {
            count,
            defaultValue: `${count} CLOSE-draft(ов) будут отправлены на ревью. Записи закроются после approve.`,
          })}
        </Alert>
      )}
      <TextInput
        label={t('dict.confirmClose.reason')}
        placeholder={t('dict.confirmClose.reasonPlaceholder')}
        value={reason}
        onChange={(e) => onReasonChange(e.target.value)}
      />
      {serverError && <Alert variant="error">{serverError}</Alert>}
      <div className="flex justify-end gap-2 pt-2 border-t border-line">
        <Button
          type="button"
          variant="ghost"
          disabled={isPending}
          onClick={onClose}
        >
          {t('form.cancel')}
        </Button>
        <Button
          type="button"
          variant="danger"
          loading={isPending}
          onClick={onSubmit}
          leftIcon={<TrashIcon weight="bold" size={14} />}
        >
          {approvalMode
            ? t('dict.bulk.confirmActionAsDraft', {
                count,
                defaultValue: `Отправить ${count} CLOSE-draft(ов) на ревью`,
              })
            : t('dict.bulk.confirmAction', { count })}
        </Button>
      </div>
    </div>
  )
}

/**
 * Bootstrap initial AOI из URL bbox param. Возвращает bbox-AOI с
 * прямоугольным GeoJSON для preview в picker'е. Невалидные значения
 * уже отфильтрованы validateSearch — но dup'аем guard на парсинге.
 */
/** snake_case → 'Snake Case' для column header'ов. */
/** Count unique outgoing FK targets across schema properties (x-references). */
const extractOutgoingFkCount = (schema: import('@/api/client').JsonSchema | undefined): number => {
  if (!schema?.properties) return 0
  const targets = new Set<string>()
  for (const prop of Object.values(schema.properties)) {
    const ref = prop['x-references']
    if (typeof ref === 'string' && ref.length > 0) {
      const [target] = ref.split('.')
      if (target) targets.add(target)
    }
  }
  return targets.size
}

const humanize = (key: string): string =>
  key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())

const bboxToAoi = (bboxCsv: string | undefined): AoiResult | null => {
  if (!bboxCsv) return null
  const parts = bboxCsv.split(',').map(Number)
  if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return null
  const [w, s, e, n] = parts
  return {
    kind: 'bbox',
    bboxCsv,
    geojson: {
      type: 'Polygon',
      coordinates: [
        [
          [w, s],
          [e, s],
          [e, n],
          [w, n],
          [w, s],
        ],
      ],
    },
  }
}

const serverErrorMessage = (err: unknown): string | null => {
  if (!err) return null
  if (axios.isAxiosError(err)) {
    const data = err.response?.data as { message?: string; error?: string } | undefined
    if (err.response?.status === 422) return data?.message ?? 'Validation failed'
    if (err.response?.status === 409) return data?.message ?? 'Conflict — request already in progress'
    return data?.message ?? data?.error ?? err.message
  }
  return err instanceof Error ? err.message : 'Unknown error'
}

// ===== Editor Tabs (Stage 3.4) =====

type EditorTab = 'records' | 'relations' | 'fields' | 'json' | 'events' | 'history'

function EditorTabBar({
  active,
  onChange,
  counts,
  actions,
}: {
  active: EditorTab
  onChange: (tab: EditorTab) => void
  /** Optional per-tab counters — render как inline mono badge per redesign. */
  counts?: Partial<Record<EditorTab, number | undefined>>
  /** Right-aligned action buttons (Schema / +Запись) per redesign prototype. */
  actions?: React.ReactNode
}) {
  const { t } = useTranslation()
  const tabs: { id: EditorTab; label: string }[] = [
    { id: 'records', label: t('editor.tab.records', { defaultValue: 'Записи' }) },
    { id: 'relations', label: t('editor.tab.relations', { defaultValue: 'Связи' }) },
    { id: 'fields', label: t('editor.tab.fields', { defaultValue: 'Поля' }) },
    { id: 'json', label: t('editor.tab.json', { defaultValue: 'JSON' }) },
    { id: 'events', label: t('editor.tab.events', { defaultValue: 'События' }) },
    { id: 'history', label: t('editor.tab.history', { defaultValue: 'История' }) },
  ]
  // QA bug #3: на 1075px tabs (6 шт.) + actions (3 кнопки) переполняли flex
  //   row → "+ Создать" обрезалось до "+ Соз". Решение: actions выносим в
  //   отдельный shrink-0 контейнер, tabs кладём в overflow-x-auto wrapper
  //   с min-w-0 → tabs скроллятся горизонтально если cramped, actions всегда
  //   видны полностью. whitespace-nowrap на tab buttons чтобы текст не
  //   переносился в кружок счётчика.
  return (
    <div className="flex items-center border-b border-line w-full">
      <div className="flex items-center gap-0 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
        {tabs.map((tab) => {
          const isActive = active === tab.id
          const count = counts?.[tab.id]
          return (
            <button
              key={tab.id}
              type="button"
              onClick={() => onChange(tab.id)}
              aria-pressed={isActive}
              className={[
                'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap',
                isActive
                  ? 'border-accent text-ink font-semibold'
                  : 'border-transparent text-ink-2 hover:text-ink',
              ].join(' ')}
            >
              {tab.label}
              {typeof count === 'number' && count > 0 && (
                <span className="text-mono text-mute">{count}</span>
              )}
            </button>
          )
        })}
      </div>
      {actions && (
        <div className="flex items-center gap-2 pl-2 pr-2 shrink-0">{actions}</div>
      )}
    </div>
  )
}

/** Fields tab — schema properties summary. */
function FieldsTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
  const props = detail.schemaJson.properties ?? {}
  const required = new Set(detail.schemaJson.required ?? [])
  const entries = Object.entries(props)
  if (entries.length === 0) {
    return (
      <div className="text-body text-mute py-8 text-center">
        Schema без properties.
      </div>
    )
  }

  // Render type per prototype: для FK → `fk → target_dict`. Для array → `string[]`.
  // Иначе native JSON-schema type.
  const renderType = (prop: import('@/api/client').JsonSchema): React.ReactNode => {
    const ref = prop['x-references']
    if (typeof ref === 'string' && ref.length > 0) {
      const [target] = ref.split('.')
      return (
        <span className="text-mono text-ink-2">
          fk <span className="text-mute">→</span>{' '}
          <span className="text-accent">{target}</span>
        </span>
      )
    }
    if (Array.isArray(prop.enum) && prop.enum.length > 0) {
      return <span className="text-mono text-ink-2">enum</span>
    }
    if (prop.type === 'array' && prop.items) {
      const itemT = typeof prop.items === 'object' ? prop.items.type ?? '—' : '—'
      return <span className="text-mono text-ink-2">{itemT}[]</span>
    }
    if (prop.format === 'date' || prop.format === 'date-time') {
      return <span className="text-mono text-ink-2">{prop.format}</span>
    }
    return <span className="text-mono text-ink-2">{prop.type ?? '—'}</span>
  }

  const checkOrDash = (v: unknown) =>
    v ? (
      <span className="text-green" aria-label="yes">
        ✓
      </span>
    ) : (
      <span className="text-mute/50" aria-hidden>
        —
      </span>
    )

  return (
    <div className="rounded-lg border border-line bg-surface overflow-hidden">
      <table className="w-full text-body">
        <thead className="text-cap font-display text-mute tracking-[0.14em] uppercase">
          <tr>
            <th className="text-left px-3 py-2.5 w-12 font-mono text-mute/70 normal-case">#</th>
            <th className="text-left px-3 py-2.5">{`Поле`}</th>
            <th className="text-left px-3 py-2.5 w-[160px]">{`Тип`}</th>
            <th className="text-center px-3 py-2.5 w-20">Required</th>
            <th className="text-center px-3 py-2.5 w-20">Unique</th>
            <th className="text-center px-3 py-2.5 w-20">I18n</th>
            <th className="text-center px-3 py-2.5 w-20">Index</th>
            <th className="text-left px-3 py-2.5">{`Описание`}</th>
          </tr>
        </thead>
        <tbody>
          {entries.map(([key, prop], idx) => (
            <tr
              key={key}
              className="border-t border-line-2 bg-surface hover:bg-surface-2 transition-colors"
            >
              <td className="px-3 py-2 text-mono text-mute tabular-nums">
                {String(idx + 1).padStart(2, '0')}
              </td>
              <td className="px-3 py-2 font-mono font-semibold text-accent">
                {key}
              </td>
              <td className="px-3 py-2">{renderType(prop)}</td>
              <td className="px-3 py-2 text-center">{checkOrDash(required.has(key))}</td>
              <td className="px-3 py-2 text-center">{checkOrDash(prop['x-unique'])}</td>
              <td className="px-3 py-2 text-center">{checkOrDash(prop['x-localized'])}</td>
              {/* INDEX: backend ещё не выводит явный флаг. Используем эвристику:
                * x-unique → автоматически индекс; x-references → FK index; иначе —. */}
              <td className="px-3 py-2 text-center">
                {checkOrDash(prop['x-unique'] || Boolean(prop['x-references']))}
              </td>
              <td className="px-3 py-2 text-ink-2 text-cell max-w-[320px] truncate">
                {prop.description ?? ''}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

/**
 * JSON tab — raw schema JSON с syntax highlight + copy button.
 * Stage 3.4 polish: handoff requested Monaco editor. Monaco = ~2MB bundle
 * cost; для read-only display это overkill. Используем regex-based highlight
 * (keys/strings/numbers/booleans), это даёт 90% UX benefit at 0 bundle cost.
 * Если потребуется EDIT (Stage 3.x) — добавим Monaco lazy через React.lazy.
 */
function JsonTabContent({ detail }: { detail: { schemaJson: import('@/api/client').JsonSchema } }) {
  const { t } = useTranslation()
  const [copied, setCopied] = useState(false)
  const json = useMemo(() => JSON.stringify(detail.schemaJson, null, 2), [detail.schemaJson])

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(json)
      setCopied(true)
      setTimeout(() => setCopied(false), 1500)
    } catch {
      // clipboard API недоступен (insecure context) — silently ignore.
    }
  }

  return (
    <div className="relative">
      <button
        type="button"
        onClick={handleCopy}
        // Copy button — dark-glass overlay поверх code block для contrast
        // с тёмным фоном (см. pre).
        className="absolute top-2 right-2 z-10 px-2.5 py-1 rounded-sm border border-[#3d3863] bg-[#1c1840]/85 backdrop-blur text-cap text-[#c5bff0] hover:text-[#f0eaff] hover:border-[#ff9a64] transition-colors"
      >
        {copied ? t('json.copied', { defaultValue: 'Скопировано' }) : t('json.copy', { defaultValue: 'Копировать' })}
      </button>
      {/* Code block — dark theme (space palette из graph), убираем cream
        * bg-surface-2 который user feedback'нул как «желтый кремовый».
        * Highlight token colors (text-accent/green/pink/warn) работают на
        * тёмном фоне OK благодаря accessible contrast. */}
      <pre className="rounded-lg border border-[#3d3863] bg-[#0c0a23] p-4 overflow-x-auto text-cell font-mono text-[#f0eaff] whitespace-pre">
        <code dangerouslySetInnerHTML={{ __html: highlightJson(json) }} />
      </pre>
    </div>
  )
}

/**
 * Lightweight JSON syntax highlight via regex. Returns HTML с inline
 * span'ами окрашенными tokens — works для display-only (Monaco не нужен).
 *
 * <p>Каждый escape перед взаимодействием: source already JSON.stringify'd
 * (safe), но regex применяется к HTML так что & < > > сначала escape'аются.
 */
/**
 * Error state для route когда `useDictionaryDetail` returns 404 либо другой
 * error. Special-case: если dict в /dictionaries/deleted (Корзина) — show
 * inline restore card вместо generic error. 7-day retention window:
 * admin может restore в этот период, дальше DictionaryPurgeJob physically
 * removes (recovery только из DB backup).
 *
 * <p>Non-admin / dict не в Корзине → стандартный QueryErrorState.
 */
function DictionaryErrorOrSoftDeletedCard({
  name,
  error,
  isAdmin,
  onRetry,
}: {
  name: string
  error: unknown
  isAdmin: boolean
  onRetry: () => void
}) {
  const { t } = useTranslation()
  const navigate = useNavigate({ from: Route.fullPath })
  const restoreMut = useRestoreDictionary()
  // Дёргаем Корзину только если admin (non-admin получает 403 от backend).
  const deletedQ = useDictionariesDeleted(isAdmin)
  const status = (error as { response?: { status?: number } })?.response?.status
  const isNotFound = status === 404
  const softDeleted = deletedQ.data?.find((d) => d.name === name)

  if (!isNotFound || !softDeleted) {
    return <QueryErrorState error={error} onRetry={onRetry} />
  }

  // Сколько осталось до auto-purge (7-day retention).
  const deletedAt =
    (softDeleted as unknown as { deletedAt?: string; updatedAt?: string }).deletedAt
    ?? softDeleted.updatedAt
  const deletedDate = deletedAt ? new Date(deletedAt) : null
  const purgeDate = deletedDate ? new Date(deletedDate.getTime() + 7 * 24 * 60 * 60 * 1000) : null
  const daysLeft = purgeDate
    ? Math.ceil((purgeDate.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
    : null

  return (
    <div className="max-w-2xl mx-auto mt-12">
      <div className="rounded-md border border-destructive/30 bg-destructive/10 px-5 py-4 space-y-4">
        <div className="flex items-start gap-3">
          <TrashIcon size={24} weight="regular" className="text-destructive shrink-0 mt-0.5" />
          <div className="flex-1 min-w-0">
            <h2 className="text-title-md text-ink mb-1">
              {t('dict.deleted.title', { defaultValue: 'Справочник удалён' })}
            </h2>
            <p className="text-cell text-ink-2 mb-1">
              <strong>{softDeleted.name}</strong>
              {softDeleted.displayName ? ` — ${softDeleted.displayName}` : null}
            </p>
            <p className="text-cell text-ink-2">
              {daysLeft !== null && daysLeft > 0
                ? t('dict.deleted.retentionLeft', {
                    defaultValue:
                      'Восстановить можно в течение {{days}} дн. — после этого записи будут безвозвратно удалены.',
                    days: daysLeft,
                  })
                : t('dict.deleted.retentionExpiringSoon', {
                    defaultValue: 'Срок восстановления истекает — purge cron сработает в ближайшие часы.',
                  })}
            </p>
          </div>
        </div>
        {isAdmin ? (
          <div className="flex justify-end gap-2 pt-2 border-t border-destructive/20">
            <button
              type="button"
              className="h-9 px-4 rounded-md border border-line bg-surface text-ink-2 text-cell hover:bg-surface-2"
              onClick={() => navigate({ to: '/dictionaries' })}
            >
              {t('dict.deleted.backToList', { defaultValue: 'К списку справочников' })}
            </button>
            <button
              type="button"
              className="h-9 px-4 rounded-md bg-accent text-on-accent text-cell font-medium hover:bg-accent/85 disabled:opacity-50 disabled:cursor-not-allowed"
              onClick={() => {
                restoreMut.mutate(name, {
                  onSuccess: () => {
                    // После restore — refetch detail + navigate back to dict.
                    onRetry()
                  },
                })
              }}
              disabled={restoreMut.isPending}
            >
              {restoreMut.isPending
                ? t('dict.deleted.restorePending', { defaultValue: 'Восстанавливаю…' })
                : t('dict.deleted.restore', { defaultValue: 'Восстановить справочник' })}
            </button>
          </div>
        ) : (
          <p className="text-cell text-mute pt-2 border-t border-destructive/20">
            {t('dict.deleted.adminOnly', {
              defaultValue: 'Восстановление справочника доступно только администратору.',
            })}
          </p>
        )}
      </div>
    </div>
  )
}

function highlightJson(json: string): string {
  const escape = (s: string) =>
    s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  return escape(json).replace(
    /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?)/g,
    (match) => {
      let cls = 'text-warn'
      if (/^"/.test(match)) {
        cls = /:$/.test(match) ? 'text-accent' : 'text-green'
      } else if (/true|false/.test(match)) {
        cls = 'text-pink'
      } else if (/null/.test(match)) {
        cls = 'text-mute italic'
      }
      return `<span class="${cls}">${match}</span>`
    },
  )
}

/**
 * Events tab — embedded last 20 audit entries filtered by dictName.
 * Stage 3.4 polish: handoff требовал inline list вместо redirect к /audit.
 *
 * <p>Не показывает payloadBefore/After (expand-detail) — для полного diff
 * остался deep-link к /audit?dict=name внизу. Здесь — таймстамп + action +
 * businessKey + author, плотно в 4-col grid.
 */
function EventsTabContent({ dictName }: { dictName: string }) {
  const { t } = useTranslation()
  const auditQ = useAudit({ dictionaryName: dictName, size: 50 })
  const { data, isLoading, error } = auditQ
  // Filter chips per prototype: все / edit / publish / review / webhook / export.
  // Backend audit actions: CREATE / UPDATE / CLOSE / EXPORT etc. — map to display
  // groups for chip filter.
  const [filter, setFilter] = useState<'all' | 'edit' | 'publish' | 'review' | 'webhook' | 'export'>('all')

  if (isLoading) return <LoadingBlock size="md" label={t('loading')} />
  if (error)
    return <QueryErrorState error={error} onRetry={() => auditQ.refetch()} />
  const rows = data?.content ?? []
  if (rows.length === 0)
    return (
      <EmptyState
        title={t('events.empty.title', { defaultValue: 'Событий пока нет' })}
        description={t('events.empty.description', {
          defaultValue: 'Любая правка записей справочника появится здесь.',
        })}
      />
    )

  /** Group audit action в chip filter category. */
  const eventCategory = (action: string): 'edit' | 'publish' | 'review' | 'webhook' | 'export' => {
    const a = action.toUpperCase()
    if (a === 'CREATE' || a === 'UPDATE' || a === 'CLOSE') return 'edit'
    if (a === 'EXPORT') return 'export'
    if (a === 'PUBLISH') return 'publish'
    if (a === 'REVIEW' || a === 'APPROVE' || a === 'DRAFT') return 'review'
    if (a === 'WEBHOOK') return 'webhook'
    return 'edit'
  }

  /** Counts per category для chip labels. */
  const counts = {
    all: rows.length,
    edit: rows.filter((r) => eventCategory(r.action) === 'edit').length,
    publish: rows.filter((r) => eventCategory(r.action) === 'publish').length,
    review: rows.filter((r) => eventCategory(r.action) === 'review').length,
    webhook: rows.filter((r) => eventCategory(r.action) === 'webhook').length,
    export: rows.filter((r) => eventCategory(r.action) === 'export').length,
  }

  const visible = filter === 'all' ? rows : rows.filter((r) => eventCategory(r.action) === filter)

  // Color chip per event type — соответствует prototype.
  const typeChipClass = (cat: 'edit' | 'publish' | 'review' | 'webhook' | 'export'): string => {
    switch (cat) {
      case 'webhook':
        return 'bg-accent-bg text-accent'
      case 'publish':
        return 'bg-green-bg text-green'
      case 'review':
        return 'bg-warn-bg text-warn'
      case 'export':
        return 'bg-surface-2 text-ink-2'
      case 'edit':
      default:
        return 'bg-accent-bg/50 text-accent'
    }
  }

  const FILTER_CHIPS: { id: typeof filter; label: string; count: number }[] = [
    { id: 'all', label: t('events.filter.all', { defaultValue: 'все' }), count: counts.all },
    { id: 'webhook', label: 'webhooks', count: counts.webhook },
    { id: 'publish', label: 'publish', count: counts.publish },
    { id: 'review', label: 'review', count: counts.review },
    { id: 'edit', label: 'edit', count: counts.edit },
    { id: 'export', label: 'export', count: counts.export },
  ]

  return (
    <div className="space-y-3">
      {/* Filter chips per prototype line 07-editor-events */}
      <div className="flex items-center gap-1.5 flex-wrap">
        {FILTER_CHIPS.filter((c) => c.id === 'all' || c.count > 0).map((c) => {
          const active = filter === c.id
          return (
            <button
              key={c.id}
              type="button"
              onClick={() => setFilter(c.id)}
              aria-pressed={active}
              className={cn(
                'h-7 px-3 rounded-md text-body transition-colors inline-flex items-center gap-1.5',
                active
                  ? 'bg-navy text-on-accent font-semibold'
                  : 'border border-line bg-surface text-ink-2 hover:bg-surface-2',
              )}
            >
              {c.label}
              <span className={cn('text-mono', active ? 'opacity-90' : 'text-mute')}>
                {c.count}
              </span>
            </button>
          )
        })}
        <Link
          to="/audit"
          search={{ dict: dictName }}
          className="ml-auto h-7 px-3 rounded-md border border-line bg-surface text-ink-2 hover:bg-surface-2 text-body inline-flex items-center transition-colors"
        >
          {t('events.viewAll.short', { defaultValue: 'Полный лог →' })}
        </Link>
      </div>

      <div className="rounded-lg border border-line bg-surface overflow-hidden">
        <table className="w-full">
          <thead>
            <tr className="text-cap text-mute tracking-[0.14em] uppercase font-display border-b border-line-2">
              <th className="text-left px-3 py-2 w-[140px]">
                {t('audit.col.time', { defaultValue: 'Время' })}
              </th>
              <th className="text-left px-3 py-2 w-24">
                {t('audit.col.action', { defaultValue: 'Тип' })}
              </th>
              <th className="text-left px-3 py-2 w-20">
                {t('audit.col.status', { defaultValue: 'Статус' })}
              </th>
              <th className="text-left px-3 py-2 w-[180px]">
                {t('audit.col.user', { defaultValue: 'Куда · Кто' })}
              </th>
              <th className="text-left px-3 py-2">
                {t('audit.col.businessKey', { defaultValue: 'Сообщение' })}
              </th>
            </tr>
          </thead>
          <tbody>
            {visible.map((r) => {
              const time = new Date(r.eventTime)
              const cat = eventCategory(r.action)
              // Backend audit currently не выставляет explicit status field;
              // treat CREATE/UPDATE/CLOSE/EXPORT как 'ok'. Future: parse error
              // logs для 'fail' state.
              const status: 'ok' | 'fail' = 'ok'
              return (
                <tr
                  key={`${r.id}`}
                  className="border-t border-line-2 bg-surface hover:bg-surface-2 transition-colors"
                >
                  <td className="text-mono text-cell tabular-nums px-3 py-2 text-ink-2">
                    {time.toLocaleString(undefined, {
                      month: '2-digit',
                      day: '2-digit',
                      hour: '2-digit',
                      minute: '2-digit',
                      second: '2-digit',
                    })}
                  </td>
                  <td className="px-3 py-2">
                    <span
                      className={cn(
                        'inline-flex items-center px-2 py-0.5 rounded-sm text-cap tracking-wider font-semibold uppercase',
                        typeChipClass(cat),
                      )}
                    >
                      {cat}
                    </span>
                  </td>
                  <td className="px-3 py-2">
                    <span className="inline-flex items-center gap-1.5 text-cell text-ink-2">
                      <span
                        className={cn(
                          'size-1.5 rounded-full',
                          status === 'ok' ? 'bg-green' : 'bg-pink',
                        )}
                        aria-hidden
                      />
                      {status}
                    </span>
                  </td>
                  <td className="px-3 py-2 text-cell text-ink-2">
                    {r.userId ? <UserCell uuid={r.userId} /> : <span className="text-mono text-mute">anonymous</span>}
                  </td>
                  <td className="px-3 py-2 text-cell text-ink-2 max-w-md truncate">
                    {r.businessKey ? (
                      <span>
                        <span className="text-mono text-accent">{r.businessKey}</span>
                        {' · '}
                        <span className="text-mute">{r.action}</span>
                      </span>
                    ) : (
                      <span className="text-mute">{r.action}</span>
                    )}
                  </td>
                </tr>
              )
            })}
          </tbody>
        </table>
      </div>
    </div>
  )
}

/**
 * History tab — schema changelog timeline. Версионные events (создание
 * словаря, schema update, draft publish) рендерятся с version badge;
 * promotional draft events (review/approve/reject/changes_requested/
 * withdraw) — без version, neutral tone. Каждый entry имеет «Сравнить»
 * который открывает {@link ChangelogDiffModal} c full before/after.
 *
 * <p>Backend: GET /api/v1/dictionaries/{name}/changelog → ChangelogResponse.
 * Diff endpoint lazy fetch'ится в modal.
 *
 * <p>Empty state — для свежесозданных словарей без update'ов.
 */
function HistoryTabContent({ detail }: { detail?: import('@/api/client').DictionaryDetail }) {
  const { t } = useTranslation()
  const [diffEntryId, setDiffEntryId] = useState<number | undefined>(undefined)
  const changelogQ = useChangelog(detail?.name)
  const { data, isLoading, error } = changelogQ

  if (!detail) return null

  return (
    <div className="space-y-3">
      {isLoading && <LoadingBlock size="md" label={t('loading')} />}
      {error && (
        <QueryErrorState error={error} onRetry={() => changelogQ.refetch()} />
      )}
      {data && data.entries.length === 0 && (
        <EmptyState
          title={t('changelog.empty.title', {
            defaultValue: 'История пуста',
          })}
          description={t('changelog.empty.description', {
            defaultValue: 'Изменения схемы и события workflow появятся здесь.',
          })}
        />
      )}
      {data && data.entries.length > 0 && (
        <ol className="relative border-l-2 border-line pl-5 space-y-3">
          {data.entries.map((entry) => (
            <ChangelogTimelineItem
              key={entry.id}
              entry={entry}
              onOpenDiff={() => setDiffEntryId(entry.id)}
            />
          ))}
        </ol>
      )}
      <ChangelogDiffModal
        open={diffEntryId !== undefined}
        onClose={() => setDiffEntryId(undefined)}
        dictionaryName={detail.name}
        entryId={diffEntryId}
      />
    </div>
  )
}

function ChangelogTimelineItem({
  entry,
  onOpenDiff,
}: {
  entry: import('@/api/client').ChangelogEntry
  onOpenDiff: () => void
}) {
  const { t } = useTranslation()
  const KindIcon = kindIcon(entry.kind)
  const occurredAt = new Date(entry.publishedAt)
  const showVersion = isVersioningKind(entry.kind) && entry.version
  const added = entry.diff.added.length
  const changed = entry.diff.changed.length
  const removed = entry.diff.removed.length

  return (
    <li className="relative">
      {/* Timeline dot marker — kind-coloured */}
      <span
        className={cn(
          'absolute -left-[27px] top-1.5 size-3 rounded-full border-2 bg-surface',
          entry.isCurrent ? 'border-accent' : 'border-line-2',
        )}
        aria-hidden
      />
      <div className="rounded-lg border border-line bg-surface p-3">
        <div className="flex items-start gap-3">
          <KindIcon weight="duotone" size={20} className="text-mute shrink-0 mt-0.5" />
          <div className="flex-1 min-w-0">
            <div className="flex items-baseline gap-2 flex-wrap">
              {showVersion && (
                <span className="text-mono text-title-sm text-ink font-semibold tabular-nums">
                  v{entry.version}
                </span>
              )}
              <Badge variant={kindBadgeVariant(entry.kind)}>
                {kindLabel(entry.kind, t)}
              </Badge>
              {entry.isCurrent && (
                <Badge variant="primary">
                  {t('history.current', { defaultValue: 'текущая' })}
                </Badge>
              )}
              <span className="text-mono text-cell text-mute tabular-nums ml-auto">
                {added > 0 && <span className="text-aurora">+{added}</span>}
                {added > 0 && (changed > 0 || removed > 0) && ' '}
                {changed > 0 && <span className="text-warn">~{changed}</span>}
                {changed > 0 && removed > 0 && ' '}
                {removed > 0 && <span className="text-danger">−{removed}</span>}
              </span>
            </div>
            {entry.headers && (
              <div className="text-body text-ink mt-1 truncate">{entry.headers}</div>
            )}
            <div className="flex items-center gap-3 mt-1 text-cell text-mute">
              <span className="tabular-nums">{occurredAt.toLocaleString()}</span>
              <UserCell uuid={entry.publishedBy.sub} />
              {entry.recordsAffected > 0 && (
                <span>
                  {t('changelog.recordsAffected', {
                    count: entry.recordsAffected,
                    defaultValue: `${entry.recordsAffected} запис.`,
                  })}
                </span>
              )}
            </div>
            <div className="mt-2">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                onClick={onOpenDiff}
              >
                {t('changelog.viewDiff', { defaultValue: 'Сравнить' })}
              </Button>
            </div>
          </div>
        </div>
      </div>
    </li>
  )
}

/**
 * RecordColumnFilters — auto-derive filterable columns из schema +
 * records data, render as compact select dropdowns "status ▾", "operator ▾".
 *
 * <p>Filterable columns детектятся по двум сигналам:
 * <ol>
 *   <li>schema property имеет `enum` array → use enum values as options</li>
 *   <li>иначе walk records, собирай уникальные значения для top non-i18n
 *       string properties (status, operator, type). Если cardinality
 *       (unique count) ≤ 12 — это reasonable filter; иначе skip (нужен search).</li>
 * </ol>
 *
 * <p>Replaces прошлый scope filter chips per user feedback ("фильтр в
 * справочнике по статусу скорее нужен чем по scope").
 */
function RecordColumnFilters({
  schema,
  records,
  value,
  onChange,
}: {
  schema?: import('@/api/client').JsonSchema
  records: FlattenedRecord[]
  value: Record<string, string>
  onChange: (v: Record<string, string>) => void
}) {
  const filters = useMemo(() => {
    if (!schema?.properties) return []
    const out: { field: string; label: string; options: string[] }[] = []
    for (const [field, prop] of Object.entries(schema.properties)) {
      // Skip localized & FK fields — слишком много unique values.
      if (prop['x-localized']) continue
      // Enum-typed → use schema enum values.
      if (Array.isArray(prop.enum) && prop.enum.length > 0 && prop.enum.length <= 20) {
        out.push({
          field,
          label: field,
          options: prop.enum.map((e) => String(e)).sort(),
        })
        continue
      }
      // Skip типы где dropdown не имеет смысла:
      // - date/date-time/email/url: высокая cardinality, нужен picker/search
      // - numbers (integer/number): range filter полезнее dropdown
      if (
        prop.format === 'date' ||
        prop.format === 'date-time' ||
        prop.format === 'email' ||
        prop.format === 'url' ||
        prop.type === 'integer' ||
        prop.type === 'number' ||
        prop.type === 'boolean' ||
        prop.type === 'array' ||
        prop.type === 'object'
      ) {
        continue
      }
      // FK (x-references) → use ALL unique values from records (target_dict
      // values typically have low cardinality at usage site).
      const isFk = typeof prop['x-references'] === 'string'
      // String-typed без enum но с low cardinality в records.
      if (prop.type === 'string' || isFk) {
        const seen = new Set<string>()
        for (const r of records) {
          const v = (r.data as Record<string, unknown>)[field]
          if (v !== null && v !== undefined && v !== '') {
            seen.add(String(v))
            if (seen.size > 12) break
          }
        }
        if (seen.size > 0 && seen.size <= 12) {
          out.push({
            field,
            label: field,
            options: Array.from(seen).sort(),
          })
        }
      }
    }
    return out.slice(0, 3) // Cap 3 filters max — не растянуть toolbar.
  }, [schema, records])

  if (filters.length === 0) return null

  return (
    <div className="flex items-center gap-2 flex-wrap">
      {filters.map((f) => (
        <select
          key={f.field}
          value={value[f.field] ?? ''}
          onChange={(e) =>
            onChange({ ...value, [f.field]: e.target.value })
          }
          className="h-8 px-2 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent cursor-pointer"
          aria-label={`Filter by ${f.label}`}
        >
          <option value="">{f.label} ▾</option>
          {f.options.map((opt) => (
            <option key={opt} value={opt}>
              {opt}
            </option>
          ))}
        </select>
      ))}
    </div>
  )
}
