import { useCallback, useMemo, useState } from 'react';
import {
  ChartRoot,
  XAxis as NativeXAxis,
  YAxis as NativeYAxis,
  Grid,
  TooltipHost,
  Legend,
  FloatingTooltip,
  bandScale,
  linearScale,
  buildLinePath,
  buildRoundedRectPath,
} from './native';
import {
  CHART_DEFAULT_Y_AXIS_TICK_COUNT,
  buildComposedSingleNumberYDomain,
  normalizeDataColorToken,
  getDefaultDataColorToken,
  snapNumericAxisToIntegerUniformTicks,
  X_AXIS_CATEGORY_PADDING,
} from './chart-common';
import {
  mergeSmartBarChartConfig,
  STACK_SEGMENT_GAP_PX,
} from './chart-bar-primitives';
import {
  CHART_DATA_LABEL_POINT_RADIUS,
  Y_LEFT,
  Y_RIGHT,
  clampSeriesIndex,
  makePercentTickFormatter,
  mergeSmartLineChartConfig,
  normalizeSeriesYAxisSide,
  seriesUsesRightAxis,
} from './chart-line-primitives';
import ChartTooltip from './ChartTooltip';
import { tokenNameToCssVar } from '../utils/token-resolver.js';
import { buildComposedChartMock } from './chart-composed-mock';

export { CHART_DATA_COLOR_SLOTS } from './chart-common';
export { buildComposedChartMock } from './chart-composed-mock';

const FALLBACK_COLOR = '#98A2B3';

const FALLBACK_MOCK = buildComposedChartMock({
  categoryCount: 9,
  barSeriesCount: 1,
  lineSeriesCount: 1,
});

const BAR_CATEGORY_GAP = 0.22;
const MAX_BAR_SIZE = 40;

/** 取色：优先 token CSS 变量 */
function resolveSeriesColor(colorToken) {
  if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(colorToken)) return colorToken;
  const v = tokenNameToCssVar(colorToken);
  if (v && v.startsWith('--')) return `var(${v}, ${FALLBACK_COLOR})`;
  return FALLBACK_COLOR;
}

function normalizeSeriesColorToken(rawColorToken, fallbackToken) {
  const token = String(rawColorToken || '').trim();
  if (!token) return normalizeDataColorToken(fallbackToken);
  if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(token)) return token;
  if (/^data-\d+$/.test(token)) return normalizeDataColorToken(token);
  return token;
}

/**
 * @param {unknown} raw
 * @returns {'left'|'right'}
 */
function normalizeYAxisId(raw) {
  if (raw === Y_RIGHT || raw === 1 || raw === '1') return Y_RIGHT;
  return Y_LEFT;
}

/**
 * 组合图（native 实现，外塔 API 与原 SmartComposedChart 一致）
 *
 * 全特性覆盖：
 * - 同坐标系 bar + line 混排
 * - 单 Y 轴 / 双 Y 轴（dualAxis）
 * - bar 堆叠（stack gap、外端圆角）
 * - line curveType (linear / monotone / step)、dots、strokeDasharray
 * - 左右轴各自独立的 number / percent 格式
 * - rightYAxisTickSuffix
 * - xAxisInterval / xAxisMinTickGap（preserveStartEnd / number）
 * - hover 类目背景 + 跟随鼠标 tooltip
 */
