import React from 'react'
import * as UiKit from '@/components/ui'
import PlatformViewHost, {
  type PlatformViewName,
} from '@/components/componentEditor/PlatformViewHost'
import apiService from '@/services/api.service'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { formatLocaleValue } from '@/utils/localeFormat'
import { FaArrowDown, FaArrowUp, FaClone, FaGripVertical, FaTrash } from 'react-icons/fa'
import { beginDesignerDragSoon, endDesignerDrag, useDesignerDrag } from './designerDrag'
import {
  buildDesignerFilterParams,
  buildSqlDefaultRecord,
  fromDesignerDate,
  getDesignerNodeFilters,
  getDesignerBindingColumns,
  getDesignerCollectionProperty,
  getDesignerTabSlot,
  getDesignerValueByPath,
  getSqlDataSourceEndpointId,
  getSqlDataSourceColumnCount,
  getDesignerColumnCaptions,
  getUsableDesignerColumnLookups,
  buildDesignerLookupIndex,
  resolveDesignerLookupText,
  getSqlDataSourceKeyField,
  getSqlFormValueProperty,
  isDesignerDateComponent,
  isDesignerDateProperty,
  isDesignerOptionComponent,
  isSqlDataSourceNode,
  LEGACY_SQL_DATA_SOURCE_TYPE,
  normalizeDesignerKeyList,
  readSqlDataSourceField,
  resolveDesignerDropdownTitle,
  resolveDesignerPreviewFilterValue,
  resolveDesignerTabValue,
  resolveSqlDataSourceRows,
  setDesignerRecordField,
  SQL_DATA_SOURCE_TYPE,
  SQL_DEFAULT_VALUE_PROP,
  toDesignerDate,
  type DesignerBinding,
  type DesignerDataSourceFilter,
  type DesignerNode,
  type SqlDataSourceMode,
} from './types'

/**
 * Editing scope opened by a SqlDataSource: a descendant whose `value`/`checked`
 * is bound to `sourceId` writes back into the record instead of into its own
 * static prop, which is what makes the container behave like an ASP.NET FormView.
 */
export interface DesignerFormScope {
  sourceId: string
  onFieldChange: (path: string, value: unknown) => void
}

export const DESIGNER_DRAG_TYPE = 'application/x-sozsoft-designer'

interface DesignerDragPayload {
  source?: 'library' | 'canvas'
  name?: string
  nodeId?: string
}

/** Drag payload of the designer; anything else dropped on the canvas is ignored. */
const readDesignerDragPayload = (
  event: React.DragEvent<HTMLElement>,
): DesignerDragPayload | null => {
  const raw =
    event.dataTransfer.getData(DESIGNER_DRAG_TYPE) || event.dataTransfer.getData('text/plain')
  if (!raw) return null
  try {
    const payload = JSON.parse(raw) as DesignerDragPayload
    return payload?.source === 'library' || payload?.source === 'canvas' ? payload : null
  } catch {
    return null
  }
}

/** `dropEffect` must match the source's `effectAllowed` or the drop never fires. */
const acceptDesignerDrag = (event: React.DragEvent<HTMLElement>) => {
  event.preventDefault()
  event.stopPropagation()
  event.dataTransfer.dropEffect = event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
}

/**
 * "Sürüklenen düğümün içindeyiz" bilgisi ağaçtan aşağı akar. Bir bileşen kendi
 * alt ağacının içine taşınamaz; onun dışındaki her hedef — kardeşler, üst
 * konteynerler, kök — geçerlidir ve toolbox'tan bırakmayla birebir aynı geri
 * bildirimi alır.
 */
const DesignerDraggedSubtreeContext = React.createContext(false)

/**
 * Açık bir bırakma bölgesi: üzerine gelindiğinde kendini vurgular. Boş konteyner
 * yer tutucuları ve Tabs sekme gövdeleri bunu kullanır.
 */
const useDesignerDropZone = (
  interactive: boolean,
  onDropPayload: (payload: DesignerDragPayload) => void,
) => {
  const [over, setOver] = React.useState(false)
  const drag = useDesignerDrag()

  React.useEffect(() => {
    if (!drag) setOver(false)
  }, [drag])

  return {
    over: over && Boolean(drag),
    dragging: Boolean(drag) && interactive,
    props: {
      onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
        if (!interactive) return
        acceptDesignerDrag(event)
        setOver(true)
      },
      onDragLeave: () => setOver(false),
      onDrop: (event: React.DragEvent<HTMLDivElement>) => {
        if (!interactive) return
        event.preventDefault()
        event.stopPropagation()
        setOver(false)
        const payload = readDesignerDragPayload(event)
        if (payload) onDropPayload(payload)
        endDesignerDrag()
      },
    },
  }
}
const getTableDimension = (value: unknown, fallback: number) =>
  Math.min(20, Math.max(1, Math.floor(Number(value) || fallback)))
const resolveStaticLanguageKeys = (value: unknown, translate: (key: string) => string): unknown => {
  if (typeof value === 'string') return value.startsWith('::') ? translate(value) : value
  if (Array.isArray(value)) return value.map((item) => resolveStaticLanguageKeys(item, translate))
  // Only plain objects are walked: `Object.entries` on a Date yields nothing and
  // would silently turn it into `{}`.
  if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, resolveStaticLanguageKeys(item, translate)]),
    )
  }
  return value
}

interface VisualCanvasProps {
  nodes: DesignerNode[]
  selectedId: string | null
  interactive?: boolean
  onSelect?: (id: string) => void
  onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
  /** Adds a toolbox component next to an existing node instead of inside it. */
  onDropComponentBeside?: (
    definitionName: string,
    targetId: string,
    placement: 'before' | 'after',
  ) => void
  /** Moves an existing node into a container/table cell, or to the root. */
  onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
  onMove?: (id: string, direction: -1 | 1) => void
  onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
  onDuplicate?: (id: string) => void
  onDelete?: (id: string) => void
  onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
  renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
  dataValues?: Record<string, unknown>
}

class PreviewBoundary extends React.Component<
  { name: string; resetKey: string; children: React.ReactNode },
  { failed: boolean }
> {
  state = { failed: false }

  static getDerivedStateFromError() {
    return { failed: true }
  }

  componentDidUpdate(previousProps: Readonly<{ resetKey: string }>) {
    if (this.state.failed && previousProps.resetKey !== this.props.resetKey) {
      this.setState({ failed: false })
    }
  }

  render() {
    if (this.state.failed) {
      return (
        <div className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
          {this.props.name} önizlemesi için ek veri veya alt bileşen gerekiyor.
        </div>
      )
    }
    return this.props.children
  }
}

const resolveUiComponent = (name: string): React.ElementType | null => {
  const parts = name.split('.')
  let component: unknown = (UiKit as Record<string, unknown>)[parts[0]]

  for (const part of parts.slice(1)) {
    if ((typeof component !== 'object' && typeof component !== 'function') || !component) {
      return null
    }
    component = (component as Record<string, unknown>)[part]
  }

  return component ? (component as React.ElementType) : null
}

/** Prop that turns a component off; react-select spells it differently. */
const getDisabledProperty = (type: string) => (type === 'Select' ? 'isDisabled' : 'disabled')

export interface DesignerRefOverride {
  props?: Record<string, unknown>
  hidden?: boolean
}

/**
 * Design time counterpart of the `refs` object the code generator emits. It lets
 * an event script drive the other components on the canvas — value, visibility,
 * enabled state, any prop — so the behaviour can be tried out before the
 * component is ever saved and compiled.
 */
interface DesignerRefStore {
  state: Record<string, DesignerRefOverride>
  nodes: Record<string, DesignerNode>
  patch: (ref: string, override: DesignerRefOverride) => void
}

const DesignerRefContext = React.createContext<DesignerRefStore | null>(null)

const buildDesignerRefs = (store: DesignerRefStore | null) => {
  const refs: Record<string, unknown> = {}
  if (!store) return refs
  Object.entries(store.nodes).forEach(([ref, node]) => {
    const overrideProps = () => store.state[ref]?.props || {}
    const disabledProperty = getDisabledProperty(node.type)
    const valueProperty =
      'checked' in node.props ? 'checked' : 'value' in node.props ? 'value' : 'children'
    const designTimeOnly = (action: string) => () =>
      console.info(`refs.${ref}.${action}() yalnızca çalışma zamanında endpoint çağırır.`)
    refs[ref] = {
      name: ref,
      type: node.type,
      getValue: () => overrideProps()[valueProperty] ?? node.props[valueProperty],
      setValue: (value: unknown) => store.patch(ref, { props: { [valueProperty]: value } }),
      getProps: () => overrideProps(),
      setProps: (patch: Record<string, unknown>) => store.patch(ref, { props: patch || {} }),
      setProp: (property: string, value: unknown) =>
        store.patch(ref, { props: { [property]: value } }),
      isVisible: () => !store.state[ref]?.hidden,
      setVisible: (visible: unknown) => store.patch(ref, { hidden: visible === false }),
      show: () => store.patch(ref, { hidden: false }),
      hide: () => store.patch(ref, { hidden: true }),
      isEnabled: () =>
        (overrideProps()[disabledProperty] ?? Boolean(node.props[disabledProperty])) !== true,
      setEnabled: (enabled: unknown) =>
        store.patch(ref, { props: { [disabledProperty]: enabled === false } }),
      setReadOnly: (readOnly: unknown) =>
        store.patch(ref, { props: { readOnly: readOnly !== false } }),
      setText: (text: unknown) => store.patch(ref, { props: { children: text } }),
      reset: () => store.patch(ref, { props: null as unknown as Record<string, unknown> }),
      ...(isSqlDataSourceNode(node.type)
        ? {
            getRecord: () => ({}),
            setRecord: designTimeOnly('setRecord'),
            getField: () => undefined,
            setField: designTimeOnly('setField'),
            setFields: designTimeOnly('setFields'),
            clearFields: designTimeOnly('clearFields'),
            getOriginal: () => ({}),
            getChanges: () => ({}),
            hasChanges: () => false,
            getKey: () => undefined,
            getKeyField: () => getSqlDataSourceKeyField(node),
            getRows: () => [],
            getRowCount: () => 0,
            getIndex: () => 0,
            getMode: () => 'edit',
            isNew: () => false,
            isBusy: () => false,
            getError: () => '',
            setError: designTimeOnly('setError'),
            goToRow: designTimeOnly('goToRow'),
            nextRow: designTimeOnly('nextRow'),
            prevRow: designTimeOnly('prevRow'),
            firstRow: designTimeOnly('firstRow'),
            lastRow: designTimeOnly('lastRow'),
            reload: designTimeOnly('reload'),
            newRecord: designTimeOnly('newRecord'),
            save: designTimeOnly('save'),
            remove: designTimeOnly('remove'),
          }
        : {}),
      // Select/AutoComplete/Menu: seçilen kaydın görünmeyen sütunları. Tasarım
      // ekranında yalnızca statik seçenekler ve önizleme değeri elde olduğu için
      // erişimciler bunlarla çalışır; çalışma zamanında aynı isimler endpoint
      // sonucundaki tüm sütunları okur.
      ...(isDesignerOptionComponent(node.type)
        ? (() => {
            const currentOption = () => {
              const value = overrideProps()[valueProperty] ?? node.props[valueProperty]
              if (value && typeof value === 'object') return value as Record<string, unknown>
              const options = node.props[getDesignerCollectionProperty(node.type)]
              if (!Array.isArray(options)) return null
              return (
                (options.find(
                  (option) =>
                    option &&
                    typeof option === 'object' &&
                    (option as Record<string, unknown>).value === value,
                ) as Record<string, unknown>) ?? null
              )
            }
            return {
              getOptions: () => {
                const options = node.props[getDesignerCollectionProperty(node.type)]
                return Array.isArray(options) ? options : []
              },
              getSelectedOption: () => currentOption(),
              getColumn: (path: string) =>
                getDesignerValueByPath(currentOption(), String(path || '')),
              getLabel: () => String(currentOption()?.label ?? ''),
              getColumnNames: () =>
                getDesignerBindingColumns(
                  node.bindings?.[getDesignerCollectionProperty(node.type)],
                ),
            }
          })()
        : {}),
    }
  })
  return refs
}

