"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useState,
  type Dispatch,
  type ReactNode,
  type SetStateAction,
} from "react";
import { chartNameOf, getChart } from "@enode/core/api/charts";
import { ApiError } from "@enode/core/api/client";
import type {
  ChartConfig,
  ChartReturn,
  ChartType,
} from "@enode/core/api/dtos/charts";
import { removeChart, upsertChart } from "@enode/core/charts/store";
import {
  useTranslation,
  type TranslateFn,
} from "@enode/core/translations/hooks";
import { ConfirmDialog } from "@enode/ui/confirm-dialog";
import { DrawerStack } from "@enode/ui/drawer-stack";
import { ErrorAlert, InlineAlert } from "@enode/ui/feedback/inline-alert";
import { StalenessHint } from "@enode/ui/feedback/staleness-hint";
import {
  DumbbellFillIcon,
  Person3FillIcon,
} from "@enode/ui/icons";
import { today } from "@enode/ui/calendar-shared";
import { TextArea } from "@enode/ui/text-area";
import { TextField } from "@enode/ui/text-field";
import { useChartDemo } from "../../../../components/charts/chart-demo";
import { useDrawerHost } from "../../../../components/use-drawer-host";
import { TourAutoStart } from "@enode/ui/tour/tour-provider";
import { useChartCreateTour } from "../../../../tours/use-chart-create-tour";
import { TOUR_TARGET } from "../../../../tours/tour-targets";
import { Field } from "../../../../components/selection/selection-field";
import { BuilderChrome } from "./builder-chrome";
import {
  builderSteps,
  firstTodoStep,
  nextStep,
  stepProgress,
  stepState,
  type BuilderStep,
  type StepContext,
} from "./builder-steps";
import {
  StepperList,
  StepperProgress,
  StepperSection,
  TypeChoices,
  useStepText,
} from "./builder-stepper";
import { readRecentScope, type RecentScope } from "./recent-scope";
import { AthleteScope, ExerciseScope } from "./scope-selection";
import { useRecipeNames } from "./builder-recipe";
import {
  exercisesOptionalFor,
  flatChartRequest,
  groupHasSelection,
  initialFlatDraft,
  initialProfileDraft,
  profileChartRequest,
  type FlatDraft,
  type GroupDraft,
  type ProfileDraft,
} from "./chart-draft";
import { flatGaps, profileGaps } from "./chart-gaps";
import { useChartTypeText } from "./chart-type-meta";
import {
  axesSummaryText,
  FlatPreviewCard,
  ProfilePreviewCard,
  profileAxesHint,
  timeframeSummary,
  useProfileGroupText,
  useScopeText,
} from "./chart-preview-card";
import { sizeAdvice, type SizeAdvice } from "./chart-size";
import { useSelectionSize } from "./use-selection-size";
import { saveFlatChart, saveProfileChart, type ChartSaved } from "./chart-save";
import { ChoiceRow } from "./guided-steps";
import { GuidedFlow } from "./guided-flow";
import { MetricAxesSelection, MetricField } from "./metric-selection";
import { useBuilderStart } from "./use-builder-start";
import {
  isTemplateAvailable,
  resolveTemplate,
  useChartTemplates,
  type ChartTemplate,
} from "./chart-templates";
import { TemplateBanner } from "./template-picker";
import { matchingPreset } from "./timeframe";
import { TimeframeField } from "./timeframe-field";
import { useMetricOptions, type MetricOption, type UseMetricOptions } from "./use-metric-options";
import type { EditSection } from "../../../../components/charts/chart-card-actions";

export type { ChartSaved } from "./chart-save";

// ─── Shared helpers ──────────────────────────────────────────────────────────

/** The drawer heading when only one part of a saved chart is edited. */
function sectionHeading(section: EditSection, t: TranslateFn): string {
  switch (section) {
    case "athletes":
      return t("Change athletes");
    case "exercises":
      return t("Change exercises");
    case "timeframe":
      return t("Change timeframe");
    case "metric":
      return t("Change metric");
    case "groups":
      return t("Change the two sides");
    case "axes":
      return t("Change metrics");
  }
}

// A duplicate's title: the source's own title (or, without one, the name the
// server generated for it) marked as a copy.
function copyTitle(
  source: ChartReturn<ChartType>,
  type: ChartType,
  t: TranslateFn,
): string {
  const own = typeof source.chartConfigDto?.title === "string"
    ? source.chartConfigDto.title.trim()
    : "";
  const base = own || chartNameOf(type, source.data).trim();
  return base ? t("{title} (copy)", { title: base }) : "";
}

// The fetch-before-edit hydrate lifecycle ("idle" = create mode).
type HydrateStatus = "idle" | "loading" | "ready" | "error" | "deleted";

// ─── Drawer wrapper + root state ─────────────────────────────────────────────

export type ChartBuilderTarget = {
  type: ChartType;
  mode: "create" | "edit";
  item?: ChartReturn<ChartType>;
  // Create mode: "templates" opens on the "what do you want to find out?"
  // questions (then the guided steps); "blank" opens the full builder.
  start?: "templates" | "blank";
  // Edit mode: open on ONE part of the definition (a header facet's "change"),
  // showing only that picker instead of the full builder.
  section?: EditSection;
};

export function ChartBuilderDrawer({
  target,
  onClose,
  onSaved,
  onShowWalkthrough,
}: {
  target: ChartBuilderTarget | null;
  onClose: () => void;
  onSaved: (saved: ChartSaved) => void;
  // Open the interactive walkthrough for a chart type (from inside the builder).
  onShowWalkthrough: (type: ChartType) => void;
}) {
  const { t } = useTranslation();
  const { open, rendered, sessionKey } = useDrawerHost(target);
  const chartCreateTour = useChartCreateTour();
  // The Insights demo leads with its own walk; no tour starts over it.
  const demo = useChartDemo();
  if (!rendered) return null;
  return (
    <>
    {/* First-creation tour over the open builder — the chain target of the
        Insights tour's click-to-advance step. Create mode only: editing a
        saved chart never triggers it. Gated on `open` so it can't fire during
        the slide-out. */}
    {open && rendered.mode === "create" && !demo && (
      <TourAutoStart tour={chartCreateTour} />
    )}
    <DrawerStack
      key={sessionKey}
      open={open}
      onClose={onClose}
      ariaLabel={rendered.mode === "create" ? t("New chart") : t("Edit chart")}
      dismissable={false}
      root={
        <ChartBuilderRoot
          initialType={rendered.type}
          mode={rendered.mode}
          item={rendered.item}
          start={rendered.start ?? "blank"}
          section={rendered.mode === "edit" ? rendered.section : undefined}
          onClose={onClose}
          onSaved={onSaved}
          onShowWalkthrough={onShowWalkthrough}
        />
      }
    />
    </>
  );
}