export default function SmartComposedChart({ spec }) {
  const {
    data,
    series: rawSeries,
    xAxisKey: xKeyRaw,
    dualAxis: dualTop,
    config: rawConfig,
  } = spec || {};
  const config = rawConfig && typeof rawConfig === 'object' ? rawConfig : {};

  const xAxisKey = typeof xKeyRaw === 'string' && xKeyRaw ? xKeyRaw : 'name';

  const barCfg = useMemo(
    () =>
      mergeSmartBarChartConfig({
        layout: 'vertical',
        stack: false,
        showLabel: false,
        cornerMode: 'rounded',
        ...(config.bar || {}),
      }),
    [config.bar],
  );

  const lineCfg = useMemo(() => mergeSmartLineChartConfig(config.line || {}), [config.line]);

  const dualAxisActive = Boolean(dualTop ?? lineCfg.dualAxis ?? lineCfg.dualYAxis);

  // ── 系列归一
  const seriesNorm = useMemo(() => {
    const src = Array.isArray(rawSeries) && rawSeries.length > 0 ? rawSeries : FALLBACK_MOCK.series;
    return src.map((item, idx) => {
      const type = item?.type === 'line' ? 'line' : 'bar';
      const dataKey = String(item?.dataKey ?? item?.key ?? '');
      const rawColorToken = String(item?.colorToken || '').trim();
      const colorToken = normalizeSeriesColorToken(rawColorToken, getDefaultDataColorToken(idx));
      return {
        type,
        dataKey,
        name: item?.name ?? dataKey,
        stackId: typeof item?.stackId === 'string' ? item.stackId : undefined,
        yAxisId: normalizeYAxisId(item?.yAxisId),
        colorToken,
        color: resolveSeriesColor(colorToken),
        strokeDasharray: item?.strokeDasharray,
      };
    });
  }, [rawSeries]);

  const barRows = useMemo(() => seriesNorm.filter((r) => r.type === 'bar'), [seriesNorm]);
  const lineRows = useMemo(() => seriesNorm.filter((r) => r.type === 'line'), [seriesNorm]);

  const chartData = useMemo(() => {
    if (Array.isArray(data) && data.length > 0) return data;
    return FALLBACK_MOCK.data;
  }, [data]);

  // ── chrome flags
  const showYAxis = lineCfg.showYAxis !== false;
  const showXAxis = lineCfg.showXAxis !== false;
  const showLegend = config.showLegend !== false;
  const showGrid = lineCfg.grid !== false;
  const legendPosition = ['top', 'bottom'].includes(config.legendPosition)
    ? config.legendPosition
    : 'bottom';
  const legendShape = lineCfg.legendShape === 'dot' ? 'dot' : 'line';

  const fillContainer = Boolean(config.fillContainer);
  const height = Number(config.height) > 0 ? Number(config.height) : 360;

  const yAxisTickCount = Number.isFinite(Number(lineCfg.yAxisTickCount))
    ? Math.max(2, Number(lineCfg.yAxisTickCount))
    : CHART_DEFAULT_Y_AXIS_TICK_COUNT;

  const dualActive =
    dualAxisActive &&
    showYAxis &&
    ((barRows.length >= 1 && lineRows.length >= 1) || lineRows.length >= 2);

  // ── 右轴 line series 决议
  const rightAxisSeriesIndexResolved = useMemo(
    () => clampSeriesIndex(lineCfg.rightYAxisSeriesIndex, lineRows.length),
    [lineCfg.rightYAxisSeriesIndex, lineRows.length],
  );

  const lineRowsWithSide = useMemo(
    () =>
      lineRows.map((row, lineIdx) => {
        const explicit = normalizeSeriesYAxisSide(row.yAxisId);
        if (!dualActive) return { ...row, side: Y_LEFT };
        if (explicit === Y_RIGHT || explicit === Y_LEFT) return { ...row, side: explicit };
        const onRight = seriesUsesRightAxis(
          lineCfg.rightAxisSeries,
          { dataKey: row.dataKey, name: row.name },
          lineIdx,
          rightAxisSeriesIndexResolved,
        );
        return { ...row, side: onRight ? Y_RIGHT : Y_LEFT };
      }),
    [lineRows, dualActive, lineCfg.rightAxisSeries, rightAxisSeriesIndexResolved],
  );

  // ── domains
  const composedNumberDomains = useMemo(() => {
    if (!showYAxis) return { left: undefined, right: undefined };
    if (!dualActive) {
      if (lineCfg.leftAxisType !== 'number') return { left: undefined, right: undefined };
      const barKeys = barRows.map((r) => r.dataKey);
      const lineKeys = lineRows.map((r) => r.dataKey);
      return {
        left: buildComposedSingleNumberYDomain(chartData, barKeys, lineKeys, !!barCfg.stack),
        right: undefined,
      };
    }
    const leftBarKeys = barRows.filter((r) => r.yAxisId !== Y_RIGHT).map((r) => r.dataKey);
    const rightBarKeys = barRows.filter((r) => r.yAxisId === Y_RIGHT).map((r) => r.dataKey);
    const leftLineKeys = lineRowsWithSide.filter((r) => r.side === Y_LEFT).map((r) => r.dataKey);
    const rightLineKeys = lineRowsWithSide.filter((r) => r.side === Y_RIGHT).map((r) => r.dataKey);
    const leftStack = !!barCfg.stack && leftBarKeys.length > 0;
    const rightStack = !!barCfg.stack && rightBarKeys.length > 0;
    return {
      left:
        lineCfg.leftAxisType === 'number'
          ? buildComposedSingleNumberYDomain(chartData, leftBarKeys, leftLineKeys, leftStack)
          : undefined,
      right:
        lineCfg.rightAxisType === 'number' && (rightBarKeys.length > 0 || rightLineKeys.length > 0)
          ? buildComposedSingleNumberYDomain(chartData, rightBarKeys, rightLineKeys, rightStack)
          : undefined,
    };
  }, [
    showYAxis,
    dualActive,
    chartData,
    barRows,
    lineRows,
    lineRowsWithSide,
    barCfg.stack,
    lineCfg.leftAxisType,
    lineCfg.rightAxisType,
  ]);

  const leftAxisUi = useMemo(() => {
    if (!composedNumberDomains.left || lineCfg.leftAxisType !== 'number') return undefined;
    return snapNumericAxisToIntegerUniformTicks(composedNumberDomains.left, yAxisTickCount);
  }, [composedNumberDomains.left, lineCfg.leftAxisType, yAxisTickCount]);

  const rightAxisUi = useMemo(() => {
    if (!composedNumberDomains.right || lineCfg.rightAxisType !== 'number') return undefined;
    return snapNumericAxisToIntegerUniformTicks(composedNumberDomains.right, yAxisTickCount);
  }, [composedNumberDomains.right, lineCfg.rightAxisType, yAxisTickCount]);

  // percent 时也需要 [0,1] 或 [0,100] 轴域，让格式化器格式化
  const leftPercentDomain = useMemo(() => {
    if (lineCfg.leftAxisType !== 'percent') return undefined;
    const keys = [
      ...barRows.filter((r) => !dualActive || r.yAxisId !== Y_RIGHT).map((r) => r.dataKey),
      ...lineRowsWithSide.filter((r) => !dualActive || r.side === Y_LEFT).map((r) => r.dataKey),
    ];
    return computePercentDomain(chartData, keys, lineCfg.leftPercentAsFraction);
  }, [lineCfg.leftAxisType, lineCfg.leftPercentAsFraction, barRows, lineRowsWithSide, chartData, dualActive]);

  const rightPercentDomain = useMemo(() => {
    if (!dualActive || lineCfg.rightAxisType !== 'percent') return undefined;
    const keys = [
      ...barRows.filter((r) => r.yAxisId === Y_RIGHT).map((r) => r.dataKey),
      ...lineRowsWithSide.filter((r) => r.side === Y_RIGHT).map((r) => r.dataKey),
    ];
    return computePercentDomain(chartData, keys, lineCfg.rightPercentAsFraction);
  }, [dualActive, lineCfg.rightAxisType, lineCfg.rightPercentAsFraction, barRows, lineRowsWithSide, chartData]);

  // tick 格式化
  const leftFormatter = useMemo(() => {
    if (lineCfg.leftAxisType === 'percent') {
      return makePercentTickFormatter(lineCfg.leftAxisType, lineCfg.leftPercentAsFraction);
    }
    return undefined;
  }, [lineCfg.leftAxisType, lineCfg.leftPercentAsFraction]);

  const rightFormatter = useMemo(() => {
    if (lineCfg.rightAxisType === 'percent') {
      return makePercentTickFormatter(lineCfg.rightAxisType, lineCfg.rightPercentAsFraction);
    }
    if (lineCfg.rightAxisType === 'number' && rightAxisUi && lineCfg.rightYAxisTickSuffix) {
      const suf = lineCfg.rightYAxisTickSuffix;
      return (/** @type {number} */ v) => `${v}${suf}`;
    }
    return undefined;
  }, [lineCfg.rightAxisType, lineCfg.rightPercentAsFraction, lineCfg.rightYAxisTickSuffix, rightAxisUi]);

  // ── X 轴抽稀
  const xAxisInterval = lineCfg.xAxisInterval;
  const xAxisMinTickGap = lineCfg.xAxisMinTickGap;

  // ── margin
  const yAxisGutter = showYAxis ? Math.max(28, Number(lineCfg.yAxisWidth ?? 28)) : 0;
  const margin = useMemo(() => {
    const m = {
      top: Math.max(Number(config.margin?.top) || 0, 16),
      right: Math.max(Number(config.margin?.right) || 0, 24),
      bottom: Math.max(Number(config.margin?.bottom) || 0, 8),
      left: Math.max(Number(config.margin?.left) || 0, 0),
    };
    if (showXAxis) m.bottom += 24;
    if (showYAxis) m.left += yAxisGutter;
    if (dualActive) m.right = Math.max(m.right, 52);
    if (showLegend) {
      // 图例自身约 20px 高 + 净距 12px + Y 轴顶部刻度文字高度 ~12px = 44~52px
      if (legendPosition === 'top') m.top += 52;
      else if (legendPosition === 'bottom') m.bottom += 52;
      else if (legendPosition === 'left') m.left += 96;
      else if (legendPosition === 'right') m.right += 96;
    }
    return m;
  }, [config.margin, showXAxis, showYAxis, yAxisGutter, dualActive, showLegend, legendPosition]);

  // ── 圆角
  const radiusPx = useMemo(() => {
    if (barCfg.cornerMode === 'square') return 0;
    return 4;
  }, [barCfg.cornerMode]);

  const [active, setActive] = useState(null);
  const handleActive = useCallback((info) => setActive(info), []);

  // fillContainer 模式：跟随父容器高度自适应（嵌入到 flex-1 卡片时用）
  const [measuredH, setMeasuredH] = useState(0);
  const wrapRef = useCallback((node) => {
    if (!node) return;
    const ro = new ResizeObserver((entries) => {
      for (const e of entries) setMeasuredH(Math.floor(e.contentRect.height));
    });
    ro.observe(node);
    const rect = node.getBoundingClientRect();
    setMeasuredH(Math.floor(rect.height));
  }, []);
  const effectiveHeight = fillContainer && measuredH > 0 ? measuredH : height;

  return (
    <div
      ref={fillContainer ? wrapRef : undefined}
      className="smart-composed-chart"
      style={{
        width: '100%',
        height: fillContainer ? '100%' : height,
        minHeight: fillContainer ? 160 : undefined,
        outline: 'none',
        background: 'transparent',
        position: 'relative',
      }}
      tabIndex={-1}
    >
      <ChartRoot height={effectiveHeight} margin={margin}>
        {({ width, height: geomH, plot }) => {
          const categories = chartData.map((row) => row[xAxisKey]);

          // X band scale
          const xScale = bandScale(categories, [plot.x, plot.x + plot.w], {
            paddingOuter: X_AXIS_CATEGORY_PADDING.left ? 0.5 : 0.5,
            paddingInner: BAR_CATEGORY_GAP,
          });
          const bandWidth = Math.min(xScale.bandwidth(), MAX_BAR_SIZE);

          // Left / Right Y scales
          const leftDomain =
            (leftAxisUi?.domain) ||
            composedNumberDomains.left ||
            leftPercentDomain ||
            [0, 1];
          const yLeftScale = linearScale(leftDomain, [plot.y + plot.h, plot.y]);
          const yLeftZero = yLeftScale(0);

          const rightDomain =
            (rightAxisUi?.domain) ||
            composedNumberDomains.right ||
            rightPercentDomain ||
            leftDomain;
          const yRightScale = linearScale(rightDomain, [plot.y + plot.h, plot.y]);

          // Y ticks
          const leftTicks = leftAxisUi?.ticks
            || (leftPercentDomain ? buildEvenTicks(leftPercentDomain, yAxisTickCount) : [leftDomain[0], leftDomain[1]]);
          const rightTicks = dualActive
            ? (rightAxisUi?.ticks
                || (rightPercentDomain ? buildEvenTicks(rightPercentDomain, yAxisTickCount) : [rightDomain[0], rightDomain[1]]))
            : [];

          // X ticks（带抽稀）
          const xVisibleIdx = computeXVisible(categories.length, xAxisInterval, xAxisMinTickGap, plot.w);
          const xTicksObj = categories
            .map((k, i) => ({ value: k, x: xScale.center(k) ?? 0, label: String(k), i }))
            .filter((t) => xVisibleIdx.has(t.i));

          const yLeftTicksObj = leftTicks.map((v) => ({
            value: v,
            y: yLeftScale(v),
            label: leftFormatter ? leftFormatter(v) : String(v),
          }));
          const yRightTicksObj = rightTicks.map((v) => ({
            value: v,
            y: yRightScale(v),
            label: rightFormatter ? rightFormatter(v) : String(v),
          }));

          // ── 给柱体分组：按 stackId 聚类
          const effStackKey = (row, idx) => {
            if (barCfg.stack && !row.stackId) return '__composed_stack';
            return row.stackId ?? `__solo_${idx}`;
          };
          const stackKeyToBars = new Map();
          barRows.forEach((row, idx) => {
            const k = effStackKey(row, idx);
            if (!stackKeyToBars.has(k)) stackKeyToBars.set(k, []);
            stackKeyToBars.get(k).push({ row, idx });
          });

          // 对每个类目、每个 stackKey、产出 rects
          const barRectsAll = [];
          chartData.forEach((dataRow) => {
            const cat = dataRow[xAxisKey];
            const center = xScale.center(cat);
            if (center == null) return;
            const stackKeys = Array.from(stackKeyToBars.keys());
            const groupCount = stackKeys.length;
            const innerGap = 2;
            const eachW = Math.max(0, (bandWidth - innerGap * (groupCount - 1)) / Math.max(1, groupCount));

            stackKeys.forEach((sk, gi) => {
              const segs = stackKeyToBars.get(sk) || [];
              const stacked = segs.length > 1;
              const xLeft = center - bandWidth / 2 + gi * (eachW + innerGap);

              if (stacked) {
                // 找 outerIndex
                let outerIndex = -1;
                segs.forEach((entry, segI) => {
                  if (Number(dataRow[entry.row.dataKey]) > 0) outerIndex = segI;
                });
                let lo = 0;
                segs.forEach((entry, segI) => {
                  const { row } = entry;
                  const v = Math.max(0, Number(dataRow[row.dataKey]) || 0);
                  const hi = lo + v;
                  const yScale = row.yAxisId === Y_RIGHT && dualActive ? yRightScale : yLeftScale;
                  const y0 = yScale(lo);
                  const y1 = yScale(hi);
                  const top = Math.min(y0, y1);
                  const bot = Math.max(y0, y1);
                  const half = STACK_SEGMENT_GAP_PX / 2;
                  const cutBottom = segI > 0 ? half : 0;
                  const cutTop = segI < segs.length - 1 && v > 0 ? half : 0;
                  const isOuter = segI === outerIndex;
                  const radius = isOuter ? [radiusPx, radiusPx, 0, 0] : [0, 0, 0, 0];
                  barRectsAll.push({
                    cat,
                    series: row,
                    x: xLeft,
                    y: top + cutTop,
                    w: eachW,
                    h: Math.max(0, bot - top - cutTop - cutBottom),
                    color: row.color,
                    radius,
                    raw: Number(dataRow[row.dataKey]) || 0,
                  });
                  lo = hi;
                });
              } else {
                const entry = segs[0];
                if (!entry) return;
                const { row } = entry;
                const v = Number(dataRow[row.dataKey]) || 0;
                const yScale = row.yAxisId === Y_RIGHT && dualActive ? yRightScale : yLeftScale;
                const yZero = yScale(0);
                const yV = yScale(v);
                const top = Math.min(yZero, yV);
                const bot = Math.max(yZero, yV);
                barRectsAll.push({
                  cat,
                  series: row,
                  x: xLeft,
                  y: top,
                  w: eachW,
                  h: bot - top,
                  color: row.color,
                  radius: [radiusPx, radiusPx, 0, 0],
                  raw: v,
                });
              }
            });
          });

          // ── 折线点位
          const linesGeo = lineRowsWithSide.map((row) => {
            const yScale = row.side === Y_RIGHT && dualActive ? yRightScale : yLeftScale;
            const pts = chartData
              .map((r) => {
                const x = xScale.center(r[xAxisKey]);
                const y = Number(r[row.dataKey]);
                if (x == null || !Number.isFinite(y)) return null;
                return { x, y: yScale(y), raw: y };
              })
              .filter(Boolean);
            return { ...row, pts };
          });

          const activeKey = active?.key;
          const activeRow = activeKey != null ? chartData.find((r) => r[xAxisKey] === activeKey) : null;
          const activeX = activeKey != null ? xScale.center(activeKey) : null;

          return (
            <>
              <svg
                width={width}
                height={geomH}
                style={{ display: 'block', overflow: 'visible' }}
              >
                {/* 网格：仅水平虚线 */}
                {showGrid && (
                  <Grid plot={plot} yTicks={yLeftTicksObj} showHorizontal />
                )}

                {/* 类目背景高亮 */}
                {activeX != null && (
                  <rect
                    x={activeX - bandWidth / 2}
                    y={plot.y}
                    width={bandWidth}
                    height={plot.h}
                    fill="var(--color-fill-base, rgba(45, 66, 107, 0.06))"
                    pointerEvents="none"
                  />
                )}

                {/* 坐标轴 */}
                {showXAxis && <NativeXAxis plot={plot} ticks={xTicksObj} />}
                {showYAxis && !dualActive && <NativeYAxis plot={plot} ticks={yLeftTicksObj} />}
                {showYAxis && dualActive && (
                  <>
                    <NativeYAxis plot={plot} ticks={yLeftTicksObj} />
                    <NativeYAxis plot={plot} ticks={yRightTicksObj} orientation="right" />
                  </>
                )}

                {/* baseline */}
                {showYAxis && Number.isFinite(yLeftZero) && (
                  <line
                    x1={plot.x}
                    x2={plot.x + plot.w}
                    y1={yLeftZero}
                    y2={yLeftZero}
                    stroke="var(--color-border-divider, #E5E6EB)"
                    strokeWidth={1}
                  />
                )}

                {/* 柱体 */}
                {barRectsAll.map((rect, i) => {
                  if (rect.w <= 0 || rect.h <= 0) return null;
                  const d = buildRoundedRectPath(rect.x, rect.y, rect.w, rect.h, rect.radius);
                  return <path key={`bar-${i}`} d={d} fill={rect.color} />;
                })}

                {/* 数值标签（柱图 showLabel） */}
                {barCfg.showLabel && barRectsAll.map((rect, i) => {
                  if (rect.w <= 0 || rect.h <= 0) return null;
                  return (
                    <text
                      key={`lbl-${i}`}
                      x={rect.x + rect.w / 2}
                      y={rect.y - 6}
                      textAnchor="middle"
                      fontSize={11}
                      fill="var(--color-foreground-secondary, #475467)"
                    >{String(rect.raw)}</text>
                  );
                })}

                {/* 折线 */}
                {linesGeo.map((row) => {
                  if (row.pts.length === 0) return null;
                  const d = buildLinePath(row.pts, lineCfg.curveType);
                  return (
                    <g key={`line-${row.dataKey}`}>
                      {lineCfg.showLabel && !lineCfg.showDots && row.pts.map((p, j) => (
                        <circle
                          key={`label-point-${j}`}
                          cx={p.x}
                          cy={p.y}
                          r={CHART_DATA_LABEL_POINT_RADIUS}
                          fill={row.color}
                          pointerEvents="none"
                        />
                      ))}
                      <path
                        d={d}
                        fill="none"
                        stroke={row.color}
                        strokeWidth={lineCfg.strokeWidth}
                        strokeLinejoin="round"
                        strokeLinecap="round"
                        strokeDasharray={row.strokeDasharray || undefined}
                      />
                      {lineCfg.showDots && row.pts.map((p, j) => (
                        <circle
                          key={j}
                          cx={p.x}
                          cy={p.y}
                          r={3}
                          fill={row.color}
                          stroke="var(--color-border-divider, #E5E6EB)"
                          strokeWidth={1}
                        />
                      ))}
                      {/* hover 高亮点 */}
                      {activeKey != null && (() => {
                        const hit = chartData.find((r) => r[xAxisKey] === activeKey);
                        if (!hit) return null;
                        const v = Number(hit[row.dataKey]);
                        if (!Number.isFinite(v)) return null;
                        const yScale = row.side === Y_RIGHT && dualActive ? yRightScale : yLeftScale;
                        const xv = xScale.center(activeKey);
                        if (xv == null) return null;
                        return (
                          <circle
                            cx={xv}
                            cy={yScale(v)}
                            r={4}
                            fill="#FFFFFF"
                            stroke={row.color}
                            strokeWidth={2}
                          />
                        );
                      })()}
                    </g>
                  );
                })}

                {/* 折线数据标签：默认关闭，不参与坐标轴布局 */}
                {lineCfg.showLabel && (() => {
                  const minGap = 14;
                  const labels = linesGeo.flatMap((row) =>
                    row.pts.map((p, pointIndex) => ({
                      key: `${row.dataKey}-${pointIndex}`,
                      x: p.x,
                      y: p.y - 8,
                      raw: p.raw,
                    })),
                  );
                  const byX = new Map();
                  labels.forEach((label) => {
                    const key = Math.round(label.x);
                    const group = byX.get(key) || [];
                    group.push(label);
                    byX.set(key, group);
                  });
                  const placed = [];
                  const labelTopBoundary = Math.max(8, plot.y - 8);
                  byX.forEach((group) => {
                    const sorted = [...group].sort((a, b) => a.y - b.y);
                    let prevY = -Infinity;
                    sorted.forEach((label) => {
                      const y = Math.max(label.y, prevY + minGap);
                      prevY = y;
                      placed.push({
                        ...label,
                        y: Math.min(Math.max(y, labelTopBoundary), plot.y + plot.h - 4),
                      });
                    });
                  });
                  return placed.map((label) => (
                    <text
                      key={label.key}
                      x={label.x}
                      y={label.y}
                      textAnchor="middle"
                      fontSize={11}
                      fill="var(--color-foreground-secondary, #475467)"
                      pointerEvents="none"
                    >
                      {String(label.raw)}
                    </text>
                  ));
                })()}

                {/* hover 探测 */}
                <TooltipHost
                  plot={plot}
                  getCategoryAtX={(px) => xScale.invert(px)}
                  onActiveChange={handleActive}
                />
              </svg>

              {/* Legend */}
              {showLegend && (
                <div
                  style={{
                    position: 'absolute',
                    left: 0,
                    right: 0,
                    ...(legendPosition === 'top' ? { top: 0 } : {}),
                    ...(legendPosition === 'bottom' ? { bottom: 0 } : {}),
                    ...(legendPosition === 'left' ? { left: 0, top: '50%', transform: 'translateY(-50%)', right: 'auto', width: 96 } : {}),
                    ...(legendPosition === 'right' ? { right: 0, top: '50%', transform: 'translateY(-50%)', left: 'auto', width: 96 } : {}),
                    padding: '4px 8px',
                  }}
                >
                  <Legend
                    items={seriesNorm.map((s) => ({
                      name: s.name,
                      color: s.color,
                      shape: s.type === 'bar' ? 'dot' : (legendShape === 'dot' ? 'dot' : 'line'),
                    }))}
                    shape={legendShape}
                    position={legendPosition}
                  />
                </div>
              )}

              {/* Tooltip 浮层（HTML 层）：跟随鼠标，向右上展开；过 70% 翻到左侧 */}
              {activeRow && active && (
                <FloatingTooltip mouse={{ clientX: active.clientX, clientY: active.clientY }}>
                  <ChartTooltip
                    variant="chart"
                    label={String(activeRow[xAxisKey])}
                    rows={seriesNorm.map((s) => {
                      const isPercent = s.type === 'line'
                        ? (lineRowsWithSide.find((r) => r.dataKey === s.dataKey)?.side === Y_RIGHT && dualActive
                            ? lineCfg.rightAxisType === 'percent'
                            : lineCfg.leftAxisType === 'percent')
                        : (s.yAxisId === Y_RIGHT && dualActive
                            ? lineCfg.rightAxisType === 'percent'
                            : lineCfg.leftAxisType === 'percent');
                      const fraction = s.type === 'line'
                        ? (lineRowsWithSide.find((r) => r.dataKey === s.dataKey)?.side === Y_RIGHT && dualActive
                            ? lineCfg.rightPercentAsFraction
                            : lineCfg.leftPercentAsFraction)
                        : (s.yAxisId === Y_RIGHT && dualActive
                            ? lineCfg.rightPercentAsFraction
                            : lineCfg.leftPercentAsFraction);
                      const rawVal = Number(activeRow[s.dataKey]);
                      return {
                        name: s.name,
                        value: isPercent
                          ? `${(fraction ? Math.round(rawVal * 100) : Math.round(rawVal))}%`
                          : rawVal,
                        color: s.color,
                        dataKey: s.dataKey,
                      };
                    })}
                  />
                </FloatingTooltip>
              )}
            </>
          );
        }}
      </ChartRoot>
    </div>
  );
}