const notifyFromScript = (message: unknown, type: unknown = 'info') =>
  UiKit.toast.push(
    <UiKit.Notification
      duration={2500}
      type={(type as 'success' | 'warning' | 'danger' | 'info') || 'info'}
    >
      {typeof message === 'string' ? message : JSON.stringify(message)}
    </UiKit.Notification>,
    { placement: 'bottom-end' },
  )

/**
 * Design time `api`. Reads go through, exactly like the endpoint test button in
 * the data panel does, so a script can be tried out on the canvas. Writes are
 * refused: the designer must never insert, update or delete real records while
 * a component is only being laid out.
 */
const blockedDesignTimeCall = (method: string) => (url: string) => {
  console.warn(`Tasarım modunda ${method} çağrısı yapılmaz: ${url}`)
  return Promise.resolve(null)
}

const designTimeApi = {
  get: (url: string, params?: Record<string, unknown>) =>
    apiService.fetchData({ url, method: 'GET', params }).then((response) => response.data),
  post: blockedDesignTimeCall('POST'),
  put: blockedDesignTimeCall('PUT'),
  patch: blockedDesignTimeCall('PATCH'),
  delete: blockedDesignTimeCall('DELETE'),
  remove: blockedDesignTimeCall('DELETE'),
  request: (config: { url: string; method?: string }) =>
    String(config?.method || 'GET').toUpperCase() === 'GET'
      ? apiService.fetchData(config).then((response) => response.data)
      : blockedDesignTimeCall(String(config?.method))(config?.url),
  errorMessage: (error: {
    response?: { data?: { error?: { message?: string }; message?: string } }
    message?: string
  }) =>
    error?.response?.data?.error?.message ||
    error?.response?.data?.message ||
    error?.message ||
    'App.Platform.OperationCouldNotBeCompleted',
}

const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as FunctionConstructor

const executeEvent = (
  script: string,
  event: unknown,
  node: DesignerNode,
  refs: Record<string, unknown>,
) => {
  if (!script.trim()) return
  try {
    // `await` is only legal inside an async function, and scripts that call the
    // API are expected to use it.
    const construct = /\bawait\b/.test(script) ? AsyncFunction : Function
    const run = construct('event', 'component', 'props', 'refs', 'api', 'notify', script)
    const result = run(event, node, node.props, refs, designTimeApi, notifyFromScript)
    if (result instanceof Promise) {
      result.catch((error) => console.error(`Designer event error (${node.type}):`, error))
    }
  } catch (error) {
    console.error(`Designer event error (${node.type}):`, error)
  }
}

const getBindingValue = (
  binding: DesignerBinding,
  dataValues: Record<string, unknown>,
  currentItem?: unknown,
) => {
  const path = binding.path.trim()
  if (currentItem !== undefined && (path === '$item' || path.startsWith('$item.'))) {
    return getDesignerValueByPath(currentItem, path === '$item' ? '' : path.slice(6))
  }
  return getDesignerValueByPath(dataValues[binding.sourceId], path)
}

const toSelectOptions = (
  value: unknown,
  labelPath = '',
  valuePath = '',
): Array<Record<string, unknown>> => {
  if (!Array.isArray(value)) return []
  return value.map((item, index) => {
    if (item === null || typeof item !== 'object' || Array.isArray(item)) {
      return { label: String(item ?? ''), value: item ?? index }
    }
    const record = item as Record<string, unknown>
    const primitiveKeys = Object.keys(record).filter(
      (key) => record[key] === null || ['string', 'number', 'boolean'].includes(typeof record[key]),
    )
    const labelKey = ['label', 'children', 'name', 'title', 'text', 'description'].find(
      (key) => record[key] !== undefined,
    )
    const valueKey = ['value', 'eventKey', 'id', 'code', 'key'].find(
      (key) => record[key] !== undefined,
    )
    const labelValue = labelPath
      ? getDesignerValueByPath(record, labelPath)
      : labelKey
        ? record[labelKey]
        : record[primitiveKeys[0]]
    const optionValue = valuePath
      ? getDesignerValueByPath(record, valuePath)
      : valueKey
        ? record[valueKey]
        : record[primitiveKeys[1] || primitiveKeys[0]]
    return {
      ...record,
      label: String(labelValue ?? `#${index + 1}`),
      value: optionValue ?? index,
    }
  })
}

/**
 * Select renders its menu inline, so any ancestor with `overflow: hidden/auto`
 * (the canvas page, a Grid/Table scroll wrapper) clips it — the menu of a Select
 * in the last row would be cut off. Rendering it in a body portal keeps it
 * visible; explicit props still win.
 */
const getSelectMenuProps = (props: Record<string, unknown>) => {
  const menuProps: Record<string, unknown> = {}
  if (props.menuPosition === undefined) menuProps.menuPosition = 'fixed'
  if (props.menuPortalTarget === undefined && typeof window !== 'undefined') {
    menuProps.menuPortalTarget = window.document.body
  }
  if (props.styles === undefined) {
    menuProps.styles = {
      menuPortal: (base: Record<string, unknown>) => ({ ...base, zIndex: 60 }),
    }
  }
  return menuProps
}

/** Resolved tab list of a Tabs node, from static items or an endpoint binding. */
const getTabOptions = (props: Record<string, unknown>) =>
  Array.isArray(props.items) ? (props.items as Array<Record<string, unknown>>) : []

/** Prop of a node that a SqlDataSource scope is allowed to write back to. */
const getFormScopeProperty = (node: DesignerNode, formScope?: DesignerFormScope) =>
  getSqlFormValueProperty(node, formScope?.sourceId)