// Owns the live chart type plus both draft shapes, so switching the type in
// the first step swaps the form but each side's inputs are preserved when you
// switch back. The type is only switchable while creating — a saved chart
// can't change its type (the API is per-type), so edit mode locks it.
//
// Create mode has two stages: the guided wizard ("guided" — the question, then
// the steps it leaves open) and the full builder ("form"). Both edit the same
// drafts, so moving between them never loses a choice.
function ChartBuilderRoot({
  initialType,
  mode,
  item,
  start,
  section,
  onClose,
  onSaved,
  onShowWalkthrough,
}: {
  initialType: ChartType;
  mode: "create" | "edit";
  item?: ChartReturn<ChartType>;
  start: "templates" | "blank";
  section?: EditSection;
  onClose: () => void;
  onSaved: (saved: ChartSaved) => void;
  onShowWalkthrough: (type: ChartType) => void;
}) {
  const { t } = useTranslation();
  const [type, setType] = useState<ChartType>(initialType);
  // Duplicate = create mode WITH a source chart: every setting is copied and
  // the title marked as a copy, so Save creates a new chart beside the
  // original (which stays untouched). No fetch-before-edit — that's edit-only.
  const [duplicateTitle] = useState(() =>
    mode === "create" && item ? copyTitle(item, initialType, t) : null,
  );
  const [flatDraft, setFlatDraft] = useState<FlatDraft>(() => {
    const draft = initialFlatDraft(item?.chartConfigDto);
    return duplicateTitle == null ? draft : { ...draft, title: duplicateTitle };
  });
  const [profileDraft, setProfileDraft] = useState<ProfileDraft>(() => {
    const draft = initialProfileDraft(item?.chartConfigDto);
    return duplicateTitle == null ? draft : { ...draft, title: duplicateTitle };
  });

  // Fetch-before-edit: re-read the chart by id the moment the edit drawer
  // opens, so the save is based on the newest server config (it may have been
  // edited in another app since the list loaded) — best effort: it reseeds only
  // a pristine form and never blocks Save (see `chartDeleted`). The fresh read
  // also updates the chart's data in the list behind the drawer. 404 = deleted
  // elsewhere.
  const [hydrateAttempt, setHydrateAttempt] = useState(0);
  const [hydrateResult, setHydrateResult] = useState<{
    key: string;
    status: "ready" | "error" | "deleted";
    config?: ChartConfig;
  } | null>(null);
  const hydrateKey =
    mode === "edit" && item ? `${item.id}:${hydrateAttempt}` : null;
  useEffect(() => {
    if (!hydrateKey || !item) return;
    let cancelled = false;
    getChart(type, item.id)
      .then((fresh) => {
        if (cancelled) return;
        setHydrateResult({
          key: hydrateKey,
          status: "ready",
          config: fresh.chartConfigDto as ChartConfig,
        });
        upsertChart(type, fresh);
      })
      .catch((err) => {
        if (cancelled) return;
        const deleted = err instanceof ApiError && err.status === 404;
        if (deleted) removeChart(type, item.id);
        setHydrateResult({
          key: hydrateKey,
          status: deleted ? "deleted" : "error",
        });
      });
    return () => {
      cancelled = true;
    };
  }, [hydrateKey, item, type]);
  const hydrateStatus: HydrateStatus =
    hydrateKey == null
      ? "idle"
      : hydrateResult?.key === hydrateKey
        ? hydrateResult.status
        : "loading";
  const onRetryHydrate = useCallback(
    () => setHydrateAttempt((a) => a + 1),
    [],
  );

  // Track edits so the fresh-config reseed (below) never clobbers input.
  // State (not a ref): the reseed reads it during render.
  const [dirty, setDirty] = useState(false);
  const setFlatDraftTracked: Dispatch<SetStateAction<FlatDraft>> = useCallback(
    (v) => {
      setDirty(true);
      setFlatDraft(v);
    },
    [],
  );
  const setProfileDraftTracked: Dispatch<SetStateAction<ProfileDraft>> =
    useCallback((v) => {
      setDirty(true);
      setProfileDraft(v);
    }, []);

  // Reseed both drafts from the fresh config while PRISTINE (render-time
  // adjustment — the hydrate usually lands before the first interaction).
  const freshConfig =
    hydrateResult?.key === hydrateKey ? hydrateResult?.config : undefined;
  const [seededFrom, setSeededFrom] = useState<ChartConfig | undefined>(
    undefined,
  );
  if (freshConfig && freshConfig !== seededFrom && !dirty) {
    setSeededFrom(freshConfig);
    setFlatDraft(initialFlatDraft(freshConfig));
    setProfileDraft(initialProfileDraft(freshConfig));
  }

  const onTypeChange = mode === "create" ? setType : undefined;

  const [confirmDiscard, setConfirmDiscard] = useState(false);
  // Guard the cancel path (the X) against losing unsaved edits — but not Save,
  // which closes through its own onClose after a successful write.
  const requestClose = useCallback(() => {
    if (dirty) setConfirmDiscard(true);
    else onClose();
  }, [dirty, onClose]);

  const discardDialog = (
    <ConfirmDialog
      open={confirmDiscard}
      title={t("Discard changes?")}
      message={t("Your unsaved changes to this chart will be lost.")}
      confirmLabel={t("Discard")}
      cancelLabel={t("Keep editing")}
      destructive
      onConfirm={() => {
        setConfirmDiscard(false);
        onClose();
      }}
      onCancel={() => setConfirmDiscard(false)}
    />
  );

  // ── Questions (create mode) ──
  // One metric list for the whole builder: templates resolve against it, and
  // both forms read it (instead of each form fetching its own).
  const metricOptions = useMetricOptions();
  const allTemplates = useChartTemplates();
  // Once the metrics are known, offer only questions whose metrics exist —
  // a question that opens with "not in your metric list" is a broken start.
  const templates = metricOptions.loading
    ? allTemplates
    : allTemplates.filter((template) => isTemplateAvailable(template, metricOptions.options));
  const [builderStart, setBuilderStart] = useBuilderStart();
  const [stage, setStage] = useState<"guided" | "form">(
    mode === "create" && start === "templates" ? "guided" : "form",
  );
  const [applied, setApplied] = useState<{
    template: ChartTemplate;
    missingMetricNames: string[];
  } | null>(null);

  const hasWhoOrWhich =
    flatDraft.userIDs.length > 0 ||
    flatDraft.userTagIDs.length > 0 ||
    flatDraft.exerciseDefinitionIDs.length > 0 ||
    flatDraft.exerciseTagIDs.length > 0 ||
    groupHasSelection(profileDraft.reference) ||
    groupHasSelection(profileDraft.profile);

  // Seeds the drafts through the UNTRACKED setters: a question is the starting
  // point, not an edit, so closing right after picking one doesn't ask to
  // discard. Who (athletes) and which (exercises) are never touched — the
  // guided steps ask for them next.
  function applyTemplate(template: ChartTemplate) {
    const resolved = resolveTemplate(template, metricOptions.options, today());
    const timeframe = resolved.timeframe;
    const title = template.title;
    const note = template.note;
    if (template.type === "profile") {
      const labels = template.groupLabels;
      setProfileDraft((d) => ({
        ...d,
        displayBaseIDs: resolved.displayBaseIDs.filter(
          (id): id is string => id !== null,
        ),
        timeframe,
        title,
        note,
        reference: { ...d.reference, label: labels?.reference ?? d.reference.label },
        profile: { ...d.profile, label: labels?.profile ?? d.profile.label },
      }));
    } else {
      setFlatDraft((d) => ({
        ...d,
        displayBaseID: resolved.displayBaseIDs[0] ?? "",
        displayBaseID2: resolved.displayBaseIDs[1] ?? "",
        timeframe,
        title,
        note,
      }));
    }
    setType(template.type);
    setApplied({ template, missingMetricNames: resolved.missingMetricNames });
  }

  if (stage === "guided") {
    return (
      <>
        <GuidedFlow
          applied={applied?.template ?? null}
          onApply={applyTemplate}
          templates={templates}
          templatesLoading={metricOptions.loading}
          hasWhoOrWhich={hasWhoOrWhich}
          onBuildYourself={() => {
            setApplied(null);
            setStage("form");
          }}
          alwaysFull={builderStart === "blank"}
          onAlwaysFullChange={(next) => setBuilderStart(next ? "blank" : "templates")}
          flatDraft={flatDraft}
          setFlatDraft={setFlatDraftTracked}
          profileDraft={profileDraft}
          setProfileDraft={setProfileDraftTracked}
          metricOptions={metricOptions}
          onAdjust={() => setStage("form")}
          onClose={requestClose}
          onSaved={(saved) => {
            onSaved(saved);
            onClose();
          }}
        />
        {discardDialog}
      </>
    );
  }

  // The full builder's banner: the applied question (with "Change"), or — for a
  // new chart started blank — a way back to the questions.
  const banner = applied ? (
    <TemplateBanner
      template={applied.template}
      missingMetricNames={applied.missingMetricNames}
      onChange={() => setStage("guided")}
    />
  ) : mode === "create" && duplicateTitle == null ? (
    <QuestionsLink onClick={() => setStage("guided")} />
  ) : null;

  const formProps = {
    mode,
    onTypeChange,
    item,
    onClose,
    onRequestClose: requestClose,
    onSaved,
    hydrateStatus,
    onRetryHydrate,
    onShowWalkthrough,
    section,
    metricOptions,
    banner,
  };

  if (type === "profile") {
    return (
      <>
        <ProfileBuilderForm
          draft={profileDraft}
          setDraft={setProfileDraftTracked}
          {...formProps}
        />
        {discardDialog}
      </>
    );
  }
  return (
    <>
      <FlatBuilderForm
        type={type}
        draft={flatDraft}
        setDraft={setFlatDraftTracked}
        {...formProps}
      />
      {discardDialog}
    </>
  );
}

