import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm, Controller, type Path, type SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import Ajv, { type ErrorObject } from 'ajv'
import addFormats from 'ajv-formats'
import { CaretDownIcon, CircleNotchIcon } from '@phosphor-icons/react'
import {
  Alert,
  Badge,
  Button,
  Checkbox,
  DatePicker,
  FieldError,
  FieldHint,
  FieldLabel,
  FormActions,
  SingleSelect,
  Tabs,
  TextArea,
  TextInput,
  type SelectOption,
  type TabItem,
} from '@/ui'
import type { CreateRecordRequest, FlattenedRecord, JsonSchema } from '@/api/client'
import { useReferencedRecords } from '@/api/queries'
import {
  combineDateTime,
  extractTime,
  formatIsoDate,
  parseFormDate,
} from '@/lib/dates'
import { pickLocalized } from '@/lib/locales'
import { cn } from '@/lib/utils'

type FormValues = {
  businessKey: string
  validFrom: string
  validTo: string
  data: Record<string, unknown>
}

type Props = {
  schema: JsonSchema
  supportedLocales: string[]
  defaultLocale: string
  defaultValues?: Partial<FormValues>
  mode: 'create' | 'edit'
  isPending: boolean
  serverError?: string | null
  onSubmit: (req: CreateRecordRequest) => void
  onCancel: () => void
  /** When provided, form renders WITHOUT internal Cancel/Save buttons —
   * footer is expected to be drawn by parent (e.g. RecordDrawer). External
   * submit triggered via `<button type="submit" form={formId}>`. */
  formId?: string
  /** Notifies parent of dirty-field count (for "N полей" footer caption). */
  onDirtyChange?: (dirtyCount: number) => void
  /** Layout mode (default 'tabs' для backward compat):
   *  - 'tabs': single bucket visible at once, tab bar для switch (legacy)
   *  - 'sections': all buckets vertically scrollable + section chips strip
   *    на верху для quick jump. Per handoff Screen 3 RecordDrawer design. */
  layout?: 'tabs' | 'sections'
  /** Field name search filter (drawer toolbar). Case-insensitive substring match
   * против labelOf(key) AND key. Empty = no filter. */
  searchFilter?: string
  /** Show only fields с non-empty values (drawer toolbar 'только заполненные').
   * Empty = number 0, '', null, undefined, false, []. */
  onlyFilled?: boolean
  /** Notifies parent of visible/total field counters (for drawer toolbar 'N / M'). */
  onCounterChange?: (visible: number, total: number) => void
}

const ajv = new Ajv({ allErrors: true, strict: false })

// Stable empty-object literal — используется как default для dataValues
// чтобы не создавать новый `{}` на каждый render (deps memo invalidation).
const EMPTY_DATA: Record<string, unknown> = Object.freeze({})
addFormats(ajv)

const isLocalized = (s: JsonSchema): boolean => Boolean(s['x-localized'])

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

const isDateFormat = (s: JsonSchema): boolean =>
  s.type === 'string' && (s.format === 'date' || s.format === 'date-time')

// Section bucketing — schema fields группируются в логические секции для
// рендера формы. Schema property может декларировать `x-section: <id>` —
// тогда используется напрямую. Иначе срабатывает auto-derive по эвристикам:
//   x-references → 'references'  (FK на другой dict)
//   x-localized   → 'description' (мультиязычные имена/описания)
//   date|date-time → 'dates'
//   key looks like geo  → 'geometry'  (lon/lat/altitude/coord/geom*/wkt)
//   required       → 'identity'
//   else           → 'extra'
//
// Predefined order ниже определяет в каком порядке секции появляются в форме.
// Custom section ID'ы (не из этого списка) добавляются после в schema-declared
// порядке первого появления.
type SectionId = string

const PREDEFINED_SECTION_ORDER: readonly SectionId[] = [
  'identity',
  'references',
  'description',
  'dates',
  'geometry',
  'physical',
  'extra',
]

const GEO_KEY_RE = /^(geo|geom|geometry|wkt|lon|lng|lat|latitude|longitude|altitude|coord)/i

const deriveSection = (
  key: string,
  schema: JsonSchema,
  required: Set<string>,
): SectionId => {
  // Explicit wins — авторам schema'ы могут точно знать в какую секцию ставить.
  const explicit = schema['x-section']
  if (explicit) return explicit
  if (schema['x-references']) return 'references'
  if (isLocalized(schema)) return 'description'
  if (isDateFormat(schema)) return 'dates'
  if (GEO_KEY_RE.test(key)) return 'geometry'
  if (required.has(key)) return 'identity'
  return 'extra'
}