const getPreviewProps = (
  node: DesignerNode,
  dataValues: Record<string, unknown>,
  currentItem?: unknown,
  onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
  translate: (key: string) => string = (key) => key,
  formScope?: DesignerFormScope,
  refs: Record<string, unknown> = {},
  refOverrides: Record<string, unknown> = {},
) => {
  const props: Record<string, unknown> = {}
  Object.entries(node.props).forEach(([key, value]) => {
    // Design time metadata, not a component prop: it seeds a new record instead
    // of being rendered.
    if (key === SQL_DEFAULT_VALUE_PROP) return
    if (key === 'children' || key === 'html' || (value === '' && key !== 'value')) return
    if (key.startsWith('on') && typeof value === 'string') return
    props[key] = resolveStaticLanguageKeys(value, translate)
  })
  Object.entries(node.events).forEach(([name, script]) => {
    if (!script.trim()) return
    props[name] = (...args: unknown[]) => {
      const event =
        node.type === 'Checkbox' && name === 'onChange'
          ? {
              checked: Boolean(args[0]),
              originalEvent: args[1],
              target:
                args[1] && typeof args[1] === 'object' && 'target' in args[1]
                  ? (args[1] as { target: unknown }).target
                  : undefined,
            }
          : args[0]
      executeEvent(script, event, node, refs)
    }
  })
  Object.entries(node.bindings || {}).forEach(([propertyName, binding]) => {
    if (propertyName !== 'children' && binding.sourceId) {
      props[propertyName] = getBindingValue(binding, dataValues, currentItem)
    }
  })
  // Stored as ISO strings; pickers only accept real Date instances.
  if (isDesignerDateComponent(node.type)) {
    Object.keys(props).forEach((propertyName) => {
      if (isDesignerDateProperty(node.type, propertyName)) {
        props[propertyName] = toDesignerDate(props[propertyName], node.type)
      }
    })
  }
  if (isDesignerOptionComponent(node.type)) {
    const collectionProperty = getDesignerCollectionProperty(node.type)
    if (node.type === 'Select') {
      const legacyAliases: Record<string, string> = {
        clearable: 'isClearable',
        disabled: 'isDisabled',
        multiple: 'isMulti',
        searchable: 'isSearchable',
      }
      Object.entries(legacyAliases).forEach(([legacyName, runtimeName]) => {
        if (props[runtimeName] === undefined && props[legacyName] !== undefined) {
          props[runtimeName] = props[legacyName]
        }
        delete props[legacyName]
      })
      Object.assign(props, getSelectMenuProps(props))
    }
    const optionsBinding = node.bindings?.[collectionProperty]
    props[collectionProperty] = toSelectOptions(
      props[collectionProperty],
      optionsBinding?.labelPath,
      optionsBinding?.valuePath,
    )
    if (node.type === 'Pagination') {
      const pages = props.items as Array<Record<string, unknown>>
      delete props.items
      if (pages.length) props.total = pages.length
    }
    if (node.type === 'Select' && 'value' in props) {
      const options = props.options as Array<Record<string, unknown>>
      const selectedValue = props.value
      props.value = props.isMulti
        ? options.filter(
            (option) => Array.isArray(selectedValue) && selectedValue.includes(option.value),
          )
        : options.find((option) => option.value === selectedValue) || null
    }
  }

  const chainHandler = (eventName: string, update: (...args: unknown[]) => void) => {
    const storedHandler =
      typeof props[eventName] === 'function'
        ? (props[eventName] as (...args: unknown[]) => void)
        : undefined
    props[eventName] = (...args: unknown[]) => {
      update(...args)
      storedHandler?.(...args)
    }
  }
  const updateProp = (propertyName: string, value: unknown) =>
    onNodePropChange?.(node.id, propertyName, value)
  const eventValue = (value: unknown) =>
    value && typeof value === 'object' && 'target' in value
      ? (value as { target?: { value?: unknown } }).target?.value
      : value

  const selectedOptionValue = (selected: unknown) =>
    Array.isArray(selected)
      ? selected.map((option) =>
          option && typeof option === 'object' && 'value' in option
            ? (option as { value: unknown }).value
            : option,
        )
      : selected && typeof selected === 'object' && 'value' in selected
        ? (selected as { value: unknown }).value
        : null
  const checkedValue = (value: unknown, originalEvent: unknown) =>
    originalEvent && typeof originalEvent === 'object' && 'target' in originalEvent
      ? Boolean((originalEvent as { target?: { checked?: unknown } }).target?.checked)
      : value && typeof value === 'object' && 'target' in value
        ? Boolean((value as { target?: { checked?: unknown } }).target?.checked)
        : Boolean(value)
  // Inside a SqlDataSource the edited value belongs to the record, not to the
  // node's static prop — otherwise typing into a bound Input would be discarded
  // on the next render because the binding always wins.
  const formScopeProperty = getFormScopeProperty(node, formScope)

  if (formScope && formScopeProperty) {
    const bindingPath = node.bindings[formScopeProperty].path
    // An empty record (New mode) yields `undefined`, which React reads as
    // "uncontrolled" and leaves the field stuck on its previous DOM value.
    if (props[formScopeProperty] === undefined && !isDesignerDateComponent(node.type)) {
      props[formScopeProperty] = formScopeProperty === 'checked' ? false : ''
    }
    const writeField = (value: unknown) => formScope.onFieldChange(bindingPath, value)
    if (node.type === 'Select') {
      chainHandler('onChange', (selected) => writeField(selectedOptionValue(selected)))
    } else if (formScopeProperty === 'checked') {
      chainHandler('onChange', (value, originalEvent) =>
        writeField(checkedValue(value, originalEvent)),
      )
    } else if (isDesignerDateComponent(node.type)) {
      chainHandler('onChange', (value) => writeField(fromDesignerDate(value, node.type)))
    } else if (node.type === 'AutoComplete') {
      chainHandler('onInputChange', (value) => writeField(value ?? ''))
    } else {
      chainHandler('onChange', (value) => writeField(eventValue(value)))
    }
  } else if (node.type === 'Select') {
    chainHandler('onChange', (selected) => updateProp('value', selectedOptionValue(selected)))
  } else if (node.type === 'AutoComplete') {
    chainHandler('onInputChange', (value) => updateProp('value', value ?? ''))
  } else if (node.type === 'Menu') {
    chainHandler('onSelect', (value) => updateProp('defaultActiveKeys', [String(value ?? '')]))
  } else if (node.type === 'Dropdown') {
    chainHandler('onSelect', (value) => updateProp('activeKey', String(value ?? '')))
  } else if (node.type === 'Tabs') {
    chainHandler('onChange', (value) => updateProp('value', String(value ?? '')))
  } else if (node.type === 'Radio.Group') {
    chainHandler('onChange', (value) => updateProp('value', value))
  } else if (node.type === 'Pagination') {
    chainHandler('onChange', (value) => updateProp('currentPage', Number(value) || 1))
  } else if (node.type === 'Steps') {
    chainHandler('onChange', (value) => updateProp('current', Number(value) || 0))
  } else if (node.type === 'ImageViewer') {
    chainHandler('onIndexChange', (value) => updateProp('activeIndex', Number(value) || 0))
  } else if (node.type === 'Upload') {
    chainHandler('onChange', (_files, fileList) =>
      updateProp('fileList', Array.isArray(fileList) ? fileList : []),
    )
  } else if (node.type === 'MenuItem') {
    chainHandler('onSelect', () => updateProp('isActive', true))
  } else if (isDesignerDateComponent(node.type)) {
    // A Date is not JSON serialisable, so the picked value is kept as an ISO
    // string — otherwise it is lost on the next render/save.
    chainHandler('onChange', (value) => updateProp('value', fromDesignerDate(value, node.type)))
  } else if ('checked' in props) {
    chainHandler('onChange', (value, originalEvent) =>
      updateProp('checked', checkedValue(value, originalEvent)),
    )
  } else if ('value' in props) {
    chainHandler('onChange', (value) => updateProp('value', eventValue(value)))
  }
  // Applied last: a script that disabled or refilled this component has to win
  // over both the toolbox defaults and the values set in the property panel.
  Object.assign(props, refOverrides)
  props.key = node.id
  return props
}

const PlatformPlaceholder = ({ node }: { node: DesignerNode }) => {
  const { translate } = useLocalization()

  return (
    <div
      className={`flex min-h-40 items-center justify-center rounded-lg border border-dashed border-sky-300 bg-gradient-to-br from-sky-50 to-indigo-50 p-6 text-center dark:border-sky-800 dark:from-slate-900 dark:to-sky-950 ${String(node.props.className || '')}`}
      style={{ minHeight: String(node.props.height || '320px') }}
    >
      <div>
        <div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-sky-600 font-bold text-white">
          S
        </div>
        <div className="font-semibold text-slate-800 dark:text-slate-100">
          {node.type.replace(/View$/, '')}
        </div>
        <div className="mt-1 text-xs text-slate-500">
          {String(
            node.props.listFormCode || translate('::App.VisualDesignerCanvas.SelectListFormCode'),
          )}
        </div>
        <div className="mt-3 text-[11px] uppercase tracking-wider text-sky-700 dark:text-sky-300">
          {translate('::App.VisualDesignerCanvas.PlatformView')}
        </div>
      </div>
    </div>
  )
}

const PLATFORM_VIEW_NAMES: Record<string, PlatformViewName> = {
  ListView: 'List',
  DataGridView: 'Grid',
  TreeView: 'Tree',
  GanttView: 'GanttView',
  TodoBoard: 'TodoBoard',
  CardView: 'CardView',
  SchedulerView: 'SchedulerView',
  PivotView: 'Pivot',
  ChartView: 'Chart',
}

const GridColumnHeaders = ({ columns }: { columns: string[] }) => {
  const { translate } = useLocalization()

  return (
    <div className="min-w-0 overflow-auto rounded-md border border-slate-200 bg-slate-100 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-800">
      {columns.length ? (
        <div className="flex min-w-max divide-x divide-slate-200 dark:divide-slate-700">
          {columns.map((column) => (
            <div
              key={column}
              className="min-w-32 px-3 py-2 text-xs font-semibold text-slate-600 dark:text-slate-200"
            >
              {column}
            </div>
          ))}
        </div>
      ) : (
        <div className="px-3 py-2 text-center text-xs text-slate-400">
          {translate('::App.VisualDesignerCanvas.SelectColumnsFromData')}
        </div>
      )}
    </div>
  )
}

// Dates and decimals follow the selected language, like the DevExtreme grids do.
const getGridCellText = (value: unknown) =>
  typeof value === 'object' && value !== null && !(value instanceof Date)
    ? JSON.stringify(value)
    : formatLocaleValue(value) || '—'

