import type { DashboardWidget, IndicatorAggregation } from "@geolibre/core";
import { MAX_DASHBOARD_COLUMNS, MIN_DASHBOARD_COLUMNS, useAppStore } from "@geolibre/core";
import { Button, Select } from "@geolibre/ui";
import {
  ChevronLeft,
  ChevronRight,
  LayoutDashboard,
  PanelBottomClose,
  PanelBottomOpen,
  Pencil,
  Plus,
  Trash2,
  X,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useTranslation } from "react-i18next";
import {
  distinctCategoryValues,
  filterRowsBySelections,
  numericValues,
  type CategorySelection,
  type ChartRow,
  type ChartType,
} from "../../lib/attribute-charts";
import { isChartableLayer, useLayerChartData } from "../../hooks/useLayerChartData";
import { ChartView, computeChart, type ChartSpec } from "./charts/chart-view";
import { WidgetEditorDialog } from "./WidgetEditorDialog";
import { PANEL_RESIZE_END_EVENT, PANEL_RESIZE_START_EVENT } from "../../lib/panel-resize";

const MIN_DASHBOARD_HEIGHT = 160;
const MAX_DASHBOARD_HEIGHT = 720;
const DEFAULT_DASHBOARD_HEIGHT = 360;
// Per-row floor once widgets wrap onto multiple rows; below it the panel
// scrolls instead of crushing the charts. A single row has no floor, so it
// fills and resizes with the panel height (issue #728).
const MIN_DASHBOARD_ROW_HEIGHT = 200;
// Shared empty selection, so an unselected widget gets a stable array identity
// and does not re-run its filter memo on every render.
const EMPTY_SELECTION: string[] = [];
const EMPTY_SELECTIONS: CategorySelection[] = [];

/** Compute the indicator value from layer data and an aggregation. Returns
 * null when there is no numeric data to aggregate. Count works on any layer. */
function computeIndicator(
  rows: ChartRow[],
  field: string | undefined,
  aggregation: IndicatorAggregation,
): number | null {
  if (aggregation === "count") {
    return rows.length;
  }
  if (!field) return null;
  // Reuse the shared coercion so numeric strings count, as they do in the charts.
  const values = numericValues(rows, field);
  if (values.length === 0) return null;
  switch (aggregation) {
    case "sum":
      return values.reduce((a, b) => a + b, 0);
    case "mean":
      return values.reduce((a, b) => a + b, 0) / values.length;
    case "min":
      return Math.min(...values);
    case "max":
      return Math.max(...values);
    case "median": {
      const sorted = [...values].sort((a, b) => a - b);
      const mid = Math.floor(sorted.length / 2);
      return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
    }
    default:
      return null;
  }
}

/** Format a number for display in the indicator tile. Large numbers get
 * locale-aware grouping; small numbers keep up to 2 decimal places. */
function formatIndicatorValue(value: number): string {
  if (Number.isInteger(value)) {
    return value.toLocaleString();
  }
  return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}

/** Turn a stored widget into the render-side {@link ChartSpec}. An indicator is
 * a KPI tile rather than a chart, so the caller passes the narrowed chart type
 * and skips this for `"indicator"` widgets. */
function widgetToSpec(widget: DashboardWidget, type: ChartType): ChartSpec {
  return {
    type,
    field: widget.field,
    xField: widget.xField,
    yField: widget.yField,
    bins: widget.bins,
    category: widget.category,
    aggregation: widget.aggregation,
    valueField: widget.valueField,
  };
}

/**
 * The Dashboard panel: a bottom-docked, resizable strip of chart widgets, each
 * bound to a layer and field(s), in the spirit of CARTO Builder / Foursquare
 * Studio (issue #401). Widgets are stored in the project, so a dashboard
 * reopens intact. Rendered only while open. Selector widgets cross-filter the
 * other widgets bound to the same layer (issue #1381); filtering the map itself
 * is still out of scope.
 */
