"use client";

import { useState } from "react";
import type {
  MetricCategoryReturnDto,
  MetricReturnDto,
} from "@enode/core/api/dtos/metrics";
import type {
  SessionMeasurementReturnDto,
  SessionReturnDto,
  SetReturnDto,
  WorkoutRepReturnDto,
} from "@enode/core/api/dtos/sessions";
import { useExerciseBase } from "@enode/core/exercise-bases/hooks";
import {
  metricCategoryColorHexOf,
  metricCategoryColorOf,
} from "@enode/core/metrics/category-colors";
import { getMetric, getMetricCategory } from "@enode/core/metrics/store";
import { TRACKING_SOURCE } from "@enode/core/tracking/store";
import {
  findLoadMeasurement,
  loadFactorScale,
} from "@enode/core/training/measurements";
import { isDisplayableMetric } from "@enode/core/training/metrics-display";
import {
  compareRepsByCreated,
  compareSetsByCreated,
} from "@enode/core/training/rep-order";
import { useDisplayName } from "@enode/core/text-content/useDisplayName";
import { useTranslation } from "@enode/core/translations/hooks";
import type { TranslateFn } from "@enode/core/translations";
import {
  useOnlyFocusMetrics,
  useShowEccentricReps,
  useShowHeatmap,
} from "@enode/core/training/flags";
import { useUnits, type Units } from "@enode/core/use-units";
import {
  ArrowDownFillIcon,
  ArrowUpFillIcon,
  ChevronRightIcon,
  EllipsisIcon,
  NoSignIcon,
  StarFillIcon,
} from "@enode/ui/icons";
import { ActionMenu, type ActionMenuItem } from "@enode/ui/action-menu";
import { SessionTagsCell } from "./session-tags-cell";
import {
  useMetricKeyboard,
  type MetricKeyboardField,
} from "@enode/ui/metric-keyboard/MetricKeyboardProvider";
import { metricKeyboardSpecFor } from "@enode/core/metrics/keyboard-spec";
import {
  isOverflowDraft,
  metricDraftValue,
} from "@enode/core/metrics/keyboard";
import { LottieCircle } from "@enode/ui/LottieCircle";
import { Td, Th } from "../../components/data-table";
import { SessionSensorEmptyState } from "./session-sensor-empty-state";
import { useFocusMetrics } from "./use-focus-metrics";
import type { SessionEditor } from "./use-session-editor";

const CONCENTRIC = "concentric";

// The two Enode sensor tracking sources — the mounted sensor and its hand-held
// (wrist-worn) mode. A set tracked by either carries raw sensor recordings that
// populate the per-rep metrics; anything else (manual entry, Apple Watch,
// iPhone) does not. Tracking-source UUIDs are case-insensitive and arrive
// upper-cased from the backend, so the set's id is lower-cased before lookup.
const SENSOR_TRACKING_SOURCE_IDS: ReadonlySet<string> = new Set([
  TRACKING_SOURCE.enodeSensor,
  TRACKING_SOURCE.enodeSensorWristWorn,
]);

function isSensorTrackedSet(set: SetReturnDto): boolean {
  const id = set.trackingSourceID?.toLowerCase();
  return id != null && SENSOR_TRACKING_SOURCE_IDS.has(id);
}

// Column sizing for the fixed-layout table. The leading (Reps / Phase) and
// trailing action columns get fixed widths (only as wide as their content needs);
// the metric columns are left auto, so under `table-fixed` they split the
// remaining width EQUALLY — staying uniform, absorbing all the slack so the
// leading columns never pad out. `METRIC_COL_PX` isn't applied per column; it's
// the per-metric floor baked into the table `min-width`, so each metric column is
// ≥112px and the table overflows to horizontal scroll once they don't all fit.
const METRIC_COL_PX = 112;
const REPS_COL_PX = 64;
const PHASE_COL_PX = 96;
// Tags sit among the LEADING columns, not at the trailing edge: the table scrolls
// horizontally over its metric columns, so a trailing tags column would spend most
// of its life off-screen — and tags are the one thing a coach scans for.
const TAGS_COL_PX = 132;
const ACTION_COL_PX = 40;

// Second floor for the table `min-width`: the set-header row must fit its
// inline measurement editors on ONE line (they sit in a spanning cell, so
// `table-fixed` won't widen the table for them). Chrome covers the fixed parts
// (cell padding, chevron, "Set N", warm-up pill, actions menu); each editor
// slot covers a metric name / input + unit plus the flex gap.
const SET_ROW_CHROME_PX = 240;
const SET_MEASUREMENT_PX = 124;

