import { useEffect, useMemo, useRef, 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,
  EmptyState,
  IconButton,
  LoadingBlock,
  Modal,
  PageHeader,
  Panel,
  SearchInput,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeaderCell,
  TableRow,
  TextInput,
} from '@nstart/ui'
import { PlusIcon, PencilSimpleIcon, XCircleIcon, GearIcon, ClockCounterClockwiseIcon, MapTrifoldIcon, TrashIcon, CheckCircleIcon, WarningIcon, XIcon, DownloadSimpleIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'
import {
  useDictionaryDetail,
  useDictPendingDrafts,
  useRecordRaw,
  useRecords,
  type RecordsFilter,
} from '@/api/queries'
import { useBulkCloseRecords, useBulkExportRecords, useCreateRecord, useUpdateRecord, useCloseRecord, useFormIdempotencyKey } from '@/api/mutations'
import { useCanMutate } from '@/auth/useCanMutate'
import type { BulkCloseResponse, CreateRecordRequest, DataScope, FlattenedRecord } from '@/api/client'
import { SchemaDrivenForm } from '@/components/form/SchemaDrivenForm'
import { DictionaryEditorDialog } from '@/components/schema/DictionaryEditorDialog'
import { DictionaryDependentsPanel } from '@/components/lineage/DictionaryDependentsPanel'
import { DictionaryHubView } from '@/components/lineage/DictionaryHubView'
import { CascadeConfirmDialog } from '@/components/lineage/CascadeConfirmDialog'
import { RecordHistoryDrawer } from '@/components/record/RecordHistoryDrawer'
import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialog'
import { TimeTravelPicker } from '@/components/timetravel/TimeTravelPicker'
import { nowIsoLocal } from '@/lib/dates'
import { recordDisplayName } from '@/lib/locales'
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'

/**
 * 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
  /** View mode: 'records' (default — table + filters) | 'hub' (relations graph
   *  с current dict в центре + neighbors на боках). См. DictionaryHubView. */
  view?: 'records' | 'hub'
}

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
  }
  if (raw.view === 'hub' || raw.view === 'records') out.view = raw.view
  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()

  // 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)
  /**
   * Approval Workflow v2 Phase 4: pending drafts на этот dict — для бейджа
   * "На review" в каждой строке records table. Polled 30s. Bypass'нем если
   * dict не approval-required (нет смысла грузить).
   */
  const pendingDraftsQuery = useDictPendingDrafts(
    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])
  const createMut = useCreateRecord(name)
  const updateMut = useUpdateRecord(name)
  // 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)

  const [edit, setEdit] = useState<EditState>({ kind: 'closed' })
  const [closeReason, setCloseReason] = useState('')
  const [schemaEditOpen, setSchemaEditOpen] = useState(false)
  const [historyKey, setHistoryKey] = useState<string | undefined>(undefined)
  const [aoiOpen, setAoiOpen] = useState(false)
  const [timeTravelOpen, setTimeTravelOpen] = useState(false)
  /** 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,
    })
  }

  const toggleScope = (scope: DataScope) => {
    const next = new Set(scopeFilter)
    if (next.has(scope)) next.delete(scope)
    else next.add(scope)
    void navigate({
      search: (prev) => ({
        ...prev,
        scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
      }),
      replace: true,
    })
  }

  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 = () => {
    void navigate({
      search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
      replace: true,
    })
  }

  const filteredRecords = useMemo(() => {
    const raw = recordsResult.data ?? []
    if (raw.length === 0) return raw
    const q = search.trim().toLowerCase()
    const scopes = scopeFilter
    return raw.filter((r) => {
      if (scopes.size > 0 && !scopes.has(r.dataScope)) 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])

  const filtersActive = search.length > 0 || scopeFilter.size > 0
  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])

  const visibleRecords = useMemo(
    () => filteredRecords.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
    [filteredRecords, 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))

  // Native checkbox indeterminate state — sync через useEffect, не callback ref.
  // Callback ref срабатывает только на mount/unmount → state становится stale
  // когда user toggle'ает selection per-row. useEffect re-runs на изменение
  // someVisibleSelected. См. design handoff review #17 + Checkbox в @nstart/ui:
  // ref forwarded к нативному <input>, но `indeterminate` это JS DOM-only prop
  // (не HTML attribute), нужен manual sync.
  const selectAllRef = useRef<HTMLInputElement>(null)
  useEffect(() => {
    if (selectAllRef.current) {
      selectAllRef.current.indeterminate = someVisibleSelected
    }
  }, [someVisibleSelected])

  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)
  }

  const handleBulkExport = () => {
    if (selection.size === 0) return
    bulkExportMut.mutate(Array.from(selection))
  }

  const handleBulkClose = () => {
    if (selection.size === 0) 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: enum + reference поля — самые info-dense
  // не-локализованные scalar'ы. Берём первые 2 чтобы не перегрузить
  // строку (с businessKey + name + scope + extras + дата + actions = 6
  // колонок, comfortably fits на 1280px). Skip name/code/businessKey
  // (уже показаны), всё нескалярное (object/array/localized).
  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 >= 2) break
      if (key === 'name' || key === 'code' || key === 'businessKey') continue
      const isLocalized = Boolean(prop['x-localized'])
      if (isLocalized) continue
      const isEnum = Array.isArray(prop.enum) && prop.enum.length > 0
      const isFk = typeof prop['x-references'] === 'string' && Boolean(prop['x-references'])
      if (!isEnum && !isFk) continue
      interesting.push({ key, label: humanize(key), isFk })
    }
    return interesting
  }, [detailQuery.data?.schemaJson])

  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(),
    }
    if (edit.kind === 'create') {
      createMut.mutate(
        { payload: submitReq, idempotencyKey: recordIdempotencyKey },
        {
          onSuccess: () => {
            setEdit({ kind: 'closed' })
            resetRecordIdempotencyKey()
          },
        },
      )
      return
    }
    if (edit.kind === 'edit') {
      updateMut.mutate(
        {
          businessKey: edit.record.businessKey,
          payload: submitReq,
          idempotencyKey: recordIdempotencyKey,
        },
        {
          onSuccess: () => {
            setEdit({ kind: 'closed' })
            resetRecordIdempotencyKey()
          },
        },
      )
    }
  }

  const handleClose = () => {
    if (edit.kind !== 'close-confirm') return
    const targetKey = edit.record.businessKey
    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)
          }
        },
      },
    )
  }

  const activeMutation =
    edit.kind === 'create' ? createMut : edit.kind === 'edit' ? updateMut : null
  const serverError = serverErrorMessage(activeMutation?.error)

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

  return (
    <div className="space-y-6">
      {scope && (
        <div
          className={`-mt-6 -mx-4 sm:-mx-6 lg:-mx-8 mb-2 h-1 border-t-4 ${SCOPE_BORDER_TOP[scope]}`}
          aria-hidden="true"
        />
      )}
      <PageHeader
        breadcrumb={
          <div className="flex items-center gap-3 text-sm">
            <Link to="/dictionaries" className="text-carbon/70 hover:text-ultramarain">
              ← {t('nav.dictionaries')}
            </Link>
            {scope && (
              <span className="flex items-center gap-1.5 text-carbon/70">
                <span className="text-carbon/40">·</span>
                <span
                  className={`inline-block size-2 rounded-full ${SCOPE_DOT[scope]}`}
                  aria-hidden="true"
                />
                <span className="font-medium">{t(`dict.list.section.${scope}`)}</span>
              </span>
            )}
          </div>
        }
        title={detailQuery.data?.displayName ?? name}
        description={
          detailQuery.data?.description
            ? `${detailQuery.data.description} · ${totalRecords} ${t('dict.list.records')}`
            : `${totalRecords} ${t('dict.list.records')}`
        }
        actions={
          // flex-wrap: при узком viewport кнопки переносятся на новую row
          // вместо вмятия в 2 строки внутри одной кнопки. whitespace-nowrap на
          // каждой Button — label всегда single line. Combination: buttons
          // expand to fit text, и весь toolbar wraps below header при узком
          // экране, а не давит лейблы.
          <div className="flex flex-wrap items-center gap-2">
            <Button
              variant="secondary"
              leftIcon={<MapTrifoldIcon weight="bold" size={16} />}
              disabled={!detailQuery.data}
              onClick={() => setAoiOpen(true)}
              className="whitespace-nowrap"
            >
              {t('aoi.button')}
            </Button>
            <Button
              variant="secondary"
              leftIcon={<ClockCounterClockwiseIcon weight="bold" size={16} />}
              disabled={!detailQuery.data}
              onClick={() => setTimeTravelOpen((v) => !v)}
              aria-pressed={Boolean(timeTravelAt)}
              className="whitespace-nowrap"
            >
              {t('timeTravel.button')}
            </Button>
            {canMutate && (
              <>
                <Button
                  variant="secondary"
                  leftIcon={<GearIcon weight="bold" size={16} />}
                  disabled={!detailQuery.data}
                  onClick={() => setSchemaEditOpen(true)}
                  className="whitespace-nowrap"
                >
                  {t('schema.action.editSchema')}
                </Button>
                <Button
                  variant="primary"
                  leftIcon={<PlusIcon weight="bold" size={16} />}
                  disabled={!detailQuery.data}
                  onClick={() => setEdit({ kind: 'create' })}
                  className="whitespace-nowrap"
                >
                  {t('dict.action.create')}
                </Button>
              </>
            )}
          </div>
        }
      />

      {/* View toggle: Записи (default table-based view) | Связи (Hub view с
          neighbors на боках). Per design handoff B. */}
      {detailQuery.data && (
        <div role="group" aria-label="View mode" className="flex border border-regolith rounded-sm overflow-hidden w-fit">
          {(['records', 'hub'] as const).map((mode) => {
            const active = (urlSearch.view ?? 'records') === mode
            return (
              <button
                key={mode}
                type="button"
                onClick={() =>
                  void navigate({
                    search: (prev) => ({
                      ...prev,
                      view: mode === 'records' ? undefined : mode,
                    }),
                    replace: true,
                  })
                }
                aria-pressed={active}
                className={`px-3 py-1 text-2xs uppercase tracking-label transition focus:outline-none focus:ring-2 focus:ring-ultramarain/40 ${
                  active
                    ? 'bg-orbit/40 text-ultramarain'
                    : 'hover:bg-orbit/20 text-carbon'
                }`}
              >
                {t(`dict.view.${mode}`, {
                  defaultValue: mode === 'records' ? 'Записи' : 'Связи',
                })}
              </button>
            )
          })}
        </div>
      )}

      {urlSearch.view === 'hub' && detailQuery.data ? (
        <DictionaryHubView detail={detailQuery.data} />
      ) : (
        /* Phase 1 dict-relationships-v2: schema-level reverse FK card.
           Hide-on-empty — большинство справочников без incoming refs не
           увидят этот блок. */
        <DictionaryDependentsPanel dictionaryName={name} />
      )}

      {aoi && (
        <div className="flex items-center gap-3 px-3 py-2 rounded-sm border border-ultramarain/30 bg-ultramarain/4 text-sm">
          <span className="font-mono text-xs text-carbon/80">
            {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-xs text-ultramarain hover:underline"
            onClick={handleAoiClear}
          >
            {t('aoi.clear')}
          </button>
        </div>
      )}

      {/* Time-travel picker — раскрывается по клику на toolbar button.
          ISO datetime в URL ?at=… — share-friendly. Active state показывает
          ambient banner справа от input'а. Phase v1 stretch. */}
      {timeTravelOpen && (
        <TimeTravelPicker
          value={timeTravelAt}
          onChange={(iso) => setTimeTravelAt(iso)}
          onClear={() => setTimeTravelAt(undefined)}
          onClose={() => setTimeTravelOpen(false)}
        />
      )}

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

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

      {recordsResult.error && (
        <Alert variant="error" title={t('error.failed')}>
          {String(recordsResult.error)}
        </Alert>
      )}

      {recordsResult.data && recordsResult.data.length === 0 && (
        <EmptyState title={t('dict.empty')} />
      )}

      {recordsResult.data && recordsResult.data.length > 0 && (
        <>
          <div className="flex flex-wrap items-center gap-3">
            <div className="flex-1 min-w-[280px] max-w-md">
              <SearchInput
                value={search}
                onChange={(e) => setSearch(e.target.value)}
                placeholder={t('dict.filter.searchPlaceholder')}
                aria-label={t('dict.filter.searchPlaceholder')}
              />
            </div>
            <div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
              {SCOPE_ORDER.map((s) => {
                const active = scopeFilter.has(s)
                return (
                  <button
                    key={s}
                    type="button"
                    onClick={() => toggleScope(s)}
                    aria-pressed={active}
                    className={[
                      'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-2xs uppercase tracking-label font-secondary transition-colors',
                      active
                        ? 'border-ultramarain bg-ultramarain/8 text-ultramarain'
                        : 'border-regolith bg-white text-carbon/70 hover:border-carbon/40',
                    ].join(' ')}
                  >
                    <span
                      className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
                      aria-hidden="true"
                    />
                    {t(`dict.list.section.${s}`)}
                  </button>
                )
              })}
            </div>
            {filtersActive && (
              <div className="flex items-center gap-2 text-xs text-carbon/70">
                <span>
                  {t('dict.filter.matched', { count: filteredCount, total: totalRecords })}
                </span>
                <button
                  type="button"
                  onClick={handleClearFilters}
                  className="text-ultramarain hover:underline"
                >
                  {t('dict.filter.clear')}
                </button>
              </div>
            )}
          </div>

          {filteredRecords.length === 0 ? (
            <EmptyState title={t('dict.filter.noMatches')} />
          ) : (
            <>
              {canMutate && selection.size > 0 && (
                <BulkSelectionToolbar
                  count={selection.size}
                  totalVisible={filteredCount}
                  onBulkClose={openBulkClose}
                  onBulkExport={handleBulkExport}
                  onClear={clearSelection}
                  isExporting={bulkExportMut.isPending}
                />
              )}
              <Panel>
                <Table>
                  <TableHead>
                    <TableRow>
                      {canMutate && (
                        <TableHeaderCell>
                          <Checkbox
                            aria-label={t('dict.bulk.selectAll')}
                            checked={allVisibleSelected}
                            ref={selectAllRef}
                            onChange={toggleSelectAll}
                          />
                        </TableHeaderCell>
                      )}
                      <TableHeaderCell>{t('dict.col.businessKey')}</TableHeaderCell>
                      <TableHeaderCell>name / code</TableHeaderCell>
                      {extraColumns.map((c) => (
                        <TableHeaderCell key={c.key}>{c.label}</TableHeaderCell>
                      ))}
                      <TableHeaderCell>{t('dict.col.scope')}</TableHeaderCell>
                      <TableHeaderCell>{t('dict.col.validFrom')}</TableHeaderCell>
                      <TableHeaderCell align="right">{t('dict.col.actions')}</TableHeaderCell>
                    </TableRow>
                  </TableHead>
                  <TableBody>
                  {visibleRecords.map((r) => (
                <TableRow key={r.id} data-selected={selection.has(r.businessKey)}>
                  {canMutate && (
                    <TableCell>
                      <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">
                      <span className="font-mono text-2xs">{r.businessKey}</span>
                      {pendingByKey.has(r.businessKey) ? (
                        <span title={t('dict.pendingReview.tooltip')}>
                          <Badge variant="warning">{t('dict.pendingReview.label')}</Badge>
                        </span>
                      ) : null}
                    </div>
                  </TableCell>
                  <TableCell>
                    {recordDisplayName(
                      r.data,
                      detailQuery.data?.defaultLocale ?? 'ru-RU',
                    )}
                  </TableCell>
                  {extraColumns.map((c) => {
                    const v = r.data?.[c.key]
                    if (v == null || v === '') {
                      return (
                        <TableCell key={c.key}>
                          <span className="text-2xs text-carbon/40">—</span>
                        </TableCell>
                      )
                    }
                    const text = String(v)
                    return (
                      <TableCell key={c.key}>
                        <span
                          className={[
                            'inline-block px-1.5 py-0.5 rounded-sm font-mono text-2xs',
                            c.isFk
                              ? 'bg-ultramarain/8 text-ultramarain'
                              : 'bg-carbon/8 text-carbon/80',
                          ].join(' ')}
                        >
                          {text}
                        </span>
                      </TableCell>
                    )
                  })}
                  <TableCell>
                    <Badge variant="info">{r.dataScope}</Badge>
                  </TableCell>
                  <TableCell>
                    <span className="text-2xs text-carbon/60">
                      {new Date(r.validFrom).toLocaleDateString()}
                    </span>
                  </TableCell>
                  <TableCell align="right">
                    <div className="flex items-center justify-end gap-1">
                      <IconButton
                        label={t('history.title')}
                        variant="default"
                        icon={<ClockCounterClockwiseIcon weight="regular" />}
                        onClick={() => setHistoryKey(r.businessKey)}
                      />
                      {canMutate && (
                        <>
                          <IconButton
                            label={t('dict.action.edit')}
                            variant="default"
                            icon={<PencilSimpleIcon weight="regular" />}
                            onClick={() => setEdit({ kind: 'edit', record: r })}
                          />
                          <IconButton
                            label={t('dict.action.close')}
                            variant="danger"
                            icon={<XCircleIcon weight="regular" />}
                            onClick={() => setEdit({ kind: 'close-confirm', record: r })}
                          />
                        </>
                      )}
                    </div>
                  </TableCell>
                </TableRow>
                  ))}
                  </TableBody>
                </Table>
              </Panel>
              {totalPages > 1 && (
                <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))}
                />
              )}
            </>
          )}
        </>
      )}

      <Modal
        isOpen={edit.kind === 'create' || edit.kind === 'edit'}
        onClose={() => setEdit({ kind: 'closed' })}
        title={
          edit.kind === 'create'
            ? t('dict.action.create')
            : edit.kind === 'edit'
              ? `${t('dict.action.edit')}: ${edit.record.businessKey}`
              : ''
        }
        maxWidth="max-w-4xl"
        panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
        bodyClassName="overflow-y-auto flex-1"
      >
        {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}
            onSubmit={handleSubmit}
            onCancel={() => setEdit({ kind: 'closed' })}
          />
        )}

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

        {detailQuery.data && edit.kind === 'edit' && rawRecordQuery.error && (
          <Alert variant="error" title={t('error.failed')}>
            {String(rawRecordQuery.error)}
          </Alert>
        )}

        {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 = «создаём новую версию с этого момента,
              // старая закрывается». validFrom оставляем пустым — handleSubmit
              // подставит nowIsoLocal() at submit-time (review #5: иначе stale
              // если user долго держит modal открытым).
              // validTo пустой = бессрочно (или до следующей правки).
              validFrom: '',
              validTo: '',
            }}
            onSubmit={handleSubmit}
            onCancel={() => setEdit({ kind: 'closed' })}
          />
        )}
      </Modal>

      <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-sm text-carbon">
              {t('dict.confirmClose.body', { key: edit.record.businessKey })}
            </p>
            <TextInput
              label={t('dict.confirmClose.reason')}
              placeholder={t('dict.confirmClose.reasonPlaceholder')}
              value={closeReason}
              onChange={(e) => setCloseReason(e.target.value)}
            />
            {serverErrorMessage(closeMut.error) && (
              <Alert variant="error">{serverErrorMessage(closeMut.error)}</Alert>
            )}
            <div className="flex justify-end gap-2 pt-2 border-t border-regolith">
              <Button
                type="button"
                variant="ghost"
                disabled={closeMut.isPending}
                onClick={() => setEdit({ kind: 'closed' })}
              >
                {t('form.cancel')}
              </Button>
              <Button
                type="button"
                variant="danger"
                loading={closeMut.isPending}
                onClick={handleClose}
              >
                {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)}
        />
      )}

      <RecordHistoryDrawer
        open={Boolean(historyKey)}
        onClose={() => setHistoryKey(undefined)}
        dictionaryName={name}
        businessKey={historyKey}
      />

      <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}
      />

      <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={bulkCloseMut.isPending}
          serverError={serverErrorMessage(bulkCloseMut.error)}
          result={bulkResult}
        />
      </Modal>
    </div>
  )
}

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

