import {
  Children,
  isValidElement,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useState,
  type ComponentType,
  type ReactElement,
  type ReactNode,
} from 'react'
import { createPortal } from 'react-dom'
import {
  Box,
  Divider,
  Grow,
  IconButton,
  Paper,
  Popper,
  type SvgIconProps,
  type SxProps,
  type Theme,
} from '@mui/material'
import { Close as CloseIcon } from '@mui/icons-material'
import { WidgetOptions } from '@carto/meridian-ds/custom-icons'
import { Tooltip } from '../../components'
import { DEFAULT_TOOLBOX_LABELS, type ToolboxLabels } from './labels'
import { styles } from './style'

export interface ToolboxProps {
  /**
   * Maximum number of (non-`data-toolbar-hidden`) children rendered inline
   * when collapsed. Excess children are revealed via the overflow trigger
   * inside a floating Paper. When omitted, all children render inline.
   */
  visibleCount?: number
  labels?: Partial<ToolboxLabels>
  /**
   * Glyph used for the closed-state trigger. Defaults to the meridian-ds
   * `WidgetOptions` icon (matches v1's `ToolbarActions`).
   */
  icon?: ComponentType<SvgIconProps>
  iconProps?: SvgIconProps
  /**
   * Side the overflow Paper opens toward. `'right'` (default) places the
   * trigger on the left and the Paper covers the row to the right;
   * `'left'` is the mirror.
   */
  direction?: 'left' | 'right'
  sx?: SxProps<Theme>
  children: ReactNode
}

/**
 * Smart overflow toolbar with **single-mount** items. Children mount once
 * into a stable, hidden host element and are *physically reparented* via
 * `appendChild` between the inline preview area and the overflow `<Paper>`
 * as the popper opens / closes — same pattern as `Widget.FullScreen.Slot`.
 * Items with no inline space when closed are kept mounted but visually
 * hidden via `display: none`.
 *
 * Why this matters: every toggle's `useTransform` registers a config /
 * data transform on mount and removes it on unmount. If items remounted
 * on each open / close, the brief setup → cleanup window would re-emit
 * the pipeline (transform missing → transform present), causing a visible
 * flicker on state-bearing transforms (Stack, Zoom, RelativeData). With
 * stable mounts the transform stays registered across the whole popover
 * lifecycle.
 *
 * Children flagged with `data-toolbar-hidden` (e.g. dividers) skip the
 * visibility budget but still render in their natural position. Trailing
 * hidden items past the budget are excluded from the inline preview so
 * they don't appear as orphan separators in the inline row; they're still
 * there in the popover.
 */