// The session's sets/reps table (Reps tab). Columns are EVERY metric present
// anywhere in the dataset (the union across all reps), ordered by metric
// category, then by the metric's own order within its category. The header is two
// tiers: a category band (icon + name + underline, in the category colour)
// spanning its metrics, then the per-metric columns — each focus metric prefixed
// with a star in the category colour. Rows are grouped by set, each rep showing
// its per-metric values and an empty cell for any metric it doesn't carry.
// Renders from the editor's draft tree (assembled full session, reps fetched per
// set on drawer open).
//
// R2 scope: editable. Per-rep actions (mark valid/invalid, delete) and per-set
// actions (working/warm-up, delete) stage changes in the `editor`; the drawer's
// footer saves them in one batch. The play button comes later.
export function SessionRepsTab({
  session,
  editor,
  loading,
  onJumpToTechnique,
}: {
  session: SessionReturnDto;
  editor: SessionEditor;
  loading: boolean;
  /** Open the Technique tab preselecting this set/rep. */
  onJumpToTechnique?: (setId: string, repId: string) => void;
}) {
  const { t } = useTranslation();
  const units = useUnits();
  const exerciseBase = useExerciseBase(session.exercise?.exerciseBaseID);
  const focusMetrics = useFocusMetrics(exerciseBase?.displayBaseExerciseGroupID);
  // Focus metrics get a coloured star in the header. Keyed for O(1) lookup — the
  // ordering itself is category-then-order, NOT focus-first.
  const focusKeys = new Set(focusMetrics.map(columnKey));
  // Concentric-only unless the shared "show eccentric reps" global is on — the
  // same in-memory flag the header "…" menu and the tracking app toggle. When
  // off, eccentric rows are hidden and the redundant Phase column is dropped.
  const showEccentricReps = useShowEccentricReps();
  const onlyConcentric = !showEccentricReps;
  // Focus-only (opens the table compact) and the heatmap tint are shared global
  // toggles, flipped from the session drawer's header "…" menu (same in-memory
  // flags pattern as the eccentric toggle above).
  const onlyFocus = useOnlyFocusMetrics();
  const showHeatmap = useShowHeatmap();

  // Order sets by when they were recorded (`created`), not the backend's
  // response order — the same rule reps use below. `Set N` labels derive from
  // this order, so an unsorted array both mis-orders and mislabels the sets.
  const sets = editor.draftSets.slice().sort(compareSetsByCreated);
  // Every metric the dataset carries, ordered by metric category, then by the
  // metric's own order — optionally narrowed to just the focus metrics (same
  // order) — then grouped into consecutive category runs for the two-tier header.
  const allColumns = orderColumns(datasetMetrics(sets));
  // Only narrow to focus metrics when the exercise actually has some, so the
  // default-on filter never collapses the table to zero columns.
  const columns =
    onlyFocus && focusKeys.size > 0
      ? allColumns.filter((m) => focusKeys.has(columnKey(m)))
      : allColumns;
  const categoryGroups = groupByCategory(columns);
  // Session-wide extremes per column: the min gets a down-triangle, the max an
  // up-triangle. Heat ranges are per-set, computed inside each SetGroup.
  const extremeCells = computeExtremeCells(sets, columns, editor);

  // Leading columns: Reps, then Phase only when not filtered to concentric (then
  // every visible rep is concentric, so the Phase column is redundant), then Tags.
  const leadingWidths = onlyConcentric
    ? [REPS_COL_PX, TAGS_COL_PX]
    : [REPS_COL_PX, PHASE_COL_PX, TAGS_COL_PX];

  // The widest set header's editor count drives the set-row min-width floor.
  const maxSetMeasurements = sets.reduce(
    (max, s) => Math.max(max, setMeasurementRows(s).length),
    0,
  );

  if (loading) {
    return (
      <p className="body-normal text-foreground-muted">{t("Loading reps…")}</p>
    );
  }

  // Sensor-only: the reps table is populated from raw sensor recordings, so a
  // session with no Enode-sensor (mounted or wrist-worn) set has nothing to
  // fill it — show the same empty state as the Performance / Technique tabs.
  if (!sets.some(isSensorTrackedSet)) {
    return <SessionSensorEmptyState />;
  }

  return (
    // Fill the drawer's tab area and lay out as a column: the toolbar stays put
    // while the table region below it scrolls (under its sticky header). The
    // table takes the full width and scrolls horizontally once the metric
    // columns don't all fit.
    <div className="flex min-h-0 flex-1 flex-col gap-3">
      <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
        <Legend showHeatmap={showHeatmap} />
      </div>

      {/* Scroll viewport (both axes) and the scroll container the sticky thead
          resolves to. Sizes to its content so a short table isn't stretched to
          full height (`flex: 0 1 auto`, no grow); when the content exceeds the
          remaining column height, `min-h-0` + `flex-shrink` cap it here and it
          scrolls under the pinned header. Best of both: content-height when
          small, scroll-with-sticky-header when tall. */}
      <div className="scrollbar-none min-h-0 overflow-auto rounded-2xl border border-surface-stroke">
        <table
          className="w-full table-fixed border-collapse"
          style={{
            // Whichever needs more room wins: the metric columns' floor or the
            // set-header row's one-line editors.
            minWidth: Math.max(
              leadingWidths.reduce((a, b) => a + b, 0) +
                columns.length * METRIC_COL_PX +
                ACTION_COL_PX,
              SET_ROW_CHROME_PX + maxSetMeasurements * SET_MEASUREMENT_PX,
            ),
          }}
        >
          <colgroup>
            {leadingWidths.map((w, i) => (
              <col key={`lead-${i}`} style={{ width: w }} />
            ))}
            {columns.map((m) => (
              // No explicit width: metric columns split the remaining space
              // equally (table-fixed), so the leading columns stay tight.
              <col key={columnKey(m)} />
            ))}
            <col style={{ width: ACTION_COL_PX }} />
          </colgroup>
          {/* Sticky header: pinned to the top of the scroll wrapper. The opaque
              cell background (via the arbitrary `[&_th]` rule) keeps body rows —
              including their heat tints — from showing through as they scroll
              under it. */}
          <thead className="sticky top-0 z-10 [&_th]:bg-surface-base">
            {/* Category band — one cell per category, spanning its metric
                columns, carrying the category icon + name + a coloured underline. */}
            {categoryGroups.length > 0 && (
              <tr>
                <th colSpan={leadingWidths.length} />
                {categoryGroups.map((g) => (
                  <th
                    key={g.key}
                    colSpan={g.metrics.length}
                    className="px-4 pt-3 align-bottom"
                  >
                    {g.category && <CategoryBand category={g.category} />}
                  </th>
                ))}
                <th colSpan={1} />
              </tr>
            )}
            <tr className="border-b border-surface-stroke text-left">
              {/* align-top so the leading labels line up with the metric titles
                  (MetricTh is align-top); the shared Th defaults to middle. */}
              <Th className="align-top">
                {/* The column is a fixed 64px (table-fixed), so a long
                    translation of "Reps" would WRAP and grow the whole header's
                    height. Truncate this one label instead — the metric columns
                    flex, so they wrap fine and are left untouched. `title` keeps
                    the full word reachable on hover. */}
                <span className="block truncate" title={t("Reps")}>
                  {t("Reps")}
                </span>
              </Th>
              {!onlyConcentric && <Th className="align-top">{t("Phase")}</Th>}
              <Th className="align-top">{t("Tags")}</Th>
              {columns.map((m) => (
                <MetricTh
                  key={columnKey(m)}
                  metric={m}
                  units={units}
                  focus={focusKeys.has(columnKey(m))}
                  color={metricCategoryColorOf(
                    getMetricCategory(m.metricCategoryID),
                  )}
                />
              ))}
              <Th className="w-10" />
            </tr>
          </thead>
          <tbody>
            {sets.map((set, i) => (
              <SetGroup
                key={set.id}
                set={set}
                index={i}
                columns={columns}
                units={units}
                onlyConcentric={onlyConcentric}
                editor={editor}
                extremeCells={extremeCells}
                showHeatmap={showHeatmap}
                onJumpToTechnique={onJumpToTechnique}
              />
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function SetGroup({
  set,
  index,
  columns,
  units,
  onlyConcentric,
  editor,
  extremeCells,
  showHeatmap,
  onJumpToTechnique,
}: {
  set: SetReturnDto;
  index: number;
  columns: MetricReturnDto[];
  units: Units;
  onlyConcentric: boolean;
  editor: SessionEditor;
  // `${metricId}:${repId}` keys → whether the cell holds the column's session
  // min or max value (down- / up-triangle marker).
  extremeCells: Map<string, "min" | "max">;
  // Whether to tint cells with the set-wise heatmap.
  showHeatmap: boolean;
  onJumpToTechnique?: (setId: string, repId: string) => void;
}) {
  const { t } = useTranslation();
  // Reps default to expanded; the set-header chevron collapses/expands them.
  const [expanded, setExpanded] = useState(true);
  const reps = (set.reps ?? [])
    .filter((r) => !onlyConcentric || r.phase === CONCENTRIC)
    .slice()
    .sort(compareRepsByCreated);
  const loadRaw = setLoadRaw(set);
  // Set-wise heatmap ranges: this set's reps are coloured relative to this set.
  // Null when the heatmap is toggled off — cells then render untinted.
  const ranges = showHeatmap ? computeSetRanges(set, columns, editor) : null;
  const warmup = !set.work;
  const setDeleted = editor.isSetDeleted(set.id);
  // Every set-level measurement (load + any others), each editable.
  const measurementRows = setMeasurementRows(set);

  // In-app measurement keyboard — the same one the tracking app uses for set
  // entry and the shared workout-builder uses for normatives. `enabled` is false
  // when a hardware keyboard is present (so desktop keeps the native input); it
  // resolves true on touch devices, which is where this actually lands.
  const keyboard = useMetricKeyboard();
  const keyboardActive =
    keyboard.enabled && editor.canWrite && !setDeleted && !editor.saving;

  // The whole row opens as ONE field group, so the keyboard's ▲▼ steps between a
  // set's metrics (load → reps → RIR) without closing. Built at open time, so the
  // values are current.
  function openMeasurementKeyboard(index: number) {
    const fields: MetricKeyboardField[] = measurementRows.map(
      ({ measurement, metric }) => {
        const spec = metricKeyboardSpecFor(metric);
        const stored = valueOf(measurement);
        return {
          spec,
          value: editableNumber(units.toDisplay(stored, metric.dimension)),
          onCommit: (draft: string) => {
            if (draft.trim() === "") return; // empty → leave the value alone
            // A bounded metric's draft can hold the overflow LABEL ("6+"), which
            // isn't a number — take the bucket's value in that case.
            const display = isOverflowDraft(draft, spec)
              ? (spec.overflow?.value ?? metricDraftValue(draft))
              : metricDraftValue(draft);
            const next = units.fromDisplay(display, metric.dimension);
            if (next !== stored) {
              editor.setSetMeasurement(set.id, measurement.metricID, next);
            }
          },
        };
      },
    );
    if (fields.length > 0) keyboard.open({ fields, index });
  }
  // Reps | (Phase) | Tags | metrics… | menu — the set header spans them all.
  // Phase is dropped when filtered to concentric only.
  const totalCols = columns.length + (onlyConcentric ? 3 : 4);

  // Every set action mutates the session → writeData only (editor.canWrite).
  const setMenu: ActionMenuItem[] = editor.canWrite
    ? [
        set.work
          ? {
              label: t("Mark as warm-up"),
              onClick: () => editor.setSetWork(set.id, false),
            }
          : {
              label: t("Delete set"),
              destructive: true,
              onClick: () => editor.toggleSetDeletion(set.id),
            },
      ]
    : [];

  return (
    <>
      {/* Set header row — its own row, spanning every column. Chevron collapses
          the set's reps; the set's measurements are editable inline here. */}
      <tr className="border-b border-surface-stroke bg-surface-base">
        <td colSpan={totalCols} className="px-4 py-3">
          {/* Baseline-last cross-alignment: "Set 1" + the warm-up pill sit on
              the input VALUE baseline (each editor is a label-over-input stack,
              so its LAST baseline is the number, not the "LOAD" caption). The
              collapse chevron rides along in the same cluster as "Set 1" — kept
              centred against the label so it stays level with it while the pair
              drops onto the baseline. Only the actions menu opts out
              (self-center) to stay vertically centred in the row. */}
          <div className="flex items-baseline-last gap-3">
            <div className="flex items-center gap-3">
              <button
                type="button"
                onClick={() => setExpanded((v) => !v)}
                aria-expanded={expanded}
                aria-label={expanded ? t("Collapse set") : t("Expand set")}
                className="flex size-6 shrink-0 items-center justify-center rounded-md text-foreground-muted hover:bg-surface-base-muted hover:text-foreground-default"
              >
                <ChevronRightIcon
                  className={`size-4 transition-transform ${expanded ? "rotate-90" : ""}`}
                />
              </button>
              <span
                className={`body-prominent ${
                  setDeleted
                    ? "text-foreground-more-muted line-through"
                    : "text-foreground-default"
                }`}
              >
                {t("Set {n}", { n: index + 1 })}
              </span>
            </div>
            {warmup && (
              <span className="stat-small shrink-0 rounded-full bg-surface-base-muted px-2 py-0.5 text-foreground-muted">
                {t("Warm-up")}
              </span>
            )}
            {measurementRows.length > 0 && (
              <div className="flex flex-wrap items-baseline-last gap-3">
                {measurementRows.map(({ measurement, metric }, i) => (
                  <EditableSetMeasurement
                    key={columnKey(metric)}
                    measurement={measurement}
                    metric={metric}
                    units={units}
                    readOnly={!editor.canWrite}
                    disabled={setDeleted || editor.saving}
                    keyboardEnabled={keyboardActive}
                    onOpenKeyboard={() => openMeasurementKeyboard(i)}
                    onCommit={(valueUser) =>
                      editor.setSetMeasurement(
                        set.id,
                        measurement.metricID,
                        valueUser,
                      )
                    }
                  />
                ))}
              </div>
            )}
            {/* The set's own tags — same control as the per-rep cells, just
                allowed more pills before collapsing, since this row spans the
                full width. Placed AFTER the normatives (load · reps · RIR): the
                measurements are what the row is read for, so the tags follow
                them rather than pushing them right. */}
            <div className="shrink-0 self-center">
              <SessionTagsCell
                tags={set.assignedTags ?? []}
                entityKind="set"
                readOnly={!editor.canWrite || setDeleted}
                max={3}
                ariaLabel={t("Set tags")}
                onChange={(next) => editor.setSetTags(set.id, next)}
              />
            </div>
            {setMenu.length > 0 && (
              <div className="ml-auto shrink-0 self-center">
                <ActionMenu
                  ariaLabel={t("Set actions")}
                  items={setMenu}
                  minWidth={160}
                  triggerClassName={ROW_MENU_TRIGGER}
                  triggerIcon={<EllipsisIcon className="size-4" />}
                />
              </div>
            )}
          </div>
        </td>
      </tr>

      {/* Rep rows — each rep (including the first) in its own row, hidden when
          the set is collapsed. */}
      {expanded && reps.length === 0 && (
        <tr className="border-b border-surface-stroke last:border-b-0">
          <Td colSpan={totalCols} className="text-foreground-muted">
            —
          </Td>
        </tr>
      )}
      {expanded &&
        reps.map((rep, ri) => {
          const repDeleted = setDeleted || editor.isRepDeleted(rep.id);
          const muted = repDeleted || !rep.valid;
          const repMenu: ActionMenuItem[] = [
            ...(onJumpToTechnique
              ? [
                  {
                    label: t("View technique"),
                    onClick: () => onJumpToTechnique(set.id, rep.id),
                  },
                ]
              : []),
            // Validity + deletion mutate the session; "View technique" above is a
            // read, so it survives without writeData.
            ...(editor.canWrite
              ? [
                  rep.valid
                    ? {
                        label: t("Mark invalid"),
                        onClick: () => editor.setRepValid(set.id, rep.id, false),
                      }
                    : {
                        label: t("Mark valid"),
                        onClick: () => editor.setRepValid(set.id, rep.id, true),
                      },
                  editor.isRepDeleted(rep.id)
                    ? {
                        label: t("Restore rep"),
                        onClick: () => editor.toggleRepDeletion(rep.id),
                      }
                    : {
                        label: t("Delete rep"),
                        destructive: true,
                        onClick: () => editor.toggleRepDeletion(rep.id),
                      },
                ]
              : []),
          ];
          return (
            <tr
              key={rep.id}
              // Reps cells sit on the neutral surface (`surface-base`). Only
              // horizontal row separators — no vertical column lines. Tighter
              // rows than the shared Td default: the child-targeting `[&>td]`
              // variant outranks the cell's own `py-3`, trimming the per-rep row
              // height without touching the other dashboard tables. Per-cell
              // heatmap tints are inline styles, so they still override this base.
              className={`border-b border-surface-stroke bg-surface-base last:border-b-0 [&>td]:py-1 ${
                muted ? "text-foreground-more-muted opacity-60" : ""
              }`}
            >
              <Td className="text-right tabular-nums">
                <span className="inline-flex items-center gap-1.5">
                  {/* Marker slot, ALWAYS reserved so the numbers stay in one
                      flush column and the markers scan as a column of their
                      own. The glyph is explained by the legend above the table,
                      the same way ★ / ▲ / ▼ are — muting alone would be
                      ambiguous here, since a deleted rep mutes identically. */}
                  <span className="inline-flex size-3 shrink-0 items-center justify-center">
                    {!rep.valid && (
                      <span
                        role="img"
                        aria-label={t("Invalid rep")}
                        title={t("Invalid rep")}
                        className="inline-flex"
                      >
                        <NoSignIcon className="size-3 text-foreground-muted" />
                      </span>
                    )}
                  </span>
                  <span className={repDeleted ? "line-through" : ""}>
                    {ri + 1}
                  </span>
                </span>
              </Td>
              {!onlyConcentric && (
                <Td className={repDeleted ? "line-through" : ""}>
                  {phaseLabel(rep.phase, t)}
                </Td>
              )}
              <Td>
                <SessionTagsCell
                  tags={rep.assignedTags ?? []}
                  entityKind="rep"
                  readOnly={!editor.canWrite || repDeleted}
                  ariaLabel={t("Rep tags")}
                  onChange={(next) => editor.setRepTags(set.id, rep.id, next)}
                />
              </Td>
              {columns.map((m) => {
                const metricId = m.id ?? "";
                const extreme = extremeCells.get(`${metricId}:${rep.id}`);
                const raw = repMetricRaw(rep, m, loadRaw);
                const value =
                  raw == null ? "" : (units.format(raw, m.dimension).text ?? "");
                // Per-column heatmap tint (transparent→mint); skipped for empty
                // cells and when the heatmap is toggled off (ranges == null).
                const heat =
                  raw == null
                    ? undefined
                    : heatBackground(raw, ranges?.get(metricId));
                return (
                  <Td
                    key={columnKey(m)}
                    className={`text-right tabular-nums ${repDeleted ? "line-through" : ""}`}
                    style={heat ? { backgroundColor: heat } : undefined}
                  >
                    {extreme ? (
                      <span className="inline-flex items-center justify-end gap-1">
                        <span
                          role="img"
                          aria-label={
                            extreme === "max"
                              ? t("Maximum in session")
                              : t("Minimum in session")
                          }
                          title={
                            extreme === "max"
                              ? t("Maximum in session")
                              : t("Minimum in session")
                          }
                          className="inline-flex shrink-0"
                        >
                          {extreme === "max" ? (
                            <ArrowUpFillIcon className="size-2.5 text-foreground-muted" />
                          ) : (
                            <ArrowDownFillIcon className="size-2.5 text-foreground-muted" />
                          )}
                        </span>
                        {value}
                      </span>
                    ) : (
                      // Empty when the rep doesn't carry this metric.
                      value
                    )}
                  </Td>
                );
              })}
              <Td
                className="text-center"
                style={{ paddingLeft: 0, paddingRight: 0 }}
              >
                {repMenu.length > 0 && (
                  <ActionMenu
                    ariaLabel={t("Rep actions")}
                    items={repMenu}
                    minWidth={160}
                    triggerClassName={ROW_MENU_TRIGGER}
                    triggerIcon={<EllipsisIcon className="size-4" />}
                  />
                )}
              </Td>
            </tr>
          );
        })}
    </>
  );
}

function MetricTh({
  metric,
  units,
  focus,
  color,
}: {
  metric: MetricReturnDto;
  units: Units;
  // Focus metric → a star prefix in the category colour.
  focus: boolean;
  // The metric's category colour (drives the focus star).
  color: string;
}) {
  const name = useDisplayName(metric, metric.key);
  const unit = units.symbol(metric.dimension);
  return (
    <th className="heading-section px-4 py-3 text-left align-top text-foreground-muted">
      {/* block + break-words so a long name wraps within the fixed column width
          (≈2 lines) instead of widening the column. The focus star is inline so
          it leads the first line. */}
      <span className="block break-words leading-tight">
        {focus && (
          <span className="mr-1 inline-block align-[-2px]" style={{ color }}>
            <StarFillIcon className="size-3" />
          </span>
        )}
        {name}
      </span>
      {unit && (
        <span className="stat-small mt-0.5 block uppercase text-foreground-more-muted">
          {unit}
        </span>
      )}
    </th>
  );
}

// Toolbar legend explaining the table's encodings: the (set-wise) heatmap ramp,
// the focus-metric star, the session extremes and the invalid-rep marker. This
// is where the table teaches its glyphs — a marker in the rows is only as
// readable as its entry here. The heat item hides when the heatmap is off.
function Legend({ showHeatmap }: { showHeatmap: boolean }) {
  const { t } = useTranslation();
  return (
    <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 stat-small text-foreground-muted">
      {showHeatmap && (
        <span className="flex items-center gap-1.5">
          <span
            aria-hidden
            className="h-3 w-12 rounded-sm ring-1 ring-inset ring-surface-stroke"
            style={{ background: `linear-gradient(to right, transparent, ${HEAT_GREEN})` }}
          />
          {t("Lower → higher (per set)")}
        </span>
      )}
      <span className="flex items-center gap-1.5">
        <StarFillIcon className="size-3 text-foreground-muted" />
        {t("Focus metric")}
      </span>
      <span className="flex items-center gap-1.5">
        <ArrowUpFillIcon className="size-2.5 text-foreground-muted" />
        {t("Maximum in session")}
      </span>
      <span className="flex items-center gap-1.5">
        <NoSignIcon className="size-3 text-foreground-muted" />
        {t("Invalid rep — not counted")}
      </span>
      <span className="flex items-center gap-1.5">
        <ArrowDownFillIcon className="size-2.5 text-foreground-muted" />
        {t("Minimum in session")}
      </span>
    </div>
  );
}

// The category band cell's content: the real metric-category icon (its `media`
// Lottie, rendered as a static tinted frame — the same glyph the focus-metric
// and Insights category strips use) + name + an underline, all in the category
// colour (the border/text via a CSS var ref so they flip with the theme; the
// icon tint needs a concrete hex).
function CategoryBand({ category }: { category: MetricCategoryReturnDto }) {
  const color = metricCategoryColorOf(category);
  const hex = metricCategoryColorHexOf(category);
  const name = useDisplayName(category, category.key ?? "");
  return (
    <div
      className="flex items-center gap-1.5 border-b-2 pb-2"
      style={{ color, borderColor: color }}
    >
      <LottieCircle
        media={category.media}
        variant="icon"
        size={18}
        surfaceClassName="bg-transparent"
        tintHex={hex}
      />
      <span className="stat-small uppercase tracking-wide">
        {name}
      </span>
    </div>
  );
}

// ─── editable set measurement ────────────────────────────────────────────────
// One set-level measurement as an editable field: metric name, a numeric input
// (the value in the active unit system), and the unit symbol. The input holds a
// local text draft so the user can type freely; on commit (blur / Enter) it
// parses, converts back to stored base units via `fromDisplay`, and reports the
// new user value. Esc reverts. The draft re-syncs (prev-value pattern, not a
// setState-in-effect) whenever the underlying stored value changes.
function EditableSetMeasurement({
  measurement,
  metric,
  units,
  disabled,
  readOnly = false,
  keyboardEnabled = false,
  onOpenKeyboard,
  onCommit,
}: {
  measurement: SessionMeasurementReturnDto;
  metric: MetricReturnDto;
  units: Units;
  disabled: boolean;
  // No writeData: render the value as plain text instead of an input, so the
  // number is still readable but there's no editing affordance at all.
  readOnly?: boolean;
  // Route entry through the in-app metric keyboard: the input suppresses the
  // native software keyboard and focusing it opens the sheet instead, which owns
  // the draft and commits through the group's own onCommit. The local draft /
  // blur-commit path below is the NATIVE fallback (hardware keyboard present).
  keyboardEnabled?: boolean;
  onOpenKeyboard?: () => void;
  onCommit: (valueUser: number) => void;
}) {
  const name = useDisplayName(metric, metric.key);
  const unit = units.symbol(metric.dimension);
  const stored = valueOf(measurement);
  const displayStr = editableNumber(units.toDisplay(stored, metric.dimension));

  const [draft, setDraft] = useState(displayStr);
  const [prevDisplay, setPrevDisplay] = useState(displayStr);
  if (prevDisplay !== displayStr) {
    setPrevDisplay(displayStr);
    setDraft(displayStr);
  }

  // Read-only: the same value + unit, as text. Rendered after the hooks above so
  // the hook order stays stable if the privilege resolves mid-mount.
  if (readOnly) {
    return (
      <span className="flex items-center gap-1">
        <span className="tabular-nums text-foreground-default">
          {displayStr}
        </span>
        {unit && (
          <span className="stat-small text-foreground-muted">{unit}</span>
        )}
      </span>
    );
  }

  function commit() {
    const parsed = Number(draft.trim().replace(",", "."));
    if (draft.trim() === "" || Number.isNaN(parsed)) {
      setDraft(displayStr); // not a number → revert
      return;
    }
    const next = units.fromDisplay(parsed, metric.dimension);
    if (next !== stored) onCommit(next);
  }

  // No visible caption — the value + unit (e.g. "40 KG", "24 RIR") carry the
  // meaning on their own. The metric name still rides along as the input's
  // aria-label so assistive tech knows what the field edits.
  return (
    <label className="flex items-center gap-1">
      <input
        type="text"
        inputMode={keyboardEnabled ? "none" : "decimal"}
        aria-label={name}
        // With the in-app keyboard the SHEET owns the draft, so the input just
        // displays the stored value; without it, the local draft + blur-commit
        // path below runs as before.
        value={keyboardEnabled ? displayStr : draft}
        disabled={disabled}
        readOnly={keyboardEnabled}
        onFocus={keyboardEnabled ? onOpenKeyboard : undefined}
        onChange={
          keyboardEnabled ? undefined : (e) => setDraft(e.target.value)
        }
        onBlur={keyboardEnabled ? undefined : commit}
        onKeyDown={
          keyboardEnabled
            ? undefined
            : (e) => {
                if (e.key === "Enter") e.currentTarget.blur();
                else if (e.key === "Escape") {
                  setDraft(displayStr);
                  e.currentTarget.blur();
                }
              }
        }
        className="w-14 rounded-md border border-surface-stroke bg-surface-base px-1.5 py-0.5 text-right tabular-nums text-foreground-default disabled:opacity-50"
      />
      {unit && (
        <span className="stat-small uppercase text-foreground-more-muted">
          {unit}
        </span>
      )}
    </label>
  );
}

// Trims a display value to a clean, typeable string (drops float noise + any
// trailing zeros): 60, 0.82, 1.5 — never "60.000".
function editableNumber(n: number): string {
  return String(Math.round(n * 1000) / 1000);
}

// ─── row action menu ─────────────────────────────────────────────────────────
// Trigger for the per-row (set / rep) ⋯ menu — a small round icon button. The
// menu itself is the shared @enode/ui ActionMenu (portaled + anchored + clamped),
// so it escapes the reps table's `overflow-x-auto` clip.
const ROW_MENU_TRIGGER =
  "inline-flex size-7 items-center justify-center rounded-full text-foreground-muted hover:bg-surface-base-muted hover:text-foreground-default";

// ─── column helpers ──────────────────────────────────────────────────────────
// Stable, always-defined column key (MetricReturnDto.id is optional).
function columnKey(m: MetricReturnDto): string {
  return m.id ?? m.key;
}

// Orders the dataset's metric columns by metric category (the category's own
// `order`), then by the metric's `order` within that category. A metric whose
// category can't be resolved sorts last (its category order falls back to +∞).
function orderColumns(dataset: MetricReturnDto[]): MetricReturnDto[] {
  const categoryOrder = (m: MetricReturnDto): number =>
    getMetricCategory(m.metricCategoryID)?.order ?? Number.MAX_SAFE_INTEGER;
  return [...dataset].sort(
    (a, b) => categoryOrder(a) - categoryOrder(b) || a.order - b.order,
  );
}

type CategoryGroup = {
  key: string;
  category: MetricCategoryReturnDto | null;
  metrics: MetricReturnDto[];
};

// Folds the (already category-ordered) columns into consecutive runs sharing a
// metric category — each run becomes one cell in the header's category band.
function groupByCategory(columns: MetricReturnDto[]): CategoryGroup[] {
  const groups: CategoryGroup[] = [];
  for (const m of columns) {
    const category = getMetricCategory(m.metricCategoryID);
    const key = category?.id ?? m.metricCategoryID ?? "unknown";
    const last = groups[groups.length - 1];
    if (last && last.key === key) last.metrics.push(m);
    else groups.push({ key, category, metrics: [m] });
  }
  return groups;
}

// ─── value helpers ───────────────────────────────────────────────────────────
function valueOf(m: SessionMeasurementReturnDto): number {
  return m.valueUser != null && m.valueUser > 0 ? m.valueUser : m.value;
}

// Human label for a rep's phase; the backend's "unknown" renders blank.
function phaseLabel(phase: WorkoutRepReturnDto["phase"], t: TranslateFn): string {
  switch (phase) {
    case "concentric":
      return t("Concentric");
    case "eccentric":
      return t("Eccentric");
    default:
      return "";
  }
}

// The set's load measurement, resolved via the metric `loading` flag (mass or
// flywheel inertia — never a hardcoded key).
function setLoadMeasurement(
  set: SetReturnDto,
): SessionMeasurementReturnDto | null {
  return findLoadMeasurement(set.measurements ?? []);
}

// The set's load in stored/base units; 1 when the set has no load measurement
// (loading-factor metrics then show their raw value, mirroring core `setLoad`).
function setLoadRaw(set: SetReturnDto): number {
  const m = setLoadMeasurement(set);
  return m ? valueOf(m) : 1;
}

// A set's measurements paired with their resolved metric (inlined or looked up
// from the catalogue), in metric order — the editable set-measurement rows.
function setMeasurementRows(
  set: SetReturnDto,
): { measurement: SessionMeasurementReturnDto; metric: MetricReturnDto }[] {
  return (set.measurements ?? [])
    .map((measurement) => {
      const metric =
        measurement.metric ?? getMetric(measurement.metricID) ?? null;
      return metric ? { measurement, metric } : null;
    })
    .filter((x): x is { measurement: SessionMeasurementReturnDto; metric: MetricReturnDto } => x != null)
    .sort((a, b) => a.metric.order - b.metric.order);
}

// The rep's raw numeric value for a metric (in stored/base units), or null when
// the rep doesn't carry that metric. loadingFactor metrics are a fraction of the
// set load, so they scale by the set's load (old portal). Shared by the cell
// formatter and the best-value computation so both agree on the comparable value.
function repMetricRaw(
  rep: WorkoutRepReturnDto,
  metric: MetricReturnDto,
  loadRaw: number,
): number | null {
  const m = (rep.measurements ?? []).find(
    (x) => (x.metric?.id ?? x.metricID) === metric.id,
  );
  if (!m) return null;
  return valueOf(m) * loadFactorScale(metric, loadRaw);
}

// Per-column heat range: min/max of the column, and whether to invert the heat
// (lower = greener) — true for the `time` category, where a smaller value is the
// better result.
export type ColumnRange = { min: number; max: number; invert: boolean };

function isTimeMetric(metric: MetricReturnDto): boolean {
  return getMetricCategory(metric.metricCategoryID)?.key === "time";
}

// The MIN and MAX value in each metric column across the WHOLE session, as a
// `${metricId}:${repId}` → "min" | "max" map (the down-/up-triangle markers);
// ties all win. Considers only valid, non-deleted reps in non-deleted sets.
// Session-wide on purpose — the markers are the session extremes, independent of
// the per-set heatmap. A column with a single distinct value carries no marker
// (min === max is not a meaningful extreme). Recomputed each render so it tracks
// edits (deleting/invalidating the leader promotes the next-best).
function computeExtremeCells(
  sets: SetReturnDto[],
  columns: MetricReturnDto[],
  editor: SessionEditor,
): Map<string, "min" | "max"> {
  const stats = new Map<
    string,
    { max: number; maxReps: string[]; min: number; minReps: string[] }
  >();
  for (const set of sets) {
    if (editor.isSetDeleted(set.id)) continue;
    const loadRaw = setLoadRaw(set);
    for (const rep of set.reps ?? []) {
      if (!rep.valid || editor.isRepDeleted(rep.id)) continue;
      for (const metric of columns) {
        const metricId = metric.id;
        if (!metricId) continue;
        const v = repMetricRaw(rep, metric, loadRaw);
        if (v == null) continue;
        const cur = stats.get(metricId);
        if (!cur) {
          stats.set(metricId, {
            max: v,
            maxReps: [rep.id],
            min: v,
            minReps: [rep.id],
          });
          continue;
        }
        if (v > cur.max) {
          cur.max = v;
          cur.maxReps = [rep.id];
        } else if (v === cur.max) cur.maxReps.push(rep.id);
        if (v < cur.min) {
          cur.min = v;
          cur.minReps = [rep.id];
        } else if (v === cur.min) cur.minReps.push(rep.id);
      }
    }
  }
  const markers = new Map<string, "min" | "max">();
  for (const [metricId, { max, maxReps, min, minReps }] of stats) {
    // Flat column (one distinct value) → no extreme to mark.
    if (max === min) continue;
    for (const repId of maxReps) markers.set(`${metricId}:${repId}`, "max");
    for (const repId of minReps) markers.set(`${metricId}:${repId}`, "min");
  }
  return markers;
}

// Per-column min/max (+ time invert) for ONE set, over that set's valid,
// non-deleted reps — the SET-WISE heatmap range. Each set's reps are coloured
// relative to that set (same load/conditions), so within-set trends (fatigue,
// ramps) read clearly and cross-set load differences don't skew the scale. Empty
// for a deleted set (no heat). Recomputed each render to track edits.
function computeSetRanges(
  set: SetReturnDto,
  columns: MetricReturnDto[],
  editor: SessionEditor,
): Map<string, ColumnRange> {
  const ranges = new Map<string, ColumnRange>();
  if (editor.isSetDeleted(set.id)) return ranges;
  const loadRaw = setLoadRaw(set);
  for (const rep of set.reps ?? []) {
    if (!rep.valid || editor.isRepDeleted(rep.id)) continue;
    for (const metric of columns) {
      const metricId = metric.id;
      if (!metricId) continue;
      const v = repMetricRaw(rep, metric, loadRaw);
      if (v == null) continue;
      const r = ranges.get(metricId);
      if (!r) ranges.set(metricId, { min: v, max: v, invert: isTimeMetric(metric) });
      else {
        if (v < r.min) r.min = v;
        if (v > r.max) r.max = v;
      }
    }
  }
  return ranges;
}

// The heat tint base: the success-SURFACE token (a pale mint in light, a deep
// green surface in dark) — purpose-built as a background, unlike the dark
// foreground greens. It's already light enough to keep cell text legible, so the
// ramp runs to full strength: `transparent` at the worst end → the full token at
// the best end. Layered over `transparent` so the table surface shows through and
// it flips with the theme.
const HEAT_GREEN = "var(--feedback-success-surface)";
const HEAT_MAX_PCT = 100;

// The cell background for `raw` within its column's range: transparent at the
// worst end, a light green at the best end (inverted for `time`). Undefined when
// there's no range or the column is flat (one distinct value → no ranking).
function heatBackground(
  raw: number,
  range: ColumnRange | undefined,
): string | undefined {
  if (!range) return undefined;
  const span = range.max - range.min;
  if (span <= 0) return undefined;
  let t = (raw - range.min) / span;
  if (range.invert) t = 1 - t;
  t = Math.max(0, Math.min(1, t));
  return `color-mix(in srgb, ${HEAT_GREEN} ${(t * HEAT_MAX_PCT).toFixed(1)}%, transparent)`;
}

// Every distinct metric present anywhere in the dataset (across all reps of all
// sets), in metric order. A measurement may inline its `metric` or carry only a
// `metricID`; for the latter we resolve the metric from the catalogue so a
// metric that's only ever referenced by id still gets a column.
function datasetMetrics(sets: SetReturnDto[]): MetricReturnDto[] {
  const byId = new Map<string, MetricReturnDto>();
  for (const set of sets)
    for (const rep of set.reps ?? [])
      for (const m of rep.measurements ?? []) {
        const id = m.metric?.id ?? m.metricID;
        if (!id || byId.has(id)) continue;
        const metric = m.metric ?? getMetric(id);
        // Skip internal bookkeeping metrics (e.g. repOrder) so they don't
        // surface as spurious columns — same filter the perf service uses.
        if (metric && isDisplayableMetric(metric)) byId.set(id, metric);
      }
  return [...byId.values()].sort((a, b) => a.order - b.order);
}