function BulkSelectionToolbar({
  count,
  totalVisible,
  onBulkClose,
  onBulkExport,
  onClear,
  isExporting,
}: BulkSelectionToolbarProps) {
  const { t } = useTranslation()
  return (
    <div
      className={[
        'sticky top-0 z-10 -mx-1 flex items-center justify-between gap-3',
        'bg-ultramarain text-white rounded-md shadow-sm px-4 py-2',
      ].join(' ')}
    >
      <div className="flex items-center gap-3">
        <span className="text-sm 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-2xs 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="secondary"
          leftIcon={<DownloadSimpleIcon weight="bold" size={14} />}
          onClick={onBulkExport}
          loading={isExporting}
        >
          {t('dict.bulk.exportAction')}
        </Button>
        <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-2xs text-carbon/70 tabular-nums">
        {t('dict.page.range', { from, to, total: totalCount })}
      </span>
      <div className="flex items-center gap-3 text-sm">
        <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-2xs text-carbon/70">
          {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
}

function BulkCloseDialog({
  count,
  reason,
  onReasonChange,
  onSubmit,
  onClose,
  isPending,
  serverError,
  result,
}: 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-sm 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-sm 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-sm 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-regolith rounded p-3 max-h-48 overflow-auto bg-regolith/30">
            <ul className="text-2xs font-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-regolith">
          <Button type="button" variant="primary" onClick={onClose}>
            {t('dict.bulk.dismiss')}
          </Button>
        </div>
      </div>
    )
  }

  return (
    <div className="space-y-4">
      <p className="text-sm text-carbon">{t('dict.bulk.confirmBody', { count })}</p>
      <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-regolith">
        <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} />}
        >
          {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'ов. */
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'
}