export function DashboardPanel() {
  const { t } = useTranslation();
  const widgets = useAppStore((s) => s.widgets);
  const layers = useAppStore((s) => s.layers);
  const columns = useAppStore((s) => s.dashboardColumns);
  const setDashboardOpen = useAppStore((s) => s.setDashboardOpen);
  const setDashboardColumns = useAppStore((s) => s.setDashboardColumns);
  const addWidget = useAppStore((s) => s.addWidget);
  const replaceWidget = useAppStore((s) => s.replaceWidget);
  const removeWidget = useAppStore((s) => s.removeWidget);
  const moveWidget = useAppStore((s) => s.moveWidget);

  // Choices for the column-count picker, derived from the supported range.
  const columnOptions = useMemo(() => {
    const values: number[] = [];
    for (let n = MIN_DASHBOARD_COLUMNS; n <= MAX_DASHBOARD_COLUMNS; n += 1) {
      values.push(n);
    }
    return values;
  }, []);

  const sectionRef = useRef<HTMLElement>(null);
  const resizeCleanupRef = useRef<(() => void) | null>(null);
  const [height, setHeight] = useState(DEFAULT_DASHBOARD_HEIGHT);
  // Collapse the panel to just its header bar for a full map view, without
  // losing the last height (issue #459). The height is kept in state so an
  // expand restores the panel to exactly the size the user last dragged it to.
  const [isCollapsed, setIsCollapsed] = useState(false);
  const [editorOpen, setEditorOpen] = useState(false);
  const [editing, setEditing] = useState<DashboardWidget | null>(null);
  // Each selector widget's picked values, by widget id. Held here rather than
  // inside the selector so the other widgets on the same layer can filter by it
  // (issue #1381). Deliberately not part of the project: a selection is a way
  // of looking at the data, not a property of it, so it starts empty each time
  // the dashboard opens.
  const [selections, setSelections] = useState<Record<string, string[]>>({});

  const setSelection = (widgetId: string, values: string[]) => {
    setSelections((prev) => ({ ...prev, [widgetId]: values }));
  };
  const clearSelection = (widgetId: string) => {
    setSelections((prev) => {
      if (!(widgetId in prev)) return prev;
      const { [widgetId]: _dropped, ...rest } = prev;
      return rest;
    });
  };

  // The filters each widget should honor: every *other* selector bound to the
  // same layer. A selector never filters itself — its own chips stay complete
  // so a choice can always be changed or undone. Memoized so a widget's filter
  // list keeps its identity until the widgets or the selections actually move.
  const selectionsByWidget = useMemo(() => {
    const selectors = widgets.filter(
      (widget): widget is DashboardWidget & { category: string } =>
        widget.type === "selector" && Boolean(widget.category),
    );
    const byWidget: Record<string, CategorySelection[]> = {};
    for (const widget of widgets) {
      byWidget[widget.id] = selectors
        .filter((other) => other.id !== widget.id && other.layerId === widget.layerId)
        .map((other) => ({
          field: other.category,
          values: selections[other.id] ?? EMPTY_SELECTION,
        }));
    }
    return byWidget;
  }, [widgets, selections]);

  // Layers that expose chartable attributes, for the editor's layer picker.
  const chartableLayers = useMemo(
    () =>
      layers
        .filter((layer) => isChartableLayer(layer))
        .map((layer) => ({ id: layer.id, name: layer.name })),
    [layers],
  );

  const startResize = (event: ReactMouseEvent<HTMLDivElement>) => {
    event.preventDefault();
    event.stopPropagation();
    const startY = event.clientY;
    const startHeight = height;
    let nextHeight = startHeight;
    let frame: number | null = null;
    const prevCursor = document.body.style.cursor;
    const prevSelect = document.body.style.userSelect;
    document.body.style.cursor = "row-resize";
    document.body.style.userSelect = "none";
    window.dispatchEvent(new Event(PANEL_RESIZE_START_EVENT));

    const onMove = (moveEvent: MouseEvent) => {
      const available = Math.max(MIN_DASHBOARD_HEIGHT, window.innerHeight - 180);
      const maxHeight = Math.min(MAX_DASHBOARD_HEIGHT, available);
      nextHeight = Math.min(
        maxHeight,
        Math.max(MIN_DASHBOARD_HEIGHT, startHeight + startY - moveEvent.clientY),
      );
      if (frame !== null) return;
      frame = window.requestAnimationFrame(() => {
        frame = null;
        if (sectionRef.current) {
          sectionRef.current.style.height = `${nextHeight}px`;
        }
      });
    };

    const cleanup = () => {
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
      if (frame !== null) window.cancelAnimationFrame(frame);
      window.dispatchEvent(new Event(PANEL_RESIZE_END_EVENT));
      document.body.style.cursor = prevCursor;
      document.body.style.userSelect = prevSelect;
    };
    const onUp = () => {
      cleanup();
      resizeCleanupRef.current = null;
      setHeight(nextHeight);
    };

    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    resizeCleanupRef.current = cleanup;
  };

  // Tear down an in-flight drag if the panel unmounts mid-resize.
  useEffect(() => () => resizeCleanupRef.current?.(), []);

  const openAdd = () => {
    setEditing(null);
    setEditorOpen(true);
  };
  const openEdit = (widget: DashboardWidget) => {
    setEditing(widget);
    setEditorOpen(true);
  };
  const handleSave = (widget: DashboardWidget) => {
    const previous = widgets.find((w) => w.id === widget.id);
    if (previous) {
      // Drop a selection the edit invalidates: values picked from another
      // layer, another field, or in multi-select mode no longer apply once the
      // selector points somewhere else.
      if (
        previous.layerId !== widget.layerId ||
        previous.category !== widget.category ||
        previous.multiple !== widget.multiple
      ) {
        clearSelection(widget.id);
      }
      // The editor hands back a complete record, so replace rather than merge:
      // it omits the optional fields that were left empty, and merging would
      // keep the previous title/color/prefix/suffix instead of clearing them.
      const { id: _id, ...next } = widget;
      replaceWidget(widget.id, next);
    } else {
      addWidget(widget);
    }
  };

  // When widgets wrap onto multiple rows, floor the grid height (rows plus the
  // gap-3 gaps between them) so it scrolls rather than crushing the charts; a
  // single row stays unbounded and fills the panel (issue #728). calc() lets
  // the browser resolve 0.75rem so the gap tracks the root font size.
  const rowCount = Math.max(1, Math.ceil(widgets.length / Math.max(1, columns)));
  const gridMinHeight =
    rowCount > 1
      ? // 0.75rem is gap-3; keep in sync if the grid's gap class changes.
        `calc(${rowCount} * ${MIN_DASHBOARD_ROW_HEIGHT}px + ${rowCount - 1} * 0.75rem)`
      : undefined;

  return (
    <section
      ref={sectionRef}
      style={isCollapsed ? undefined : { height }}
      className="relative flex shrink-0 flex-col border-t bg-card"
    >
      {!isCollapsed ? (
        <div
          role="separator"
          aria-orientation="horizontal"
          aria-label={t("dashboard.resize")}
          aria-valuenow={Math.round(height)}
          aria-valuemin={MIN_DASHBOARD_HEIGHT}
          aria-valuemax={MAX_DASHBOARD_HEIGHT}
          tabIndex={0}
          className="absolute -top-1 left-0 right-0 z-20 h-2 cursor-row-resize select-none border-t border-transparent hover:border-primary focus-visible:border-primary focus-visible:outline-none"
          onMouseDown={startResize}
          onKeyDown={(event) => {
            // Arrow keys resize for keyboard-only users (Shift = larger step).
            const step = event.shiftKey ? 24 : 8;
            if (event.key === "ArrowUp") {
              setHeight((h) => Math.min(MAX_DASHBOARD_HEIGHT, h + step));
            } else if (event.key === "ArrowDown") {
              setHeight((h) => Math.max(MIN_DASHBOARD_HEIGHT, h - step));
            } else {
              return;
            }
            event.preventDefault();
          }}
        />
      ) : null}
      <div className="flex items-center gap-2 border-b px-3 py-1.5">
        <LayoutDashboard className="h-4 w-4 text-muted-foreground" />
        <span className="text-sm font-semibold">{t("dashboard.title")}</span>
        <span className="text-xs text-muted-foreground">
          {t("dashboard.widgetCount", { count: widgets.length })}
        </span>
        <div className="ms-auto flex items-center gap-2">
          {!isCollapsed && widgets.length > 0 ? (
            <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <span className="hidden sm:inline">{t("dashboard.columns")}</span>
              <Select
                aria-label={t("dashboard.columns")}
                className="h-8 w-16"
                value={String(columns)}
                onChange={(event) => setDashboardColumns(Number(event.target.value))}
              >
                {columnOptions.map((n) => (
                  <option key={n} value={n}>
                    {n}
                  </option>
                ))}
              </Select>
            </label>
          ) : null}
          {!isCollapsed ? (
            <Button
              variant="outline"
              size="sm"
              className="h-8 px-2"
              onClick={openAdd}
              disabled={chartableLayers.length === 0}
              title={
                chartableLayers.length === 0
                  ? t("dashboard.noLayersHint")
                  : t("dashboard.addWidget")
              }
            >
              <Plus className="h-3.5 w-3.5" />
              <span className="hidden sm:inline">{t("dashboard.addWidget")}</span>
            </Button>
          ) : null}
          <Button
            variant="ghost"
            size="icon"
            className="h-8 w-8"
            aria-label={isCollapsed ? t("dashboard.expand") : t("dashboard.collapse")}
            title={isCollapsed ? t("dashboard.expand") : t("dashboard.collapse")}
            onClick={() => setIsCollapsed((c) => !c)}
          >
            {isCollapsed ? (
              <PanelBottomOpen className="h-4 w-4" />
            ) : (
              <PanelBottomClose className="h-4 w-4" />
            )}
          </Button>
          <Button
            variant="ghost"
            size="icon"
            className="h-8 w-8"
            aria-label={t("dashboard.close")}
            title={t("dashboard.close")}
            onClick={() => setDashboardOpen(false)}
          >
            <X className="h-4 w-4" />
          </Button>
        </div>
      </div>

      {!isCollapsed ? (
        <div className="min-h-0 flex-1 overflow-auto p-3">
          {widgets.length === 0 ? (
            <div className="flex h-full flex-col items-center justify-center gap-1 text-center">
              <p className="text-sm text-muted-foreground">
                {chartableLayers.length === 0 ? t("dashboard.emptyNoLayers") : t("dashboard.empty")}
              </p>
            </div>
          ) : (
            <div
              className="grid h-full gap-3"
              style={{
                gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
                // Equal-height rows that shrink with the panel (issue #728).
                gridAutoRows: "minmax(0, 1fr)",
                minHeight: gridMinHeight,
              }}
            >
              {widgets.map((widget, index) => (
                <WidgetCard
                  key={widget.id}
                  widget={widget}
                  index={index}
                  count={widgets.length}
                  selected={selections[widget.id] ?? EMPTY_SELECTION}
                  onSelect={(values) => setSelection(widget.id, values)}
                  selections={selectionsByWidget[widget.id] ?? EMPTY_SELECTIONS}
                  onEdit={() => openEdit(widget)}
                  onRemove={() => {
                    clearSelection(widget.id);
                    removeWidget(widget.id);
                  }}
                  onMove={(toIndex) => moveWidget(widget.id, toIndex)}
                />
              ))}
            </div>
          )}
        </div>
      ) : null}

      <WidgetEditorDialog
        open={editorOpen}
        onOpenChange={setEditorOpen}
        widget={editing}
        layers={chartableLayers}
        onSave={handleSave}
      />
    </section>
  );
}

