// ---------------------------------------------------------------------------
// viz / DashboardView.tsx  (was customer-grid/DashboardView.tsx — EXIT wave 2,
// W2-5/Y3: a MOVE. Only the import lines and `ChartCard`'s export changed.)
//
// Wave-8 I19c (contract C2) — the Dashboard display mode: a per-view board of
// charts computed from the SAME pipeline rows the grid paints, so the filters,
// the search and the cohort scope apply by construction. Change a condition and
// every chart moves with the grid's count, because it IS the grid's count.
//
// Rendering crosses one adapter boundary into Vega-Lite. Saved views and the
// arithmetic remain renderer-neutral, so the renderer can be replaced without
// migrating persisted chart definitions.
//
// Honesty (rule 8b) is rendered, not just computed: every card states its
// denominator, says when it dropped buckets past the cap and what they were
// worth, counts blank groups as a real "(blank)" bucket, and names the rows an
// average could not use. A chart that cannot be drawn says WHY in plain words
// instead of rendering an empty frame that reads as "no data".
// ---------------------------------------------------------------------------

import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import type { Field, Row } from "./types";
import {
  CHART_AGGS,
  CHART_AGG_LABELS,
  CHART_FORMATS,
  CHART_H_RANGE,
  CHART_W_RANGE,
  CHART_KINDS,
  CHART_PALETTES,
  MAX_AXIS_LABEL,
  MAX_BUCKETS,
  MAX_CHARTS,
  chartModel,
  PERIOD_KINDS,
  chartValueText,
  defaultChart,
  fieldOptions,
  groupableForChart,
  isMetricKey,
  isPeriodChart,
  measurableForChart,
} from "./chartData";
// Wave-16 C-CHARTCAP — the doors to wave-15's engines: the table kind's data and the KPI
// card's delta line. Pure assemblers, so `verify_charts` drives the same functions this
// component renders.
import { kpiYoyFromSeries, tableFromSpec } from "./salesParity";
import type { GroupTable, KpiTile } from "./salesParity";
import type {
  ChartAgg,
  ChartAxisSide,
  ChartModel,
  ChartFormat,
  ChartKind,
  ChartPalette,
  ChartSpec,
} from "./chartData";
import { toVegaLiteSpec } from "./vega/toVegaLiteSpec";
// ⚠ Still customer-grid's (Y3 amendment, 2026-07-30): the icon layer is ONE
// system with its own gate (verify_icons.py's token-parity leg), and a second
// set of chart-kind glyphs is exactly the drift that gate exists to catch.
import { ChartKindIcon } from "../customer-grid/icons";
// R3 — the trend path. The SAME channel the time-series view uses, deliberately: two
// implementations of "sales by month" that could disagree is the thing worth avoiding here,
// not the shared import. `TS_BUCKETS` is a value (a vocabulary), not a layering violation —
// viz already imports the grid's icon layer for the identical "one system, one gate" reason.
import { fetchTimeseries } from "../customer-grid/apiBridge";
import type { SurfaceScope } from "../customer-grid/apiBridge";
import {
  DEFAULT_TS_LAST_N,
  TS_YOY_BACK,
  buildTsRequest,
  buildTsTable,
  tsCompareChartModel,
  tsRowToChartModel,
} from "../customer-grid/timeSeriesData";
import type { TsPayload } from "../customer-grid/timeSeriesData";
import { TS_BUCKETS } from "../customer-grid/types";
import type { TsBucket } from "../customer-grid/types";
import "./DashboardView.css";

const VegaLiteChart = lazy(() => import("./vega/VegaLiteChart"));

/**
 * Wave-9 I11 (C2) — what each palette NAME means, in the user's words. The stored value is a
 * colour JOB, never a hex: a browser must not be able to post a literal, or a tenant restyle
 * is defeated by whatever somebody saved last year.
 * ⚠ STATUS colours are deliberately not offered — they are reserved signal, and reusing them
 * as "series 4" is how a chart starts lying.
 */
/** The board is a 12-column grid; a new card takes half of it. */
export const CHART_BOARD_COLS = 12;
const DEFAULT_CHART_W = 6;

function clampRange(n: number, [lo, hi]: readonly [number, number]): number {
  return Math.max(lo, Math.min(hi, n));
}

const PALETTE_LABELS: Record<ChartPalette, string> = {
  brand: "Brand",
  categorical: "Distinct categories",
  sequential: "Low to high",
  diverging: "Below / above a midpoint",
};

const FORMAT_LABELS: Record<ChartFormat, string> = {
  auto: "Automatic",
  number: "Plain number",
  currency: "Currency",
  percent: "Percent",
  compact: "Compact (1.2k)",
};

/**
 * Merge one side of the axis spec, dropping keys that have been emptied so a cleared label
 * does not persist as `""` — the host trims and drops it anyway, and a spec that disagrees
 * with what the host stored is a spec that churns on every echo.
 */
function withAxis(
  spec: ChartSpec,
  side: "x" | "y",
  patch: Partial<ChartAxisSide>
): ChartSpec {
  const merged: ChartAxisSide = { ...(spec.axis?.[side] ?? {}), ...patch };
  if (!merged.label?.trim()) delete merged.label;
  if (merged.format === "auto") delete merged.format;
  const axis = { ...(spec.axis ?? {}) };
  if (merged.label === undefined && merged.format === undefined) delete axis[side];
  else axis[side] = merged;
  return { ...spec, axis: axis.x || axis.y ? axis : undefined };
}