const GridDataTablePreview = ({
  borderlessRow = false,
  captions,
  compact = false,
  hoverable = true,
  items,
  lookupIndexes,
  overflow = true,
  selectedColumns,
}: {
  borderlessRow?: boolean
  /** Header override per column; a `::` value is a localization key. */
  captions?: Record<string, string>
  compact?: boolean
  hoverable?: boolean
  items: unknown[]
  /** Value → text map per column, for the columns configured as a lookup. */
  lookupIndexes?: Record<string, Map<string, unknown>>
  overflow?: boolean
  selectedColumns?: string[]
}) => {
  const { translate } = useLocalization()
  const firstObject = items.find(
    (item): item is Record<string, unknown> =>
      Boolean(item) && typeof item === 'object' && !Array.isArray(item),
  )
  const columns = selectedColumns ?? (firstObject ? Object.keys(firstObject) : ['value'])

  if (!columns.length) {
    return (
      <div className="rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
        {translate('::App.VisualDesignerCanvas.SelectAtLeastOneColumn')}
      </div>
    )
  }

  return (
    <div
      className={`min-w-0 rounded-lg border border-slate-200 bg-white shadow-sm [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 ${overflow ? 'overflow-auto' : 'overflow-visible'}`}
    >
      <table className={`w-full border-collapse text-left ${compact ? 'text-[11px]' : 'text-xs'}`}>
        <thead className="bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-200">
          <tr>
            {columns.map((column) => (
              <th
                key={column}
                className={`whitespace-nowrap border-b border-slate-200 dark:border-slate-700 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
              >
                {captions?.[column]
                  ? String(resolveStaticLanguageKeys(captions[column], translate))
                  : column === 'value'
                    ? 'Value'
                    : column}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {items.slice(0, 100).map((item, rowIndex) => (
            <tr
              key={rowIndex}
              className={`${borderlessRow ? '' : 'border-b border-slate-100 last:border-b-0 dark:border-slate-800'} ${hoverable ? 'transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/70' : ''}`}
            >
              {columns.map((column) => {
                const value = column === 'value' ? item : getDesignerValueByPath(item, column)
                // A column with a lookup paints the matched option text; a value
                // with no matching row keeps showing the raw id rather than an
                // empty cell, so a broken mapping stays visible.
                const lookupIndex = lookupIndexes?.[column]
                const lookupText = lookupIndex
                  ? resolveDesignerLookupText(lookupIndex, value)
                  : undefined
                const text = getGridCellText(lookupText === undefined ? value : lookupText)
                return (
                  <td
                    key={column}
                    className={`max-w-64 truncate text-slate-800 dark:text-slate-100 ${compact ? 'px-2 py-1' : 'px-3 py-2'}`}
                    title={text}
                  >
                    {text}
                  </td>
                )
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  )
}

/**
 * A new record starts at the top of the form: the first enabled editor takes the
 * cursor once React has painted the emptied fields.
 */
const focusFirstFormField = (host: React.RefObject<HTMLElement | null>) => {
  window.requestAnimationFrame(() => {
    const field = host.current?.querySelector<HTMLElement>(
      "input:not([type='hidden']):not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])",
    )
    if (!field) return
    field.focus()
    if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) field.select()
  })
}

const SqlDataSourceView = ({
  node,
  dataValues,
  interactive,
  renderChildren,
}: {
  node: DesignerNode
  dataValues: Record<string, unknown>
  interactive: boolean
  renderChildren: (
    childDataValues: Record<string, unknown>,
    formScope: DesignerFormScope,
  ) => React.ReactNode
}) => {
  const { translate } = useLocalization()
  const selectId = getSqlDataSourceEndpointId(node, 'selectEndpoint')
  const keyField = getSqlDataSourceKeyField(node)
  const collectionPath = String(node.props.collectionPath ?? '')
  const columnCount = getSqlDataSourceColumnCount(node)
  const rows = React.useMemo(
    () => (selectId ? resolveSqlDataSourceRows(dataValues[selectId], collectionPath) : []),
    [collectionPath, dataValues, selectId],
  )
  // Container element, so New can hand the cursor to the first field of the form.
  const hostRef = React.useRef<HTMLDivElement | null>(null)
  const [rowIndex, setRowIndex] = React.useState(0)
  const [mode, setMode] = React.useState<SqlDataSourceMode>('edit')
  // Holds the whole record while editing, so New mode can show an empty form
  // instead of falling back to the loaded row.
  const [draft, setDraft] = React.useState<Record<string, unknown> | null>(null)
  const activeRow = React.useMemo(() => rows[rowIndex] ?? {}, [rowIndex, rows])
  // A fresh Select result invalidates the local edits, otherwise the canvas would
  // keep showing values that no longer exist in the response.
  const rowsFingerprint = React.useMemo(() => JSON.stringify(rows), [rows])
  React.useEffect(() => {
    setRowIndex(0)
    setDraft(null)
    setMode('edit')
  }, [rowsFingerprint])

  const record = draft ?? activeRow
  const formScope = React.useMemo<DesignerFormScope>(
    () => ({
      sourceId: node.id,
      onFieldChange: (path, value) =>
        setDraft((current) => setDesignerRecordField(current ?? activeRow, path, value)),
    }),
    [activeRow, node.id],
  )
  const childDataValues = React.useMemo(
    () => ({ ...dataValues, [node.id]: record }),
    [dataValues, node.id, record],
  )

  const goToRow = (index: number) => {
    setRowIndex(index)
    setDraft(null)
    setMode('edit')
  }
  const keyValue = readSqlDataSourceField(record, keyField)
  const hasKey = keyValue !== undefined && keyValue !== null && keyValue !== ''
  const canInsert = Boolean(getSqlDataSourceEndpointId(node, 'insertEndpoint'))
  const canUpdate = Boolean(getSqlDataSourceEndpointId(node, 'updateEndpoint'))
  const canDelete = Boolean(getSqlDataSourceEndpointId(node, 'deleteEndpoint'))
  // Save follows the explicit mode, exactly like the generated runtime does.
  const canSave = mode === 'new' ? canInsert : canUpdate
  const designTimeTitle = translate('::App.VisualDesignerCanvas.DesignTimeNoCall')
  // A filled Title turns the container into a Card; the header extra carries the
  // key of the record on screen, which is the only identity the user can check.
  const cardTitle = String(
    resolveStaticLanguageKeys(node.props.title ?? '', translate) ?? '',
  ).trim()
  const headerExtra = hasKey
    ? `${keyField}: ${String(keyValue)}`
    : translate('::App.VisualDesignerCanvas.NewRecord')

  const toolbarButton = (
    label: string,
    enabled: boolean,
    tone: 'primary' | 'danger' | 'plain',
    disabledTitle: string,
    onClick?: () => void,
  ) => (
    <button
      key={label}
      className={`rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:cursor-not-allowed disabled:opacity-40 ${
        tone === 'primary'
          ? 'bg-sky-600 text-white hover:bg-sky-700'
          : tone === 'danger'
            ? 'bg-red-600 text-white hover:bg-red-700'
            : 'border border-slate-300 text-slate-600 hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:text-slate-300'
      }`}
      disabled={!enabled}
      title={enabled && !onClick ? designTimeTitle : disabledTitle}
      type="button"
      onClick={(event) => {
        event.stopPropagation()
        onClick?.()
      }}
    >
      {label}
    </button>
  )

  const body = (
    <div
      ref={hostRef}
      className={String(node.props.className || '')}
      style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
    >
      {interactive && (
        <div className="flex flex-wrap items-center gap-2 rounded-md border border-dashed border-sky-300 bg-sky-50 px-2.5 py-1.5 text-[10px] text-sky-800 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-200">
          <span className="font-semibold uppercase tracking-wider">{SQL_DATA_SOURCE_TYPE}</span>
          <span>
            key: <code>{keyField}</code>
          </span>
          <span>
            {selectId
              ? translate('::App.Platform.RecordCount', { count: rows.length })
              : translate('::App.VisualDesignerCanvas.NoSelectEndpoint')}
          </span>
          <span className="rounded bg-sky-600 px-1.5 py-0.5 font-semibold text-white">
            {translate(
              mode === 'new'
                ? '::App.VisualDesignerCanvas.NewRecord'
                : '::App.Platform.Editing',
            )}
          </span>
          {rows.length > 1 && (
            <label className="flex items-center gap-1">
              {translate('::App.Platform.Row')}
              <select
                className="rounded border border-sky-300 bg-white px-1 py-0.5 text-[10px] dark:border-sky-800 dark:bg-slate-900"
                value={rowIndex}
                onChange={(event) => goToRow(Number(event.target.value) || 0)}
                onClick={(event) => event.stopPropagation()}
              >
                {rows.map((_, index) => (
                  <option key={index} value={index}>
                    {index + 1}
                  </option>
                ))}
              </select>
            </label>
          )}
        </div>
      )}
      {columnCount > 1 ? (
        // Grid only once more than one column is asked for, so a single column
        // form keeps the exact flex layout it had before the setting existed.
        <div
          style={{
            display: 'grid',
            gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
            gap: Number(node.props.gap) || 0,
            alignItems: 'start',
          }}
        >
          {renderChildren(childDataValues, formScope)}
        </div>
      ) : (
        renderChildren(childDataValues, formScope)
      )}
      {/* The drop zone belongs with the content, above the command toolbar. */}
      {interactive && !node.children.length && (
        <div className="rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400 dark:border-slate-700">
          {translate('::App.VisualDesignerCanvas.DropAndBind')}
        </div>
      )}
      {node.props.showToolbar !== false && (
        <div className="flex flex-wrap items-center gap-2 border-t border-slate-200 pt-3 dark:border-slate-800">
          {/* Navigation appears on its own once there is more than one record. */}
          {rows.length > 1 && (
            <>
              {toolbarButton(
                translate('::App.Platform.Previous'),
                rowIndex > 0,
                'plain',
                translate('::App.VisualDesignerCanvas.AtFirstRecord'),
                () => goToRow(Math.max(0, rowIndex - 1)),
              )}
              <span className="text-xs text-slate-500">
                {rows.length ? `${rowIndex + 1} / ${rows.length}` : '0 / 0'}
              </span>
              {toolbarButton(
                translate('::App.Platform.Next2'),
                rowIndex < rows.length - 1,
                'plain',
                translate('::App.VisualDesignerCanvas.AtLastRecord'),
                () => goToRow(Math.min(rows.length - 1, rowIndex + 1)),
              )}
              <span className="mx-1 h-5 w-px bg-slate-300 dark:bg-slate-700" />
            </>
          )}
          {/* New and Reload only touch local state, so they work at design time. */}
          {toolbarButton(
            translate('::App.Platform.New'),
            canInsert,
            'plain',
            translate('::App.VisualDesignerCanvas.SelectInsertEndpoint'),
            () => {
              // New mode starts from the defaults declared on the bound children,
              // exactly like the generated runtime does.
              setDraft(buildSqlDefaultRecord(node))
              setMode('new')
              focusFirstFormField(hostRef)
            },
          )}
          {toolbarButton(
            translate('::App.Platform.Save'),
            canSave,
            'primary',
            translate(
              mode === 'new'
                ? '::App.VisualDesignerCanvas.SelectInsertEndpoint'
                : '::App.VisualDesignerCanvas.SelectUpdateEndpoint',
            ),
          )}
          {toolbarButton(
            translate('::App.Platform.Delete'),
            canDelete && hasKey && mode === 'edit',
            'danger',
            canDelete
              ? translate('::App.VisualDesignerCanvas.KeyFieldRequired', { field: keyField })
              : translate('::App.VisualDesignerCanvas.SelectDeleteEndpoint'),
          )}
          {toolbarButton(
            translate('::App.Platform.Refresh'),
            Boolean(selectId),
            'plain',
            translate('::App.VisualDesignerCanvas.SelectSelectEndpoint'),
            () => goToRow(rowIndex),
          )}
        </div>
      )}
    </div>
  )

  return (
    <UiKit.Card
      bodyClass="p-4"
      header={cardTitle || undefined}
      headerExtra={cardTitle ? headerExtra : undefined}
    >
      {body}
    </UiKit.Card>
  )
}

/**
 * A node's own content, resolved in the order the runtime uses: a `setText`
 * override beats a data binding, which beats the static `children` prop. Child
 * nodes are not considered here — the caller decides whether they take over.
 */
const resolveNodeContent = (
  node: DesignerNode,
  dataValues: Record<string, unknown>,
  currentItem: unknown,
  translate: (key: string) => string,
  refOverrides: Record<string, unknown> = {},
): React.ReactNode => {
  if (refOverrides.children !== undefined) return refOverrides.children as React.ReactNode
  const childrenBinding = node.bindings?.children
  const boundChildren = childrenBinding?.sourceId
    ? getBindingValue(childrenBinding, dataValues, currentItem)
    : undefined
  if (boundChildren === null || boundChildren === undefined) {
    return (
      (resolveStaticLanguageKeys(node.props.children, translate) as React.ReactNode) || undefined
    )
  }
  if (React.isValidElement(boundChildren)) return boundChildren
  return typeof boundChildren === 'object' ? JSON.stringify(boundChildren) : String(boundChildren)
}

/**
 * Platform view in preview mode. Its filters are resolved the same way the data
 * source preview resolves them — a static value as typed, anything else from the
 * sampling value — and handed to the view as the `filter` expression it already
 * understands. A required filter without a sampling value renders nothing, which
 * is what the generated page does too.
 */
const PlatformViewPreview = ({ node }: { node: DesignerNode }) => {
  const filterKey = JSON.stringify(getDesignerNodeFilters(node))
  const params = React.useMemo(() => {
    const filters = JSON.parse(filterKey) as DesignerDataSourceFilter[]
    if (!filters.length) return undefined
    return buildDesignerFilterParams(filters, resolveDesignerPreviewFilterValue)
  }, [filterKey])
  if (params === null) return null
  return (
    <PlatformViewHost
      className={String(node.props.className || '')}
      height={String(node.props.height || '420px')}
      listFormCode={String(node.props.listFormCode || '')}
      searchParams={params}
      view={PLATFORM_VIEW_NAMES[node.type] || (node.type as PlatformViewName)}
    />
  )
}

const renderElement = (
  node: DesignerNode,
  children: React.ReactNode,
  dataValues: Record<string, unknown>,
  currentItem: unknown,
  interactive: boolean,
  translate: (key: string) => string,
  onNodePropChange?: (id: string, propertyName: string, value: unknown) => void,
  renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode,
  formScope?: DesignerFormScope,
  refs: Record<string, unknown> = {},
  refOverrides: Record<string, unknown> = {},
) => {
  // Built in NodeView so the container can own the record state and expose it to
  // its children through an augmented `dataValues` map.
  if (isSqlDataSourceNode(node.type)) return <>{children}</>
  if (node.type === 'Spacer') {
    return (
      <div
        aria-hidden="true"
        className={String(node.props.className || '')}
        style={{ height: Number(node.props.height) || 24 }}
      />
    )
  }
  if (node.type === 'FlexRow') {
    return (
      <div
        className={String(node.props.className || '')}
        style={{
          display: 'flex',
          gap: Number(node.props.gap) || 0,
          flexWrap: node.props.wrap ? 'wrap' : 'nowrap',
          alignItems: String(node.props.align || 'stretch'),
        }}
      >
        {children}
      </div>
    )
  }
  if (node.type === 'PageContainer') {
    return (
      <div
        className={String(node.props.className || '')}
        style={{
          display: 'flex',
          flexDirection: 'column',
          gap: Number(node.props.gap) || 0,
          margin: '0 auto',
          maxWidth: String(node.props.maxWidth || '1280px'),
          padding: Number(node.props.padding) || 0,
          width: '100%',
        }}
      >
        {children}
      </div>
    )
  }
  if (node.type === 'TwoColumns' || node.type === 'SidebarContent') {
    return (
      <div
        className={String(node.props.className || '')}
        style={{
          display: 'grid',
          gap: Number(node.props.gap) || 0,
          gridTemplateColumns:
            node.type === 'SidebarContent'
              ? `${String(node.props.sidebarWidth || '280px')} minmax(0, 1fr)`
              : 'repeat(2, minmax(0, 1fr))',
        }}
      >
        {children}
      </div>
    )
  }
  if (node.type === 'HeaderContent') {
    return (
      <div
        className={String(node.props.className || '')}
        style={{ display: 'flex', flexDirection: 'column', gap: Number(node.props.gap) || 0 }}
      >
        {children}
      </div>
    )
  }
  if (node.type === 'Grid') {
    return (
      <div
        className={String(node.props.className || '')}
        style={{
          display: 'grid',
          gridTemplateColumns: `repeat(${Number(node.props.cols) || 3}, minmax(0, 1fr))`,
          gap: (Number(node.props.gap) || 0) * 4,
        }}
      >
        {children}
      </div>
    )
  }
  if (node.kind === 'platform') {
    if (interactive) return <PlatformPlaceholder node={node} />
    return <PlatformViewPreview node={node} />
  }
  // Own content and child nodes coexist, exactly as the generator emits them:
  // the text first, then the nodes dropped underneath it.
  const ownContent = resolveNodeContent(node, dataValues, currentItem, translate, refOverrides)
  const hasOwnContent = ownContent !== undefined && ownContent !== null && ownContent !== ''
  const content: React.ReactNode =
    React.Children.count(children) > 0 ? (
      hasOwnContent ? (
        <>
          {ownContent}
          {children}
        </>
      ) : (
        children
      )
    ) : (
      ownContent
    )
  const props = getPreviewProps(
    node,
    dataValues,
    currentItem,
    onNodePropChange,
    translate,
    formScope,
    refs,
    refOverrides,
  )
  if (node.kind === 'custom') {
    return renderCustomComponent?.(node.type, { ...props, children: content }) || null
  }
  if (node.type === 'Table') return <>{children}</>
  if (node.type === 'Menu') {
    const menuProps = { ...props }
    const options = Array.isArray(menuProps.items)
      ? (menuProps.items as Array<Record<string, unknown>>)
      : []
    delete menuProps.items
    delete menuProps.variant
    menuProps.defaultActiveKeys = normalizeDesignerKeyList(menuProps.defaultActiveKeys)
    menuProps.defaultExpandedKeys = normalizeDesignerKeyList(menuProps.defaultExpandedKeys)
    return (
      <UiKit.Menu {...(menuProps as React.ComponentProps<typeof UiKit.Menu>)}>
        {options.map((option, index) => (
          <UiKit.Menu.MenuItem
            key={String(option.value ?? index)}
            eventKey={String(option.value ?? index)}
          >
            {String(option.label ?? option.value ?? `#${index + 1}`)}
          </UiKit.Menu.MenuItem>
        ))}
        {content}
      </UiKit.Menu>
    )
  }
  if (node.type === 'Dropdown') {
    const dropdownProps = { ...props }
    const options = Array.isArray(dropdownProps.items)
      ? (dropdownProps.items as Array<Record<string, unknown>>)
      : []
    delete dropdownProps.items
    // Without this the toggle keeps showing the static title and the component
    // looks untouched after a selection.
    dropdownProps.title = resolveDesignerDropdownTitle(
      options,
      dropdownProps.activeKey,
      dropdownProps.title,
    )
    return (
      <UiKit.Dropdown {...(dropdownProps as React.ComponentProps<typeof UiKit.Dropdown>)}>
        {options.map((option, index) => (
          <UiKit.Dropdown.Item
            key={String(option.value ?? index)}
            disabled={Boolean(option.disabled)}
            eventKey={String(option.value ?? index)}
          >
            {String(option.label ?? option.value ?? `#${index + 1}`)}
          </UiKit.Dropdown.Item>
        ))}
        {content}
      </UiKit.Dropdown>
    )
  }
  // Built in NodeView so each tab can own its own children and drop target.
  if (node.type === 'Tabs') return <>{children}</>
  if (node.type === 'Radio.Group') {
    const groupProps = { ...props }
    const options = Array.isArray(groupProps.items)
      ? (groupProps.items as Array<Record<string, unknown>>)
      : []
    delete groupProps.items
    delete groupProps.checked
    delete groupProps.defaultChecked
    delete groupProps.readOnly
    return (
      <UiKit.Radio.Group {...(groupProps as React.ComponentProps<typeof UiKit.Radio.Group>)}>
        {options.map((option, index) => (
          <UiKit.Radio
            key={String(option.value ?? index)}
            disabled={Boolean(option.disabled)}
            value={option.value ?? index}
          >
            {String(option.label ?? option.value ?? `#${index + 1}`)}
          </UiKit.Radio>
        ))}
        {content}
      </UiKit.Radio.Group>
    )
  }
  if (node.type === 'checkbox') {
    return <input {...(props as React.InputHTMLAttributes<HTMLInputElement>)} type="checkbox" />
  }
  if (node.kind === 'html') return React.createElement(node.type, props, content)

  const Component = resolveUiComponent(node.type)
  if (!Component) {
    return (
      <div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
        {node.type}
      </div>
    )
  }
  return React.createElement(Component, props, content)
}