// A quiet way from the full builder back to the questions, for a new chart
// that was started blank (e.g. with "Always open the full builder" on).
function QuestionsLink({ onClick }: { onClick: () => void }) {
  const { t } = useTranslation();
  return (
    <button
      type="button"
      onClick={onClick}
      className="clickable-small body-small text-foreground-muted transition-colors hover:text-foreground-default"
    >
      {t("Not sure what to build?")}{" "}
      <span className="font-medium text-foreground-red-orange">{t("Start from a question")}</span>
    </button>
  );
}

// ─── Stepper state ───────────────────────────────────────────────────────────

// Which step is open. A new chart opens on its first missing step; a saved one
// opens with every step folded. `finish` is a finished pick or Continue: in
// create mode it moves on to the next missing step, in edit mode it folds.
// The next step is chosen on the render after the pick, from the gaps the pick
// produced (a render-time adjustment, like the hydrate reseed above).
function useStepper(type: ChartType, ctx: StepContext, mode: "create" | "edit") {
  const [open, setOpen] = useState<BuilderStep | null>(() =>
    mode === "edit" ? null : firstTodoStep(type, ctx),
  );
  const [advanceFrom, setAdvanceFrom] = useState<BuilderStep | null>(null);
  if (advanceFrom != null) {
    setAdvanceFrom(null);
    setOpen(nextStep(type, ctx, advanceFrom));
  }
  return {
    open,
    setOpen,
    toggle: (step: BuilderStep) => setOpen((current) => (current === step ? null : step)),
    finish: (step: BuilderStep) => (mode === "edit" ? setOpen(null) : setAdvanceFrom(step)),
  };
}

// The edit drawer's notes about the fetch-before-edit read.
function HydrateAlerts({ status, onRetry }: { status: HydrateStatus; onRetry: () => void }) {
  const { t } = useTranslation();
  if (status === "deleted")
    return (
      <InlineAlert title={t("This chart no longer exists")}>
        {t("It was deleted in another app and has been removed from the list.")}
      </InlineAlert>
    );
  if (status === "error")
    return (
      <StalenessHint onRetry={onRetry}>
        {t("Couldn’t refresh this chart — saving uses the version shown in the list.")}
      </StalenessHint>
    );
  return null;
}

// "Right now: opens as …" inside the open athletes / exercises step, so the
// view a selection turns into is visible while picking.
function LiveLayout({ layout }: { layout: SizeAdvice | null }) {
  const { t } = useTranslation();
  if (!layout) return null;
  return (
    <div>
      <p className="body-small font-medium text-foreground-platinum-green-bright">
        {t("Right now: opens as {view}", { view: layout.opensAs })}
      </p>
      {layout.warning ? (
        <p className="body-small mt-0.5 text-feedback-warning">{layout.warning}</p>
      ) : null}
    </div>
  );
}

// ─── Flat form: Trend / Ranking / Distribution / Correlation ─────────────────