const KIND_LABELS: Record<ChartKind, string> = {
  bar: "Bar",
  line: "Line",
  area: "Area",
  donut: "Donut",
  kpi: "Single number",
  table: "Table",
};

// Wave-16: ONE agg vocabulary, shared with the table kind's column headers (chartData.ts).
const AGG_LABELS = CHART_AGG_LABELS;

/**
 * ── R3 (item 18): a chart card may be a TREND over time buckets ────────────────────────────
 *
 * Not a new chart kind — the same bar/line/area, drawn over PERIODS instead of over a
 * category. A chart whose number is a metric gains a Period control; setting one routes its
 * series through the time-series channel (`fetchTimeseries`) rather than through
 * `chartData`'s grouping, and clearing it puts the card back exactly where it was.
 *
 * ⚠ The 12-bucket cap in `chartData` stays CATEGORY law and is never applied here. That cap is
 * right for "the top 12 groups" and catastrophic for periods, where dropping buckets means
 * dropping MONTHS while the axis still reads like a complete run.
 *
 * Offered on bar / line / area only, and that is a judgment worth stating: a donut of months
 * is a part-of-whole claim about time that nobody makes, and a single-number card has no axis
 * to put periods on. The host narrows the same key from the other side — it keeps `bucket`
 * only when the chart's `y` is measure-backed (C-ACC).
 */
const PERIOD_LABELS: Record<TsBucket, string> = {
  week: "Weekly",
  month: "Monthly",
  quarter: "Quarterly",
  year: "Yearly",
};

const PERIOD_SPANS = [6, 12, 24, 36];

/**
 * Wave-16 C-CHARTCAP — how many buckets a card's request actually FETCHES.
 *
 * A compare card fetches `TS_YOY_BACK[bucket]` extra so every DRAWN bucket has a companion a
 * year back (`tsCompareChartModel` trims the picture back to the asked span). Without the
 * widening, a "Last 12, compared" monthly card would have nothing to compare its own first
 * eleven months against and the engine would refuse the whole series.
 */
function fetchLastN(spec: ChartSpec): number {
  const shown = spec.span?.lastN ?? DEFAULT_TS_LAST_N;
  if (spec.compare !== "prior_year") return shown;
  return shown + (TS_YOY_BACK[spec.bucket as TsBucket] ?? 12);
}

/** Charts sharing a period AND a FETCH span share ONE request; the endpoint takes many
 *  fields. The key is the fetch span, so a compare card (which fetches wider) never
 *  piggybacks on a plain card's narrower request. */
function periodGroupKey(spec: ChartSpec): string {
  return `${spec.bucket}|${fetchLastN(spec)}`;
}

/**
 * The KPI delta line's series: MONTHLY, latest bucket vs 12 back — the same-point-last-year
 * comparison (`pages_sales`' YTD-vs-LY-YTD law: a window slid to this month-end against the
 * same month-end a year earlier). A `year` bucket would compare a part-year against a full
 * one, which is the partial-vs-complete misread the monthly anchor avoids.
 *
 * Offered on SUM of a metric only: the time-series channel pools the metric by summing it,
 * so for avg/min/max the channel's number is a different statistic from the card's and the
 * delta would compare unlike things.
 */
const KPI_COMPARE_BUCKET: TsBucket = "month";
const KPI_COMPARE_FETCH = TS_YOY_BACK.month + 1;

function isKpiCompareSpec(spec: ChartSpec): boolean {
  return (
    spec.kind === "kpi" &&
    spec.compare === "prior_year" &&
    spec.agg === "sum" &&
    isMetricKey(spec.y)
  );
}

function kpiCompareGroupKey(): string {
  return `${KPI_COMPARE_BUCKET}|${KPI_COMPARE_FETCH}`;
}

function niceNumber(v: number): string {
  if (!Number.isFinite(v)) return "—";
  const abs = Math.abs(v);
  if (abs >= 1e9) return (v / 1e9).toFixed(1) + "B";
  if (abs >= 1e6) return (v / 1e6).toFixed(1) + "M";
  if (abs >= 1e3) return (v / 1e3).toFixed(1) + "k";
  return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(2);
}

/** One card: the picture, the title, the honesty footer and (when `onChange`
 *  is wired) the edit pane. Exported since W2-5/Y3 names it a public viz export
 *  — the Y1 `chart` block renders one of these per spec. Behaviour is unchanged;
 *  only the `export` keyword is new. */
