// ---------------------------------------------------------------------------
// viz / SeriesChart.tsx — EXIT wave 2 (W2-7, contracts Y1 + Y3).
//
// The Y1 `chart` block's renderer: ONE category axis, N SERIES, hand-rolled SVG.
//
// WHY THIS EXISTS RATHER THAN `<ChartCard>`. S1 measured it and it is worth
// restating, because the spec looks like it says otherwise: **the chart engine
// has no series dimension.** `ChartSpec.splitBy` is persisted, validated by
// `cleanCharts`, offered in the grid's chart editor and covered by four gate
// checks — and it never reaches `chartData()`'s bucketing or `DashboardView`'s
// renderer. `ChartCard` draws exactly one flat `buckets[]`, so it structurally
// cannot draw revenue-vs-last-year. Y1 amendment 5's shape is therefore the
// only honest one: a series is one `chartData` call with its own `y` over the
// SAME wide rows. That needs no change to `chartData` — which is also what
// keeps W2-5's pure-move proof intact, since touching the engine would move
// `verify_charts.py`'s verdict.
//
// ⛔ THE SERVER OWNS THE CHRONOLOGY (Y1 amendment 7). `chartData` sorts a
// non-date category axis by VALUE DESCENDING, which would render any trend as a
// revenue-ordered sawtooth; and typing the axis `date` is not an escape, since
// `monthOf()` buckets to `YYYY-MM` and would collapse twelve weeks into four
// months. So the x field stays `text` and the block carries `x_order` — the
// re-ordering below is the whole fix, and it touches neither `chartData` nor
// its gate.
//
// Colour comes from CSS classes reading `--lp-*`, never from a literal in this
// file: an SVG `fill` written as a hex is exactly as invisible to a palette
// change as a hex in a stylesheet.
// ---------------------------------------------------------------------------

import { useMemo } from "react";
import { chartData } from "./chartData";
import type { Bucket, ChartSpec } from "./chartData";
// The data prep lives next door, pure and React-free, so `verify_ui.py` can run
// the two rules that matter (the server's chronology, the field vocabulary)
// under bare node instead of inferring them from a screenshot.
import { asFields, inServerOrder } from "./seriesData";
import type { WireField } from "./seriesData";
import type { Row } from "./types";

/** The palette slots a series cycles through. CLASS names, not colours — the
 *  `.pg-ser-N` rules in index.css read the tokens. Four is deliberate: past
 *  four series a grouped bar chart is unreadable and the answer is small
 *  multiples, not a fifth hue. */
export const SERIES_SLOTS = 4;

const W = 720;
const H = 230;
const PAD_L = 52;
const PAD_R = 10;
const PAD_T = 12;
const PAD_B = 34;

export interface SeriesDef {
  y: string;
  label: string;
}

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);
}

/** The printed delta: signed, 1dp under 10, whole above, and the big-ratio
 *  rule the KPI formatter also applies — a tiny LY base makes "+14,975%" read
 *  as noise, so ≥999 renders as a multiple. (Local, not ../ui/fmt: viz sits
 *  below ui in the layering and must not import up.) */
function deltaLabel(v: number): string {
  const sign = v > 0 ? "+" : v < 0 ? "−" : "";
  const abs = Math.abs(v);
  if (abs >= 999) return `${sign}${Math.round(abs / 100)}×`;
  return `${sign}${abs.toFixed(abs < 10 ? 1 : 0)}%`;
}

export type { WireField } from "./seriesData";

export interface SeriesChartProps {
  spec: ChartSpec;
  series: SeriesDef[];
  fields: WireField[];
  rows: Row[];
  xOrder?: string[];
  /** SERVER-computed per-bucket delta % (0–100 scale) to print over each
   *  group — the "YoY % on the chart" rule. Absent key = no label (the
   *  server's partial-period rule); this component never derives one. */
  deltaByKey?: Map<string, number>;
  /** Fired with the x BUCKET KEY when a group is clicked; the caller resolves it
   *  to the row's own drill descriptor. */
  onPick?: (xKey: string) => void;
  /** Whether a pick actually opens anything — a bar must not invite a click it
   *  cannot honour. */
  pickable?: boolean;
}

