// charts.jsx  v6  —  共用图表组件：RadarChart · TrendChart · ACWRChart · ComparisonBars
// 职责：纯展示组件，无自有 state，接收数据 props 渲染
// 依赖：无内部依赖 · 由 dashboard-views / team / cmj 引用

const { useMemo: chartUseMemo, useState: chartUseState, useEffect: chartUseEffect, useRef: chartUseRef } = React;

// ──────────────────────────────────────────────────────────────────────────
// RadarChart — group scores across athletes (single or compared)
// data: array of { label: athleteName, color, scores: { groupId: 0-100 } }
// axes: array of { id, label, accent }
// ──────────────────────────────────────────────────────────────────────────
const RadarChart = ({ axes, data, size = 360, showSquadAvg = true, squadAvg = null, prevScores = null }) => {
  const cx = size / 2;
  const cy = size / 2;
  const radius = size / 2 - 56;
  const n = axes.length;

  const pointFor = (i, r01) => {
    const angle = (Math.PI * 2 * i) / n - Math.PI / 2;
    const r = radius * r01;
    return [cx + Math.cos(angle) * r, cy + Math.sin(angle) * r];
  };

  const polygon = (scores) => axes.map((a, i) => {
    const score = (scores[a.id] || 0) / 100;
    return pointFor(i, score).join(',');
  }).join(' ');

  const rings = [0.25, 0.5, 0.75, 1];

  return (
    <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ display: 'block' }}>
      <defs>
        {data.map((d, i) => (
          <radialGradient key={i} id={`radarFill-${i}`}>
            <stop offset="0%" stopColor={d.color} stopOpacity="0.35"/>
            <stop offset="100%" stopColor={d.color} stopOpacity="0.05"/>
          </radialGradient>
        ))}
      </defs>

      {/* grid rings */}
      {rings.map((r, i) => (
        <polygon
          key={i}
          points={axes.map((_, idx) => pointFor(idx, r).join(',')).join(' ')}
          fill="none"
          stroke={i === rings.length - 1 ? 'var(--chart-grid-strong)' : 'var(--chart-grid)'}
          strokeWidth={i === rings.length - 1 ? 1 : 0.5}
        />
      ))}
      {/* axis lines */}
      {axes.map((a, i) => {
        const [x, y] = pointFor(i, 1);
        return <line key={a.id} x1={cx} y1={cy} x2={x} y2={y} stroke="var(--chart-grid)" strokeWidth={0.5}/>;
      })}

      {/* ring labels */}
      {rings.map((r, i) => (
        <text key={i} x={cx + 4} y={cy - radius * r + 3}
          fill="var(--muted-2)" fontSize="9" fontFamily="var(--font-mono)">
          {Math.round(r * 100)}
        </text>
      ))}

      {/* squad avg */}
      {showSquadAvg && squadAvg && (
        <polygon
          points={polygon(squadAvg)}
          fill="none"
          stroke="var(--muted)"
          strokeWidth="1"
          strokeDasharray="3 4"
          opacity={0.7}
        />
      )}

      {/* previous period ghost overlay */}
      {prevScores && (
        <polygon
          points={polygon(prevScores)}
          fill="rgba(15,23,42,.05)"
          stroke="rgba(15,23,42,.32)"
          strokeWidth="1.5"
          strokeDasharray="4 4"
          opacity={0.7}
        />
      )}

      {/* data polygons */}
      {data.map((d, i) => (
        <g key={d.label} className="radar-chart-poly" style={{ transition: 'opacity .3s', '--poly-delay': `${i * 0.06}s` }}>
          <polygon
            points={polygon(d.scores)}
            fill={`url(#radarFill-${i})`}
            stroke={d.color}
            strokeWidth="2"
            strokeLinejoin="round"
          />
          {axes.map((a, ai) => {
            const score = (d.scores[a.id] || 0) / 100;
            const [px, py] = pointFor(ai, score);
            return (
              <circle key={a.id} className="radar-chart-vertex" cx={px} cy={py} r="3.5"
                fill={d.color} stroke="var(--panel)" strokeWidth="1.5"
                style={{ '--vertex-delay': `${(i * axes.length + ai) * 0.03}s` }}>
                <title>{`${d.label} · ${a.label} · ${Math.round((d.scores[a.id] || 0))}`}</title>
              </circle>
            );
          })}
        </g>
      ))}

      {/* axis labels */}
      {axes.map((a, i) => {
        const [x, y] = pointFor(i, 1.18);
        return (
          <g key={a.id}>
            <text x={x} y={y} textAnchor="middle" dominantBaseline="middle"
              fill="var(--muted-2)" fontSize="10"
              style={{ textTransform: 'uppercase', letterSpacing: '.08em', fontWeight: 600 }}>
              {a.label}
            </text>
          </g>
        );
      })}
    </svg>
  );
};