export function ChartCard({
  spec,
  data,
  fields,
  yField,
  onChange,
  onRemove,
  canPeriod,
  table,
  kpiCompare,
}: {
  spec: ChartSpec;
  data: ChartModel;
  fields: Field[];
  yField?: Field;
  onChange: (next: ChartSpec) => void;
  onRemove: () => void;
  /**
   * R3 — may this card offer a PERIOD? False (the default) hides the control entirely.
   *
   * Absent means the trend channel is not wired where this card is mounted — no surface scope,
   * so no fetch. Offering the control anyway would let a user set a period and then read an
   * error message about the host, which is a dead end they were walked into. Hidden rather
   * than disabled: a dead control invites the click that does nothing, and the wave-13
   * Insights precedent this follows hid the whole tab for exactly this reason.
   */
  canPeriod?: boolean;
  /** Wave-16 — the `table` kind's data, computed by the caller from the SAME rows the other
   *  cards chart. Absent for every other kind; null while the spec cannot produce one. */
  table?: GroupTable | null;
  /** Wave-16 — the KPI delta line. `undefined` = not asked for; `null` = asked for but not
   *  answerable yet (loading, or the channel had no series) — either way no line renders,
   *  because a delta placeholder under a real number reads as a broken figure. */
  kpiCompare?: KpiTile | null;
}) {
  const [editing, setEditing] = useState(false);
  const groupable = useMemo(() => groupableForChart(fields), [fields]);
  const measurable = useMemo(() => measurableForChart(fields), [fields]);
  // ⛔ Every field picker below renders `fieldOptions(...)`, never the offered
  // list raw: a stored key that has fallen out of the offer (a retyped column, a
  // deleted one) MUST still appear, or the control silently disowns a chart that
  // is working. See `fieldOptions` for what each case is called and why.
  const xOptions = useMemo(() => fieldOptions(groupable, fields, spec.x), [groupable, fields, spec.x]);
  const splitOptions = useMemo(
    () => fieldOptions(groupable, fields, spec.splitBy),
    [groupable, fields, spec.splitBy]
  );
  const yOptions = useMemo(() => fieldOptions(measurable, fields, spec.y), [measurable, fields, spec.y]);
  // R3 — a trend's x axis is TIME, so the "by <field>" clause is replaced by the period. A
  // card titled "Sum of Sales" that is actually monthly reads as a single total.
  const period = isPeriodChart(spec) ? PERIOD_LABELS[spec.bucket as TsBucket] : undefined;
  const xLabel = period
    ? "Period"
    : spec.x
      ? fields.find((f) => f.key === spec.x)?.label
      : undefined;
  const by = period ? `, ${period.toLowerCase()}` : xLabel ? ` by ${xLabel}` : "";
  const auto =
    spec.agg === "count"
      ? `Count of records${by}`
      : `${AGG_LABELS[spec.agg]} ${yField?.label ?? "…"}${by}`;
  const rendererSpec = useMemo(
    () =>
      toVegaLiteSpec({
        spec,
        model: data,
        xTitle: spec.axis?.x?.label ?? xLabel,
        yTitle: spec.axis?.y?.label ?? (spec.agg === "count" ? "Records" : yField?.label),
        formatValue: (value) =>
          chartValueText(value, yField, spec.axis?.y?.format, niceNumber),
      }),
    [data, spec, xLabel, yField]
  );

  return (
    <div
      className="cg-ch-card"
      // I10 — the stored size. `w` is grid columns on a 12-column board, `h` is px; both
      // were already clamped by cleanCharts (and again host-side), so nothing here can put
      // a card outside the board.
      style={{
        gridColumn: `span ${spec.size?.w ?? DEFAULT_CHART_W}`,
        height: spec.size?.h ? `${spec.size.h}px` : undefined,
      }}
    >
      <div className="cg-ch-head">
        <span className="cg-ch-title" title={spec.title || auto}>
          {spec.title || auto}
        </span>
        <button
          type="button"
          className="cg-icon-btn cg-ch-cfg"
          aria-label="Configure this chart"
          aria-expanded={editing}
          onClick={() => setEditing((v) => !v)}
        >
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
            <path
              d="M3 4.5h10M3 8h10M3 11.5h10"
              stroke="currentColor"
              strokeWidth={1.3}
              strokeLinecap="round"
            />
          </svg>
        </button>
        <button
          type="button"
          className="cg-icon-btn cg-ch-del"
          aria-label="Remove this chart"
          onClick={onRemove}
        >
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
            <path
              d="M4 4l8 8M12 4l-8 8"
              stroke="currentColor"
              strokeWidth={1.3}
              strokeLinecap="round"
            />
          </svg>
        </button>
      </div>

      {editing && (
        <div className="cg-ch-cfgbody">
          {/* I16 — every chart type carries an icon. NOT a native <select>: an <option>
              cannot render SVG and unicode glyphs are gate-banned (wave 8 ruled this a real
              limit, not something to "solve" with a glyph), so the picker is a radio-row
              list — the same shape the mode switcher uses for the same reason. */}
          <div className="cg-ch-row cg-ch-kindrow" role="radiogroup" aria-label="Chart type">
            <span>Chart</span>
            <div className="cg-ch-kinds">
              {CHART_KINDS.map((k) => (
                <label
                  key={k}
                  className={"cg-ch-kind" + (spec.kind === k ? " is-on" : "")}
                  title={KIND_LABELS[k]}
                >
                  <input
                    type="radio"
                    name={`cg-ch-kind-${spec.id}`}
                    checked={spec.kind === k}
                    onChange={() => onChange({ ...spec, kind: k })}
                  />
                  <ChartKindIcon kind={k} />
                  <span>{KIND_LABELS[k]}</span>
                </label>
              ))}
            </div>
          </div>
          {/* Item 18's wording sweep — "Measure" was the internal word for the mechanism
              (wave-13 C-NAME: the wire says measure, the screen says Metric), and this row
              does not pick one: it picks what to DO with the numbers. */}
          <label className="cg-ch-row">
            <span>Show</span>
            <select
              className="cg-select"
              value={spec.agg}
              onChange={(e) => {
                const agg = e.target.value as ChartAgg;
                onChange({
                  ...spec,
                  agg,
                  // Wave-16: the KPI delta is a SUM-only comparison (the TS channel pools a
                  // metric by summing it), so moving the card off "sum" takes the compare
                  // with it — the period-belongs-to-a-metric law, one row down.
                  ...(spec.kind === "kpi" && agg !== "sum" ? { compare: undefined } : {}),
                });
              }}
            >
              {CHART_AGGS.map((a) => (
                <option key={a} value={a}>
                  {AGG_LABELS[a]}
                </option>
              ))}
            </select>
          </label>
          {spec.agg !== "count" && (
            <label className="cg-ch-row">
              <span>Which number</span>
              <select
                className="cg-select"
                value={spec.y ?? ""}
                onChange={(e) =>
                  onChange({
                    ...spec,
                    y: e.target.value || undefined,
                    // R3 — a period belongs to a METRIC. Moving the card onto an ordinary
                    // number must take the period with it, or the spec keeps a key the host
                    // drops and the control reads set for a chart that cannot use it (the
                    // `stacked`-beside-`splitBy` precedent two rows down). Wave-16: the
                    // compare rides the same rule — both its forms need a metric.
                    ...(isMetricKey(e.target.value)
                      ? {}
                      : { bucket: undefined, span: undefined, compare: undefined }),
                  })
                }
              >
                <option value="">Choose a number…</option>
                {yOptions.map((o) => (
                  <option key={o.key} value={o.key}>
                    {o.label}
                  </option>
                ))}
              </select>
            </label>
          )}

          {/* ── R3 (item 18): the period controls. Present on every card whose number is a
                metric; absent everywhere else, where they would promise a time axis the
                field does not have. */}
          {canPeriod && isMetricKey(spec.y) && PERIOD_KINDS.includes(spec.kind) && (
            <>
              <label className="cg-ch-row">
                <span>Over time</span>
                <select
                  className="cg-select"
                  value={spec.bucket ?? ""}
                  onChange={(e) =>
                    onChange({
                      ...spec,
                      bucket: e.target.value || undefined,
                      span: e.target.value ? spec.span : undefined,
                      // Wave-16: the compare SERIES is a claim about periods, so clearing
                      // the period clears it — a kept key would resurrect a companion line
                      // the moment a bucket comes back, which nobody asked for twice.
                      compare: e.target.value ? spec.compare : undefined,
                    })
                  }
                >
                  {/* An explicit empty option, never a bare first one — a <select> missing
                      its value renders the FIRST option and silently claims a choice. */}
                  <option value="">No — group by a field</option>
                  {TS_BUCKETS.map((b) => (
                    <option key={b} value={b}>
                      {PERIOD_LABELS[b]}
                    </option>
                  ))}
                </select>
              </label>
              {!!spec.bucket && (
                <label className="cg-ch-row">
                  <span>How far back</span>
                  <select
                    className="cg-select"
                    value={String(spec.span?.lastN ?? DEFAULT_TS_LAST_N)}
                    onChange={(e) =>
                      onChange({ ...spec, span: { lastN: Number(e.target.value) } })
                    }
                  >
                    {PERIOD_SPANS.map((n) => (
                      <option key={n} value={n}>
                        Last {n}
                      </option>
                    ))}
                  </select>
                </label>
              )}
              {/* ── Wave-16 C-CHARTCAP (a): the compare toggle — the door to
                    `buildTsCompare`. The request widens by a year so every drawn bucket has
                    its companion; the picture stays the asked span. */}
              {!!spec.bucket && (
                <label className="cg-ch-row cg-ch-check">
                  <input
                    type="checkbox"
                    checked={spec.compare === "prior_year"}
                    onChange={(e) =>
                      onChange({
                        ...spec,
                        compare: e.target.checked ? "prior_year" : undefined,
                      })
                    }
                  />
                  <span>Compare to the year before</span>
                </label>
              )}
            </>
          )}

          {/* ── Wave-16 C-CHARTCAP (b): the KPI delta — the door to `kpiYoyFromSeries`.
                Sum-of-metric only: the TS channel pools a metric by summing, so any other
                aggregate would compare unlike statistics (the rule stated on
                KPI_COMPARE_BUCKET). */}
          {spec.kind === "kpi" &&
            canPeriod &&
            spec.agg === "sum" &&
            isMetricKey(spec.y) && (
              <label className="cg-ch-row cg-ch-check">
                <input
                  type="checkbox"
                  checked={spec.compare === "prior_year"}
                  onChange={(e) =>
                    onChange({
                      ...spec,
                      compare: e.target.checked ? "prior_year" : undefined,
                    })
                  }
                />
                <span>Compare to the year before</span>
              </label>
            )}

          {/* A trend's x axis IS time, so the grouping picker would be a second, contradictory
              answer to the same question. Hidden rather than disabled: a dead control invites
              the click that does nothing. Wave-16: gated on the kind actually BEING a period
              chart, so a table/donut carrying a stale bucket (kind switched off a trend) does
              not lose the one control that gives it a grouping. */}
          {spec.kind !== "kpi" &&
            !(canPeriod && spec.bucket && PERIOD_KINDS.includes(spec.kind)) && (
            <label className="cg-ch-row">
              <span>Grouped by</span>
              <select
                className="cg-select"
                value={spec.x ?? ""}
                onChange={(e) =>
                  onChange({
                    ...spec,
                    x: e.target.value || undefined,
                    // ⛔ `cleanCharts` drops a `splitBy` equal to `x` ON READ, but
                    // nothing enforced it at the door that can CREATE the
                    // collision — so picking the split column as the axis left
                    // the split stored and filtered out of its own picker, which
                    // is this ticket's defect inside the control that fixes it.
                    // One rule, now enforced at both ends.
                    splitBy: e.target.value && e.target.value === spec.splitBy ? undefined : spec.splitBy,
                  })
                }
              >
                <option value="">Choose a field…</option>
                {xOptions.map((o) => (
                  <option key={o.key} value={o.key}>
                    {o.label}
                  </option>
                ))}
              </select>
            </label>
          )}
          <label className="cg-ch-row">
            <span>Title</span>
            <input
              className="cg-input"
              value={spec.title ?? ""}
              placeholder={auto}
              maxLength={60}
              onChange={(e) => onChange({ ...spec, title: e.target.value || undefined })}
            />
          </label>

          {/* ── I11 (C2): colour and stacking — the two the owner named, first. ────────── */}
          {spec.kind !== "kpi" &&
            spec.kind !== "donut" &&
            spec.kind !== "table" &&
            !(canPeriod && spec.bucket && PERIOD_KINDS.includes(spec.kind)) && (
            <label className="cg-ch-row">
              <span>Break down by</span>
              <select
                className="cg-select"
                value={spec.splitBy ?? ""}
                onChange={(e) =>
                  onChange({
                    ...spec,
                    splitBy: e.target.value || undefined,
                    // Stacking is only a question once there ARE series. Clearing the split
                    // must clear the stack with it, or the spec keeps a flag the host drops
                    // and the checkbox reads true for a chart that cannot stack.
                    stacked: e.target.value ? spec.stacked : undefined,
                  })
                }
              >
                {/* An explicit empty option, never a bare first one — a <select> missing its
                    value renders the FIRST option and silently claims a choice. */}
                <option value="">Don't break it down</option>
                {/* ⚠ The x column is filtered out — splitting a chart by the same
                    column it is grouped by draws one series per bar. A stored
                    splitBy survives that filter only because BOTH doors now keep
                    `splitBy !== x`: `cleanCharts` on read, and the x picker's own
                    onChange above. Neither alone is enough. */}
                {splitOptions
                  .filter((o) => o.key !== spec.x)
                  .map((o) => (
                    <option key={o.key} value={o.key}>
                      {o.label}
                    </option>
                  ))}
              </select>
            </label>
          )}
          {!!spec.splitBy && (spec.kind === "bar" || spec.kind === "area") && (
            <label className="cg-ch-row cg-ch-check">
              <input
                type="checkbox"
                checked={spec.stacked === true}
                onChange={(e) => onChange({ ...spec, stacked: e.target.checked || undefined })}
              />
              <span>Stack them on top of each other</span>
            </label>
          )}
          {/* A table paints no series, so a palette row would be a control that does
              nothing — hidden, same rule as every other dead control here. */}
          {spec.kind !== "table" && (
            <label className="cg-ch-row">
              <span>Colours</span>
              <select
                className="cg-select"
                value={spec.palette ?? ""}
                onChange={(e) =>
                  onChange({ ...spec, palette: (e.target.value || undefined) as ChartPalette })
                }
              >
                <option value="">Default</option>
                {CHART_PALETTES.map((pk) => (
                  <option key={pk} value={pk}>
                    {PALETTE_LABELS[pk]}
                  </option>
                ))}
              </select>
            </label>
          )}

          {/* ── I10 (C2): axis labels and number format. ─────────────────────────────── */}
          {spec.kind !== "kpi" && spec.kind !== "table" && (
            <>
              <label className="cg-ch-row">
                <span>Bottom axis label</span>
                <input
                  className="cg-input"
                  value={spec.axis?.x?.label ?? ""}
                  placeholder={xLabel ?? "Category"}
                  maxLength={MAX_AXIS_LABEL}
                  onChange={(e) => onChange(withAxis(spec, "x", { label: e.target.value }))}
                />
              </label>
              <label className="cg-ch-row">
                <span>Side axis label</span>
                <input
                  className="cg-input"
                  value={spec.axis?.y?.label ?? ""}
                  placeholder={yField?.label ?? "Value"}
                  maxLength={MAX_AXIS_LABEL}
                  onChange={(e) => onChange(withAxis(spec, "y", { label: e.target.value }))}
                />
              </label>
            </>
          )}
          <label className="cg-ch-row">
            <span>Number format</span>
            <select
              className="cg-select"
              value={spec.axis?.y?.format ?? "auto"}
              onChange={(e) =>
                onChange(withAxis(spec, "y", { format: e.target.value as ChartFormat }))
              }
            >
              {CHART_FORMATS.map((f) => (
                <option key={f} value={f}>
                  {FORMAT_LABELS[f]}
                </option>
              ))}
            </select>
          </label>
        </div>
      )}

      {spec.kind === "table" ? (
        // ── Wave-16 C-CHARTCAP (c): the table kind — the door to `groupTable`. A real HTML
        // table, not a Vega mark: a table's job is exact values in rows, which an axis
        // grammar can only imitate.
        !table || table.problem ? (
          <div className="cg-ch-empty">{table?.problem ?? "Choose a field to group by."}</div>
        ) : table.rows.length === 0 ? (
          <div className="cg-ch-empty">
            No records match this view, so there is nothing to list.
          </div>
        ) : (
          <div className="cg-ch-viz cg-ch-viz--table">
            <GroupTableView table={table} xLabel={xLabel ?? "Group"} yField={yField} />
          </div>
        )
      ) : data.pending ? (
        /* wave17 GRID — item 3 / R6. BEFORE `problem`, on purpose: a card still waiting has
           nothing to refuse yet, and showing a refusal that resolves itself a moment later is
           the reading error this branch exists to end. */
        <div className="cg-ch-empty">
          <span className="lp-spin" role="status" aria-label="Loading" />
        </div>
      ) : data.problem ? (
        <div className="cg-ch-empty">{data.problem}</div>
      ) : data.points.length === 0 ? (
        <div className="cg-ch-empty">No records match this view, so there is nothing to chart.</div>
      ) : (
        <div className={`cg-ch-viz cg-ch-viz--${spec.kind}`}>
          {/* R6: the renderer chunk's own wait, same bare icon as every other load. */}
          <Suspense
            fallback={
              <div className="cg-ch-loading">
                <span className="lp-spin" role="status" aria-label="Loading" />
              </div>
            }
          >
            <VegaLiteChart spec={rendererSpec} label={spec.title || auto} />
          </Suspense>
        </div>
      )}

      {/* ── Wave-16 C-CHARTCAP (b): the delta line under a KPI number. Renders ONLY when the
            comparison has an answer — `kpiTile`'s refusals arrive as `note` text, so "no
            orders yet" is said in words rather than shown as −100%. */}
      {spec.kind === "kpi" && kpiCompare && (
        <div
          className={`cg-ch-kpidelta is-${kpiCompare.dir}`}
          title={
            kpiCompare.prior != null
              ? `This period ${chartValueText(kpiCompare.value, yField, spec.axis?.y?.format, niceNumber)} · a year earlier ${chartValueText(kpiCompare.prior, yField, spec.axis?.y?.format, niceNumber)} (month-end anchored)`
              : undefined
          }
        >
          {kpiCompare.delta != null ? (
            <>
              <svg width="10" height="10" viewBox="0 0 14 14" aria-hidden>
                {kpiCompare.dir === "up" ? (
                  <path d="M3 9.5l4-5 4 5z" fill="currentColor" />
                ) : kpiCompare.dir === "down" ? (
                  <path d="M3 4.5l4 5 4-5z" fill="currentColor" />
                ) : (
                  <path d="M3 7h8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
                )}
              </svg>
              <span>
                {Math.abs(kpiCompare.delta).toFixed(1)}% {kpiCompare.deltaLabel}
              </span>
            </>
          ) : (
            <span>{kpiCompare.note}</span>
          )}
        </div>
      )}

      {/* The card's footnotes — every one of them is a disclosure, not decoration. */}
      <div className="cg-ch-foot">
        <span>{data.rows.toLocaleString()} records</span>
        {spec.kind === "table" && table && !table.problem ? (
          // The table's own disclosures — the category model's footnotes describe a chart
          // this card is not drawing, so they are replaced, not appended.
          <>
            {table.shown < table.total && (
              <span
                title={`${(table.total - table.shown).toLocaleString()} more groups are not listed. The table is sorted by its first column, largest first.`}
              >
                · top {table.shown.toLocaleString()} of {table.total.toLocaleString()} groups
              </span>
            )}
            {table.blanks > 0 && (
              <span>· {table.blanks.toLocaleString()} with no value grouped as blank</span>
            )}
          </>
        ) : (
          <>
            {data.omitted > 0 && (
              <span title={`The ${data.omitted.toLocaleString()} smallest groups are not drawn. Together they account for ${niceNumber(data.omittedValue)}.`}>
                · top {MAX_BUCKETS} of {(data.xOrder.length + data.omitted).toLocaleString()} groups
                shown (+{niceNumber(data.omittedValue)} not drawn)
              </span>
            )}
            {data.omittedSeries > 0 && (
              <span
                title={`${data.omittedSeries.toLocaleString()} smaller series are not drawn. Together they account for ${niceNumber(data.omittedSeriesValue)}.`}
              >
                · {data.omittedSeries.toLocaleString()} series not drawn
              </span>
            )}
            {data.nonPositive > 0 && (
              <span
                title={`Donut slices require positive values. These groups have a combined value of ${niceNumber(data.nonPositiveValue)} and are not included in the circle.`}
              >
                · {data.nonPositive.toLocaleString()} zero/negative group
                {data.nonPositive === 1 ? "" : "s"} not drawn
              </span>
            )}
            {data.blanks > 0 && <span>· {data.blanks.toLocaleString()} with no value grouped as blank</span>}
            {data.missingY > 0 && (
              <span title="These records carry no number for the measured field, so they are counted in the group but excluded from the aggregate.">
                · {data.missingY.toLocaleString()} not measurable
              </span>
            )}
          </>
        )}
      </div>

      {/*
        I10 — "drag width/length". Pointer events, not a drag-and-drop payload: this resizes
        an element, it never transfers anything, and `setPointerCapture` keeps the gesture
        alive when the pointer leaves the card (a drag that dies at the card edge is the
        commonest resize bug).
        Keyboard-reachable too: arrows resize, so the feature is not mouse-only.
      */}
      <button
        type="button"
        className="cg-ch-resize"
        aria-label={`Resize ${spec.title || auto}`}
        title="Drag to resize · arrow keys also work"
        onPointerDown={(e) => {
          e.preventDefault();
          e.currentTarget.setPointerCapture(e.pointerId);
          const card = e.currentTarget.parentElement as HTMLElement | null;
          const board = card?.parentElement as HTMLElement | null;
          if (!card || !board) return;
          const startX = e.clientX;
          const startY = e.clientY;
          const startW = spec.size?.w ?? DEFAULT_CHART_W;
          const startH = spec.size?.h ?? card.getBoundingClientRect().height;
          // One board column, measured rather than assumed — the board is responsive, so a
          // hard-coded column width would make the drag lag the cursor at other widths.
          const colPx = Math.max(1, board.getBoundingClientRect().width / CHART_BOARD_COLS);
          const move = (ev: PointerEvent) => {
            const w = clampRange(
              Math.round(startW + (ev.clientX - startX) / colPx),
              CHART_W_RANGE
            );
            const h = clampRange(Math.round(startH + (ev.clientY - startY)), CHART_H_RANGE);
            onChange({ ...spec, size: { w, h } });
          };
          const up = () => {
            window.removeEventListener("pointermove", move);
            window.removeEventListener("pointerup", up);
          };
          window.addEventListener("pointermove", move);
          window.addEventListener("pointerup", up);
        }}
        onKeyDown={(e) => {
          const dw = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
          const dh = e.key === "ArrowDown" ? 20 : e.key === "ArrowUp" ? -20 : 0;
          if (!dw && !dh) return;
          e.preventDefault();
          onChange({
            ...spec,
            size: {
              w: clampRange((spec.size?.w ?? DEFAULT_CHART_W) + dw, CHART_W_RANGE),
              h: clampRange((spec.size?.h ?? 260) + dh, CHART_H_RANGE),
            },
          });
        }}
      >
        <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden>
          <path
            d="M9 3.5L3.5 9M9 7L7 9"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.3"
            strokeLinecap="round"
          />
        </svg>
      </button>
    </div>
  );
}