const bucketProperties = (
  properties: Record<string, JsonSchema>,
  required: Set<string>,
): { buckets: Record<SectionId, string[]>; order: SectionId[] } => {
  const buckets: Record<SectionId, string[]> = {}
  const customOrder: SectionId[] = []
  for (const [key, schema] of Object.entries(properties)) {
    const id = deriveSection(key, schema, required)
    if (!buckets[id]) {
      buckets[id] = []
      if (!PREDEFINED_SECTION_ORDER.includes(id)) customOrder.push(id)
    }
    buckets[id].push(key)
  }
  // identity всегда первая — даже если в schema нет required полей, секция
  // нужна для businessKey + validFrom/validTo.
  if (!buckets['identity']) buckets['identity'] = []
  const order: SectionId[] = [
    ...PREDEFINED_SECTION_ORDER.filter((id) => buckets[id]?.length || id === 'identity'),
    ...customOrder,
  ]
  return { buckets, order }
}

export const SchemaDrivenForm = ({
  schema,
  supportedLocales,
  defaultLocale,
  defaultValues,
  mode,
  isPending,
  serverError,
  onSubmit,
  onCancel,
  formId,
  onDirtyChange,
  layout = 'tabs',
  searchFilter,
  onlyFilled,
  onCounterChange,
}: Props) => {
  const { t } = useTranslation()
  const [activeTab, setActiveTab] = useState<SectionId>('identity')

  const validator = useMemo(() => {
    try {
      return ajv.compile(schema)
    } catch {
      return null
    }
  }, [schema])

  const properties = schema.properties ?? {}
  const required = new Set(schema.required ?? [])
  const { buckets, order: sectionOrder } = useMemo(
    () => bucketProperties(properties, required),
    [properties, required],
  )

  // Resolve i18n label для секции. Predefined IDs имеют ключи form.tabs.{id},
  // custom — fallback на humanize(id). humanize конвертит snake/kebab в
  // Title Case ("orbit_params" → "Orbit Params").
  const sectionLabel = (id: SectionId): string =>
    t(`form.tabs.${id}`, { defaultValue: humanize(id) })

  const { register, handleSubmit, control, setError, formState } = useForm<FormValues>({
    defaultValues: {
      businessKey: defaultValues?.businessKey ?? '',
      validFrom: defaultValues?.validFrom ?? '',
      validTo: defaultValues?.validTo ?? '',
      data: defaultValues?.data ?? {},
    },
  })

  const labelOf = (k: string, s: JsonSchema): string =>
    s.title ?? t(`field.${k}`, { defaultValue: humanize(k) })

  const idSource = (schema as { 'x-id-source'?: string })['x-id-source']

  // === Drawer toolbar filtering ===
  // search: substring match (case-insensitive) против key OR labelOf(key)
  // onlyFilled: hide fields где data[key] empty (null/undefined/''/false/[]/0)
  // Применяется только в sections layout (drawer use case).
  // useMemo чтобы default {} не создавал new object literal каждый render —
  // иначе isFieldVisible useMemo ниже invalidate'ится → каскад re-renders.
  const dataValues = useMemo(
    () => (mode === 'edit' ? defaultValues?.data : undefined) ?? EMPTY_DATA,
    [mode, defaultValues?.data],
  )
  const isFieldVisible = useMemo(() => {
    const search = (searchFilter ?? '').trim().toLowerCase()
    return (key: string): boolean => {
      if (search) {
        const label = labelOf(key, properties[key]).toLowerCase()
        if (!key.toLowerCase().includes(search) && !label.includes(search)) return false
      }
      if (onlyFilled) {
        const v = (dataValues as Record<string, unknown>)[key]
        if (v == null || v === '' || v === false || v === 0) return false
        if (Array.isArray(v) && v.length === 0) return false
        if (typeof v === 'object' && Object.keys(v as object).length === 0) return false
      }
      return true
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [searchFilter, onlyFilled, properties, dataValues, t])

  // Apply filter ON TOP of bucketing — каждая секция после filter содержит
  // visible subset. Used для render + counters.
  const filteredBuckets = useMemo(() => {
    const out: Record<SectionId, string[]> = {}
    for (const id of sectionOrder) {
      out[id] = (buckets[id] ?? []).filter(isFieldVisible)
    }
    return out
  }, [buckets, sectionOrder, isFieldVisible])

  // Counter visible/total для drawer toolbar caption "N / M".
  const totalFieldCount = sectionOrder.reduce(
    (acc, id) => acc + (buckets[id]?.length ?? 0),
    0,
  )
  const visibleFieldCount = sectionOrder.reduce(
    (acc, id) => acc + (filteredBuckets[id]?.length ?? 0),
    0,
  )
  // Ref-pattern для callback: parent часто передаёт inline arrow
  // (() => setState(...)) — identity меняется каждый render. Если включить
  // callback в effect deps → effect fires → setState → re-render → loop
  // ("Maximum update depth exceeded"). Держим latest callback в ref и
  // вызываем по value-deps только. (review #infinite-loop-2026-05-11)
  const onCounterChangeRef = useRef(onCounterChange)
  useEffect(() => {
    onCounterChangeRef.current = onCounterChange
  })
  useEffect(() => {
    onCounterChangeRef.current?.(visibleFieldCount, totalFieldCount)
  }, [visibleFieldCount, totalFieldCount])

  const submit: SubmitHandler<FormValues> = (values) => {
    if (values.validFrom && values.validTo) {
      const from = new Date(values.validFrom)
      const to = new Date(values.validTo)
      if (!isNaN(from.getTime()) && !isNaN(to.getTime()) && from >= to) {
        setError('validTo', { type: 'manual', message: t('form.error.validToBeforeFrom') })
        setActiveTab('identity')
        return
      }
    }
    if (validator) {
      const ok = validator(values.data)
      if (!ok && validator.errors && validator.errors.length > 0) {
        applyAjvErrors(validator.errors, setError)
        const firstField = firstFieldFromErrors(validator.errors)
        if (firstField) {
          const tabForField = findSectionForField(firstField, buckets, sectionOrder)
          if (tabForField) setActiveTab(tabForField)
        }
        return
      }
    }
    const derivedKey = idSource && mode === 'create'
      ? String(values.data?.[idSource] ?? '').trim()
      : values.businessKey.trim()
    onSubmit({
      businessKey: derivedKey,
      data: values.data,
      validFrom: values.validFrom || undefined,
      validTo: values.validTo || undefined,
    })
  }

  const tabErrorCount = countErrorsPerSection(formState.errors, buckets, sectionOrder)
  const visibleTabs: TabItem[] = sectionOrder
    .filter((id) => id === 'identity' || (buckets[id]?.length ?? 0) > 0)
    .map((id) => ({
      id,
      label: sectionLabel(id),
      count:
        (tabErrorCount[id] ?? 0) > 0 ? (
          <Badge variant="error">{tabErrorCount[id]}</Badge>
        ) : undefined,
    }))

  // Guard: если schema'а сменилась и activeTab указывает на больше-не-существующую
  // секцию (например, переход между dict'ами) — сбросим на первую видимую.
  useEffect(() => {
    if (!visibleTabs.some((t) => t.id === activeTab)) {
      setActiveTab(visibleTabs[0]?.id ?? 'identity')
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sectionOrder])

  // Surface dirty-field count к parent (RecordDrawer footer caption).
  // Используем formState.dirtyFields shallow keys + nested data keys.
  const dirtyCount = useMemo(() => {
    const top = Object.keys(formState.dirtyFields).filter(
      (k) => k !== 'data' && (formState.dirtyFields as Record<string, unknown>)[k],
    ).length
    const dataDirty = formState.dirtyFields.data
      ? Object.keys(formState.dirtyFields.data).length
      : 0
    return top + dataDirty
  }, [formState.dirtyFields])
  // Same ref-pattern as onCounterChange: parent's inline callback identity
  // changes every render → loop. Subscribe to value (dirtyCount) only.
  const onDirtyChangeRef = useRef(onDirtyChange)
  useEffect(() => {
    onDirtyChangeRef.current = onDirtyChange
  })
  useEffect(() => {
    onDirtyChangeRef.current?.(dirtyCount)
  }, [dirtyCount])

  // Section visibility — в 'sections' layout все buckets рендерятся подряд,
  // в 'tabs' — только active. Sections layout per handoff Screen 3 prototype.
  const showAllSections = layout === 'sections'
  const sectionVisible = (id: SectionId) => showAllSections || activeTab === id

  // Scroll-to-section для chip click + rail click. Использует form container
  // (scrollable parent = drawer body) вместо window — section header sticky
  // считается от form container coordinate space.
  const scrollToSection = (id: SectionId) => {
    document.getElementById(`form-section-${formId ?? 'default'}-${id}`)?.scrollIntoView({
      behavior: 'smooth',
      block: 'start',
    })
  }

  // Wide-mode left rail УБРАН — user feedback: «в редакторе записей все
  // таки левая навигация лишняя». В sections layout остался только chip
  // strip сверху (sticky), он же выполняет функцию quick-jump.
  const formContainerRef = useRef<HTMLFormElement>(null)

  // Scroll-spy: IntersectionObserver следит за section anchors, highlight'ит
  // chip активной секции (= видимая ближе всего к верху scroll viewport'а).
  // rootMargin '-44px 0px -60% 0px' создаёт «detection band» в верхней 40%
  // viewport'а: header sticky ~44px сверху, section считается «активной»
  // если её top попадает в этот диапазон. Это даёт ощущение «active = что
  // юзер сейчас читает», а не «что в самом верху». threshold 0 — fires при
  // любом пересечении границы, дешевле чем массив тысяч threshold'ов.
  //
  // Сохраняем Map<id, isIntersecting> и считаем active как первую секцию
  // в sectionOrder у которой isIntersecting=true. Это устойчивее чем
  // «entries[0].target.id» — observer callback приходит batched и порядок
  // entries не гарантирован.
  const [activeSection, setActiveSection] = useState<SectionId | null>(null)
  const intersectingRef = useRef<Map<SectionId, boolean>>(new Map())

  const handleIntersect = useCallback(
    (entries: IntersectionObserverEntry[]) => {
      const map = intersectingRef.current
      for (const entry of entries) {
        const id = (entry.target as HTMLElement).dataset.sectionId as SectionId | undefined
        if (!id) continue
        map.set(id, entry.isIntersecting)
      }
      // Pick first in sectionOrder that is currently intersecting. Fallback —
      // оставляем предыдущий active (избегаем «мигания» в null когда юзер
      // между секциями в gap-промежутке).
      for (const id of sectionOrder) {
        if (map.get(id)) {
          setActiveSection(id)
          return
        }
      }
    },
    [sectionOrder],
  )

  useEffect(() => {
    if (!showAllSections) {
      setActiveSection(null)
      intersectingRef.current.clear()
      return
    }
    const form = formContainerRef.current
    if (!form) return
    // root: ближайший scrollable ancestor — drawer body. null = viewport,
    // но для embedded drawer'а правильнее scoped root. Ищем через closest
    // overflow-y-auto. Если не найдено — fallback на null (window scroll).
    let root: Element | null = form.parentElement
    while (root && root !== document.body) {
      const style = window.getComputedStyle(root)
      if (
        style.overflowY === 'auto' ||
        style.overflowY === 'scroll' ||
        style.overflowY === 'overlay'
      ) {
        break
      }
      root = root.parentElement
    }
    const observer = new IntersectionObserver(handleIntersect, {
      root: root && root !== document.body ? root : null,
      // Top 44px reserved для chip strip sticky. Bottom -60% — секция
      // считается active пока её top в верхних 40% viewport'а.
      rootMargin: '-44px 0px -60% 0px',
      threshold: 0,
    })
    // Observe все section anchor divs (data-section-id). Querying inside
    // form ref scope чтобы не зацепить чужие элементы.
    const anchors = form.querySelectorAll<HTMLElement>('[data-section-id]')
    anchors.forEach((el) => observer.observe(el))
    return () => {
      observer.disconnect()
      intersectingRef.current.clear()
    }
    // sectionOrder в deps чтобы при изменении schema'ы observer'ы пересоздались
    // для новых anchor'ов.
  }, [showAllSections, handleIntersect, sectionOrder])

  const sectionsChipStrip = showAllSections ? (
    <div
      className="flex flex-wrap items-center gap-1.5 sticky top-0 bg-surface pb-2 -mt-1 z-10"
      role="navigation"
      aria-label={t('drawer.sectionsNav', { defaultValue: 'Секции формы' })}
    >
      {visibleTabs.map((tab) => {
        const isActive = activeSection === tab.id
        return (
          <button
            key={tab.id}
            type="button"
            onClick={() => scrollToSection(tab.id)}
            aria-current={isActive ? 'location' : undefined}
            className={cn(
              'inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border text-cap transition-colors',
              isActive
                ? 'bg-accent-bg border-accent text-accent'
                : 'bg-surface-2 border-line text-ink-2 hover:bg-accent-bg/40 hover:border-accent/60 hover:text-accent',
            )}
          >
            {tab.label}
            <span className={cn('text-mono', isActive ? 'text-accent/80' : 'text-mute')}>
              {filteredBuckets[tab.id]?.length ?? 0}
            </span>
          </button>
        )
      })}
    </div>
  ) : null

  return (
    <form
      id={formId}
      onSubmit={handleSubmit(submit)}
      ref={formContainerRef}
    >
      <div className="space-y-4 min-w-0">
      {showAllSections ? sectionsChipStrip : (
        <Tabs items={visibleTabs} value={activeTab} onValueChange={(id) => setActiveTab(id)} />
      )}

      {/* Dynamic section rendering — order приходит из bucketProperties.
        * identity всегда первая (она содержит businessKey/validFrom/validTo
        * + identity-bucket schema fields). Остальные секции — generic
        * grid рендер. Custom x-section ID'ы автоматически включаются
        * через sectionOrder. */}
      {sectionOrder.map((id) => {
        const isIdentity = id === 'identity'
        const fields = filteredBuckets[id] ?? []
        // Скрываем секцию (кроме identity) если в ней 0 visible fields после
        // filter — иначе rail/chip уже это делает, но render лишних header'ов
        // создаёт визуальный шум.
        if (!isIdentity && fields.length === 0) {
          // В tabs layout всё равно нужен anchor для switch, чтобы tab клик
          // не падал. Рендерим пустой div только когда секция активна, иначе
          // 'hidden'. В sections layout — полностью skip.
          if (showAllSections) return null
        }
        return (
          <div
            key={id}
            id={`form-section-${formId ?? 'default'}-${id}`}
            data-section-id={id}
            className={sectionVisible(id) ? 'block scroll-mt-12' : 'hidden'}
          >
            {showAllSections && (isIdentity || fields.length > 0) && (
              <h3 className="text-cap text-mute mb-2 sticky top-10 bg-surface py-1 z-[5]">
                {sectionLabel(id)}
              </h3>
            )}
            <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
              {isIdentity && (
                <>
                  {!idSource && (
                    <div className="sm:col-span-2">
                      <TextInput
                        label={t('form.businessKey')}
                        required
                        disabled={mode === 'edit'}
                        placeholder="MY_RECORD_KEY"
                        error={formState.errors.businessKey ? t('form.error.required') : undefined}
                        {...register('businessKey', { required: !idSource, minLength: idSource ? 0 : 1 })}
                      />
                    </div>
                  )}
                  {idSource && (
                    <div className="sm:col-span-2 text-cell text-mute px-2">
                      {t('form.idSourceNote', { field: idSource })}
                    </div>
                  )}
                  <Controller
                    control={control}
                    name="validFrom"
                    render={({ field }) => (
                      <DateTimeField
                        label={t('form.validFrom')}
                        hint={t('form.validFromHint')}
                        value={field.value as string | undefined}
                        defaultTime="00:00"
                        onChange={field.onChange}
                      />
                    )}
                  />
                  <Controller
                    control={control}
                    name="validTo"
                    render={({ field }) => (
                      <DateTimeField
                        label={t('form.validTo')}
                        hint={t('form.validToHint')}
                        error={(formState.errors.validTo as { message?: string } | undefined)?.message}
                        value={field.value as string | undefined}
                        defaultTime="23:59"
                        onChange={field.onChange}
                      />
                    )}
                  />
                </>
              )}
              {fields.map((key) => (
                <FieldRenderer
                  key={key}
                  fieldKey={key}
                  schema={properties[key]}
                  required={required.has(key)}
                  control={control}
                  supportedLocales={supportedLocales}
                  defaultLocale={defaultLocale}
                  fieldError={formState.errors.data?.[key as keyof FormValues['data']]}
                  labelOf={labelOf}
                />
              ))}
            </div>
          </div>
        )
      })}

      {serverError && <Alert variant="error">{serverError}</Alert>}

      {/* Когда formId не передан — внутренний footer (backward compat).
       * Когда передан — drawer footer (см. RecordDrawer) рисует кнопки и
       * сабмитит через атрибут form={formId} на <button type="submit">. */}
      {!formId && (
        <FormActions align="end">
          <Button type="button" variant="ghost" onClick={onCancel} disabled={isPending}>
            {t('form.cancel')}
          </Button>
          <Button type="submit" variant="primary" loading={isPending}>
            {isPending ? t('form.saving') : t('form.save')}
          </Button>
        </FormActions>
      )}
      </div>
    </form>
  )
}

const isFullWidth = (s: JsonSchema): boolean => {
  if (s['x-localized']) return true
  if (s.type === 'array') return true
  if ((s.maxLength ?? 0) > 200) return true
  if (s.type === 'object') return true
  return false
}

type FieldRendererProps = {
  fieldKey: string
  schema: JsonSchema
  required: boolean
  control: ReturnType<typeof useForm<FormValues>>['control']
  supportedLocales: string[]
  defaultLocale: string
  fieldError?: unknown
  labelOf: (key: string, schema: JsonSchema) => string
}

const FieldRenderer = (props: FieldRendererProps) => {
  const wrapperClass = isFullWidth(props.schema) ? 'sm:col-span-2' : ''
  return (
    <div className={wrapperClass}>
      <FieldBody {...props} />
    </div>
  )
}

const FieldBody = ({
  fieldKey,
  schema,
  required,
  control,
  supportedLocales,
  defaultLocale,
  fieldError,
  labelOf,
}: FieldRendererProps) => {
  const label = labelOf(fieldKey, schema)
  const errorMsg = (fieldError as { message?: string } | undefined)?.message

  if (isLocalized(schema)) {
    return (
      <div>
        <FieldLabel required={required}>
          {label}{' '}
          <span className="text-cap text-mute ml-1">i18n</span>
        </FieldLabel>
        <div className="space-y-2 pl-3 border-l-2 border-line mt-1">
          {supportedLocales.map((loc) => (
            <Controller
              key={loc}
              control={control}
              name={`data.${fieldKey}.${loc}` as `data.${string}`}
              render={({ field }) => (
                <div className="flex items-center gap-3">
                  <span className="text-mono text-ink-2 normal-case tracking-normal whitespace-nowrap shrink-0 w-14">
                    {loc === defaultLocale ? <strong>{loc}</strong> : loc}
                  </span>
                  <div className="flex-1 min-w-0">
                    <TextInput {...field} value={(field.value as string | undefined) ?? ''} />
                  </div>
                </div>
              )}
            />
          ))}
        </div>
        {schema.description && <FieldHint>{schema.description}</FieldHint>}
        <FieldError>{errorMsg}</FieldError>
      </div>
    )
  }

  if (schema['x-references']) {
    return (
      <ReferenceSelectField
        fieldKey={fieldKey}
        schema={schema}
        required={required}
        control={control}
        defaultLocale={defaultLocale}
        label={label}
        errorMsg={errorMsg}
      />
    )
  }

  if (schema.enum) {
    const options: SelectOption[] = schema.enum.map((v) => ({
      id: String(v),
      label: String(v),
    }))
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <SingleSelect
            label={label}
            required={required}
            hint={schema.description}
            error={errorMsg}
            options={options}
            value={(field.value as string | undefined) ?? ''}
            onChange={field.onChange}
            placeholder="—"
          />
        )}
      />
    )
  }

  if (schema.type === 'boolean') {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <Checkbox
            label={label}
            description={schema.description}
            error={errorMsg}
            checked={Boolean(field.value)}
            onChange={(e) => field.onChange(e.target.checked)}
          />
        )}
      />
    )
  }

  if (isDateFormat(schema)) {
    const isDateTime = schema.format === 'date-time'
    if (isDateTime) {
      return (
        <Controller
          control={control}
          name={`data.${fieldKey}` as `data.${string}`}
          render={({ field }) => (
            <DateTimeField
              label={label}
              value={field.value as string | undefined}
              defaultTime="00:00"
              onChange={(iso) => field.onChange(iso || undefined)}
            />
          )}
        />
      )
    }
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <DatePicker
            label={label}
            required={required}
            hint={schema.description}
            error={errorMsg}
            value={parseFormDate(field.value)}
            onChange={(d) => field.onChange(d ? formatIsoDate(d) : undefined)}
          />
        )}
      />
    )
  }

  if (schema.type === 'integer' || schema.type === 'number') {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <TextInput
            type="number"
            step={schema.type === 'integer' ? 1 : 'any'}
            label={label}
            required={required}
            hint={schema.description}
            error={errorMsg}
            value={(field.value as number | undefined) ?? ''}
            onChange={(e) =>
              field.onChange(e.target.value === '' ? undefined : Number(e.target.value))
            }
          />
        )}
      />
    )
  }

  if (schema.type === 'object') {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <JsonField
            label={label}
            required={required}
            hint={schema.description}
            error={errorMsg}
            value={field.value as object | undefined}
            onChange={field.onChange}
          />
        )}
      />
    )
  }

  if (schema.type === 'array' && schema.items?.type === 'object') {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <JsonField
            label={label}
            required={required}
            hint={schema.description ? `${schema.description} (JSON array)` : 'JSON array'}
            error={errorMsg}
            value={field.value as unknown[] | undefined}
            onChange={field.onChange}
          />
        )}
      />
    )
  }

  if (schema.type === 'array' && schema.items?.type === 'string') {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <TextInput
            label={label}
            required={required}
            hint={schema.description ? `${schema.description} (comma-separated)` : 'comma-separated'}
            error={errorMsg}
            value={Array.isArray(field.value) ? (field.value as string[]).join(', ') : ''}
            onChange={(e) =>
              field.onChange(
                e.target.value
                  .split(',')
                  .map((s) => s.trim())
                  .filter(Boolean),
              )
            }
          />
        )}
      />
    )
  }

  return (
    <Controller
      control={control}
      name={`data.${fieldKey}` as `data.${string}`}
      render={({ field }) => {
        const isLong = (schema.maxLength ?? 0) > 200
        if (isLong) {
          return (
            <TextArea
              label={label}
              required={required}
              hint={schema.description}
              error={errorMsg}
              rows={3}
              {...field}
              value={(field.value as string | undefined) ?? ''}
            />
          )
        }
        return (
          <TextInput
            type={schema.format === 'date-time' ? 'datetime-local' : 'text'}
            label={label}
            required={required}
            hint={schema.description}
            error={errorMsg}
            pattern={schema.pattern}
            {...field}
            value={(field.value as string | undefined) ?? ''}
          />
        )
      }}
    />
  )
}