// What the root hands both forms.
type FormProps = {
  mode: "create" | "edit";
  onTypeChange?: (type: ChartType) => void;
  item?: ChartReturn<ChartType>;
  onClose: () => void;
  /** Guarded cancel (the X): confirms first if there are unsaved edits. */
  onRequestClose: () => void;
  onSaved: (saved: ChartSaved) => void;
  hydrateStatus: HydrateStatus;
  onRetryHydrate: () => void;
  onShowWalkthrough: (type: ChartType) => void;
  /** Show only this part of the definition (a facet's "change"); the steps when unset. */
  section?: EditSection;
  metricOptions: UseMetricOptions;
  /** The applied template's banner, or the way back to the questions. */
  banner: ReactNode;
};

function FlatBuilderForm({
  type,
  draft,
  setDraft,
  mode,
  onTypeChange,
  item,
  onClose,
  onRequestClose,
  onSaved,
  hydrateStatus,
  onRetryHydrate,
  onShowWalkthrough,
  section,
  metricOptions,
  banner,
}: {
  type: Exclude<ChartType, "profile">;
  draft: FlatDraft;
  setDraft: Dispatch<SetStateAction<FlatDraft>>;
} & FormProps) {
  const { t } = useTranslation();
  const typeText = useChartTypeText();
  // The picker offers only analysis-category (rep/set/session/user) bases —
  // anything else is dropped by the analysis backends (see use-metric-options).
  const { options, loading, canonicalize } = metricOptions;
  const [submitting, setSubmitting] = useState(false);
  // Save stays clickable while the form is incomplete — a greyed-out Save gave
  // no reason, which read as "charts can't be created". A Save attempt opens
  // the first missing step and marks every missing step with its reason; the
  // marks are derived (the live gaps), so they clear once fixed.
  const [showValidation, setShowValidation] = useState(false);
  // Fetch-before-edit never gates Save: the fresh read goes through the
  // recomputing `/data` endpoint, which on a large team is slow or fails — and
  // a gate on it left Save dead with no PUT ever sent. The read only reseeds a
  // pristine form; a failed one saves the list's config. Only a chart deleted
  // elsewhere blocks (the PUT would 404).
  const chartDeleted = hydrateStatus === "deleted";

  const isCorrelation = type === "correlation";

  // Stored ids resolved to the picker's offering (a chart saved before the
  // export-base restriction may hold a non-export base): the metric's export
  // base, or "" when the metric has none — which asks for a fresh pick. New
  // picks are already canonical, so this is a no-op for them.
  const displayBaseID = canonicalize(draft.displayBaseID) ?? "";
  const displayBaseID2 = canonicalize(draft.displayBaseID2) ?? "";

  // Display names for the folded summaries.
  const names = useRecipeNames(draft);

  const metricLabel =
    options.find((o) => o.displayBaseID === displayBaseID)?.label ?? null;
  const metricYLabel =
    options.find((o) => o.displayBaseID === displayBaseID2)?.label ?? null;
  // A title suggestion from the metric + first exercise.
  const suggestedTitle = metricLabel
    ? names.exerciseNames[0]
      ? `${metricLabel} · ${names.exerciseNames[0]}`
      : metricLabel
    : null;

  const set = <K extends keyof FlatDraft>(key: K, value: FlatDraft[K]) =>
    setDraft((d) => ({ ...d, [key]: value }));
  // The last saved chart's athletes/exercises, for the "Same as last chart"
  // quick pick (read once per open; a device-local convenience).
  const [recent] = useState(readRecentScope);

  const hasExercises =
    draft.exerciseDefinitionIDs.length > 0 || draft.exerciseTagIDs.length > 0;
  const levelOf = (id: string) =>
    options.find((o) => o.displayBaseID === id)?.level;
  // An athlete-level metric (sleep, soreness, …) isn't recorded against an
  // exercise, so exercises become optional — never for a Distribution, which
  // compares sets (see exercisesOptionalFor).
  const exercisesOptional = exercisesOptionalFor(
    type,
    isCorrelation ? [levelOf(displayBaseID), levelOf(displayBaseID2)] : [levelOf(displayBaseID)],
  );

  // What the selection turns into (M3): series counted with today's tag
  // members, the view the chart opens with, and a note only where even that
  // view gets hard to read. Never blocks — experts keep every combination.
  const size = useSelectionSize(draft);
  const scopeText = useScopeText(draft);
  const seriesCount =
    size.athletes > 0 && (size.lifts > 0 || exercisesOptional)
      ? size.athletes * Math.max(size.lifts, 1)
      : null;
  const layout = seriesCount != null ? sizeAdvice(type, size, t) : null;

  const formGaps = flatGaps({
    type,
    draft,
    displayBaseID,
    displayBaseID2,
    levelOf,
    loading,
    t,
  });
  const validationProblem = formGaps[0]?.message ?? null;

  const ctx: StepContext = {
    gaps: formGaps,
    exercisesNotNeeded: exercisesOptional && !hasExercises,
    hasTitle: draft.title.trim().length > 0,
  };
  const stepper = useStepper(type, ctx, mode);
  const edit = mode === "edit";

  async function handleSave() {
    if (chartDeleted) return;
    if (formGaps.length > 0) {
      setShowValidation(true);
      stepper.setOpen(formGaps[0].step);
      return;
    }
    setSubmitting(true);
    const saved = await saveFlatChart(
      type,
      { mode, itemID: item?.id },
      draft,
      { displayBaseID, displayBaseID2 },
      t,
    );
    setSubmitting(false);
    if (!saved) return;
    onSaved(saved);
    onClose();
  }

  // ── Step bodies (shared with the one-part edits) ──
  const sameMetricTwice = isCorrelation && !!displayBaseID && displayBaseID === displayBaseID2;
  const pickMetric = (key: "displayBaseID" | "displayBaseID2", value: string) => {
    set(key, value);
    const other = key === "displayBaseID" ? displayBaseID2 : displayBaseID;
    // A correlation moves on once both are picked and differ.
    if (!isCorrelation || (other && other !== value)) stepper.finish("metric");
  };
  const metricBody = isCorrelation ? (
    <>
      <Field label={t("When this goes up…")}>
        <MetricField
          value={displayBaseID}
          onChange={(value) => pickMetric("displayBaseID", value)}
          options={options}
          loading={loading}
          title={t("When this goes up…")}
        />
      </Field>
      <Field label={t("…does this go up too?")}>
        <MetricField
          value={displayBaseID2}
          onChange={(value) => pickMetric("displayBaseID2", value)}
          options={options}
          loading={loading}
          title={t("…does this go up too?")}
        />
      </Field>
      {sameMetricTwice ? (
        <p className="body-small text-feedback-warning">{t("Pick two different metrics.")}</p>
      ) : null}
      {displayBaseID || displayBaseID2 ? (
        <button
          type="button"
          onClick={() =>
            setDraft((d) => ({
              ...d,
              displayBaseID: d.displayBaseID2,
              displayBaseID2: d.displayBaseID,
            }))
          }
          className="clickable-small body-small text-foreground-red-orange transition-opacity hover:opacity-80"
        >
          {t("Swap the two")}
        </button>
      ) : null}
    </>
  ) : (
    <>
      {type === "trend" ? (
        <TrendMetricSuggestions
          options={options}
          value={displayBaseID}
          onChange={(value) => pickMetric("displayBaseID", value)}
        />
      ) : null}
      <MetricField
        value={displayBaseID}
        onChange={(value) => pickMetric("displayBaseID", value)}
        options={options}
        loading={loading}
        title={type === "trend" ? t("Other metric") : t("Metric")}
      />
    </>
  );
  const athletesBody = (
    <>
      <AthleteScope
        pickerTitle={t("Athletes")}
        ids={draft.userIDs}
        tagIDs={draft.userTagIDs}
        recent={recent?.athletes}
        onChange={({ ids, tagIDs }) =>
          setDraft((d) => ({
            ...d,
            userIDs: ids ?? d.userIDs,
            userTagIDs: tagIDs ?? d.userTagIDs,
          }))
        }
      />
      <LiveLayout layout={layout} />
    </>
  );
  const exercisesBody = (
    <>
      {type === "distribution" ? (
        <p className="body-small text-foreground-muted">
          {t("A distribution compares sets, so it always needs an exercise.")}
        </p>
      ) : exercisesOptional && metricLabel ? (
        <p className="body-small text-foreground-muted">
          {t("Not needed for {metric}: it's recorded per athlete, not per exercise.", { metric: metricLabel })}
        </p>
      ) : null}
      <ExerciseScope
        pickerTitle={t("Exercises")}
        ids={draft.exerciseDefinitionIDs}
        tagIDs={draft.exerciseTagIDs}
        recent={recent?.exercises}
        athleteIDs={draft.userIDs}
        emptyHint={
          type === "distribution"
            ? t("A distribution needs at least one exercise.")
            : ""
        }
        onChange={({ ids, tagIDs }) =>
          setDraft((d) => ({
            ...d,
            exerciseDefinitionIDs: ids ?? d.exerciseDefinitionIDs,
            exerciseTagIDs: tagIDs ?? d.exerciseTagIDs,
          }))
        }
      />
      {hasExercises ? <LiveLayout layout={layout} /> : null}
    </>
  );
  const timeframeBody = (
    <TimeframeField
      value={draft.timeframe}
      onChange={(timeframe) => {
        setDraft((d) => ({ ...d, timeframe }));
        // A preset is one pick; custom dates take several, so they wait for Continue.
        if (matchingPreset(timeframe, today()) != null) stepper.finish("timeframe");
      }}
    />
  );

  const alerts = <HydrateAlerts status={hydrateStatus} onRetry={onRetryHydrate} />;

  // A header facet's "change": today's compact drawer with only that picker.
  if (section) {
    return (
      <BuilderChrome
        heading={sectionHeading(section, t)}
        onClose={onRequestClose}
        onSave={handleSave}
        saveDisabled={chartDeleted}
        saving={submitting}
        notice={showValidation && validationProblem ? <ErrorAlert>{validationProblem}</ErrorAlert> : null}
      >
        {alerts}
        <div className="space-y-4">
          {section === "metric" ? metricBody : null}
          {section === "athletes" ? athletesBody : null}
          {section === "exercises" ? exercisesBody : null}
          {section === "timeframe" ? timeframeBody : null}
        </div>
      </BuilderChrome>
    );
  }

  // ── Folded summaries ──
  const summaries: Record<BuilderStep, string> = {
    type: typeText.label(type),
    metric: isCorrelation
      ? metricLabel && metricYLabel
        ? t("{x} vs {y}", { x: metricLabel, y: metricYLabel })
        : ""
      : (metricLabel ?? ""),
    athletes: scopeText.athletes,
    exercises:
      ctx.exercisesNotNeeded && metricLabel
        ? t("Not needed for {metric}", { metric: metricLabel })
        : scopeText.exercises,
    timeframe: timeframeSummary(draft.timeframe, t),
    name: draft.title.trim() || (suggestedTitle ? t("Automatic: “{title}”", { title: suggestedTitle }) : t("Optional")),
    axes: "",
    profileGroup: "",
    referenceGroup: "",
  };

  const bodies: Partial<Record<BuilderStep, ReactNode>> = {
    type: onTypeChange ? (
      <TypeChoices
        value={type}
        onChange={(next) => {
          setShowValidation(false);
          onTypeChange(next);
          stepper.finish("type");
        }}
        onShowWalkthrough={onShowWalkthrough}
      />
    ) : null,
    metric: metricBody,
    athletes: athletesBody,
    exercises: exercisesBody,
    timeframe: timeframeBody,
    name: (
      <>
        <TitleField value={draft.title} onChange={(value) => set("title", value)} suggested={suggestedTitle} />
        <NoteField value={draft.note} onChange={(value) => set("note", value)} />
      </>
    ),
  };

  return (
    <BuilderStepperChrome
      type={type}
      heading={edit ? t("Edit chart") : t("New chart")}
      ctx={ctx}
      stepper={stepper}
      edit={edit}
      locked={!onTypeChange}
      showValidation={showValidation}
      summaries={summaries}
      bodies={bodies}
      banner={banner}
      alerts={alerts}
      onClose={onRequestClose}
      onSave={handleSave}
      saveDisabled={chartDeleted}
      saving={submitting}
      result={
        <FlatPreviewCard
          type={type}
          request={formGaps.length === 0 ? flatChartRequest(type, draft, { displayBaseID, displayBaseID2 }) : null}
          draft={draft}
          title={draft.title.trim() || suggestedTitle}
          metricLabel={metricLabel}
          metricYLabel={metricYLabel}
          metricLevel={levelOf(displayBaseID)}
          metricYLevel={levelOf(displayBaseID2)}
          exercisesOptional={exercisesOptional}
          complete={formGaps.length === 0}
          onShowWalkthrough={() => onShowWalkthrough(type)}
        />
      }
    />
  );
}