const NodeView = ({
  node,
  index,
  siblingCount = 1,
  isRoot = false,
  selectedId,
  interactive,
  onSelect,
  onDropComponent,
  onDropComponentBeside,
  onMoveIntoContainer,
  onMove,
  onReorder,
  onDuplicate,
  onDelete,
  onNodePropChange,
  renderCustomComponent,
  dataValues,
  currentItem,
  formScope,
}: {
  node: DesignerNode
  index: number
  siblingCount?: number
  isRoot?: boolean
  selectedId: string | null
  interactive: boolean
  onSelect?: (id: string) => void
  onDropComponent?: (definitionName: string, parentId: string | null, slot?: string) => void
  onDropComponentBeside?: (
    definitionName: string,
    targetId: string,
    placement: 'before' | 'after',
  ) => void
  onMoveIntoContainer?: (nodeId: string, parentId: string | null, slot?: string) => void
  onMove?: (id: string, direction: -1 | 1) => void
  onReorder?: (sourceId: string, targetId: string, placement: 'before' | 'after') => void
  onDuplicate?: (id: string) => void
  onDelete?: (id: string) => void
  onNodePropChange?: (id: string, propertyName: string, value: unknown) => void
  renderCustomComponent?: (name: string, props?: Record<string, unknown>) => React.ReactNode
  dataValues: Record<string, unknown>
  currentItem?: unknown
  formScope?: DesignerFormScope
}) => {
  const { translate } = useLocalization()
  const refStore = React.useContext(DesignerRefContext)
  // Rebuilt on every render so the accessors read the current override state;
  // memoising it would hand the scripts a stale snapshot.
  const refs = buildDesignerRefs(refStore)
  const refOverride = node.ref ? refStore?.state[node.ref] : undefined
  const refOverrides = refOverride?.props || {}
  const refHidden = Boolean(refOverride?.hidden)
  const selected = interactive && selectedId === node.id
  // A nested node can always move: at the edge of its container it is lifted out.
  // Only the first/last node at the root has nowhere left to go.
  const canMoveUp = !isRoot || index > 0
  const canMoveDown = !isRoot || index < siblingCount - 1
  const acceptsDroppedChildren = [
    'PageContainer',
    'TwoColumns',
    'SidebarContent',
    'HeaderContent',
    'FlexRow',
    'div',
    'Card',
    'FormContainer',
    'Timeline',
    // Dropped components land in whichever tab is open.
    'Tabs',
    SQL_DATA_SOURCE_TYPE,
    LEGACY_SQL_DATA_SOURCE_TYPE,
  ].includes(node.type)

  /* ---- Sürükleme geri bildirimi ---------------------------------- */
  const drag = useDesignerDrag()
  const [dropHint, setDropHint] = React.useState<'before' | 'inside' | 'after' | null>(null)
  /** Tabs sekmesi / tablo hücresi gibi slot hedeflerinden hangisinin üzerindeyiz. */
  const [overSlot, setOverSlot] = React.useState<string | null>(null)
  React.useEffect(() => {
    if (drag) return
    setDropHint(null)
    setOverSlot(null)
  }, [drag])
  /** Slot hedeflerinin ortak sürükleme davranışı: kabul et ve kendini işaretle. */
  const slotDropProps = (slot: string) => ({
    onDragOver: (event: React.DragEvent<HTMLElement>) => {
      if (!interactive) return
      acceptDesignerDrag(event)
      setOverSlot(slot)
    },
    onDragLeave: () => setOverSlot((current) => (current === slot ? null : current)),
  })
  const dragging = interactive && Boolean(drag)
  const isDragSource = drag?.nodeId === node.id
  // Kendisi ve alt ağacı dışında her hedef geçerli: kardeşinin yanına, bir üst
  // konteynerin içine ya da köke taşımak hep anlamlı işlemler.
  const insideDraggedSubtree = React.useContext(DesignerDraggedSubtreeContext) || isDragSource
  const canDropInside = acceptsDroppedChildren && !insideDraggedSubtree
  const canDropBeside = !insideDraggedSubtree
  const dragLabel = drag?.label || drag?.name || translate('::App.VisualDesignerCanvas.Component')
  /**
   * Taşımayı başlatan ortak kod. Hem düğüm gövdesi hem de tutamak bunu kullanır:
   * Input/Select/Textarea gibi canlı önizleme kontrolleri Chrome'da üst öğenin
   * sürüklenmesini başlatmadığı için, gövdesi tamamen bir kontrolle kaplı bir
   * bileşenin tutulabileceği tek yer tutamaktır.
   */
  const startNodeDrag = (event: React.DragEvent<HTMLElement>) => {
    event.stopPropagation()
    const payload = JSON.stringify({ source: 'canvas', nodeId: node.id })
    event.dataTransfer.effectAllowed = 'move'
    event.dataTransfer.setData(DESIGNER_DRAG_TYPE, payload)
    event.dataTransfer.setData('text/plain', payload)
    // `dragstart` içinde senkron bir state güncellemesi sürüklenen düğümü hemen
    // yeniden render eder ve tarayıcı sürüklemeyi iptal eder. Vurguları bir tık
    // sonraya bırakınca sürükleme sağlam başlar, geri bildirim de kaybolmaz.
    beginDesignerDragSoon({ source: 'canvas', nodeId: node.id, label: node.ref || node.type })
  }
  /** Pointer'ın dikey konumu bırakmanın anlamını belirler. */
  const resolveDropHint = (event: React.DragEvent<HTMLDivElement>) => {
    const bounds = event.currentTarget.getBoundingClientRect()
    const offset = event.clientY - bounds.top
    // Konteynerlerde orta bant "içine", dış çeyrekler "yanına" demektir.
    if (canDropInside && offset > bounds.height * 0.25 && offset < bounds.height * 0.75) {
      return 'inside' as const
    }
    return offset < bounds.height / 2 ? ('before' as const) : ('after' as const)
  }
  /** Bırakmanın karşılığı olan ağaç işlemi. Kenar şeritleri de bunu kullanır. */
  const applyDrop = (payload: DesignerDragPayload, hint: 'before' | 'inside' | 'after') => {
    const placement = hint === 'before' ? 'before' : 'after'
    if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
      if (hint === 'inside') onMoveIntoContainer?.(payload.nodeId, node.id)
      else onReorder?.(payload.nodeId, node.id, placement)
    } else if (payload.source === 'library' && payload.name) {
      if (hint === 'inside') onDropComponent?.(payload.name, node.id)
      else onDropComponentBeside?.(payload.name, node.id, placement)
    }
  }
  /**
   * Düğümler arasındaki boşluk hiçbir düğüme ait değildi; oraya bırakılan bileşen
   * canvas'ın köküne, yani sayfanın en altına gidiyordu. Sürükleme sürerken her
   * düğüm kendi üst ve alt boşluğunu kaplayan görünmez birer şerit açar, böylece
   * "araya bırak" tam olarak nişan alınan yere düşer.
   */
  const edgeStripProps = (placement: 'before' | 'after') => ({
    onDragOver: (event: React.DragEvent<HTMLDivElement>) => {
      if (!interactive) return
      acceptDesignerDrag(event)
      setDropHint(canDropBeside ? placement : null)
    },
    onDragLeave: () => setDropHint(null),
    onDrop: (event: React.DragEvent<HTMLDivElement>) => {
      if (!interactive) return
      event.preventDefault()
      event.stopPropagation()
      setDropHint(null)
      endDesignerDrag()
      const payload = readDesignerDragPayload(event)
      if (payload) applyDrop(payload, placement)
    },
  })
  const staticChildren = node.props.children
  const hasStaticChildren =
    staticChildren !== undefined &&
    staticChildren !== null &&
    (typeof staticChildren !== 'string' || staticChildren.length > 0)
  const hasChildrenBinding = Boolean(node.bindings?.children?.sourceId)
  const hasVisibleChildren = node.children.length > 0 || hasStaticChildren || hasChildrenBinding
  const itemsBinding = node.type === 'Grid' ? node.bindings?.items : undefined
  // Option rows of every column configured as a lookup. They come from the same
  // sampled data the rest of the canvas paints from, so a mapping can be checked
  // while designing instead of only after the component is generated.
  const gridLookupIndexes = React.useMemo(
    () =>
      Object.fromEntries(
        Object.entries(getUsableDesignerColumnLookups(node)).map(([column, lookup]) => [
          column,
          buildDesignerLookupIndex(dataValues[lookup.sourceId], lookup),
        ]),
      ),
    [dataValues, node],
  )
  const boundItems = itemsBinding?.sourceId
    ? getBindingValue(itemsBinding, dataValues, currentItem)
    : node.props.items
  const sourceRootItems = itemsBinding?.sourceId ? dataValues[itemsBinding.sourceId] : undefined
  const effectiveBoundItems =
    Array.isArray(boundItems) || currentItem !== undefined
      ? boundItems
      : Array.isArray(sourceRootItems)
        ? sourceRootItems
        : boundItems
  const repeatedItems = Array.isArray(effectiveBoundItems)
    ? interactive && !(node.type === 'Grid' && node.children.length === 0)
      ? effectiveBoundItems.slice(0, 1)
      : effectiveBoundItems
    : []
  const childContexts = repeatedItems.length ? repeatedItems : [currentItem]
  const renderChildNodes = (
    childDataValues: Record<string, unknown>,
    childFormScope?: DesignerFormScope,
  ) =>
    childContexts.flatMap((childItem, itemIndex) =>
      node.children.map((child, childIndex) => (
        <NodeView
          key={`${child.id}_${itemIndex}`}
          node={child}
          index={childIndex}
          siblingCount={node.children.length}
          selectedId={selectedId}
          interactive={interactive}
          renderCustomComponent={renderCustomComponent}
          dataValues={childDataValues}
          currentItem={childItem}
          formScope={childFormScope}
          onSelect={onSelect}
          onDropComponent={onDropComponent}
          onDropComponentBeside={onDropComponentBeside}
          onMoveIntoContainer={onMoveIntoContainer}
          onMove={onMove}
          onReorder={onReorder}
          onDuplicate={onDuplicate}
          onDelete={onDelete}
          onNodePropChange={onNodePropChange}
        />
      )),
    )
  const children = renderChildNodes(dataValues, formScope)
  const sqlDataSourceContent = isSqlDataSourceNode(node.type) ? (
    <SqlDataSourceView
      dataValues={dataValues}
      interactive={interactive}
      node={node}
      renderChildren={renderChildNodes}
    />
  ) : null
  const tabsContent = (() => {
    if (node.type !== 'Tabs') return null
    const tabsProps = getPreviewProps(
      node,
      dataValues,
      currentItem,
      onNodePropChange,
      translate,
      formScope,
      refs,
      refOverrides,
    )
    const options = getTabOptions(tabsProps)
    delete tabsProps.items
    const activeValue = resolveDesignerTabValue(options, tabsProps.value)
    tabsProps.value = activeValue
    // Children created before per-tab slots existed belong to the first tab.
    const firstSlot = options.length ? getDesignerTabSlot(String(options[0]?.value ?? 0)) : ''

    return (
      <UiKit.Tabs {...(tabsProps as React.ComponentProps<typeof UiKit.Tabs>)}>
        <UiKit.Tabs.TabList>
          {options.map((option, index) => (
            <UiKit.Tabs.TabNav
              key={String(option.value ?? index)}
              disabled={Boolean(option.disabled)}
              value={String(option.value ?? index)}
            >
              {String(option.label ?? option.value ?? `#${index + 1}`)}
            </UiKit.Tabs.TabNav>
          ))}
        </UiKit.Tabs.TabList>
        {options.map((option, index) => {
          const tabValue = String(option.value ?? index)
          const slot = getDesignerTabSlot(tabValue)
          const tabNodes = node.children.filter((child) => (child.slot || firstSlot) === slot)
          return (
            <UiKit.Tabs.TabContent key={tabValue} value={tabValue}>
              <div
                className={`min-h-14 rounded py-2 transition ${
                  overSlot === slot
                    ? 'bg-emerald-50 outline outline-2 outline-emerald-500 dark:bg-emerald-950/40'
                    : dragging
                      ? 'outline-dashed outline-1 outline-sky-400/70'
                      : ''
                }`}
                {...slotDropProps(slot)}
                onDrop={(event) => {
                  if (!interactive) return
                  event.preventDefault()
                  event.stopPropagation()
                  setOverSlot(null)
                  endDesignerDrag()
                  const payload = readDesignerDragPayload(event)
                  if (!payload) return
                  if (payload.source === 'library' && payload.name) {
                    onDropComponent?.(payload.name, node.id, slot)
                  } else if (payload.source === 'canvas' && payload.nodeId) {
                    onMoveIntoContainer?.(payload.nodeId, node.id, slot)
                  }
                }}
              >
                {option.content ? <div>{String(option.content)}</div> : null}
                {tabNodes.map((child, childIndex) => (
                  <NodeView
                    key={child.id}
                    node={child}
                    index={childIndex}
                    siblingCount={tabNodes.length}
                    selectedId={selectedId}
                    interactive={interactive}
                    renderCustomComponent={renderCustomComponent}
                    dataValues={dataValues}
                    currentItem={currentItem}
                    formScope={formScope}
                    onSelect={onSelect}
                    onDropComponent={onDropComponent}
                    onDropComponentBeside={onDropComponentBeside}
                    onMoveIntoContainer={onMoveIntoContainer}
                    onMove={onMove}
                    onReorder={onReorder}
                    onDuplicate={onDuplicate}
                    onDelete={onDelete}
                    onNodePropChange={onNodePropChange}
                  />
                ))}
                {interactive && !tabNodes.length && !option.content && (
                  <div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
                    {translate('::App.VisualDesignerCanvas.DropComponentInTab')}
                  </div>
                )}
              </div>
            </UiKit.Tabs.TabContent>
          )
        })}
      </UiKit.Tabs>
    )
  })()

  const tableContent =
    node.type === 'Table' ? (
      <UiKit.Table
        borderlessRow
        className={`${String(node.props.className || '')} [&>tbody>tr>td]:!px-1.5 [&>tbody>tr>td]:!py-1.5`}
        compact={Boolean(node.props.compact)}
        hoverable={node.props.hoverable !== false}
        overflow={node.props.overflow !== false}
        style={{
          minWidth: '100%',
          tableLayout: 'fixed',
          width: '100%',
        }}
      >
        <tbody>
          {Array.from({ length: getTableDimension(node.props.rowCount, 2) }, (_, rowIndex) => (
            <tr key={`row_${rowIndex}`}>
              {Array.from(
                { length: getTableDimension(node.props.columnCount, 3) },
                (_, columnIndex) => {
                  const slot = `table:${rowIndex}:${columnIndex}`
                  const cellNodes = node.children.filter(
                    (child, childIndex) =>
                      (child.slot ||
                        `table:${Math.floor(
                          childIndex / getTableDimension(node.props.columnCount, 3),
                        )}:${childIndex % getTableDimension(node.props.columnCount, 3)}`) === slot,
                  )
                  return (
                    <td
                      key={slot}
                      className={
                        overSlot === slot
                          ? 'border-2 border-emerald-500 bg-emerald-50 align-top dark:bg-emerald-950/40'
                          : dragging
                            ? 'border border-dashed border-sky-400 align-top'
                            : interactive
                              ? 'border border-slate-200 align-top dark:border-slate-700'
                              : 'align-top'
                      }
                      {...slotDropProps(slot)}
                      onDrop={(event) => {
                        if (!interactive) return
                        event.preventDefault()
                        event.stopPropagation()
                        setOverSlot(null)
                        endDesignerDrag()
                        const payload = readDesignerDragPayload(event)
                        if (!payload) return
                        if (payload.source === 'library' && payload.name) {
                          onDropComponent?.(payload.name, node.id, slot)
                        } else if (payload.source === 'canvas' && payload.nodeId) {
                          // Lets a component be dragged back into a cell, from
                          // another cell or from anywhere else on the canvas.
                          onMoveIntoContainer?.(payload.nodeId, node.id, slot)
                        }
                      }}
                    >
                      <div className="min-h-14 min-w-0 p-1">
                        {cellNodes.map((child, childIndex) => (
                          <NodeView
                            key={child.id}
                            node={child}
                            index={childIndex}
                            siblingCount={node.children.length}
                            selectedId={selectedId}
                            interactive={interactive}
                            renderCustomComponent={renderCustomComponent}
                            dataValues={dataValues}
                            currentItem={currentItem}
                            formScope={formScope}
                            onSelect={onSelect}
                            onDropComponent={onDropComponent}
                            onDropComponentBeside={onDropComponentBeside}
                            onMoveIntoContainer={onMoveIntoContainer}
                            onMove={onMove}
                            onReorder={onReorder}
                            onDuplicate={onDuplicate}
                            onDelete={onDelete}
                            onNodePropChange={onNodePropChange}
                          />
                        ))}
                        {interactive && !cellNodes.length && (
                          <div className="flex min-h-12 items-center justify-center rounded border border-dashed border-slate-300 px-2 text-center text-[10px] text-slate-400 dark:border-slate-700">
                            {translate('::App.VisualDesignerCanvas.DropComponent')}
                          </div>
                        )}
                      </div>
                    </td>
                  )
                },
              )}
            </tr>
          ))}
        </tbody>
      </UiKit.Table>
    ) : null
  const renderedChildren = sqlDataSourceContent
    ? sqlDataSourceContent
    : node.type === 'Tabs'
      ? tabsContent
      : node.type === 'Table'
        ? tableContent
        : node.type === 'Grid' && interactive && node.children.length === 0
          ? repeatedItems.length
            ? [
                <GridDataTablePreview
                  key={`grid_data_${node.id}`}
                  borderlessRow={Boolean(node.props.borderlessRow)}
                  compact={Boolean(node.props.compact)}
                  captions={getDesignerColumnCaptions(node)}
                  hoverable={node.props.hoverable !== false}
                  items={repeatedItems}
                  lookupIndexes={gridLookupIndexes}
                  overflow={node.props.overflow !== false}
                  selectedColumns={
                    Array.isArray(node.props.dataColumns)
                      ? node.props.dataColumns.filter(
                          (column): column is string => typeof column === 'string',
                        )
                      : undefined
                  }
                />,
              ]
            : [
                <GridColumnHeaders
                  key={`grid_headers_${node.id}`}
                  columns={
                    Array.isArray(node.props.dataColumns)
                      ? node.props.dataColumns.filter(
                          (column): column is string => typeof column === 'string',
                        )
                      : []
                  }
                />,
              ]
          : node.type === 'Grid' &&
              !interactive &&
              itemsBinding?.sourceId &&
              effectiveBoundItems === undefined
            ? [
                <div
                  key={`grid_loading_${node.id}`}
                  className="rounded-lg border border-slate-200 bg-slate-50 p-4 text-center text-xs text-slate-500 [grid-column:1/-1] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400"
                >
                  {translate('::App.VisualDesignerCanvas.LoadingData')}
                </div>,
              ]
            : node.type === 'Grid' &&
                itemsBinding?.sourceId &&
                effectiveBoundItems !== undefined &&
                !Array.isArray(effectiveBoundItems)
              ? [
                  <div
                    key={`grid_binding_error_${node.id}`}
                    className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200"
                  >
                    Grid items bağlantısı bir koleksiyon döndürmelidir. Seçili path:{' '}
                    <code>{itemsBinding.path || '(root)'}</code>
                  </div>,
                ]
              : node.type === 'Grid' && node.children.length === 0 && repeatedItems.length
                ? [
                    <GridDataTablePreview
                      key={`grid_data_${node.id}`}
                      borderlessRow={Boolean(node.props.borderlessRow)}
                      compact={Boolean(node.props.compact)}
                      captions={getDesignerColumnCaptions(node)}
                      hoverable={node.props.hoverable !== false}
                      items={repeatedItems}
                      lookupIndexes={gridLookupIndexes}
                      overflow={node.props.overflow !== false}
                      selectedColumns={
                        Array.isArray(node.props.dataColumns)
                          ? node.props.dataColumns.filter(
                              (column): column is string => typeof column === 'string',
                            )
                          : undefined
                      }
                    />,
                  ]
                : children
  // An empty container's placeholder is an explicit "inside" target, so it never
  // depends on where the pointer happens to sit within the node.
  const containerDropZone = useDesignerDropZone(interactive, (payload) => {
    if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, node.id)
    else if (payload.source === 'canvas' && payload.nodeId && payload.nodeId !== node.id) {
      onMoveIntoContainer?.(payload.nodeId, node.id)
    }
  })
  const containerDropZoneProps = containerDropZone.props
  // While a Card holds no child node the drop target stays visible below its own
  // content — otherwise a Card filled by its `children` text looks like it takes
  // no components. renderElement keeps that text above whatever is passed here.
  const contentChildren =
    interactive && node.type === 'Card' && node.children.length === 0
      ? [
          <div
            key={`card_drop_${node.id}`}
            className={`rounded border border-dashed px-3 py-4 text-center text-xs transition ${
              containerDropZone.over
                ? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
                : containerDropZone.dragging
                  ? 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
                  : 'border-slate-300 text-slate-400 dark:border-slate-700'
            } ${hasStaticChildren || hasChildrenBinding ? 'mt-3' : ''}`}
            {...containerDropZoneProps}
          >
            {containerDropZone.over
              ? translate('::App.VisualDesignerCanvas.WillDropHere', { label: dragLabel })
              : translate('::App.VisualDesignerCanvas.DropComponentHere')}
          </div>,
        ]
      : renderedChildren

  // A script hid this node. In preview it is gone, exactly like at runtime; while
  // designing it stays selectable so the property panel can still reach it.
  if (refHidden && !interactive) return null

  return (
    <DesignerDraggedSubtreeContext.Provider value={insideDraggedSubtree}>
      <div
        className={`group/node relative min-h-[28px] rounded-md ${refHidden ? 'opacity-40' : ''} ${
          // Sürüklenen düğümün kendisi soluklaşır; nereye gittiği hedeften okunur.
          isDragSource ? 'opacity-40' : ''
        } ${
          dropHint === 'inside'
            ? 'z-10 bg-emerald-500/10 outline outline-2 outline-emerald-500 outline-offset-2'
            : // Sürükleme sürerken içine bırakılabilen her konteyner işaretlenir, böylece
              // kullanıcı hedefe varmadan önce nereye bırakabileceğini görür.
              dragging && canDropInside
              ? 'outline-dashed outline-1 outline-sky-400/70 outline-offset-2'
              : interactive
                ? selected
                  ? 'z-10 outline outline-2 outline-sky-500 outline-offset-2'
                  : 'outline outline-1 outline-transparent hover:outline-sky-300'
                : ''
        }`}
        data-designer-node={node.id}
        draggable={interactive}
        onClickCapture={() => {
          if (interactive) onSelect?.(node.id)
        }}
        onClick={(event) => {
          if (!interactive) return
          event.stopPropagation()
          onSelect?.(node.id)
        }}
        onDragStart={(event) => {
          if (!interactive) return
          startNodeDrag(event)
        }}
        onDragEnd={() => endDesignerDrag()}
        onDragOver={(event) => {
          if (!interactive) return
          event.preventDefault()
          event.stopPropagation()
          // Must match the source's effectAllowed, otherwise the browser rejects
          // the drop and never fires onDrop.
          event.dataTransfer.dropEffect =
            event.dataTransfer.effectAllowed === 'copy' ? 'copy' : 'move'
          setDropHint(canDropBeside || canDropInside ? resolveDropHint(event) : null)
        }}
        onDragLeave={() => setDropHint(null)}
        onDrop={(event) => {
          if (!interactive) return
          event.preventDefault()
          event.stopPropagation()
          setDropHint(null)
          endDesignerDrag()
          const payload = readDesignerDragPayload(event)
          if (!payload) return
          // Bırakma noktası vurguyla birebir aynı kuraldan hesaplanır; kullanıcı
          // ekranda ne gördüyse o olur.
          applyDrop(payload, resolveDropHint(event))
        }}
      >
        {/* Üst ve alt boşluğu kaplayan görünmez "araya bırak" şeritleri. */}
        {dragging && canDropBeside && (
          <>
            <div className="absolute inset-x-0 -top-2 z-20 h-2" {...edgeStripProps('before')} />
            <div className="absolute inset-x-0 -bottom-2 z-20 h-2" {...edgeStripProps('after')} />
          </>
        )}
        {/*
        Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen kontrolle
        kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini başlatmaz; tutamak
        her bileşen için garanti bir tutma noktası verir. Sürükleme sürerken
        gizlenir, aksi halde komşu düğümün bırakma alanını kapatırdı.
      */}
        {/* Yanına bırakma çizgisi: bileşen tam olarak buraya girer. */}
        {dropHint === 'before' && (
          <div className="pointer-events-none absolute inset-x-0 -top-1 z-30 flex items-center gap-1">
            <span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
              {dragLabel} üstüne
            </span>
            <span className="h-[3px] flex-1 rounded bg-emerald-500" />
          </div>
        )}
        {dropHint === 'after' && (
          <div className="pointer-events-none absolute inset-x-0 -bottom-1 z-30 flex items-center gap-1">
            <span className="rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
              {dragLabel} altına
            </span>
            <span className="h-[3px] flex-1 rounded bg-emerald-500" />
          </div>
        )}
        {dropHint === 'inside' && (
          <span className="pointer-events-none absolute left-1 top-1 z-30 rounded bg-emerald-600 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
            {node.type} içine
          </span>
        )}
        {/* Sürükleme sürerken bırakılamayan hedef açıkça söylenir. */}
        {dragging && insideDraggedSubtree && !isDragSource && (
          <span className="pointer-events-none absolute right-1 top-1 z-30 rounded bg-slate-500 px-1.5 py-[1px] text-[10px] font-semibold text-white shadow">
            {translate('::App.VisualDesignerCanvas.CannotMoveHere')}
          </span>
        )}
        {interactive && (
          <div
            className={`absolute right-1 top-1 z-20 items-center overflow-hidden rounded-md bg-sky-600 text-white shadow-lg ${selected ? 'flex' : 'hidden'}`}
          >
            {/*
            Taşıma tutamağı. Bir Input ya da Select'in gövdesi tamamen canlı
            kontrolle kaplıdır ve tarayıcı oradan üst öğenin sürüklenmesini
            başlatmaz; bu rozet her bileşen için garanti bir tutma noktası verir.
          */}
            <span
              draggable
              className="flex cursor-grab items-center gap-1 px-2 text-[10px] font-semibold active:cursor-grabbing"
              title={translate('::App.VisualDesignerCanvas.DragToMove', {
                name: node.ref || node.type,
              })}
              onDragStart={startNodeDrag}
              onDragEnd={() => endDesignerDrag()}
            >
              <FaGripVertical /> {node.type}
            </span>
            <button
              className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
              disabled={!canMoveUp}
              title={translate(
                canMoveUp ? '::App.Platform.MoveUp' : '::App.VisualDesignerCanvas.AlreadyAtTop',
              )}
              type="button"
              onClick={(event) => {
                event.stopPropagation()
                onMove?.(node.id, -1)
              }}
            >
              <FaArrowUp />
            </button>
            <button
              className="p-1.5 enabled:hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-40"
              disabled={!canMoveDown}
              title={translate(
                canMoveDown
                  ? '::App.Platform.MoveDown'
                  : '::App.VisualDesignerCanvas.AlreadyAtBottom',
              )}
              type="button"
              onClick={(event) => {
                event.stopPropagation()
                onMove?.(node.id, 1)
              }}
            >
              <FaArrowDown />
            </button>
            <button
              className="p-1.5 hover:bg-sky-700"
              title={translate('::App.Platform.Duplicate')}
              type="button"
              onClick={(event) => {
                event.stopPropagation()
                onDuplicate?.(node.id)
              }}
            >
              <FaClone />
            </button>
            <button
              className="p-1.5 hover:bg-red-600"
              title={translate('::App.Platform.Delete')}
              type="button"
              onClick={(event) => {
                event.stopPropagation()
                onDelete?.(node.id)
              }}
            >
              <FaTrash />
            </button>
          </div>
        )}
        {interactive && refHidden && (
          <span className="absolute left-1 top-1 z-10 rounded bg-slate-700 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-white">
            Gizli
          </span>
        )}
        <PreviewBoundary
          name={node.type}
          resetKey={JSON.stringify([node.props, node.bindings, node.events])}
        >
          {renderElement(
            node,
            contentChildren,
            dataValues,
            currentItem,
            interactive,
            translate,
            onNodePropChange,
            renderCustomComponent,
            formScope,
            refs,
            refOverrides,
          )}
        </PreviewBoundary>
        {/* Tabs has a drop zone inside every tab, so it needs no outer placeholder. */}
        {interactive &&
          !hasVisibleChildren &&
          acceptsDroppedChildren &&
          node.type !== 'Card' &&
          node.type !== 'Tabs' &&
          // SqlDataSource renders its own placeholder below the toolbar.
          !isSqlDataSourceNode(node.type) && (
            <div
              className="m-2 rounded border border-dashed border-slate-300 px-3 py-4 text-center text-xs text-slate-400"
              {...containerDropZoneProps}
            >
              {translate('::App.VisualDesignerCanvas.DropComponentHere')}
            </div>
          )}
        <span className="hidden">{index}</span>
      </div>
    </DesignerDraggedSubtreeContext.Provider>
  )
}

