import { useCallback, useMemo, useRef, useState } from 'react'
import { Box, useTheme } from '@mui/material'
import { CategoryRowSingle } from './components/category-row-single'
import { CategoryRowMulti } from './components/category-row-multi'
import { CategoryRowStacked } from './components/category-row-stacked'
import { CategoryRowOther } from './components/category-row-other'
import { CategoryLegend } from './components/category-legend'
import type {
  CategoryKey,
  CategoryLabels,
  CategorySeriesConfig,
  CategorySize,
  CategoryWidgetData,
} from './types'
import { styles } from './style'

const DEFAULT_MAX_ITEMS = 20

// Fallback viewport height used in scroll mode ONLY when no natural
// measurement is available yet — i.e., the consumer mounts directly into
// `maxItems === 0` (e.g., SearcherToggle pre-enabled), so the list has
// never rendered in capped form for us to measure.
//
//   small: label(~20) + gap(4) + bar(4) + list-gap(8) ≈ 36
//   medium: label(~20) + gap(4) + bar(12) + list-gap(8) ≈ 44
const ROW_HEIGHT_PX: Record<CategorySize, number> = { small: 36, medium: 44 }
const SCROLL_VIEWPORT_FALLBACK_ROWS = 8

export interface CategoryUIProps {
  data: CategoryWidgetData
  /** Currently-selected category names (destination-owned). */
  selection?: readonly CategoryKey[]
  /** Fires with the next selection set when a row is clicked. */
  onSelectionChange?: (next: readonly CategoryKey[]) => void
  /** Number formatter applied to each row's value. */
  formatter?: (value: number) => string
  /**
   * Display-only transform for category names. Selection callbacks always
   * receive the raw `item.name` regardless of this formatter.
   */
  labelFormatter?: (value: string | number) => string | number
  /**
   * Per-series metadata. Enables the legend (for multi-series) and
   * overrides palette colors per series index. Length need not match
   * `data.length`; mismatches degrade gracefully (extras ignored, gaps
   * fall back to palette).
   */
  series?: readonly CategorySeriesConfig[]
  /**
   * Caps the number of visible category rows.
   *
   * - `undefined` (omitted) — caps at {@link DEFAULT_MAX_ITEMS} (20).
   *   Surplus rows fold into a single "Others <count>" footer row.
   *   `undefined` cannot mean "no cap" because it's consumed by the
   *   default-parameter syntax — pass `null` instead.
   * - positive finite N — same as the default but with a custom cap.
   * - `0` — **no cap WITH scroll**: every row renders inside a
   *   fixed-height viewport that scrolls internally. The viewport's
   *   max-height is the list's natural `clientHeight` measured on the
   *   first render where the list was NOT in scroll mode (i.e., the
   *   capped view) — frozen for the component's lifetime. If the
   *   consumer mounts directly into scroll mode and we never see a
   *   capped render, the viewport falls back to `8 × ROW_HEIGHT_PX`.
   *   Composers flip into this mode while the user is searching
   *   (`maxItems = searcherOpen ? 0 : userMaxItems`) so the widget
   *   card height stays stable.
   * - `null` / `Infinity` / negative — **no cap, no scroll**: render
   *   every row; the list grows with content. Matches v1's "undefined"
   *   behavior. Use `null` as the canonical explicit form.
   */
  maxItems?: number | null
  /** Labels for the "Other" overflow row. `{count}` placeholder is replaced. */
  labels?: CategoryLabels
  /**
   * When provided, the "Other" overflow row becomes a button — clicking it
   * fires `onShowAll`, which a composer typically wires to expand the widget
   * (drop the row cap) so every category becomes reachable. When omitted the
   * overflow row stays a static summary.
   */
  onShowAll?: () => void
  /** Manual override for the bar-width denominator. */
  maxOverride?: number
  /**
   * Visual density of the bar primitive. `'small'` (default) keeps the
   * historical 4px-tall pill; `'medium'` switches to a 12px-tall track
   * with a 2px corner radius. Only the bar track + fill change.
   */
  size?: CategorySize
  /**
   * Multi-series stacked mode. When `true` and `data.length > 1`, each
   * category renders as a single segmented bar (one segment per series)
   * with a `formatter(sum)` total in the header and a per-series
   * breakdown line below. Composers typically thread this from
   * `useTransformEnabled(id, 'stack-toggle')`.
   *
   * No-op for single-series data — same convention as the legend.
   */
  stacked?: boolean
}

interface GroupedRow {
  name: CategoryKey
  values: number[]
  /** Per-series per-row color override (first non-undefined wins). */
  rowColors: (string | undefined)[]
}

/**
 * Group items by `name` across series so each unique name produces one
 * row containing N values (zero-filled where a series has no entry for
 * that name). First-seen order across series is preserved.
 *
 * Ported from v1
 * (`packages/react-ui/src/widgets/category/category-ui.tsx:137-166`)
 * with readonly typing + per-row color capture.
 */