type DateTimeFieldProps = {
  label: string
  value: string | undefined
  defaultTime: string
  onChange: (iso: string) => void
  hint?: string
  error?: string
}

const DateTimeField = ({ label, value, defaultTime, onChange, hint, error }: DateTimeFieldProps) => {
  const dateValue = parseFormDate(value)
  const timeValue = extractTime(value, defaultTime)
  // flex-wrap вместо fixed grid: на узких ячейках (drawer 520px ÷ 2 cols =
  // ~210px) time wraps под date. На wide (880px ÷ 2 = ~410px) — inline.
  // min-w на date обеспечивает разумный размер до wrap'a. Время 6rem (96px)
  // достаточно для HH:mm + tap target 44px touch-safe.
  return (
    <div>
      <div className="flex flex-wrap gap-2 items-end">
        <div className="flex-1 min-w-[8rem]">
          <DatePicker
            label={label}
            value={dateValue}
            onChange={(d) => onChange(combineDateTime(d, timeValue, value ?? ''))}
          />
        </div>
        <div className="w-24 shrink-0">
          <TextInput
            label="HH:mm"
            type="time"
            value={timeValue}
            onChange={(e) => onChange(combineDateTime(dateValue, e.target.value, value ?? ''))}
          />
        </div>
      </div>
      {hint && !error && <p className="text-cell text-mute mt-1">{hint}</p>}
      {error && <p className="text-cell text-mars mt-1">{error}</p>}
    </div>
  )
}