const VisualCanvas = ({
  nodes,
  selectedId,
  interactive = true,
  onSelect,
  onDropComponent,
  onDropComponentBeside,
  onMoveIntoContainer,
  onMove,
  onReorder,
  onDuplicate,
  onDelete,
  onNodePropChange,
  renderCustomComponent,
  dataValues = {},
}: VisualCanvasProps) => {
  const { translate } = useLocalization()
  const previewDataValues = dataValues
  const [refState, setRefState] = React.useState<Record<string, DesignerRefOverride>>({})
  const nodesByRef = React.useMemo(() => {
    const map: Record<string, DesignerNode> = {}
    const visit = (list: DesignerNode[]) =>
      list.forEach((node) => {
        if (node.ref) map[node.ref] = node
        visit(node.children || [])
      })
    visit(nodes)
    return map
  }, [nodes])
  // Overrides are preview state, not document state: adding, removing or
  // renaming a component starts from a clean sheet, while editing a property
  // leaves whatever the last event script set in place.
  const refFingerprint = Object.keys(nodesByRef).sort().join('|')
  React.useEffect(() => setRefState({}), [refFingerprint])
  const drag = useDesignerDrag()
  // Sürükleme canvas dışında bırakılırsa da vurgular temizlenmeli.
  React.useEffect(() => {
    if (typeof window === 'undefined') return
    const clear = () => endDesignerDrag()
    window.addEventListener('dragend', clear)
    window.addEventListener('drop', clear)
    return () => {
      window.removeEventListener('dragend', clear)
      window.removeEventListener('drop', clear)
    }
  }, [])
  const rootDropZone = useDesignerDropZone(interactive, (payload) => {
    if (payload.source === 'library' && payload.name) onDropComponent?.(payload.name, null)
    // Kök alana bırakmak düğümü içinde bulunduğu konteynerden çıkarır.
    else if (payload.source === 'canvas' && payload.nodeId) {
      onMoveIntoContainer?.(payload.nodeId, null)
    }
  })

  const refStore = React.useMemo<DesignerRefStore>(
    () => ({
      state: refState,
      nodes: nodesByRef,
      patch: (ref, override) =>
        setRefState((current) => ({
          ...current,
          [ref]: {
            ...current[ref],
            ...override,
            // `props: null` resets; anything else is merged on top.
            props:
              override.props === null
                ? {}
                : { ...(current[ref]?.props || {}), ...(override.props || {}) },
          },
        })),
    }),
    [nodesByRef, refState],
  )

  return (
    <DesignerRefContext.Provider value={refStore}>
      <div
        className={`min-h-full p-8 text-slate-900 transition dark:text-slate-100 ${
          drag && interactive ? 'bg-sky-50/40 dark:bg-sky-950/20' : ''
        }`}
        onClick={() => interactive && onSelect?.('')}
        onDragOver={(event) => interactive && event.preventDefault()}
        onDrop={(event) => {
          if (!interactive) return
          event.preventDefault()
          endDesignerDrag()
          const raw =
            event.dataTransfer.getData(DESIGNER_DRAG_TYPE) ||
            event.dataTransfer.getData('text/plain')
          if (!raw) return
          const payload = JSON.parse(raw)
          if (payload.source === 'library') {
            onDropComponent?.(payload.name, null)
          } else if (payload.source === 'canvas' && payload.nodeId) {
            // Dropping on empty canvas takes the node out of its container.
            onMoveIntoContainer?.(payload.nodeId, null)
          }
        }}
      >
        {drag && interactive && (
          <div className="pointer-events-none sticky top-0 z-40 mb-3 flex items-center justify-center">
            <span className="rounded-full bg-slate-900/90 px-3 py-1 text-[11px] font-medium text-white shadow-lg">
              <b>{drag.label || drag.name}</b>{' '}
              {translate('::App.VisualDesignerCanvas.DraggingHint')}
            </span>
          </div>
        )}
        {nodes.length ? (
          <div className="space-y-4">
            {nodes.map((node, index) => (
              <NodeView
                key={node.id}
                isRoot
                node={node}
                index={index}
                siblingCount={nodes.length}
                selectedId={selectedId}
                interactive={interactive}
                renderCustomComponent={renderCustomComponent}
                dataValues={previewDataValues}
                onSelect={onSelect}
                onDropComponent={onDropComponent}
                onDropComponentBeside={onDropComponentBeside}
                onMoveIntoContainer={onMoveIntoContainer}
                onMove={onMove}
                onReorder={onReorder}
                onDuplicate={onDuplicate}
                onDelete={onDelete}
                onNodePropChange={onNodePropChange}
              />
            ))}
            {/* Sürükleme sürerken kök seviye açık bir hedef olur; aksi halde bir
              konteynerin dışına çıkmanın görünür bir yolu yok. */}
            {drag && interactive && (
              <div
                className={`rounded-lg border border-dashed px-3 py-4 text-center text-xs transition ${
                  rootDropZone.over
                    ? 'border-emerald-500 bg-emerald-50 font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-200'
                    : 'border-sky-400 bg-sky-50/60 text-sky-600 dark:bg-sky-950/40 dark:text-sky-300'
                }`}
                {...rootDropZone.props}
              >
                {translate('::App.VisualDesignerCanvas.DropAtPageEnd')}
              </div>
            )}
          </div>
        ) : (
          <div
            className={`flex min-h-[520px] items-center justify-center rounded-xl border bg-white/70 text-center transition dark:bg-slate-900/70 ${
              rootDropZone.over
                ? 'border-2 border-dashed border-emerald-500 bg-emerald-50/70 dark:bg-emerald-950/40'
                : drag && interactive
                  ? 'border-2 border-dashed border-sky-400'
                  : 'border-slate-300 dark:border-slate-700'
            }`}
            {...(interactive ? rootDropZone.props : {})}
          >
            <div className="w-full max-w-2xl px-6">
              <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-100 text-2xl text-sky-600">
                +
              </div>
              <h3 className="font-semibold text-slate-700 dark:text-slate-200">
                {translate('::App.VisualDesignerCanvas.ChoosePageLayout')}
              </h3>
              <p className="mt-1 text-sm text-slate-500">
                {translate('::App.VisualDesignerCanvas.ChoosePageLayoutHint')}
              </p>
              {interactive && (
                <div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-4">
                  {[
                    ['PageContainer', translate('::App.VisualDesignerLayout.PageContainer'), '□'],
                    ['TwoColumns', translate('::App.VisualDesignerLayout.TwoColumns'), '▥'],
                    [
                      'SidebarContent',
                      translate('::App.VisualDesignerLayout.SidebarContent'),
                      '◧',
                    ],
                    ['HeaderContent', translate('::App.VisualDesignerLayout.HeaderContent'), '⊟'],
                  ].map(([name, label, icon]) => (
                    <button
                      key={name}
                      className="rounded-lg border border-slate-200 bg-white p-3 text-xs font-semibold text-slate-600 shadow-sm transition hover:border-sky-400 hover:text-sky-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
                      type="button"
                      onClick={(event) => {
                        event.stopPropagation()
                        onDropComponent?.(name, null)
                      }}
                    >
                      <span className="mb-1 block text-xl text-sky-500">{icon}</span>
                      {label}
                    </button>
                  ))}
                </div>
              )}
            </div>
          </div>
        )}
      </div>
    </DesignerRefContext.Provider>
  )
}

export default VisualCanvas