function generateGroupedData(data: CategoryWidgetData): GroupedRow[] {
  if (!data || data.length === 0) return []
  const seriesCount = Math.max(data.length, 1)
  const grouped = new Map<CategoryKey, GroupedRow>()
  const order: CategoryKey[] = []
  for (let s = 0; s < data.length; s++) {
    const series = data[s] ?? []
    for (const item of series) {
      let row = grouped.get(item.name)
      if (!row) {
        row = {
          name: item.name,
          values: new Array<number>(seriesCount).fill(0),
          rowColors: new Array<string | undefined>(seriesCount).fill(undefined),
        }
        grouped.set(item.name, row)
        order.push(item.name)
      }
      row.values[s] = item.value
      row.rowColors[s] = item.color
    }
  }
  return order.map((k) => grouped.get(k)!)
}

/**
 * Pure presentational component for the Category widget. Renders
 * single-series rows (`data.length === 1`) as a compact grid or
 * multi-series rows (`data.length > 1`) as stacked-bar groups, capped at
 * `maxItems` with an overflow summary, and an optional sticky color
 * legend when `series` metadata is supplied.
 *
 * Selection is destination-owned: clicking a row fires
 * `onSelectionChange(next)` with the toggled set; the consumer keeps the
 * list in their own store. `aria-pressed` + Enter/Space keyboard
 * activation give full a11y parity with the mouse path.
 *
 * Returns `null` when there's nothing to render — `Widget.State` is the
 * empty-state authority in the canonical compositor.
 */