// The drawer for both forms: header, progress, every step of the type (one
// open), then the result card.
function BuilderStepperChrome({
  type,
  heading,
  ctx,
  stepper,
  edit,
  locked,
  showValidation,
  summaries,
  bodies,
  banner,
  alerts,
  onClose,
  onSave,
  saveDisabled,
  saving,
  result,
}: {
  type: ChartType;
  heading: string;
  ctx: StepContext;
  stepper: ReturnType<typeof useStepper>;
  edit: boolean;
  /** The type can't change (a saved chart). */
  locked: boolean;
  showValidation: boolean;
  /** Each step's folded line (ignored while the step is missing). */
  summaries: Record<BuilderStep, string>;
  bodies: Partial<Record<BuilderStep, ReactNode>>;
  banner: ReactNode;
  alerts: ReactNode;
  onClose: () => void;
  onSave: () => void;
  saveDisabled: boolean;
  saving: boolean;
  result: ReactNode;
}) {
  const { t } = useTranslation();
  const text = useStepText();
  const progress = stepProgress(type, ctx);
  const steps = builderSteps(type);
  return (
    <BuilderChrome
      heading={heading}
      onClose={onClose}
      onSave={onSave}
      saveDisabled={saveDisabled}
      saving={saving}
      banner={banner}
      progress={
        <StepperProgress done={progress.done} total={progress.total} ready={ctx.gaps.length === 0} />
      }
    >
      {alerts}
      <StepperList>
        {steps.map((step, i) => {
          const state = stepState(step, ctx);
          const isLocked = step === "type" && locked;
          const attention = showValidation
            ? (ctx.gaps.find((gap) => gap.step === step)?.message ?? null)
            : null;
          return (
            <StepperSection
              key={step}
              index={i + 1}
              title={text.label(step, type)}
              state={state}
              open={stepper.open === step && !isLocked}
              summary={state === "todo" ? t("Required") : summaries[step]}
              prompt={text.prompt(step, type)}
              locked={isLocked}
              attention={attention}
              continueLabel={edit || step === "name" ? t("Done") : t("Continue")}
              onToggle={() => stepper.toggle(step)}
              onContinue={() => stepper.finish(step)}
              tourTarget={step === "type" ? TOUR_TARGET.chartType : undefined}
            >
              {bodies[step]}
            </StepperSection>
          );
        })}
      </StepperList>
      {result}
    </BuilderChrome>
  );
}