/**
 * Wave-16 C-CHARTCAP (c) — the table kind's picture: one row per group, the measure column
 * first, "Records" always last. Numbers format through `chartValueText`, the SAME formatter
 * the cells and tooltips use, so a revenue figure cannot read one way in the grid and
 * another in a table beside it. An unanswerable cell prints an em dash, the sheet's own
 * vocabulary for "no answer" — never 0, which would sum in the reader's head.
 */
function GroupTableView({
  table,
  xLabel,
  yField,
}: {
  table: GroupTable;
  xLabel: string;
  yField?: Field;
}) {
  return (
    <div className="cg-ch-table">
      <table>
        <thead>
          <tr>
            <th>{xLabel}</th>
            {table.columns.map((c) => (
              <th key={c.key} className="cg-ch-td-num">
                {c.label}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {table.rows.map((r) => (
            <tr key={r.key || "(blank)"}>
              <td title={r.label}>{r.label}</td>
              {r.values.map((v, i) => {
                const col = table.columns[i];
                return (
                  <td key={col.key} className="cg-ch-td-num">
                    {v == null
                      ? "—"
                      : col.agg === "count"
                        ? v.toLocaleString()
                        : chartValueText(v, col.y ? yField : undefined, col.format, niceNumber)}
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export function DashboardView({
  rows,
  fields,
  fieldByKey,
  charts,
  onCharts,
  scope,
}: {
  /** DISTINCT data rows from the full pipeline — the same set the grid counts. */
  rows: Row[];
  fields: Field[];
  fieldByKey: Map<string, Field>;
  charts: ChartSpec[];
  onCharts: (next: ChartSpec[]) => void;
  /**
   * Wave-14 R3 — which surface these rows came from, for the trend channel's fetch.
   *
   * ⚠ ABSENT DISABLES THE PERIOD CONTROLS, and there is no default. Asking the customer
   * endpoint about a cohort's pids succeeds — a cohort's customers are usually in the
   * reader's customer book too — and returns customer-scope numbers under a cohort-scope
   * card: no 403, no marker, a plausible wrong answer. That is the wave-13 Insights scar
   * exactly, and the answer is the same one: render nothing rather than answer a question
   * nobody asked.
   */
  scope?: SurfaceScope;
}) {
  const datas = useMemo(
    () => charts.map((c) => chartModel(c, rows, fieldByKey)),
    [charts, rows, fieldByKey]
  );
  // Wave-16: the table kind's data, one `GroupTable` per table card, from the SAME rows.
  const tables = useMemo(
    () => charts.map((c) => (c.kind === "table" ? tableFromSpec(c, rows, fieldByKey) : null)),
    [charts, rows, fieldByKey]
  );

  // ── R3: the trend channel. Charts sharing a period AND a FETCH span share ONE request,
  // because the endpoint takes many fields and runs one grouped query per field regardless —
  // twelve monthly cards on a board should cost one round trip, not twelve. Wave-16 adds two
  // riders: a compare card fetches `TS_YOY_BACK` wider (see fetchLastN), and a KPI compare
  // card joins with its fixed monthly 13-bucket request.
  const pids = useMemo(() => rows.map((r) => Number(r.pid)).filter(Number.isFinite), [rows]);
  const periodSpecs = useMemo(() => charts.filter(isPeriodChart), [charts]);
  const kpiCompareSpecs = useMemo(() => charts.filter(isKpiCompareSpec), [charts]);
  const groups = useMemo(() => {
    const map = new Map<string, { bucket: TsBucket; lastN: number; fields: string[] }>();
    const add = (key: string, bucket: TsBucket, lastN: number, field: string | undefined) => {
      const entry = map.get(key) ?? { bucket, lastN, fields: [] };
      if (field && !entry.fields.includes(field)) entry.fields.push(field);
      map.set(key, entry);
    };
    for (const spec of periodSpecs)
      add(periodGroupKey(spec), spec.bucket as TsBucket, fetchLastN(spec), spec.y);
    for (const spec of kpiCompareSpecs)
      add(kpiCompareGroupKey(), KPI_COMPARE_BUCKET, KPI_COMPARE_FETCH, spec.y);
    return map;
  }, [periodSpecs, kpiCompareSpecs]);
  // The dependency is the GROUP SHAPE, not the array identity — a card resized or retitled
  // must not re-issue every trend request on the board.
  const groupSig = useMemo(
    () =>
      JSON.stringify(
        [...groups.entries()].map(([k, g]) => [k, [...g.fields].sort()])
      ),
    [groups]
  );
  const [series, setSeries] = useState<Record<string, TsPayload | null>>({});
  const [pending, setPending] = useState(false);
  const reqRef = useRef(0);

  useEffect(() => {
    if (!scope || !groups.size || !pids.length) {
      setSeries({});
      setPending(false);
      return;
    }
    const seq = ++reqRef.current;
    setPending(true);
    Promise.all(
      [...groups.entries()].map(([key, g]) =>
        fetchTimeseries(
          buildTsRequest({ pids, bucket: g.bucket, span: { lastN: g.lastN }, fields: g.fields }),
          scope
        ).then(({ payload }) => [key, payload] as const)
      )
    ).then((pairs) => {
      // The board the user is waiting on is the LAST one they asked for, not the last set of
      // responses to arrive.
      if (seq !== reqRef.current) return;
      setSeries(Object.fromEntries(pairs));
      setPending(false);
    });
  }, [scope, groupSig, pids, groups]);

  /** The model for one card: the trend payload when it asked for one, else the category one. */
  const modelFor = (spec: ChartSpec, fallback: ChartModel): ChartModel => {
    if (!isPeriodChart(spec)) return fallback;
    if (!scope)
      return {
        ...fallback,
        points: [],
        problem:
          "This chart is a trend over periods, but the host has not said which surface this view is, so it cannot be loaded.",
      };
    const payload = series[periodGroupKey(spec)];
    if (!payload)
      // wave17 GRID — item 3 / R6. Waiting and refusing were one string here; they are two
      // states now. `pending` renders the spinner, and the refusal keeps its sentence for the
      // case that is genuinely an answer.
      return pending
        ? { ...fallback, points: [], pending: true }
        : {
            ...fallback,
            points: [],
            problem:
              "The periods could not be loaded, so nothing is drawn rather than a guess.",
          };
    const table = buildTsTable(payload);
    const row = table.rows.find((r) => r.field === spec.y);
    if (!row)
      return {
        ...fallback,
        points: [],
        problem:
          "The server returned no series for this metric — a composite metric has no single aggregate to bucket.",
      };
    // ⚠ `tsRowToChartModel`, never `buildChartData`: the 12-bucket cap is category law, and
    // applying it here would silently delete PERIODS off a run that still looks complete.
    // Wave-16: a compare card's request fetched a year wider — the two-series builder trims
    // the picture back to the asked span so every drawn bucket has its companion.
    if (spec.compare === "prior_year")
      return tsCompareChartModel(
        row,
        table.columns,
        spec.bucket as TsBucket,
        spec.span?.lastN ?? DEFAULT_TS_LAST_N
      );
    return tsRowToChartModel(row, table.columns);
  };

  /** Wave-16 — the KPI card's delta, or null while the channel has not answered. */
  const kpiCompareFor = (spec: ChartSpec): KpiTile | null | undefined => {
    if (!isKpiCompareSpec(spec)) return undefined;
    if (!scope) return null;
    const payload = series[kpiCompareGroupKey()];
    if (!payload) return null;
    const row = buildTsTable(payload).rows.find((r) => r.field === spec.y);
    if (!row) return null;
    return kpiYoyFromSeries({
      label: spec.title ?? "",
      values: row.cells.map((c) => c.value),
      back: TS_YOY_BACK[KPI_COMPARE_BUCKET],
      format: spec.axis?.y?.format,
    });
  };

  return (
    <div className="cg-dashview">
      <div className="cg-dash-bar">
        <span className="cg-cal-note">
          {rows.length.toLocaleString()} records · every chart describes exactly this view's
          rows, so filters and cohort scope already apply
        </span>
        <button
          type="button"
          className="cg-btn cg-btn--primary cg-dash-add"
          disabled={charts.length >= MAX_CHARTS}
          title={
            charts.length >= MAX_CHARTS
              ? `A view holds at most ${MAX_CHARTS} charts.`
              : undefined
          }
          onClick={() => {
            const id = `ch_${Math.random().toString(36).slice(2, 10)}`;
            onCharts([...charts, defaultChart(id, fields)]);
          }}
        >
          + Add chart
        </button>
      </div>
      {charts.length === 0 ? (
        <div className="cg-mode-empty">
          No charts on this view yet. "+ Add chart" builds one from the records this view
          already matches.
        </div>
      ) : (
        <div className="cg-dash-grid">
          {charts.map((c, i) => (
            <ChartCard
              key={c.id}
              spec={c}
              data={modelFor(c, datas[i])}
              fields={fields}
              yField={c.y ? fieldByKey.get(c.y) : undefined}
              canPeriod={!!scope}
              table={tables[i]}
              kpiCompare={kpiCompareFor(c)}
              onChange={(next) => onCharts(charts.map((o) => (o.id === c.id ? next : o)))}
              onRemove={() => onCharts(charts.filter((o) => o.id !== c.id))}
            />
          ))}
        </div>
      )}
    </div>
  );
}