function WidgetCard({
  widget,
  index,
  count,
  selected,
  onSelect,
  selections,
  onEdit,
  onRemove,
  onMove,
}: {
  widget: DashboardWidget;
  index: number;
  count: number;
  selected: string[];
  onSelect: (values: string[]) => void;
  selections: CategorySelection[];
  onEdit: () => void;
  onRemove: () => void;
  onMove: (toIndex: number) => void;
}) {
  const { t } = useTranslation();
  const data = useLayerChartData(widget.layerId);
  // Rows narrowed by the other selectors on this layer. A selector reads the
  // unfiltered rows instead, so its own chip list always offers every value.
  const rows = useMemo(
    () => (widget.type === "selector" ? data.rows : filterRowsBySelections(data.rows, selections)),
    [data.rows, selections, widget.type],
  );
  const result = useMemo(
    () =>
      widget.type === "indicator" || widget.type === "selector" || widget.type === "list"
        ? null
        : computeChart(rows, widgetToSpec(widget, widget.type)),
    [rows, widget],
  );
  // A selector's chip values and its "N of total" counts. Memoized because the
  // render branch below is an IIFE: without this, every re-render of the card
  // (including ones driven by unrelated dashboard state) repeats a full scan of
  // the layer's rows.
  const selectorData = useMemo(() => {
    if (widget.type !== "selector" || !widget.category) return null;
    const category = widget.category;
    // Both counts are measured against the same baseline — the rows left by the
    // *other* selectors on this layer — so "N of total" never reads as a
    // fraction of the whole layer while another selector has already narrowed
    // it.
    const narrowed = filterRowsBySelections(rows, selections);
    return {
      values: distinctCategoryValues(rows, category),
      total: narrowed.length,
      matched:
        selected.length > 0
          ? filterRowsBySelections(narrowed, [{ field: category, values: selected }]).length
          : null,
    };
  }, [rows, selections, selected, widget.type, widget.category]);
  // A readable title from the widget's chart type and fields when untitled.
  const defaultWidgetTitle = (): string => {
    switch (widget.type) {
      case "histogram":
        return `${t("dashboard.chartType.histogram")} · ${widget.field ?? ""}`;
      case "scatter":
        return `${widget.yField ?? ""} / ${widget.xField ?? ""}`;
      case "bar": {
        const agg =
          widget.aggregation === "sum"
            ? t("dashboard.aggregate.sum")
            : widget.aggregation === "mean"
              ? t("dashboard.aggregate.mean")
              : t("dashboard.aggregate.count");
        return `${agg} · ${widget.category ?? ""}`;
      }
      case "line":
        return `${t("dashboard.chartType.line")} · ${widget.field ?? ""}`;
      case "box":
        return `${t("dashboard.chartType.box")} · ${widget.field ?? ""}`;
      case "pie":
        return `${t("dashboard.chartType.pie")} · ${widget.category ?? ""}`;
      case "indicator": {
        const agg = widget.indicatorAggregation ?? "count";
        const aggLabel = t(`dashboard.indicatorAggregation.${agg}`);
        return widget.field ? `${aggLabel} · ${widget.field}` : aggLabel;
      }
      case "selector":
        return `${t("dashboard.chartType.selector")} · ${widget.category ?? ""}`;
      case "list": {
        // The layer name is already the subtitle, so fall back to the chosen
        // columns rather than repeating it (or showing the internal layer id).
        const columns = widget.listFields?.join(", ") ?? "";
        return columns
          ? `${t("dashboard.chartType.list")} · ${columns}`
          : t("dashboard.chartType.list");
      }
    }
  };
  const title = widget.title?.trim() || defaultWidgetTitle();

  return (
    <div className="flex min-h-0 flex-col gap-2 overflow-hidden rounded-md border bg-background p-3">
      <div className="flex shrink-0 items-center gap-2">
        <div className="min-w-0 flex-1">
          <div className="truncate text-sm font-medium" title={title}>
            {title}
          </div>
          <div className="truncate text-xs text-muted-foreground" title={data.layerName}>
            {data.hasData ? data.layerName : t("dashboard.layerMissing")}
          </div>
        </div>
        <div className="flex shrink-0 items-center">
          <Button
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label={t("dashboard.moveBack")}
            title={t("dashboard.moveBack")}
            disabled={index === 0}
            onClick={() => onMove(index - 1)}
          >
            <ChevronLeft className="h-3.5 w-3.5 rtl:rotate-180" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label={t("dashboard.moveForward")}
            title={t("dashboard.moveForward")}
            disabled={index === count - 1}
            onClick={() => onMove(index + 1)}
          >
            <ChevronRight className="h-3.5 w-3.5 rtl:rotate-180" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label={t("dashboard.editWidget")}
            title={t("dashboard.editWidget")}
            onClick={onEdit}
          >
            <Pencil className="h-3.5 w-3.5" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            className="h-7 w-7"
            aria-label={t("dashboard.removeWidget")}
            title={t("dashboard.removeWidget")}
            onClick={onRemove}
          >
            <Trash2 className="h-3.5 w-3.5" />
          </Button>
        </div>
      </div>

      {/* Indicator widgets render a KPI tile instead of a chart. */}
      {widget.type === "indicator" ? (
        <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-1">
          {(() => {
            const agg = widget.indicatorAggregation ?? "count";
            const value = computeIndicator(rows, widget.field, agg);
            if (value === null) {
              return (
                <p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
              );
            }
            const formatted = formatIndicatorValue(value);
            const colorStyle = widget.color ? { color: widget.color } : undefined;
            return (
              <>
                <span className="text-3xl font-bold leading-none tracking-tight" style={colorStyle}>
                  {widget.prefix ?? ""}
                  {formatted}
                  {widget.suffix ?? ""}
                </span>
                <span className="text-xs text-muted-foreground">
                  {t(`dashboard.indicatorAggregation.${agg}`)}
                  {widget.field ? ` · ${widget.field}` : ""}
                </span>
              </>
            );
          })()}
        </div>
      ) : widget.type === "selector" ? (
        <div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-auto">
          {(() => {
            if (!data.hasData || !selectorData || selectorData.values.length === 0) {
              return (
                <p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
              );
            }

            // `matched` is how many features the dashboard is left looking at,
            // including this selector's own choice. Shown so a selection reads
            // as having done something even on a dashboard with no other widget
            // to filter — otherwise the chip highlight is the only feedback.
            return (
              <SelectorValues
                values={selectorData.values}
                multiple={widget.multiple ?? false}
                selected={selected}
                onChange={onSelect}
                matched={selectorData.matched}
                total={selectorData.total}
                matchLabel={(count, total) => t("dashboard.selectorMatches", { count, total })}
                onClear={() => onSelect(EMPTY_SELECTION)}
                clearLabel={t("dashboard.selectorClear")}
              />
            );
          })()}
        </div>
      ) : widget.type === "list" ? (
        <div className="flex min-h-0 flex-1 flex-col overflow-auto">
          {(() => {
            if (!data.hasData || !widget.listFields || widget.listFields.length === 0) {
              return (
                <p className="text-center text-xs text-muted-foreground">{t("dashboard.noData")}</p>
              );
            }
            // `rows` is the outer memo: already narrowed by the selectors on
            // this layer, so a list cross-filters like every other widget.
            const sortBy = widget.sortBy;
            const sortDir = widget.sortDir ?? "desc";
            const limit = widget.limit ?? 20;

            // Sort rows if sortBy is set.
            let sorted = rows;
            if (sortBy) {
              sorted = [...rows].sort((a, b) => {
                const av = (a as unknown as Record<string, unknown>)[sortBy];
                const bv = (b as unknown as Record<string, unknown>)[sortBy];
                // Numeric comparison if both values are numbers.
                const an = Number(av);
                const bn = Number(bv);
                if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) {
                  return sortDir === "asc" ? an - bn : bn - an;
                }
                // Fall back to string comparison.
                const as = String(av ?? "");
                const bs = String(bv ?? "");
                return sortDir === "asc" ? as.localeCompare(bs) : bs.localeCompare(as);
              });
            }

            return (
              <ListTable
                fields={widget.listFields}
                rows={sorted.slice(0, limit) as unknown as Record<string, unknown>[]}
              />
            );
          })()}
        </div>
      ) : (
        <div className="flex min-h-0 flex-1 flex-col [&>svg]:min-h-0 [&>svg]:flex-1">
          {data.hasData && result ? (
            <ChartView result={result} color={widget.color} />
          ) : (
            <p className="flex flex-1 items-center justify-center py-4 text-center text-xs text-muted-foreground">
              {t("dashboard.noData")}
            </p>
          )}
        </div>
      )}
    </div>
  );
}