export function Toolbox({
  visibleCount,
  labels,
  icon: Icon = WidgetOptions,
  iconProps,
  direction = 'right',
  sx,
  children,
}: ToolboxProps) {
  const _labels = { ...DEFAULT_TOOLBOX_LABELS, ...labels }
  // Each useState below is independent UI state — not "related state" that
  // `useReducer` would clean up. Justifications:
  //   - `open`: toggles the overflow popover, drives JSX rendering.
  //   - `anchorEl`: feeds `<Popper anchorEl>`; the Popper must re-render
  //     when the trigger element first attaches.
  //   - `inlineEl` / `paperEl`: read by the layout-effect dep array below
  //     to reparent the stable host between shells; useRef wouldn't fire
  //     the effect when the elements attach.
  //   - `host` (further down): see its own comment — kept in state to make
  //     the React Compiler happy reading it during render.
  const [open, setOpen] = useState(false)
  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null)
  const [inlineEl, setInlineEl] = useState<HTMLElement | null>(null)
  const [paperEl, setPaperEl] = useState<HTMLElement | null>(null)

  const handleToggle = useCallback(() => setOpen((v) => !v), [])
  const handleTriggerRef = useCallback((node: HTMLButtonElement | null) => {
    setAnchorEl(node)
  }, [])

  // Esc closes the overflow paper. Outside-click is intentionally NOT wired:
  // an action inside the Paper (e.g. RelativeData toggling, a Brush menu)
  // can update widget state which momentarily reflows the page; if that's
  // treated as a click-outside, the Paper would dismiss itself mid-action.
  // Users explicitly close via the trigger or Escape.
  useEffect(() => {
    if (!open) return undefined
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false)
    }
    document.addEventListener('keydown', onKey)
    return () => document.removeEventListener('keydown', onKey)
  }, [open])

  // Stable host element shared by every toolbox cycle. `display: contents`
  // keeps it transparent in flow so items inherit layout (flex row + gap)
  // from whichever shell currently parents the host (the inline preview
  // Box or the popover Paper). Held in state — not a ref — so the React
  // Compiler is happy reading it during render.
  const [host] = useState<HTMLDivElement | null>(() => {
    if (typeof document === 'undefined') return null
    const div = document.createElement('div')
    div.style.display = 'contents'
    return div
  })

  const items = Children.toArray(children).filter((c): c is ReactElement =>
    isValidElement(c),
  )

  const visibleCountActual = items.reduce(
    (n, c) => n + (isToolbarHidden(c) ? 0 : 1),
    0,
  )
  const showOverflow =
    visibleCount !== undefined && visibleCountActual > visibleCount

  // Indices that are visible inline when the popper is closed. When the
  // popper is open, every item is visible (in the popover). `null` means
  // "no overflow — all items visible inline".
  const inlineVisibleIndices = useMemo<Set<number> | null>(() => {
    if (!showOverflow || visibleCount === undefined) return null
    return computeInlineVisibleIndices(items, visibleCount)
  }, [items, visibleCount, showOverflow])

  // Auto-close the popper when overflow disappears (e.g. `visibleCount`
  // flips from a number to `undefined` because the consumer entered
  // fullscreen and wants every action visible inline). Without this the
  // popper component unmounts under us, `paperEl` goes null, and the host
  // would be stranded in a detached node — the toolbox visibly disappears.
  //
  // Uses React's documented "store information from previous renders"
  // pattern: a guarded inline `setOpen(false)` during render. The guard
  // fires at most once per transition (`open` flips false the same render
  // and the next render's predicate is false). React rebases the in-flight
  // render before commit, so the layout effect below sees `open=false`
  // immediately. See
  // https://react.dev/reference/react/useState#storing-information-from-previous-renders
  if (!showOverflow && open) {
    setOpen(false)
  }

  // Reparent the host into the active shell whenever `open` (or either
  // shell) changes. `useLayoutEffect` runs synchronously after commit
  // (after refs have been written), so the popover Paper ref is already
  // live when we read it. Falling back to `inlineEl` whenever `paperEl`
  // is missing handles cases where the popper has unmounted (showOverflow
  // flipped false) — the host always has a valid destination.
  useLayoutEffect(() => {
    const target = open && paperEl ? paperEl : inlineEl
    if (host && target && host.parentNode !== target) {
      target.appendChild(host)
    }
  }, [open, inlineEl, paperEl, host])

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

  const tooltipLabel = open ? _labels.close : _labels.trigger
  const flexDirection = direction === 'left' ? 'row-reverse' : 'row'

  return (
    <Box sx={{ ...styles.root, flexDirection, ...sx }}>
      {showOverflow && (
        <>
          <Tooltip title={tooltipLabel}>
            <IconButton
              ref={handleTriggerRef}
              size='small'
              aria-label={tooltipLabel}
              aria-pressed={open}
              onClick={handleToggle}
              className={open ? 'active' : undefined}
              sx={{ ...(open && styles.triggerActive) }}
            >
              {open ? (
                <CloseIcon fontSize='small' />
              ) : (
                <Icon fontSize='small' {...iconProps} />
              )}
            </IconButton>
          </Tooltip>
          <Divider orientation='vertical' flexItem sx={{ mx: 0.5 }} />
        </>
      )}

      {/* Inline shell: when closed (or when there's no overflow) the host
          lives here so first-N items appear inline beside the trigger. */}
      <Box ref={setInlineEl} sx={styles.preview} />

      {showOverflow && (
        <Popper
          open={open}
          anchorEl={anchorEl}
          placement={direction === 'left' ? 'left' : 'right'}
          transition
          // Disable flipping so the Paper stays on the configured side
          // even when the trigger sits near a viewport edge — wrapper
          // consumers expect a stable layout.
          modifiers={[{ name: 'flip', enabled: false }]}
          // Slot the popper *above* most modal stacking, but below MUI's
          // own modal layer.
          sx={{ zIndex: 'tooltip', mt: 0 }}
        >
          {({ TransitionProps }) => (
            <Grow
              {...TransitionProps}
              style={{
                transformOrigin:
                  direction === 'left' ? 'right center' : 'left center',
              }}
            >
              <Paper ref={setPaperEl} sx={styles.paper} />
            </Grow>
          )}
        </Popper>
      )}

      {/* Items render once into the stable host. The host's parent is
          whichever shell is currently active (inline or popper). When
          closed-with-overflow, items beyond the budget get `display: none`
          so they're mounted but invisible. The wrapper uses an inline
          `style` (not `sx`) so the visibility decision lands on the
          element itself — tests can inspect it without traversing
          generated MUI class names. */}
      {host
        ? createPortal(
            items.map((item, i) => {
              const visible =
                open || !inlineVisibleIndices || inlineVisibleIndices.has(i)
              const key = item.key ?? i
              return (
                <div
                  key={key}
                  data-toolbox-item-visible={visible ? 'true' : 'false'}
                  style={{ display: visible ? 'contents' : 'none' }}
                >
                  {item}
                </div>
              )
            }),
            host,
          )
        : null}
    </Box>
  )
}

function isToolbarHidden(child: ReactElement): boolean {
  // Opt-in flag callers set on slot children (e.g. dividers between groups)
  // so they don't count toward the inline visibility budget.
  const props = (
    child as unknown as { props: { 'data-toolbar-hidden'?: unknown } }
  ).props
  return props['data-toolbar-hidden'] === true
}

/**
 * Compute the set of item indices visible inline when the popper is closed.
 * Walk the items in order, accept each one until the visible budget is
 * exhausted; hidden items (dividers, etc.) accept without bumping the
 * counter. Strip trailing hidden items from the visible set so the inline
 * row doesn't end with an orphan separator.
 */
function computeInlineVisibleIndices(
  items: readonly ReactElement[],
  visibleCount: number,
): Set<number> {
  const indices: number[] = []
  let count = 0
  for (let i = 0; i < items.length; i++) {
    if (count >= visibleCount) break
    indices.push(i)
    if (!isToolbarHidden(items[i]!)) count++
  }
  // Strip trailing hidden items.
  while (
    indices.length > 0 &&
    isToolbarHidden(items[indices[indices.length - 1]!]!)
  ) {
    indices.pop()
  }
  return new Set(indices)
}