type JsonFieldProps = {
  label: string
  required: boolean
  hint?: string
  error?: string
  value: unknown
  onChange: (v: unknown) => void
}

const JsonField = ({ label, required, hint, error, value, onChange }: JsonFieldProps) => {
  const [text, setText] = useState<string>(() =>
    value === undefined || value === null ? '' : JSON.stringify(value, null, 2),
  )
  const [parseError, setParseError] = useState<string | null>(null)

  return (
    <TextArea
      label={label}
      required={required}
      hint={hint ? `${hint} (JSON)` : 'JSON'}
      error={parseError ?? error}
      rows={6}
      value={text}
      onChange={(e) => {
        const next = e.target.value
        setText(next)
        if (next.trim() === '') {
          setParseError(null)
          onChange(undefined)
          return
        }
        try {
          onChange(JSON.parse(next))
          setParseError(null)
        } catch (err) {
          setParseError(err instanceof Error ? err.message : 'Invalid JSON')
        }
      }}
    />
  )
}

const applyAjvErrors = (
  errors: ErrorObject[],
  setError: ReturnType<typeof useForm<FormValues>>['setError'],
) => {
  for (const err of errors) {
    // AJV instancePath: "/foo/bar" → RHF path: "data.foo.bar".
    // Раньше было "data" (root) для всех ошибок из-за {@code as 'data'} cast'а —
    // RHF складывал все ошибки в один ключ {@code formState.errors.data},
    // tab badge со счётчиком стабильно показывал 0/1, поле не подсвечивалось.
    const instancePath = err.instancePath.replace(/^\//, '').replace(/\//g, '.')
    // {@code required} keyword: instancePath пустой, имя missing field в
    // params.missingProperty (root уровень) или params.missingProperty
    // относительно instancePath (nested objects).
    let path = instancePath
    if (
      err.keyword === 'required' &&
      err.params &&
      typeof err.params === 'object' &&
      'missingProperty' in err.params
    ) {
      const missing = String((err.params as { missingProperty: string }).missingProperty)
      path = path ? `${path}.${missing}` : missing
    }
    const fieldPath = (path ? `data.${path}` : 'data') as Path<FormValues>
    setError(fieldPath, { type: 'ajv', message: err.message ?? 'invalid' })
  }
}

const firstFieldFromErrors = (errors: ErrorObject[]): string | null => {
  for (const err of errors) {
    const path = err.instancePath.replace(/^\//, '')
    // required keyword: instancePath relative to parent object, missing
    // property name в params.missingProperty. Combine, take top-level segment
    // — это то, что нужно для tab routing.
    if (
      err.keyword === 'required' &&
      err.params &&
      typeof err.params === 'object' &&
      'missingProperty' in err.params
    ) {
      const missing = String((err.params as { missingProperty: string }).missingProperty)
      const full = path ? `${path}/${missing}` : missing
      return full.split('/')[0]
    }
    if (path) return path.split('/')[0]
  }
  return null
}

const findSectionForField = (
  fieldKey: string,
  buckets: Record<SectionId, string[]>,
  order: SectionId[],
): SectionId | null => {
  for (const id of order) {
    if (buckets[id]?.includes(fieldKey)) return id
  }
  return null
}

type ReferenceSelectFieldProps = {
  fieldKey: string
  schema: JsonSchema
  required: boolean
  control: ReturnType<typeof useForm<FormValues>>['control']
  defaultLocale: string
  label: string
  errorMsg?: string
}

/**
 * SingleSelect populated from a referenced dictionary's records.
 * Schema must have `x-references: "dict.field"`. Renders TextInput fallback
 * when the target dict is not accessible (scope-hide → 404/403 → empty list).
 *
 * Label format: `<businessKey> — <localized name>` if record has a name field,
 * otherwise just `<businessKey>`. Defaults to user's locale, falls back to
 * any available locale.
 */
const ReferenceSelectField = ({
  fieldKey,
  schema,
  required,
  control,
  defaultLocale,
  label,
  errorMsg,
}: ReferenceSelectFieldProps) => {
  const ref = schema['x-references'] ?? ''
  const [refDict, refField] = ref.split('.', 2)
  const { data: records, isLoading, isError } = useReferencedRecords(refDict)

  const options: SelectOption[] = useMemo(() => {
    if (!records || records.length === 0) return []
    return records
      .map((r) => buildOption(r, refField, defaultLocale))
      .filter((o): o is SelectOption => o !== null)
      .sort((a, b) => String(a.label ?? '').localeCompare(String(b.label ?? ''), 'ru'))
  }, [records, refField, defaultLocale])

  const hint = schema.description ?? `FK на ${ref}`

  // Fallback: target dict not accessible OR empty → free text input so user
  // can still enter the FK value manually (server-side ReferenceValidator
  // will validate on submit). Better than blocking edit entirely.
  const accessible = !isError && (isLoading || (records && records.length > 0))

  if (!accessible) {
    return (
      <Controller
        control={control}
        name={`data.${fieldKey}` as `data.${string}`}
        render={({ field }) => (
          <div>
            <FieldLabel required={required}>{label}</FieldLabel>
            <TextInput {...field} value={(field.value as string | undefined) ?? ''} />
            <FieldHint>{hint}</FieldHint>
            <FieldError>{errorMsg}</FieldError>
          </div>
        )}
      />
    )
  }

  // Native <select> вместо SingleSelect: для FK с длинным списком
  // (50+ статусов, типов КА) и tight modal'ом native browser dropdown
  // лучше — popup auto-positioning не клипается viewport'ом, native
  // scroll и type-ahead search работают OOTB. SingleSelect popup
  // через createPortal с position:fixed не делает auto-flip и уходит
  // за нижний край когда триггер близко к низу modal'а.
  //
  // Right adornment: spinner если идёт fetch options, иначе caret.
  // pointer-events-none — иконка не блокирует click по нативному
  // select (он принимает все клики, включая поверх стрелки).
  return (
    <Controller
      control={control}
      name={`data.${fieldKey}` as `data.${string}`}
      render={({ field }) => (
        <div>
          <FieldLabel required={required}>{label}</FieldLabel>
          <div className="relative">
            <select
              value={(field.value as string | undefined) ?? ''}
              onChange={(e) => field.onChange(e.target.value || undefined)}
              disabled={isLoading}
              aria-invalid={Boolean(errorMsg)}
              aria-busy={isLoading}
              // appearance-none убирает OS default стрелку.
              // pr-9 — место под custom адорнмент (chevron at right-3 + 12px).
              // truncate/text-ellipsis форсят ellipsis на длинных option labels
              // (предотвращает 'наезд' текста на chevron, см. buildOption — там
              // ещё дополнительный length cap чтобы native select не overflowed).
              className={[
                'w-full appearance-none border rounded-sm pl-3 pr-9 py-2 text-body font-display',
                'truncate overflow-hidden whitespace-nowrap text-ellipsis',
                'bg-surface text-ink transition-colors',
                'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
                errorMsg ? 'border-mars' : 'border-line hover:border-ink/40',
                isLoading ? 'cursor-wait text-mute' : 'cursor-pointer',
              ].join(' ')}
            >
              <option value="">{isLoading ? '…' : '—'}</option>
              {options.map((opt) => (
                <option key={opt.id} value={opt.id}>
                  {opt.label}
                </option>
              ))}
            </select>
            <div
              className="pointer-events-none absolute inset-y-0 right-3 flex items-center"
              aria-hidden="true"
            >
              {isLoading ? (
                <CircleNotchIcon size={16} weight="bold" className="text-accent animate-spin" />
              ) : (
                <CaretDownIcon size={16} weight="regular" className="text-mute/70" />
              )}
            </div>
          </div>
          <FieldHint>{hint}</FieldHint>
          <FieldError>{errorMsg}</FieldError>
        </div>
      )}
    />
  )
}

/**
 * Build a SelectOption from a record. Uses `record.data[refField]` as the
 * option id (the FK value). Label combines id с локализованным name'ом —
 * `OPERATIONAL — Действующий` понятнее чем чистый `OPERATIONAL`. Если
 * комбинация длиннее MAX_LABEL chars, name truncate'ится с ellipsis —
 * иначе native `<select>` trigger перекрывается chevron'ом (см. screenshot
 * 2026-05-11 'наезжают'). Полный display name остаётся в hint поля.
 */
const MAX_FK_LABEL_CHARS = 28

const buildOption = (
  record: FlattenedRecord,
  refField: string | undefined,
  defaultLocale: string,
): SelectOption | null => {
  const id = refField ? record.data?.[refField] : record.businessKey
  const idStr = id != null ? String(id) : record.businessKey
  if (!idStr) return null

  const nameField = record.data?.['name']
  const display = pickLocalized(nameField, defaultLocale)
  if (!display) return { id: idStr, label: idStr }

  const combined = `${idStr} — ${display}`
  if (combined.length <= MAX_FK_LABEL_CHARS) return { id: idStr, label: combined }
  // Truncate display, keep code intact (код — primary identifier).
  const allowedDisplayLen = Math.max(4, MAX_FK_LABEL_CHARS - idStr.length - 4) // ' — ' + '…'
  const truncated = `${display.slice(0, allowedDisplayLen).trimEnd()}…`
  return { id: idStr, label: `${idStr} — ${truncated}` }
}

const countErrorsPerSection = (
  errors: ReturnType<typeof useForm<FormValues>>['formState']['errors'],
  buckets: Record<SectionId, string[]>,
  order: SectionId[],
): Record<SectionId, number> => {
  const counts: Record<SectionId, number> = {}
  for (const id of order) counts[id] = 0
  if (errors.businessKey) counts['identity'] = (counts['identity'] ?? 0) + 1
  const dataErrors = errors.data as Record<string, unknown> | undefined
  if (!dataErrors) return counts
  for (const key of Object.keys(dataErrors)) {
    const section = findSectionForField(key, buckets, order)
    if (section) counts[section] = (counts[section] ?? 0) + 1
  }
  return counts
}