export function CategoryUI({
  data,
  selection,
  onSelectionChange,
  formatter,
  labelFormatter,
  series,
  maxItems = DEFAULT_MAX_ITEMS,
  labels,
  onShowAll,
  maxOverride,
  size = 'small',
  stacked = false,
}: CategoryUIProps) {
  const theme = useTheme()
  const fmt = formatter ?? ((n: number) => String(n))

  const hasSelection = !!selection && selection.length > 0
  const selectionSet = useMemo(
    () => new Set<CategoryKey>(selection ?? []),
    [selection],
  )

  const toggle = useCallback(
    (name: CategoryKey) => {
      if (!onSelectionChange) return
      const cur = selection ?? []
      const next = cur.includes(name)
        ? cur.filter((n) => n !== name)
        : [...cur, name]
      onSelectionChange(next)
    },
    [onSelectionChange, selection],
  )

  // Palette: same convention as Bar/Pie/Histogram/Scatterplot/Timeseries v2.
  const paletteColors = useMemo<readonly string[]>(
    () => [
      theme.palette.secondary.main,
      ...Object.values(
        (theme.palette as { qualitative?: { bold?: Record<string, string> } })
          .qualitative?.bold ?? {},
      ),
    ],
    [theme],
  )

  const colorAt = useCallback(
    (seriesIndex: number, rowColor?: string): string => {
      if (rowColor) return rowColor
      const seriesColor = series?.[seriesIndex]?.color
      if (seriesColor) return seriesColor
      return (
        paletteColors[seriesIndex % paletteColors.length] ??
        theme.palette.secondary.main
      )
    },
    [series, paletteColors, theme],
  )

  // Dim color for non-selected rows when a selection exists. Swapped in
  // at the bar-fill level only — text + track stay at their real colors.
  // Theme-driven so it survives palette swaps.
  const dimColor = theme.palette.action.disabled

  const grouped = useMemo(() => generateGroupedData(data), [data])

  const maxValue = useMemo(() => {
    if (typeof maxOverride === 'number' && maxOverride > 0) return maxOverride
    let m = 0
    for (const row of grouped) {
      for (const v of row.values) if (v > m) m = v
    }
    return m
  }, [grouped, maxOverride])

  // Three modes:
  //   * positive finite N (default `DEFAULT_MAX_ITEMS = 20` when prop
  //     omitted): cap at N rows; surplus folds into "Others <count>".
  //   * `0`: no cap WITH scroll — every row renders, the list locks to a
  //     fixed viewport and scrolls internally. Composers use this to
  //     bypass pagination while the user is searching:
  //         `maxItems = searcherOpen ? 0 : userMaxItems`
  //   * `null` / `Infinity` / negative: no cap, no scroll — every row
  //     renders and the list grows with content. `null` is the canonical
  //     explicit form (`undefined` is consumed by the default param above).
  const scrollMode = maxItems === 0

  // Natural-height measurement. In scroll mode (`maxItems === 0`,
  // set by the composer when the SearcherToggle is open) every row
  // renders inside a scrollable viewport. The viewport's max-height
  // must match the pre-overflow content area — i.e., whatever the
  // list was rendering BEFORE the composer dropped the cap.
  //
  // We measure the list's `clientHeight` exactly once, the first time
  // it attaches while NOT in scroll mode (otherwise we'd capture the
  // wrong layout). Once set, `naturalHeight` is the lock anchor
  // forever — matches v1's frozen-at-mount semantics, just with a
  // real DOM measurement instead of a hard-coded constant.
  //
  // Implemented via a callback ref instead of `useLayoutEffect` so
  // setState fires during commit, not inside an effect — satisfies
  // the `react-hooks/set-state-in-effect` rule. React invokes
  // callback refs on attach (and re-invokes them when the callback
  // identity changes, which it does when `scrollMode` flips), so
  // toggling the searcher off after a scroll-mode mount triggers the
  // first measurement at that point.
  //
  // Fallback: if the consumer never lands a non-scroll render
  // (e.g., searcher pre-enabled and never closed), `naturalHeight`
  // stays `null` → viewport falls back to `8 × ROW_HEIGHT_PX`.
  const measuredRef = useRef(false)
  const [naturalHeight, setNaturalHeight] = useState<number | null>(null)
  const listRefCallback = useCallback(
    (node: HTMLDivElement | null) => {
      if (!node) return
      if (measuredRef.current) return
      if (scrollMode) return
      const h = node.clientHeight
      if (h > 0) {
        measuredRef.current = true
        setNaturalHeight(h)
      }
    },
    [scrollMode],
  )

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

  const hasCap =
    typeof maxItems === 'number' && Number.isFinite(maxItems) && maxItems > 0
  const visible = hasCap ? grouped.slice(0, maxItems) : grouped
  const hiddenCount = hasCap ? grouped.length - visible.length : 0

  const isMulti = data.length > 1
  const hasLegend = isMulti && !!series && series.length > 0

  // Fallback viewport height matches the documented "8 × ROW_HEIGHT_PX"
  // (see the constants at the top of the file). Earlier code multiplied
  // this by `seriesCount`, making a 3-series widget render 3× the
  // intended viewport on the first frame before `naturalHeight` was
  // measured — a visible layout jump.
  const scrollMaxHeight = scrollMode
    ? (naturalHeight ?? ROW_HEIGHT_PX[size] * SCROLL_VIEWPORT_FALLBACK_ROWS)
    : undefined

  return (
    <Box sx={styles.root}>
      <Box
        ref={listRefCallback}
        sx={scrollMode ? { ...styles.list, ...styles.listScroll } : styles.list}
        style={
          scrollMaxHeight !== undefined
            ? { maxHeight: scrollMaxHeight, overflowY: 'auto' }
            : undefined
        }
      >
        {visible.map((row) => {
          const selected = selectionSet.has(row.name)
          const dimmed = hasSelection && !selected
          const displayName = labelFormatter
            ? labelFormatter(row.name)
            : undefined
          // When the row is dimmed, every series' bar fill is replaced
          // with `dimColor`. Text + track are untouched. This is the
          // ONLY visual difference between selected and non-selected
          // rows — no row-level opacity, no `rowSelected` bg tint.
          const fill = (i: number): string =>
            dimmed ? dimColor : colorAt(i, row.rowColors[i])
          if (isMulti) {
            if (stacked) {
              // Stacked-mode branch: one segmented bar per row. Toggling
              // `stacked` after the natural-height measurement does NOT
              // re-trigger measurement (see the `naturalHeight` comment
              // above) — accepted trade-off: stacked rows have a slightly
              // different height, so the scroll viewport may show extra
              // whitespace after the toggle. Visual only, not functional.
              // Stacked rows are per-row normalized — they don't take
              // the global `maxValue`. Each row's bar fills 100% of the
              // track and the segments split it by `value_i / sum`. The
              // numeric total in the header preserves the per-row
              // magnitude information.
              return (
                <CategoryRowStacked
                  key={String(row.name)}
                  name={row.name}
                  displayName={displayName}
                  values={row.values}
                  colors={row.values.map((_, i) => fill(i))}
                  seriesNames={series?.map((s) => s.name) ?? []}
                  formatter={fmt}
                  selected={selected}
                  onToggle={toggle}
                  size={size}
                />
              )
            }
            return (
              <CategoryRowMulti
                key={String(row.name)}
                name={row.name}
                displayName={displayName}
                values={row.values}
                colors={row.values.map((_, i) => fill(i))}
                maxValue={maxValue}
                formatter={fmt}
                selected={selected}
                onToggle={toggle}
                size={size}
              />
            )
          }
          return (
            <CategoryRowSingle
              key={String(row.name)}
              name={row.name}
              displayName={displayName}
              value={row.values[0] ?? 0}
              maxValue={maxValue}
              color={fill(0)}
              formatter={fmt}
              selected={selected}
              onToggle={toggle}
              size={size}
            />
          )
        })}
        {hiddenCount > 0 && (
          <CategoryRowOther
            hiddenCount={hiddenCount}
            otherLabel={labels?.other}
            otherCountLabel={labels?.otherCount}
            showAllLabel={labels?.showAll}
            onShowAll={onShowAll}
          />
        )}
      </Box>
      {hasLegend && series && (
        <CategoryLegend series={series} colorAt={(i) => colorAt(i)} />
      )}
    </Box>
  )
}