/** Renders the selector widget body: a scrollable list of clickable value
 * chips. In single mode clicking a value selects it alone; in multi mode each
 * chip toggles independently, and clicking a selected chip clears it. Controlled
 * by the panel, which holds the selection so the other widgets on the layer can
 * filter by it. */
function SelectorValues({
  values,
  multiple,
  selected,
  onChange,
  matched,
  total,
  matchLabel,
  onClear,
  clearLabel,
}: {
  values: string[];
  multiple: boolean;
  selected: string[];
  onChange: (values: string[]) => void;
  /** Features left after this selection, or null when nothing is selected. */
  matched: number | null;
  total: number;
  matchLabel: (count: number, total: number) => string;
  onClear: () => void;
  clearLabel: string;
}) {
  const active = new Set(selected);

  const toggle = (value: string) => {
    if (active.has(value)) {
      onChange(selected.filter((entry) => entry !== value));
      return;
    }
    onChange(multiple ? [...selected, value] : [value]);
  };

  return (
    <>
      <div className="flex flex-wrap gap-1.5">
        {values.map((value) => {
          const isSelected = active.has(value);
          return (
            <button
              key={value}
              type="button"
              aria-pressed={isSelected}
              onClick={() => toggle(value)}
              className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
                isSelected
                  ? "border-primary bg-primary text-primary-foreground"
                  : "border-border bg-background text-muted-foreground hover:border-primary/50"
              }`}
            >
              {value}
            </button>
          );
        })}
      </div>
      {matched !== null ? (
        <div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
          {/* aria-live so the count is announced when a chip changes it; the
              chips themselves only convey selection, not its effect. */}
          <span aria-live="polite">{matchLabel(matched, total)}</span>
          <button
            type="button"
            onClick={onClear}
            className="rounded px-1.5 py-0.5 underline-offset-2 hover:underline"
          >
            {clearLabel}
          </button>
        </div>
      ) : null}
    </>
  );
}

/** Renders the list widget body: a compact scrollable HTML table showing the
 * selected columns for the top-N features (by sortBy/sortDir, limited by limit).
 * Like the selector, this does not yet participate in cross-filtering. */
function ListTable({ fields, rows }: { fields: string[]; rows: Record<string, unknown>[] }) {
  return (
    <table className="w-full border-collapse text-xs">
      <thead>
        <tr>
          {fields.map((f) => (
            <th
              key={f}
              className="border-b border-border px-1.5 py-1 text-left font-medium text-muted-foreground"
            >
              {f}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row, i) => (
          <tr key={i} className="hover:bg-muted/50">
            {fields.map((f) => {
              const v = row[f];
              return (
                <td key={f} className="border-b border-border/50 px-1.5 py-0.5">
                  {v === null || v === undefined ? "" : String(v)}
                </td>
              );
            })}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