// ──────────────────────────────────────────────────────────────────────────
// TrendChart — line over dates, with day/week/month grouping + hover tooltip
// series: [{ label, color, values: [{ x: iso, y }] }]
// squadAvg: [{ x: iso, y }]
// squadRange: [{ x: iso, best, worst }]
// grouping: 'day' | 'week' | 'month'
// ──────────────────────────────────────────────────────────────────────────
function bucketKey(iso, grouping) {
  const d = new Date(iso);
  if (grouping === 'month') {
    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
  }
  if (grouping === 'week') {
    // Week index since epoch, treats Monday as start
    const day = d.getUTCDay() || 7;
    const monday = new Date(d); monday.setUTCDate(d.getUTCDate() - day + 1);
    return Math.floor(+monday / (7*24*60*60*1000)).toString();
  }
  return iso;
}

function aggregateSeries(values, grouping) {
  if (!values || !values.length) return [];
  if (grouping === 'day') {
    return values
      .filter(v => v.y != null && !isNaN(v.y))
      .map(v => ({ x: +new Date(v.x), y: v.y, count: 1, dateIso: v.x }))
      .sort((a,b) => a.x - b.x);
  }
  const groups = {};
  values.forEach(v => {
    if (v.y == null || isNaN(v.y)) return;
    const key = bucketKey(v.x, grouping);
    if (!groups[key]) groups[key] = { ys: [], dates: [] };
    groups[key].ys.push(v.y);
    groups[key].dates.push(+new Date(v.x));
  });
  return Object.entries(groups).map(([key, g]) => {
    const centroid = g.dates.reduce((a,b)=>a+b,0) / g.dates.length;
    return {
      key,
      x: centroid,
      y: g.ys.reduce((a,b)=>a+b,0) / g.ys.length,
      count: g.ys.length,
      dateIso: new Date(centroid).toISOString().slice(0,10),
    };
  }).sort((a,b) => a.x - b.x);
}

function aggregateRange(rangeArr, grouping) {
  if (!rangeArr || !rangeArr.length) return [];
  if (grouping === 'day') {
    return rangeArr.map(r => ({ x: +new Date(r.x), best: r.best, worst: r.worst }))
      .sort((a,b) => a.x - b.x);
  }
  const groups = {};
  rangeArr.forEach(r => {
    const key = bucketKey(r.x, grouping);
    if (!groups[key]) groups[key] = { bests: [], worsts: [], dates: [] };
    groups[key].bests.push(r.best);
    groups[key].worsts.push(r.worst);
    groups[key].dates.push(+new Date(r.x));
  });
  return Object.values(groups).map(g => ({
    x: g.dates.reduce((a,b)=>a+b,0) / g.dates.length,
    best: g.bests.reduce((a,b)=>a+b,0) / g.bests.length,
    worst: g.worsts.reduce((a,b)=>a+b,0) / g.worsts.length,
  })).sort((a,b) => a.x - b.x);
}

function computeXTicks(tMin, tMax, grouping) {
  if (tMin == null || tMax == null || !isFinite(tMin) || !isFinite(tMax)) return [];
  const DAY = 24*60*60*1000;
  const monthsSpan = (tMax - tMin) / (DAY * 30);
  let stepMonths = 1;
  if (monthsSpan > 30) stepMonths = 6;
  else if (monthsSpan > 18) stepMonths = 3;
  else if (monthsSpan > 9)  stepMonths = 2;
  else stepMonths = 1;
  const ticks = [];
  const start = new Date(tMin); start.setDate(1); start.setHours(0,0,0,0);
  let cur = new Date(start);
  while (+cur <= tMax + 5*DAY) {
    if (+cur >= tMin - 5*DAY) {
      const iso = cur.toISOString().slice(0,10);
      ticks.push({
        epoch: +cur,
        label: (window.formatDate ? window.formatDate(iso, 'month') : iso),
      });
    }
    cur.setMonth(cur.getMonth() + stepMonths);
  }
  if (ticks.length === 0 && isFinite(tMin)) {
    const iso = new Date(tMin).toISOString().slice(0,10);
    ticks.push({ epoch: tMin, label: window.formatDate ? window.formatDate(iso, 'month') : iso });
  }
  return ticks;
}

// DATE-ROBUSTNESS guard (audit 2026-07-07). A malformed date string in the data
// (e.g. the season key "2025/7/5 9" from a bad manual entry) parses to NaN via
// `+new Date`, which then poisons `Math.min/max(...dates)` → every point collapses
// to x=0 (data piles at the left) — and crashes date bucketing at
// `new Date(NaN).toISOString()`. Every time-axis chart must drop unplottable-date
// points BEFORE building a time scale. Keep this predicate the single source of
// truth; mirrored in team.jsx (TeamTrendChart/ACWRChart) and app.jsx (trendData).
const trPlottablePoints = (points) => (points || []).filter(p => p && !isNaN(+new Date(p.x)));