/**
 * 计算 percent 类型 Y 轴 domain：
 * - asFraction=true：[0, max(value)] 但下限保持 0、上限至少 1（即 100%）
 * - asFraction=false：[0, max(value)] 但上限至少 100
 */
function computePercentDomain(rows, keys, asFraction) {
  const ks = Array.isArray(keys) ? keys.filter(Boolean) : [];
  if (!Array.isArray(rows) || rows.length === 0 || ks.length === 0) return undefined;
  let max = -Infinity;
  let min = Infinity;
  for (const r of rows) {
    for (const k of ks) {
      const v = Number(r[k]);
      if (!Number.isFinite(v)) continue;
      if (v > max) max = v;
      if (v < min) min = v;
    }
  }
  if (!Number.isFinite(max)) return undefined;
  const lower = Number.isFinite(min) && min < 0 ? min : 0;
  const ceilTo = asFraction ? 1 : 100;
  const upper = Math.max(ceilTo, niceCeilTo(max, asFraction ? 0.1 : 10));
  return [lower, upper];
}

function niceCeilTo(v, step) {
  if (!Number.isFinite(v) || step <= 0) return v;
  return Math.ceil(v / step) * step;
}

function buildEvenTicks([lo, hi], n) {
  const count = Math.max(2, Math.floor(n) || 7);
  const step = (hi - lo) / (count - 1);
  return Array.from({ length: count }, (_, i) => lo + step * i);
}

/**
 * 计算 X 轴可见 tick 索引：
 * - interval = 0: 全部
 * - interval = N(>0): 每 N 取 1
 * - 'preserveStartEnd': 始末必显，按 minTickGap 抽稀
 */
function computeXVisible(total, interval, minTickGap, plotW) {
  const set = new Set();
  if (total <= 0) return set;
  if (interval === 0) {
    for (let i = 0; i < total; i++) set.add(i);
    return set;
  }
  if (Number.isFinite(interval) && interval > 0) {
    for (let i = 0; i < total; i += interval + 1) set.add(i);
    set.add(total - 1);
    return set;
  }
  // preserveStartEnd
  const gap = Math.max(0, Number(minTickGap) || 24);
  const eachW = total > 0 ? plotW / total : 0;
  const stride = Math.max(1, Math.ceil(gap / Math.max(1, eachW)));
  for (let i = 0; i < total; i += stride) set.add(i);
  set.add(total - 1);
  set.add(0);
  return set;
}