// The metrics a trend reads best with, each with what it shows, above the full
// picker ("Other metric"). Only those the coach's metric list offers appear.
function TrendMetricSuggestions({
  options,
  value,
  onChange,
}: {
  options: MetricOption[];
  value: string;
  onChange: (displayBaseID: string) => void;
}) {
  const { t } = useTranslation();
  const suggestions: { key: string; detail: string }[] = [
    { key: "velocityMean", detail: t("Drops first when an athlete is tired.") },
    { key: "rm1", detail: t("Shows strength across a block.") },
    { key: "powerPeak", detail: t("Shows explosiveness.") },
  ];
  const offered = suggestions
    .map((suggestion) => ({
      ...suggestion,
      option: options.find((o) => o.key === suggestion.key),
    }))
    .filter((s): s is typeof s & { option: MetricOption } => s.option != null);
  if (offered.length === 0) return null;
  return (
    <div className="space-y-2" role="radiogroup" aria-label={t("Suggested for a trend")}>
      <p className="body-small text-foreground-muted">{t("Suggested for a trend")}</p>
      {offered.map(({ option, detail }) => (
        <ChoiceRow
          key={option.displayBaseID}
          active={value === option.displayBaseID}
          title={option.label}
          detail={detail}
          onClick={() => onChange(option.displayBaseID)}
        />
      ))}
    </div>
  );
}

// ─── Profile form: reference vs profile group across metric axes ─────────────