const TrendChart = ({ series, squadAvg, squadRange, yUnit, direction = 'higher', height = 280, grouping = 'day', annotations = null, dualAxis = false, minimumLinePoints = 1, lineMode = 'smooth' }) => {
  // MB-3: dual-Y mode overlays 2 metrics (left = series[0], right = series[1]) on
  // INDEPENDENT scales — no shared axis, no normalization, no squad band. Detected
  // from the dualAxis prop + exactly 2 series carrying distinct axis markers.
  const dual = dualAxis && series.length === 2 && series[0].axis === 'left' && series[1].axis === 'right';
  const pad = { top: 30, right: dual ? 52 : 36, bottom: 38, left: 52 };
  const [w, setW] = chartUseState(640);
  const [hover, setHover] = chartUseState(null);
  const [dualHover, setDualHover] = chartUseState(null);
  const ref = chartUseRef(null);

  chartUseEffect(() => {
    if (!ref.current) return;
    const ro = new ResizeObserver((entries) => {
      for (const e of entries) {
        const cw = e.contentRect.width;
        if (cw > 160) setW(cw);
      }
    });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, []);

  // Drop unplottable-date points before aggregation (see trPlottablePoints): a bad
  // date would NaN-poison tMin/tMax (left-pile) and crash week/month toISOString.
  const aggSeries = chartUseMemo(
    () => series.map(s => ({ ...s, values: aggregateSeries(trPlottablePoints(s.values), grouping) })),
    [series, grouping]
  );
  const aggAvg   = chartUseMemo(() => aggregateSeries(trPlottablePoints(squadAvg), grouping), [squadAvg, grouping]);
  const aggRange = chartUseMemo(() => aggregateRange(trPlottablePoints(squadRange), grouping), [squadRange, grouping]);

  // gather all dates; y values feed the PRIMARY (left) scale. In dual mode the
  // right series is excluded from the left scale (it reads its own axis); in
  // single mode every series + band/avg shares the one scale (today's behavior).
  const allDates = [];
  const allYs = [];
  aggSeries.forEach((s, i) => s.values.forEach(v => { allDates.push(v.x); if (!(dual && i === 1)) allYs.push(v.y); }));
  aggAvg.forEach(v => { allDates.push(v.x); allYs.push(v.y); });
  aggRange.forEach(r => { allDates.push(r.x); allYs.push(r.best, r.worst); });

  // Independent scale for one series' values, with the same 12% padding.
  const scaleOf = (vals) => {
    let mn = vals.length ? Math.min(...vals) : 0;
    let mx = vals.length ? Math.max(...vals) : 1;
    const r = (mx - mn) || 1;
    return { min: mn - r * 0.12, max: mx + r * 0.12 };
  };

  const tMin = allDates.length ? Math.min(...allDates) : 0;
  const tMax = allDates.length ? Math.max(...allDates) : 1;
  const leftScale = scaleOf(allYs);
  let yMin = leftScale.min, yMax = leftScale.max;
  // Right axis (dual only): scaled from series[1]'s own values.
  const rightScale = dual ? scaleOf(aggSeries[1].values.map(v => v.y)) : null;

  const W = w, H = height;
  const innerW = Math.max(40, W - pad.left - pad.right);
  const innerH = H - pad.top - pad.bottom;

  const tSpan = Math.max(1, tMax - tMin);
  const xAt = (epoch) => pad.left + (innerW * (epoch - tMin)) / tSpan;
  const yAt = (y) => pad.top + innerH * (1 - (y - yMin) / (yMax - yMin));
  const yAtRight = (y) => pad.top + innerH * (1 - (y - rightScale.min) / (rightScale.max - rightScale.min));
  // Per-series y-mapper: left scale for series[0] (and all single-mode series),
  // right scale for series[1] in dual mode.
  const yAtSeries = (i, y) => (dual && i === 1) ? yAtRight(y) : yAt(y);

  const yTicks = [0, 0.25, 0.5, 0.75, 1].map(t => yMin + (yMax - yMin) * t);
  const yTicksRight = dual ? [0, 0.25, 0.5, 0.75, 1].map(t => rightScale.min + (rightScale.max - rightScale.min) * t) : [];
  const xTicks = chartUseMemo(() => computeXTicks(tMin, tMax, grouping), [tMin, tMax, grouping]);
  // Dual-mode hover: the union of both series' epochs (ascending), so a hover
  // strip exists at every date either metric has a point.
  const dualDates = dual
    ? Array.from(new Set([].concat(...aggSeries.map(s => s.values.map(v => v.x))))).sort((a, b) => a - b)
    : [];

  // ST-3a (P2-T0 §3.8): trend line uses the shared Catmull-Rom smoothing
  // helper (trSmoothPath, training.jsx) instead of a raw polyline join —
  // reused as-is, not reimplemented.
  const linePath = (values, sIdx = 0) => {
    const points = values.map(v => ({ x: xAt(v.x), y: yAtSeries(sIdx, v.y) }));
    if (lineMode === 'linear') {
      return points.map((point, i) => `${i === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
    }
    return window.trSmoothPath
      ? window.trSmoothPath(points)
      : points.map((point, i) => `${i === 0 ? 'M' : 'L'}${point.x},${point.y}`).join(' ');
  };

  // Empty state: no series have any datapoints
  const totalPoints = aggSeries.reduce((s, srs) => s + srs.values.length, 0) + aggAvg.length + aggRange.length;
  if (totalPoints === 0) {
    return (
      <div ref={ref} style={{ width: '100%', height, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, color: 'var(--muted)', fontSize: 12, border: '1px dashed var(--border)', borderRadius: 8, background: 'var(--panel-2)' }}>
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
          <path d="M3 18 L9 12 L13 16 L21 6"/>
        </svg>
        <span>No data points to plot</span>
      </div>
    );
  }

  // squad range band path (top edge then bottom edge reversed)
  let bandPath = null;
  if (aggRange.length > 1) {
    const top = aggRange.map(r => `${xAt(r.x)},${yAt(r.best)}`);
    const bot = aggRange.map(r => `${xAt(r.x)},${yAt(r.worst)}`).reverse();
    bandPath = `M${top.join(' L')} L${bot.join(' L')} Z`;
  }

  const isImprovement = (delta) => direction === 'lower' ? delta < 0 : delta > 0;

  return (
    <div ref={ref} style={{ width: '100%', position: 'relative' }}>
      <svg width={W} height={H} style={{ display: 'block' }}>
        <defs>
          {aggSeries.map((s, i) => (
            <linearGradient key={i} id={`tg-${i}`} x1="0" x2="0" y1="0" y2="1">
              <stop offset="0%" stopColor={s.color} stopOpacity="0.22"/>
              <stop offset="100%" stopColor={s.color} stopOpacity="0"/>
            </linearGradient>
          ))}
        </defs>

        {/* y grid — left axis. Ticks colored to their series in dual mode. */}
        {yTicks.map((t, i) => (
          <g key={i}>
            <line x1={pad.left} x2={W - pad.right} y1={yAt(t)} y2={yAt(t)}
              stroke="var(--chart-grid)" strokeWidth="1"/>
            <text x={pad.left - 8} y={yAt(t)} textAnchor="end" dominantBaseline="middle"
              fill={dual ? aggSeries[0].color : 'var(--muted-2)'} fillOpacity={dual ? 0.9 : 1}
              fontSize="10" fontFamily="var(--font-mono)">
              {formatTick(t)}
            </text>
          </g>
        ))}

        {/* Right axis ticks (dual only) — mirrored on the right edge, in series[1]'s color */}
        {dual && yTicksRight.map((t, i) => (
          <text key={`r${i}`} x={W - pad.right + 8} y={yAtRight(t)} textAnchor="start" dominantBaseline="middle"
            fill={aggSeries[1].color} fillOpacity="0.9" fontSize="10" fontFamily="var(--font-mono)">
            {formatTick(t)}
          </text>
        ))}

        {/* x axis */}
        <line x1={pad.left} x2={W - pad.right} y1={H - pad.bottom} y2={H - pad.bottom} stroke="var(--chart-grid-strong)"/>
        {xTicks.map((tk, i) => {
          const x = xAt(tk.epoch);
          if (x < pad.left - 4 || x > W - pad.right + 4) return null;
          return (
            <g key={i}>
              <line x1={x} x2={x} y1={H - pad.bottom} y2={H - pad.bottom + 4} stroke="var(--chart-axis)"/>
              <text x={x} y={H - pad.bottom + 18} textAnchor="middle"
                fill="var(--muted-2)" fontSize="10" fontFamily="var(--font-mono)" style={{ letterSpacing: '.04em' }}>
                {tk.label}
              </text>
            </g>
          );
        })}

        {/* squad range band */}
        {bandPath && (
          <path d={bandPath} fill="var(--chart-grid)" stroke="var(--chart-grid)" strokeWidth="0.5"/>
        )}

        {/* Timeline annotations (injury / intervention / competition) */}
        {annotations && annotations.length > 0 && window.AnnotationFlags && (() => {
          const Flags = window.AnnotationFlags;
          return (
            <Flags
              annotations={annotations}
              xScale={(dateStr) => {
                const t = +new Date(dateStr);
                if (!isFinite(t) || t < tMin || t > tMax) return null;
                return xAt(t);
              }}
              hostTop={pad.top}
              hostBottom={H - pad.bottom}
            />
          );
        })()}

        {/* squad avg dashed */}
        {aggAvg.length > 1 && (
          <path d={aggAvg.map((v, i) => `${i === 0 ? 'M' : 'L'}${xAt(v.x)},${yAt(v.y)}`).join(' ')}
            fill="none" stroke="var(--muted)" strokeWidth="1.2" strokeDasharray="4 4" opacity="0.7"/>
        )}

        {/* series */}
        {aggSeries.map((s, i) => {
          if (!s.values.length) return null;
          const lastIdx = s.values.length - 1;
          const showTrendLine = s.values.length >= minimumLinePoints;
          // Per-series unit: dual mode reads each metric's own unit (raw values on
          // its own axis); single mode uses the shared yUnit.
          const sUnit = dual ? (s.unit || '') : yUnit;
          const area = `${linePath(s.values, i)} L${xAt(s.values[lastIdx].x)},${H - pad.bottom} L${xAt(s.values[0].x)},${H - pad.bottom} Z`;
          // crude length estimate for the dash-based draw animation
          let approxLen = 0;
          for (let k = 1; k < s.values.length; k++) {
            const dx = xAt(s.values[k].x) - xAt(s.values[k-1].x);
            const dy = yAtSeries(i, s.values[k].y) - yAtSeries(i, s.values[k-1].y);
            approxLen += Math.hypot(dx, dy);
          }
          approxLen = Math.max(120, approxLen);
          // P4-1: small-N scatter — Weissgerber 2015: for ≤10 points render filled dots
          // so individual observations are unambiguously visible.
          const smallN = s.values.length <= 10;
          return (
            <g key={s.label}>
              {/* Dual mode drops the area fill (lines-only, per the mockup) to avoid
                  two overlapping gradients muddying the plot; single mode keeps it. */}
              {!dual && showTrendLine && (
                <path d={area} fill={`url(#tg-${i})`} style={{ opacity: 'var(--chart-area-alpha, 0.22)' }}/>
              )}
              {showTrendLine && (
                <path d={linePath(s.values, i)} fill="none" stroke={s.color}
                  strokeWidth="1.75"
                  strokeLinejoin="round" strokeLinecap="round"
                  className="chart-line-draw trend-chart-line"
                  style={{
                    '--draw-len': approxLen,
                    filter: `drop-shadow(var(--chart-line-shadow, 0 0 0 transparent) ${s.color}66)`,
                  }}/>
              )}
              {s.values.map((v, vi) => {
                const prev = vi > 0 ? s.values[vi - 1] : null;
                const isHover = hover && hover.sIdx === i && hover.vIdx === vi;
                const baseR = smallN ? 4.5 : 3;
                const isLast = vi === lastIdx;
                const cy = yAtSeries(i, v.y);
                const pointTitle = `${window.formatDate ? window.formatDate(v.dateIso, 'medium') : v.dateIso} · ${formatTick(v.y)}${sUnit ? ` ${sUnit}` : ''}`;
                return (
                  <g key={vi} className="trend-chart-pt" style={{ '--pt-delay': `${Math.min(vi, 24) * 0.03}s` }}>
                    {/* Per-point hover hit-area — single mode only. Dual mode uses a
                        date-based hit strip below to surface BOTH metrics at once. */}
                    {!dual && (
                      <circle cx={xAt(v.x)} cy={cy}
                        r={12}
                        fill="transparent"
                        style={{ cursor: 'pointer' }}
                        onMouseEnter={() => setHover({ sIdx: i, vIdx: vi, color: s.color, label: s.label, v, prev })}
                        onMouseLeave={() => setHover(null)}
                      >
                        <title>{pointTitle}</title>
                      </circle>
                    )}
                    {/* Filled scatter dot for small-N (P4-1) */}
                    {smallN && !isHover && (
                      <circle cx={xAt(v.x)} cy={cy}
                        r={baseR}
                        fill={s.color} opacity="0.75"
                        style={{ pointerEvents: 'none' }}/>
                    )}
                    <circle cx={xAt(v.x)} cy={cy}
                      r={isHover ? 5.5 : (smallN ? baseR : 3)}
                      fill={isHover ? 'var(--panel)' : (smallN ? s.color : 'var(--panel)')}
                      stroke={s.color} strokeWidth={isHover ? 2.5 : (smallN ? 0 : 1.75)}
                      opacity={isHover ? 1 : (smallN ? 0.85 : 1)}
                      style={{
                        pointerEvents: 'none',
                        transition: 'r .15s cubic-bezier(.4,0,.2,1)',
                        filter: isHover ? `drop-shadow(0 0 var(--hover-halo-w, 4px) ${s.color}99)` : 'none',
                      }}/>
                    {/* Last-point value: non-positional numeric readout — hover only (§3.8) */}
                    {isLast && !dual && (
                      <circle cx={xAt(v.x)} cy={cy} r={9} fill="transparent" style={{ pointerEvents: 'none' }}>
                        <title>{pointTitle}</title>
                      </circle>
                    )}
                  </g>
                );
              })}
            </g>
          );
        })}

        {/* Dual-mode hover: date-based hit strips surface BOTH metrics' raw values
            at the hovered date. Built from the union of both series' epochs; each
            marker highlights whichever series has a point on that date. */}
        {dual && dualDates.map((epoch, di) => {
          const x = xAt(epoch);
          const bandW = dualDates.length > 1 ? Math.max(8, innerW / (dualDates.length - 1)) : innerW;
          return (
            <rect key={`hz${di}`} x={x - bandW / 2} y={pad.top} width={bandW} height={innerH}
              fill="transparent" style={{ cursor: 'pointer' }}
              onMouseEnter={() => setDualHover(epoch)} onMouseLeave={() => setDualHover(null)}/>
          );
        })}
        {dual && dualHover != null && aggSeries.map((s, i) => {
          const pt = s.values.find(v => v.x === dualHover);
          if (!pt) return null;
          return (
            <circle key={`dh${i}`} cx={xAt(dualHover)} cy={yAtSeries(i, pt.y)} r={5.5}
              fill="var(--panel)" stroke={s.color} strokeWidth="2.5"
              style={{ pointerEvents: 'none', filter: `drop-shadow(0 0 var(--hover-halo-w, 4px) ${s.color}99)` }}/>
          );
        })}
        {dual && dualHover != null && (
          <line x1={xAt(dualHover)} x2={xAt(dualHover)} y1={pad.top} y2={H - pad.bottom}
            stroke="var(--chart-grid-strong)" strokeWidth="1" strokeDasharray="2 3" opacity="0.5"
            style={{ pointerEvents: 'none' }}/>
        )}

        {/* hover crosshair */}
        {hover && (
          <line
            x1={xAt(hover.v.x)} x2={xAt(hover.v.x)}
            y1={pad.top} y2={H - pad.bottom}
            stroke={hover.color} strokeWidth="1" strokeDasharray="2 3" opacity="0.4"
            style={{ pointerEvents: 'none' }}
          />
        )}
      </svg>

      {/* hover tooltip */}
      {hover && (() => {
        const tx = xAt(hover.v.x);
        const ty = yAt(hover.v.y);
        const v = hover.v;
        const prev = hover.prev;
        const delta = prev ? (v.y - prev.y) : null;
        // Guard against divide-by-zero: when prev.y is 0, percentage change is undefined.
        // Also guard against non-finite results from very tiny denominators.
        const pct = (prev && Math.abs(prev.y) > 1e-9 && Number.isFinite(delta / prev.y))
          ? (delta / Math.abs(prev.y)) * 100
          : null;
        const improved = delta != null ? isImprovement(delta) : null;
        const deltaColor = improved == null ? 'var(--muted)' : improved ? 'var(--pos)' : 'var(--neg)';
        const arrow = delta == null ? '' : delta > 0 ? '▲' : delta < 0 ? '▼' : '–';
        // position: above the point, flip to below if too close to top
        const above = ty > 80;
        // Clamp center so tooltip (≈170px wide) stays inside the chart container
        const HALF_TIP = 85;
        const tooltipCx = Math.max(pad.left + HALF_TIP, Math.min(W - pad.right - HALF_TIP, tx));
        const tooltipStyle = {
          position: 'absolute',
          left: tooltipCx,
          top: above ? ty - 12 : ty + 14,
          transform: above ? 'translate(-50%, -100%)' : 'translate(-50%, 0)',
          background: 'var(--panel-2)', border: `1px solid ${hover.color}`,
          borderRadius: 6, padding: '8px 10px',
          fontSize: 11, pointerEvents: 'none',
          boxShadow: '0 6px 24px rgba(15,23,42,.12)',
          minWidth: 160,
          zIndex: 5,
          color: 'var(--text)',
          animation: 'fadeUp .18s ease both',
        };
        return (
          <div style={tooltipStyle}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 8 }}>
              <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.08em' }}>
                {hover.label}
              </span>
              <span style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
                {window.formatDate ? window.formatDate(v.dateIso, 'medium') : v.dateIso}
              </span>
            </div>
            <div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginTop: 4 }}>
              <span className="mono" style={{ fontSize: 18, fontWeight: 600, color: hover.color }}>
                {formatTick(v.y)}
              </span>
              {yUnit && <span style={{ fontSize: 11, color: 'var(--muted)' }}>{yUnit}</span>}
              {v.count > 1 && (
                <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)' }}>
                  n={v.count}
                </span>
              )}
            </div>
            {delta != null ? (
              <div style={{
                marginTop: 6, paddingTop: 6,
                borderTop: '1px dashed var(--border)',
                display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'baseline',
              }}>
                <span style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.08em' }}>
                  {window.t('vs prev')}
                </span>
                <span className="mono" style={{ fontSize: 12, color: deltaColor, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
                  <span style={{ fontSize: 9 }}>{arrow}</span>
                  {Math.abs(delta).toFixed(Math.abs(delta) >= 10 ? 1 : 2)}
                  {pct != null && (
                    <span style={{ color: deltaColor, opacity: .85, marginLeft: 4 }}>
                      ({pct > 0 ? '+' : ''}{pct.toFixed(1)}%)
                    </span>
                  )}
                </span>
              </div>
            ) : (
              <div style={{
                marginTop: 6, paddingTop: 6,
                borderTop: '1px dashed var(--border)',
                fontSize: 10, color: 'var(--muted)', fontStyle: 'italic',
              }}>First measurement — no prior point.</div>
            )}
          </div>
        );
      })()}

      {/* Dual-mode combined tooltip: BOTH metrics' raw values at the hovered date,
          each with its own unit (honors the ruling "hover 显各轴原值"). */}
      {dual && dualHover != null && (() => {
        const tx = xAt(dualHover);
        const rows = aggSeries.map((s) => {
          const pt = s.values.find(v => v.x === dualHover);
          return pt ? { label: s.label, color: s.color, unit: s.unit || '', v: pt } : null;
        }).filter(Boolean);
        if (!rows.length) return null;
        const dateIso = rows[0].v.dateIso;
        const HALF_TIP = 90;
        const tooltipCx = Math.max(pad.left + HALF_TIP, Math.min(W - pad.right - HALF_TIP, tx));
        return (
          <div style={{
            position: 'absolute', left: tooltipCx, top: pad.top + 6,
            transform: 'translate(-50%, 0)',
            background: 'var(--panel-2)', border: '1px solid var(--border-strong)',
            borderRadius: 6, padding: '8px 10px', fontSize: 11, pointerEvents: 'none',
            boxShadow: '0 6px 24px rgba(15,23,42,.12)', minWidth: 168, zIndex: 5,
            color: 'var(--text)', animation: 'fadeUp .18s ease both',
          }}>
            <div style={{ fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', marginBottom: 6 }}>
              {window.formatDate ? window.formatDate(dateIso, 'medium') : dateIso}
            </div>
            {rows.map((r, ri) => (
              <div key={ri} style={{ display: 'flex', alignItems: 'baseline', gap: 6, marginTop: ri ? 4 : 0 }}>
                <span style={{ width: 8, height: 8, borderRadius: 2, background: r.color, flexShrink: 0, alignSelf: 'center' }}/>
                <span style={{ fontSize: 10, color: 'var(--muted)', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                  {r.label}
                </span>
                <span className="mono" style={{ fontSize: 14, fontWeight: 600, color: r.color, marginLeft: 'auto' }}>
                  {formatTick(r.v.y)}
                </span>
                {r.unit && <span style={{ fontSize: 10, color: 'var(--muted)' }}>{r.unit}</span>}
              </div>
            ))}
          </div>
        );
      })()}
    </div>
  );
};

const formatTick = (v) => {
  if (Math.abs(v) >= 100) return Math.round(v).toString();
  if (Math.abs(v) >= 10)  return v.toFixed(1);
  return v.toFixed(2);
};

// ──────────────────────────────────────────────────────────────────────────
// ComparisonBars — vertical bars per group, athlete vs squad best/avg/worst
// ──────────────────────────────────────────────────────────────────────────
const ComparisonBars = ({ groups, values, squadStats, scores, height = 140 }) => {
  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: `repeat(${groups.length}, 1fr)`,
      gap: 8,
      padding: '12px 0',
    }}>
      {groups.map(g => {
        const score = scores[g.id];
        return (
          <div key={g.id} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
            <div style={{ height, width: '100%', position: 'relative', display: 'flex', alignItems: 'flex-end', justifyContent: 'center', padding: '0 12px' }}>
              {/* worst line (red) */}
              <div style={{ position:'absolute', left:8, right:8, bottom: `${10}%`, height:1, background:'var(--neg)', opacity:.55 }}/>
              {/* avg line (white dashed) */}
              <div style={{ position:'absolute', left:8, right:8, bottom: `${50}%`, height:0, borderTop:'1px dashed rgba(15,23,42,.45)' }}/>
              {/* best line (green) */}
              <div style={{ position:'absolute', left:8, right:8, top: `${10}%`, height:1, background:'var(--pos)', opacity:.55 }}/>
              {/* bar */}
              <div style={{
                width: '52%', height: `${Math.max(6, Math.min(100, score))}%`,
                background: `linear-gradient(180deg, ${g.accent}, ${g.accent}88)`,
                borderRadius: '3px 3px 0 0',
                position: 'relative',
                boxShadow: `0 0 16px ${g.accent}55`,
                transition: 'height .5s cubic-bezier(.65,0,.35,1)',
              }}>
                <div className="mono" style={{
                  position: 'absolute', top: -22, left: 0, right: 0,
                  textAlign: 'center', fontSize: 14, fontWeight: 600, color: 'var(--text)',
                }}>{score}</div>
              </div>
            </div>
            <div style={{
              width: '100%', textAlign: 'center',
              fontSize: 11, color: g.accent,
              textTransform: 'uppercase', letterSpacing: '.1em',
              padding: '6px 0', borderTop: `1px solid ${g.accent}55`,
            }}>{g.label}</div>
          </div>
        );
      })}
      <div style={{
        position: 'absolute',
      }}/>
    </div>
  );
};