export function SeriesChart({
  spec,
  series,
  fields,
  rows,
  xOrder,
  deltaByKey,
  onPick,
  pickable,
}: SeriesChartProps) {
  const vizFields = useMemo(() => asFields(fields), [fields]);
  const fieldByKey = useMemo(
    () => new Map(vizFields.map((f) => [f.key, f])),
    [vizFields]
  );

  const computed = useMemo(
    () =>
      series.map((s) => ({
        def: s,
        data: chartData({ ...spec, y: s.y }, rows, fieldByKey),
      })),
    [series, spec, rows, fieldByKey]
  );

  // One x axis for every series. They bucket the SAME rows by the SAME x, so the
  // key sets agree; the union is belt-and-braces against a series whose measure
  // is absent for a whole bucket.
  const axis = useMemo(() => {
    const seen = new Map<string, string>();
    for (const c of computed)
      for (const b of c.data.buckets) if (!seen.has(b.key)) seen.set(b.key, b.label);
    const merged: Bucket[] = [...seen].map(([key, label]) => ({ key, label, value: 0, n: 0 }));
    return inServerOrder(merged, xOrder);
  }, [computed, xOrder]);

  const problem = computed.find((c) => c.data.problem)?.data.problem;
  if (problem) return <p className="pg-empty">{problem}</p>;
  if (!axis.length)
    return <p className="pg-empty">No periods in this scope yet, so there is nothing to plot.</p>;

  const byKey = computed.map((c) => ({
    def: c.def,
    data: c.data,
    lookup: new Map(c.data.buckets.map((b) => [b.key, b])),
  }));

  const max = Math.max(
    0,
    ...byKey.flatMap((s) => s.data.buckets.map((b) => b.value))
  );
  const scale = max > 0 ? (H - PAD_T - PAD_B) / max : 0;
  const plotW = W - PAD_L - PAD_R;
  const slotW = plotW / axis.length;
  const barW = Math.max(2, (slotW * 0.72) / Math.max(1, byKey.length));

  // Enough labels to read, never so many they collide. One every nth slot.
  const labelEvery = Math.max(1, Math.ceil(axis.length / 12));

  const base = H - PAD_B;
  const rowsSeen = byKey[0]?.data.rows ?? 0;
  const missing = byKey.reduce((a, s) => a + s.data.missingY, 0);
  const omitted = byKey[0]?.data.omitted ?? 0;
  const omittedValue = byKey[0]?.data.omittedValue ?? 0;

  return (
    <div className="pg-charts">
      <div className="pg-legend">
        {byKey.map((s, i) => (
          <span key={s.def.y} className="pg-legend-key">
            <span className={`pg-legend-swatch pg-ser-${i % SERIES_SLOTS}`} />
            {s.def.label}
          </span>
        ))}
      </div>
      <svg
        className="pg-chart-svg"
        viewBox={`0 0 ${W} ${H}`}
        role="img"
        aria-label={spec.title ?? "Chart"}
      >
        {/* The baseline and the top gridline — two rules, not a grid. A chart
            that needs five gridlines to be read is a table. */}
        <line className="pg-ax" x1={PAD_L} y1={base} x2={W - PAD_R} y2={base} />
        <line className="pg-ax pg-ax-soft" x1={PAD_L} y1={PAD_T} x2={W - PAD_R} y2={PAD_T} />
        <text className="pg-ax-lab" x={PAD_L - 6} y={PAD_T + 4} textAnchor="end">
          {niceNumber(max)}
        </text>
        <text className="pg-ax-lab" x={PAD_L - 6} y={base} textAnchor="end">
          0
        </text>

        {axis.map((slot, xi) => {
          const x0 = PAD_L + xi * slotW;
          const groupW = barW * byKey.length;
          const left = x0 + (slotW - groupW) / 2;
          return (
            <g key={slot.key || `(blank)-${xi}`}>
              {pickable && onPick ? (
                // The whole slot is the target, not the 6px bar. A click target
                // narrower than a fingertip is a control that only works for
                // people who already know it is there.
                <rect
                  className="pg-slot"
                  x={x0}
                  y={PAD_T}
                  width={slotW}
                  height={base - PAD_T}
                  onClick={() => onPick(slot.key)}
                >
                  <title>{`Open ${slot.label}`}</title>
                </rect>
              ) : null}
              {byKey.map((s, si) => {
                const b = s.lookup.get(slot.key);
                const v = b?.value ?? 0;
                const h = Math.max(v > 0 ? 1 : 0, v * scale);
                return (
                  <rect
                    key={s.def.y}
                    className={`pg-bar pg-ser-${si % SERIES_SLOTS}`}
                    x={left + si * barW}
                    y={base - h}
                    width={Math.max(1, barW - 1.5)}
                    height={h}
                  >
                    <title>{`${slot.label} · ${s.def.label}: ${niceNumber(v)}`}</title>
                  </rect>
                );
              })}
              {(() => {
                // The server-sent delta over the group ("YoY % on the chart").
                // Sits above the group's tallest bar; a slot the server sent
                // no value for (the partial period) stays honestly unlabelled.
                const d = deltaByKey?.get(slot.key);
                if (d == null) return null;
                const tallest = Math.max(
                  0,
                  ...byKey.map((s) => {
                    const v = s.lookup.get(slot.key)?.value ?? 0;
                    return Math.max(v > 0 ? 1 : 0, v * scale);
                  })
                );
                return (
                  <text
                    className={`pg-bar-delta ${d > 0 ? "is-up" : d < 0 ? "is-down" : ""}`}
                    x={x0 + slotW / 2}
                    y={Math.max(8, base - tallest - 4)}
                    textAnchor="middle"
                  >
                    {deltaLabel(d)}
                  </text>
                );
              })()}
              {xi % labelEvery === 0 ? (
                <text className="pg-ax-lab" x={x0 + slotW / 2} y={base + 14} textAnchor="middle">
                  {slot.label}
                </text>
              ) : null}
            </g>
          );
        })}
      </svg>

      {/* The honesty footer — rule 8b, rendered rather than merely computed.
          Every claim here comes from `chartData`'s return value, so it cannot
          disagree with the picture above it. */}
      <p className="pg-chart-note">
        {`${rowsSeen.toLocaleString()} period${rowsSeen === 1 ? "" : "s"} plotted`}
        {omitted > 0
          ? ` · ${omitted} more not drawn, worth ${niceNumber(omittedValue)}`
          : ""}
        {missing > 0 ? ` · ${missing} value${missing === 1 ? "" : "s"} the measure could not use` : ""}
        {pickable ? " · click a period to decompose it" : ""}
      </p>
    </div>
  );
}