function ProfileBuilderForm({
  draft,
  setDraft,
  mode,
  onTypeChange,
  item,
  onClose,
  onRequestClose,
  onSaved,
  hydrateStatus,
  onRetryHydrate,
  onShowWalkthrough,
  section,
  metricOptions,
  banner,
}: {
  draft: ProfileDraft;
  setDraft: Dispatch<SetStateAction<ProfileDraft>>;
} & FormProps) {
  const { t } = useTranslation();
  // Profile axes come one-per-returned-base, and the backend only computes
  // analysis-category bases — the picker offers only those (see
  // use-metric-options). `canonicalize` maps a chart saved before this
  // restriction onto what the picker now offers.
  const { options, loading, canonicalize } = metricOptions;
  const groupLabels = { profile: t("Profile group"), reference: t("Reference group") };
  // Stored ids resolved to the picker's offering: a metric's non-export base
  // collapses to its export base; a metric with no export base drops out. New
  // picks are already canonical, so this is a no-op for them.
  const selectedIDs = useMemo(() => {
    const mapped = draft.displayBaseIDs
      .map((id) => canonicalize(id))
      .filter((id): id is string => id !== null);
    return [...new Set(mapped)];
  }, [draft.displayBaseIDs, canonicalize]);
  const metricNames = selectedIDs
    .map((id) => options.find((o) => o.displayBaseID === id)?.label)
    .filter((n): n is string => !!n);
  const suggestedTitle = metricNames[0]
    ? `${t("Profile")} · ${metricNames[0]}`
    : null;
  const [submitting, setSubmitting] = useState(false);
  // Save stays clickable while incomplete — see the flat variant above.
  const [showValidation, setShowValidation] = useState(false);
  // Fetch-before-edit never gates Save — see the flat variant above.
  const chartDeleted = hydrateStatus === "deleted";
  const [recent] = useState(readRecentScope);
  const typeText = useChartTypeText();
  const groupText = useProfileGroupText(draft, groupLabels);

  const setGroup = (key: "reference" | "profile", next: GroupDraft) =>
    setDraft((d) => ({ ...d, [key]: next }));

  const anyExerciseAxis = selectedIDs.some(
    (id) => options.find((o) => o.displayBaseID === id)?.level !== "user",
  );
  const formGaps = profileGaps({
    draft,
    metricCount: selectedIDs.length,
    anyExerciseAxis,
    groupName: (key) => draft[key].label.trim() || groupLabels[key],
    loading,
    t,
  });
  const validationProblem = formGaps[0]?.message ?? null;
  // The radar reads best with a handful of axes; say so outside that range.
  const axesHint = profileAxesHint(selectedIDs.length, t);

  const ctx: StepContext = {
    gaps: formGaps,
    exercisesNotNeeded: false,
    hasTitle: draft.title.trim().length > 0,
  };
  const stepper = useStepper("profile", ctx, mode);
  const edit = mode === "edit";

  async function handleSave() {
    if (chartDeleted) return;
    if (formGaps.length > 0) {
      setShowValidation(true);
      stepper.setOpen(formGaps[0].step);
      return;
    }
    setSubmitting(true);
    const saved = await saveProfileChart({ mode, itemID: item?.id }, draft, selectedIDs, t);
    setSubmitting(false);
    if (!saved) return;
    onSaved(saved);
    onClose();
  }

  const axesBody = (
    <>
      <MetricAxesSelection
        options={options}
        loading={loading}
        selectedIDs={selectedIDs}
        onChange={(ids) => setDraft((d) => ({ ...d, displayBaseIDs: ids }))}
        title={t("Metrics")}
      />
      {axesHint ? (
        <p className="body-small text-foreground-red-orange">{axesHint}</p>
      ) : null}
    </>
  );
  const timeframeBody = (
    <TimeframeField
      value={draft.timeframe}
      onChange={(timeframe) => {
        setDraft((d) => ({ ...d, timeframe }));
        if (matchingPreset(timeframe, today()) != null) stepper.finish("timeframe");
      }}
    />
  );
  const groupBody = (key: "profile" | "reference") => (
    <>
      {anyExerciseAxis ? (
        <p className="body-small text-foreground-muted">
          {t("Some of your metrics are measured per exercise, so this group needs exercises too.")}
        </p>
      ) : null}
      <ProfileGroupFields
        groupKey={key}
        draft={draft}
        setGroup={setGroup}
        labels={groupLabels}
        recent={recent}
      />
    </>
  );
  const alerts = <HydrateAlerts status={hydrateStatus} onRetry={onRetryHydrate} />;

  // A header facet's "change": today's compact drawer with only that picker.
  if (section) {
    return (
      <BuilderChrome
        heading={sectionHeading(section, t)}
        onClose={onRequestClose}
        onSave={handleSave}
        saveDisabled={chartDeleted}
        saving={submitting}
        notice={showValidation && validationProblem ? <ErrorAlert>{validationProblem}</ErrorAlert> : null}
      >
        {alerts}
        <div className="space-y-4">
          {section === "axes" || section === "metric" ? axesBody : null}
          {section === "groups" ? (
            <ProfileGroupsEditor draft={draft} setGroup={setGroup} labels={groupLabels} recent={recent} />
          ) : null}
          {section === "timeframe" ? timeframeBody : null}
        </div>
      </BuilderChrome>
    );
  }

  const summaries: Record<BuilderStep, string> = {
    type: typeText.label("profile"),
    axes: axesSummaryText(metricNames, selectedIDs.length, t),
    profileGroup: groupText.summary.profile,
    referenceGroup: groupText.summary.reference,
    timeframe: timeframeSummary(draft.timeframe, t),
    name: draft.title.trim() || (suggestedTitle ? t("Automatic: “{title}”", { title: suggestedTitle }) : t("Optional")),
    metric: "",
    athletes: "",
    exercises: "",
  };

  const bodies: Partial<Record<BuilderStep, ReactNode>> = {
    type: onTypeChange ? (
      <TypeChoices
        value="profile"
        onChange={(next) => {
          setShowValidation(false);
          onTypeChange(next);
          stepper.finish("type");
        }}
        onShowWalkthrough={onShowWalkthrough}
      />
    ) : null,
    axes: axesBody,
    profileGroup: groupBody("profile"),
    referenceGroup: groupBody("reference"),
    timeframe: timeframeBody,
    name: (
      <>
        <TitleField
          value={draft.title}
          onChange={(value) => setDraft((d) => ({ ...d, title: value }))}
          suggested={suggestedTitle}
        />
        <NoteField
          value={draft.note}
          onChange={(value) => setDraft((d) => ({ ...d, note: value }))}
        />
      </>
    ),
  };

  const complete = formGaps.length === 0;
  return (
    <BuilderStepperChrome
      type="profile"
      heading={edit ? t("Edit chart") : t("New chart")}
      ctx={ctx}
      stepper={stepper}
      edit={edit}
      locked={!onTypeChange}
      showValidation={showValidation}
      summaries={summaries}
      bodies={bodies}
      banner={banner}
      alerts={alerts}
      onClose={onRequestClose}
      onSave={handleSave}
      saveDisabled={chartDeleted}
      saving={submitting}
      result={
        <ProfilePreviewCard
          request={complete ? profileChartRequest(draft, selectedIDs) : null}
          draft={draft}
          title={draft.title.trim() || suggestedTitle}
          metricNames={metricNames}
          groupNames={groupText.name}
          axesSummary={summaries.axes}
          warning={axesHint}
          complete={complete}
          onShowWalkthrough={() => onShowWalkthrough("profile")}
        />
      }
    />
  );
}

function CopyPill({ label, onClick }: { label: string; onClick: () => void }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="body-small inline-flex items-center rounded-full border border-surface-stroke px-2.5 py-1 text-foreground-muted transition-colors hover:bg-surface-base-muted hover:text-foreground-default"
    >
      {label}
    </button>
  );
}

// One-part edit of a Profile's two groups (a header facet's "change the two
// sides"): a segmented control switches which group you're editing — one clean
// editor at a time.
function ProfileGroupsEditor({
  draft,
  setGroup,
  labels,
  recent,
}: {
  draft: ProfileDraft;
  setGroup: (key: "reference" | "profile", next: GroupDraft) => void;
  labels: { profile: string; reference: string };
  recent: RecentScope | null;
}) {
  const { t } = useTranslation();
  // Edit the profile group first — you usually have the group you care about in
  // mind, then define the baseline to compare it against.
  const [active, setActive] = useState<"profile" | "reference">("profile");
  const groupKeys = ["profile", "reference"] as const;

  return (
    <section>
      {/* Register tabs — one per group, the active one merging into the card
          below so its athletes + exercises clearly belong to it. A dot marks
          which groups already carry a selection. */}
      <div role="tablist" aria-label={t("Group to edit")} className="flex gap-1">
        {groupKeys.map((g) => {
          const isActive = active === g;
          const filled = groupHasSelection(draft[g]);
          return (
            <button
              key={g}
              type="button"
              role="tab"
              aria-selected={isActive}
              onClick={() => setActive(g)}
              className={`relative -mb-px flex items-center gap-2 rounded-t-xl border px-4 py-2.5 transition-colors ${
                isActive
                  ? "z-10 border-surface-stroke border-b-transparent bg-surface-base text-foreground-default"
                  : "border-surface-stroke bg-surface-base-muted/40 text-foreground-muted hover:bg-surface-base-muted hover:text-foreground-default"
              }`}
            >
              <span className="body-prominent">{labels[g]}</span>
              <span
                aria-hidden
                className={`size-1.5 rounded-full transition-colors ${
                  filled
                    ? "bg-foreground-platinum-green-bright"
                    : "bg-surface-stroke"
                }`}
              />
            </button>
          );
        })}
      </div>

      {/* The active group's card, hanging off its tab. */}
      <div className="rounded-b-2xl rounded-tr-2xl border border-surface-stroke bg-surface-base p-4">
        <ProfileGroupFields
          groupKey={active}
          draft={draft}
          setGroup={setGroup}
          labels={labels}
          recent={recent}
        />
      </div>
    </section>
  );
}