// ──────────────────────────────────────────────────────────────────────────
// LeaderboardRow — bar for athlete vs others (used in sidebar of overall)
// ──────────────────────────────────────────────────────────────────────────
const Leaderboard = ({ athletes, selectedId, onSelect, max = 100 }) => {
  const sorted = [...athletes].sort((a,b) => b.overall - a.overall);
  const [expanded, setExpanded] = chartUseState(false);
  const [search, setSearch] = chartUseState('');
  const selectedIndex = Math.max(0, sorted.findIndex(athlete => athlete.id === selectedId));
  const windowStart = Math.max(0, Math.min(selectedIndex - 2, sorted.length - 5));
  const nearby = sorted.slice(windowStart, windowStart + 5);
  const normalizedSearch = search.trim().toLocaleLowerCase();
  const visible = expanded
    ? sorted.filter(athlete => !normalizedSearch || String(athlete.name || '').toLocaleLowerCase().includes(normalizedSearch))
    : nearby;
  const selected = sorted.find(athlete => athlete.id === selectedId) || sorted[0] || null;
  const mean = sorted.length
    ? sorted.reduce((sum, athlete) => sum + Number(athlete.overall || 0), 0) / sorted.length
    : 0;

  return (
    <section className="overall-score-board" data-overall-score-board data-expanded={expanded ? 'true' : 'false'}>
      <header className="overall-score-summary">
        <div><small>当前运动员</small><strong>{selected?.overall ?? '—'}</strong></div>
        <div><small>队内排名</small><strong>{selected ? `${selectedIndex + 1} / ${sorted.length}` : '—'}</strong></div>
        <div><small>队列均值</small><strong>{sorted.length ? mean.toFixed(1) : '—'}</strong></div>
      </header>
      {expanded && <label className="overall-score-search">
        <span>⌕</span>
        <input value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索运动员" aria-label="搜索 Overall Score 运动员" />
      </label>}
      <div className="overall-score-list" data-overall-score-list>
      {visible.map(a => {
        const active = a.id === selectedId;
        const rank = sorted.findIndex(row => row.id === a.id) + 1;
        return (
          <button key={a.id} type="button" data-overall-score-row data-athlete-id={a.id} aria-current={active ? 'true' : undefined}
            onClick={() => onSelect(a.id)}>
            <span className="overall-score-rank">{rank}</span>
            <span className="overall-score-name">{a.name}</span>
            <span className="overall-score-track" title={`${a.name} · ${a.overall}`}>
              <i style={{ width: `${Math.max(0, Math.min(100, (a.overall / max) * 100))}%` }} />
            </span>
            <strong className="mono">{a.overall}</strong>
          </button>
        );
      })}
      {!visible.length && <div className="overall-score-empty">没有匹配的运动员。</div>}
      </div>
      {sorted.length > 5 && <button type="button" className="overall-score-toggle" onClick={() => {
        setExpanded(value => !value);
        if (expanded) setSearch('');
      }}>{expanded ? '收起队列' : `查看完整队列 · ${sorted.length}`}</button>}
    </section>
  );
};

Object.assign(window, { RadarChart, TrendChart, ComparisonBars, Leaderboard });