// One Profile group: "Copy from {other group}", its label, then Athletes and
// Exercises as their own labelled blocks. A one-tap copy seeds a shared athlete
// or exercise set so it isn't picked twice.
function ProfileGroupFields({
  groupKey,
  draft,
  setGroup,
  labels,
  recent,
}: {
  groupKey: "profile" | "reference";
  draft: ProfileDraft;
  setGroup: (key: "reference" | "profile", next: GroupDraft) => void;
  labels: { profile: string; reference: string };
  recent: RecentScope | null;
}) {
  const { t } = useTranslation();
  const other = groupKey === "profile" ? "reference" : "profile";
  const group = draft[groupKey];
  const otherGroup = draft[other];
  const patch = (next: Partial<GroupDraft>) =>
    setGroup(groupKey, { ...group, ...next });

  const canCopyAthletes =
    otherGroup.userIDs.length > 0 || otherGroup.userTagIDs.length > 0;
  const canCopyExercises =
    otherGroup.exerciseDefinitionIDs.length > 0 ||
    otherGroup.exerciseTagIDs.length > 0;

  return (
    <div>
      {canCopyAthletes || canCopyExercises ? (
        <div className="mb-4 flex flex-wrap items-center gap-2">
          <span className="body-small text-foreground-muted">
            {t("Copy from {group}", { group: labels[other] })}
          </span>
          {canCopyAthletes ? (
            <CopyPill
              label={t("Athletes")}
              onClick={() =>
                patch({
                  userIDs: [...otherGroup.userIDs],
                  userTagIDs: [...otherGroup.userTagIDs],
                })
              }
            />
          ) : null}
          {canCopyExercises ? (
            <CopyPill
              label={t("Exercises")}
              onClick={() =>
                patch({
                  exerciseDefinitionIDs: [...otherGroup.exerciseDefinitionIDs],
                  exerciseTagIDs: [...otherGroup.exerciseTagIDs],
                })
              }
            />
          ) : null}
        </div>
      ) : null}

      <div>
        <span className="body-small mb-1.5 block text-foreground-muted">
          {t("Label (optional)")}
        </span>
        <TextField
          className="w-full"
          value={group.label}
          onChange={(e) => patch({ label: e.target.value })}
          placeholder={t("Defaults to an auto name")}
        />
      </div>

      <GroupField icon={<Person3FillIcon className="size-4" />} title={t("Athletes")}>
        <AthleteScope
          pickerTitle={labels[groupKey]}
          ids={group.userIDs}
          tagIDs={group.userTagIDs}
          recent={recent?.athletes}
          onChange={({ ids, tagIDs }) =>
            patch({
              userIDs: ids ?? group.userIDs,
              userTagIDs: tagIDs ?? group.userTagIDs,
            })
          }
        />
      </GroupField>

      <GroupField
        icon={<DumbbellFillIcon className="size-4" />}
        title={t("Exercises")}
      >
        <ExerciseScope
          pickerTitle={labels[groupKey]}
          ids={group.exerciseDefinitionIDs}
          tagIDs={group.exerciseTagIDs}
          recent={recent?.exercises}
          athleteIDs={group.userIDs}
          onChange={({ ids, tagIDs }) =>
            patch({
              exerciseDefinitionIDs: ids ?? group.exerciseDefinitionIDs,
              exerciseTagIDs: tagIDs ?? group.exerciseTagIDs,
            })
          }
        />
      </GroupField>
    </div>
  );
}

// One labelled block inside a group's fields — an icon + heading over its
// controls, with a divider above so Athletes and Exercises read as two
// distinct sections rather than one run-on list.
function GroupField({
  icon,
  title,
  children,
}: {
  icon: ReactNode;
  title: string;
  children: ReactNode;
}) {
  return (
    <div className="mt-4 border-t border-surface-stroke pt-4">
      <div className="mb-3 flex items-center gap-1.5">
        <span className="text-foreground-muted">{icon}</span>
        <h4 className="body-small text-foreground-muted">{title}</h4>
      </div>
      <div className="space-y-3">{children}</div>
    </div>
  );
}

// ─── Reusable fields ─────────────────────────────────────────────────────────

// The chart's title — in the last step, once the chart is defined. When blank
// it offers a suggestion derived from the metric/exercise; the placeholder
// previews it too.
function TitleField({
  value,
  onChange,
  suggested,
}: {
  value: string;
  onChange: (value: string) => void;
  suggested: string | null;
}) {
  const { t } = useTranslation();
  return (
    <section>
      <h3 className="body-small mb-2 text-foreground-muted">{t("Title")}</h3>
      <TextField
        className="w-full"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder={suggested ?? t("Name this chart")}
        aria-label={t("Title")}
      />
      {!value.trim() && suggested ? (
        <button
          type="button"
          onClick={() => onChange(suggested)}
          className="clickable-small body-small mt-1.5 text-foreground-red-orange transition-opacity hover:opacity-80"
        >
          {t("Use suggestion")}
        </button>
      ) : null}
    </section>
  );
}

// A roomy multi-line note field — a section-style heading over the text area,
// no card (like the title). The note shows in the chart header.
function NoteField({
  value,
  onChange,
}: {
  value: string;
  onChange: (value: string) => void;
}) {
  const { t } = useTranslation();
  return (
    <section>
      <h3 className="body-small mb-2 text-foreground-muted">{t("Note")}</h3>
      <TextArea
        value={value}
        onChange={(e) => onChange(e.target.value)}
        rows={3}
        placeholder={t("Optional context, shown in the chart's header.")}
        aria-label={t("Note")}
        className="block w-full resize-y"
      />
    </section>
  );
}
