// DataTableView：数据明细视图 —— Excel 式全字段宽表（维度切换 / 列组开关 / 指标排序 /
// 首列冻结 / CSV 导出）。行数据保持后端原始 snake_case 口径，渲染时经 metrics.js 统一格式化。

import { useEffect, useMemo, useState } from "react";
import Button from "@mui/material/Button";
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import { SocialAppIdentity, useSocialBi } from "../SocialBiApp.jsx";
import {
  METRIC_GROUPS,
  aggregateMetricMaps,
  averageMetricMaps,
  formatMetric,
  metricChartValue,
  metricLabel,
  metricTooltip
} from "../metrics.js";
import { downloadCsv, formatCount, formatDurationMs, formatMoneyMinor, isBlank } from "../format.js";
import { rangeLabel } from "../state.js";
import "./data-table-view.css";

const GROUPS_STORAGE_KEY = "hyapp-admin.sbi-table-groups";
const MAX_RENDER_ROWS = 400;
const SKELETON_WIDTHS = ["42%", "100%", "96%", "91%", "86%", "81%"];
const EMPTY_ROWS = [];

// 维度列定义：value 取行上的展示文本，totalText 是合计行占位（日期列"汇总"，其余"全部"）。
const DIM_COLUMNS = {
  app: { key: "app", label: "App", totalText: "全部", value: (row) => row.app_name || row.app_code || "--" },
  country: { key: "country", label: "国家", totalText: "全部", value: (row) => row.country_name || row.country_code || "--" },
  day: { key: "day", label: "日期", totalText: "汇总", value: (row) => row.stat_day || "--" },
  region: { key: "region", label: "区域", totalText: "全部", value: (row) => row.region_name || row.region_code || "--" }
};

// 五种明细维度：key 与 derived 的行集字段一一对应。
// 日维度把 App 放在首列（冻结列）：日行按 App 分块连续展示，App 列就是分块标识。
const DIMENSIONS = [
  { csvName: "app-daily", dims: ["app", "day"], hasDay: true, key: "appDaily", label: "App×日" },
  { csvName: "app-total", dims: ["app"], hasDay: false, key: "appTotals", label: "App 合计" },
  { csvName: "region-daily", dims: ["app", "region", "day"], hasDay: true, key: "regionDaily", label: "区域×日" },
  { csvName: "region-total", dims: ["app", "region"], hasDay: false, key: "regionTotals", label: "区域合计" },
  { csvName: "country", dims: ["app", "country"], hasDay: false, key: "countryTotals", label: "国家" }
];

const TABLE_MODES = [
  { key: "wide", label: "运营宽表" },
  { key: "requirements", label: "数据需求" }
];

const REQUIREMENT_SECTIONS = [
  {
    csvName: "p1-revenue",
    fallbackColumns: [
      { key: "recharge_usd_minor", label: "充值", type: "money_minor" },
      { key: "recharge_users", label: "充值用户", type: "count" },
      { key: "first_recharge_users", label: "首充用户", type: "count" },
      { key: "repeat_recharge_users", label: "复购用户", type: "count" },
      { key: "arppu_usd_minor", label: "ARPPU", type: "money_minor" }
    ],
    key: "revenue",
    label: "充值",
    newUserFilter: true,
    payerFilter: true,
    roleFilter: true
  },
  {
    csvName: "p0-new-users",
    fallbackColumns: [
      { key: "app_first_open_users", label: "下载APP人数", type: "count" },
      { key: "new_users", label: "新增注册", type: "count" },
      { key: "profile_complete_users", label: "完善资料", type: "count" },
      { key: "first_room_join_users", label: "首次进房", type: "count" }
    ],
    key: "new_users",
    label: "新增"
  },
  {
    csvName: "p2-active",
    fallbackColumns: [
      { key: "active_users", label: "活跃用户", type: "count" },
      { key: "room_join_users", label: "进房用户", type: "count" },
      { key: "mic_users", label: "上麦用户", type: "count" },
      { key: "message_users", label: "发言用户", type: "count" },
      { key: "avg_session_ms", label: "人均在线", type: "duration_ms" }
    ],
    key: "active",
    label: "日活",
    newUserFilter: true,
    payerFilter: true,
    roleFilter: true
  },
  {
    csvName: "p4-gift",
    fallbackColumns: [
      { key: "gift_panel_open_users", label: "礼物面板用户", type: "count" },
      { key: "gift_senders", label: "送礼用户", type: "count" },
      { key: "gift_coin_spent", label: "礼物流水", type: "coin" },
      { key: "gift_paid_users", label: "付费送礼用户", type: "count" }
    ],
    key: "gift",
    label: "礼物",
    newUserFilter: true,
    roleFilter: true
  },
  {
    csvName: "p5-game",
    fallbackColumns: [
      { key: "probability_game_panel_open_users", label: "概率游戏面板用户", type: "count" },
      { key: "game_players", label: "押注用户", type: "count" },
      { key: "game_turnover", label: "游戏流水", type: "coin" },
      { key: "game_profit", label: "游戏利润", type: "coin" },
      { key: "game_profit_rate", label: "游戏利润率", type: "ratio" }
    ],
    key: "game",
    label: "游戏",
    newUserFilter: true,
    roleFilter: true
  },
  {
    csvName: "p6-lucky-gift-v3",
    fallbackColumns: [
      { key: "draw_count", label: "抽奖次数", type: "count" },
      { key: "turnover_coin", label: "投入金币", type: "coin" },
      { key: "payout_coin", label: "返奖金币", type: "coin" },
      { key: "profit_coin", label: "利润金币", type: "coin" },
      { key: "payout_rate", label: "返奖率", type: "ratio" },
      { key: "profit_rate", label: "利润率", type: "ratio" }
    ],
    key: "lucky_gift_v3",
    label: "幸运礼物V3"
  },
  {
    csvName: "p2-retention",
    fallbackColumns: [
      { key: "active_d1_active_base_users", label: "活跃用户基数", type: "count" },
      { key: "active_d1_active_users", label: "次留人数", type: "count" },
      { key: "active_d1_active_rate", label: "用户次留", type: "ratio" },
      { key: "active_d7_active_rate", label: "用户7日留", type: "ratio" },
      { key: "active_d30_active_rate", label: "用户30日留", type: "ratio" }
    ],
    key: "retention",
    label: "留存",
    newUserFilter: true,
    payerFilter: true,
    roleFilter: true
  }
];

const USER_ROLE_OPTIONS = [
  { label: "全部角色", value: "all" },
  { label: "主播", value: "host" },
  { label: "用户", value: "user" }
];

const PAYER_TYPE_OPTIONS = [
  { label: "全部付费", value: "all" },
  { label: "已付费", value: "paid" },
  { label: "未付费", value: "unpaid" }
];

// 新用户 = 当天注册成功的用户；非新用户 = 当天未注册（历史注册）的用户。
const NEW_USER_OPTIONS = [
  { label: "全部用户", value: "all" },
  { label: "新用户", value: "new" },
  { label: "非新用户", value: "old" }
];

// P5 按游戏明细的兜底列（正常由后端 game_columns 下发）。
const FALLBACK_GAME_COLUMNS = [
  { key: "game_bet_users", label: "押注用户数", type: "count" },
  { key: "game_d1_retention_rate", label: "游戏次留", type: "ratio" },
  { key: "game_arppu_coin", label: "ARPPU(金币)", type: "coin" },
  { key: "game_bet_coin", label: "押注金币", type: "coin" }
];

const GAME_SOURCE_LABELS = { self: "自研", vendor: "厂商" };

function readClosedGroups() {
  try {
    const raw = window.localStorage.getItem(GROUPS_STORAGE_KEY);
    const parsed = raw ? JSON.parse(raw) : [];
    if (Array.isArray(parsed)) {
      const validKeys = new Set(METRIC_GROUPS.map((group) => group.key));
      return parsed.filter((key) => validKeys.has(key));
    }
  } catch {
    // localStorage 不可用或内容损坏时回退为全部列组展开。
  }
  return [];
}

function writeClosedGroups(keys) {
  try {
    window.localStorage.setItem(GROUPS_STORAGE_KEY, JSON.stringify(keys));
  } catch {
    // 存储失败只影响持久化，不影响本次会话内的开关状态。
  }
}

function metricSortValue(row, key) {
  const value = row?.[key];
  if (isBlank(value)) {
    return null;
  }
  const numeric = Number(value);
  return Number.isFinite(numeric) ? numeric : null;
}

// null 视为最小：desc 时缺失口径沉底，asc 时排最前。
function compareMetric(a, b, key, dir) {
  const left = metricSortValue(a, key);
  const right = metricSortValue(b, key);
  const leftValue = left === null ? Number.NEGATIVE_INFINITY : left;
  const rightValue = right === null ? Number.NEGATIVE_INFINITY : right;
  if (leftValue === rightValue) {
    return 0;
  }
  return dir === "asc" ? leftValue - rightValue : rightValue - leftValue;
}

function rowIdentity(row, dimKey, index) {
  const parts = [row.app_code, row.region_id, row.country_code, row.stat_day].filter(
    (part) => part !== undefined && part !== null && part !== ""
  );
  return parts.length ? `${dimKey}:${parts.join("|")}` : `${dimKey}:${index}`;
}

function requirementSectionConfig(key) {
  return REQUIREMENT_SECTIONS.find((section) => section.key === key) || REQUIREMENT_SECTIONS[0];
}

function asArray(value) {
  return Array.isArray(value) ? value : [];
}

function sectionMatches(item, sectionKey) {
  return String(item?.section ?? item?.key ?? item?.code ?? "") === sectionKey;
}

function normalizeRequirementColumn(column) {
  const key = String(column?.key ?? column?.field ?? column?.name ?? "").trim();
  if (!key) {
    return null;
  }
  return {
    key,
    label: column.label || column.title || column.name || key,
    tooltip: column.tooltip || column.description || column.remark || "",
    type: column.type || column.value_type || column.format || ""
  };
}

function requirementAppContext(app) {
  return {
    app_code: app?.app_code || app?.appCode || "",
    app_name: app?.app_name || app?.appName || app?.app_code || app?.appCode || "",
    kind: app?.kind || "",
    restricted: Boolean(app?.restricted)
  };
}

function flattenRequirementRow(row, context = {}) {
  if (!row || typeof row !== "object") {
    return null;
  }
  const metrics = row.metrics && typeof row.metrics === "object" && !Array.isArray(row.metrics) ? row.metrics : {};
  const flattened = { ...context, ...row, ...metrics };
  delete flattened.metrics;
  return flattened;
}

function normalizedRequirementColumns(sectionItem, fallbackColumns) {
  const columns = asArray(sectionItem?.columns || sectionItem?.fields || sectionItem?.metrics).map(normalizeRequirementColumn).filter(Boolean);
  return columns.length ? columns : fallbackColumns;
}

// 日行统一按日期倒序展示（最新在最上），不依赖后端返回顺序；无日期的行（如"汇总"占位）保持原相对位置沉底。
function sortRequirementDailyRows(rows) {
  return [...rows].sort((a, b) => {
    const left = String(a?.stat_day || "");
    const right = String(b?.stat_day || "");
    if (left === right) {
      return 0;
    }
    return left < right ? 1 : -1;
  });
}

function normalizeRequirementPayload(payload, sectionKey) {
  const fallbackColumns = requirementSectionConfig(sectionKey).fallbackColumns;
  const appSections = asArray(payload?.apps).flatMap((app) => {
    const context = requirementAppContext(app);
    return asArray(app?.sections)
      .filter((section) => sectionMatches(section, sectionKey))
      .map((section) => ({ context, section }));
  });
  if (appSections.length) {
    const firstSection = appSections.find(({ section }) => asArray(section?.columns || section?.fields || section?.metrics).length)?.section
      || appSections[0].section;
    const columns = normalizedRequirementColumns(firstSection, fallbackColumns);
    if (appSections.length === 1) {
      const { context, section } = appSections[0];
      return {
        average: flattenRequirementRow(section.average, context),
        columns,
        rows: sortRequirementDailyRows(
          asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
            .map((row) => flattenRequirementRow(row, context))
            .filter(Boolean)
        ),
        total: flattenRequirementRow(section.total || section.totals || section.summary, context)
      };
    }
    const rows = appSections.flatMap(({ context, section }) => {
      const total = flattenRequirementRow(section.total || section.totals || section.summary, { ...context, stat_day: "汇总" });
      const average = flattenRequirementRow(section.average, { ...context, stat_day: "平均值" });
      const dailyRows = sortRequirementDailyRows(
        asArray(section.daily_rows || section.dailyRows || section.daily_series || section.rows || section.items)
          .map((row) => flattenRequirementRow(row, context))
          .filter(Boolean)
      );
      return [total, average, ...dailyRows].filter(Boolean);
    });
    return { average: null, columns, rows, total: null };
  }

  const sectionItem = asArray(payload?.sections).find((item) => sectionMatches(item, sectionKey))
    || asArray(payload?.items).find((item) => sectionMatches(item, sectionKey) && (item.columns || item.daily_rows || item.dailyRows || item.total || item.totals))
    || payload
    || {};
  const columns = normalizedRequirementColumns(sectionItem, fallbackColumns);
  const rows = sortRequirementDailyRows(
    asArray(sectionItem.daily_rows || sectionItem.dailyRows || sectionItem.daily_series || sectionItem.rows || sectionItem.items)
      .map((row) => flattenRequirementRow(row))
      .filter(Boolean)
  );
  const total = flattenRequirementRow(sectionItem.total || sectionItem.totals || sectionItem.summary);
  return {
    average: flattenRequirementRow(sectionItem.average),
    columns,
    rows,
    total
  };
}

// P5 按游戏明细：每个游戏输出 [汇总行 + 日行(倒序)]，行带 app/game 上下文；列优先取后端 game_columns。
// 该明细不受用户身份/付费身份/是否新用户筛选影响（后端口径限制）。
function normalizeRequirementGamePayload(payload) {
  const appSections = asArray(payload?.apps).flatMap((app) => {
    const context = requirementAppContext(app);
    return asArray(app?.sections)
      .filter((section) => sectionMatches(section, "game"))
      .map((section) => ({ context, section }));
  });
  const sections = appSections.length
    ? appSections
    : asArray(payload?.sections)
        .filter((section) => sectionMatches(section, "game"))
        .map((section) => ({ context: {}, section }));
  const columns =
    sections
      .map(({ section }) => asArray(section.game_columns).map(normalizeRequirementColumn).filter(Boolean))
      .find((cols) => cols.length) || FALLBACK_GAME_COLUMNS;
  const rows = sections.flatMap(({ context, section }) =>
    asArray(section.games).flatMap((game) => {
      const gameContext = {
        ...context,
        game_key: game.game_key || "",
        game_name: game.game_name || game.game_key || "--",
        game_source: game.game_source || ""
      };
      const total = flattenRequirementRow(game.total, { ...gameContext, stat_day: "汇总" });
      const daily = sortRequirementDailyRows(
        asArray(game.daily_series)
          .map((row) => flattenRequirementRow(row, gameContext))
          .filter(Boolean)
      );
      return total ? [total, ...daily] : daily;
    })
  );
  return { columns, rows };
}

function requirementGameLabel(row) {
  const source = GAME_SOURCE_LABELS[String(row?.game_source || "")] || "";
  const name = row?.game_name || row?.game_key || "--";
  return source ? `${source}·${name}` : name;
}

function inferRequirementType(column) {
  const type = String(column.type || "").toLowerCase();
  const key = String(column.key || "").toLowerCase();
  if (type.includes("money") || key.endsWith("_usd_minor")) {
    return "money_minor";
  }
  if (type.includes("ratio") || type.includes("rate") || type.includes("percent") || key.endsWith("_rate")) {
    return "ratio";
  }
  if (type.includes("duration") || key.endsWith("_ms")) {
    return "duration_ms";
  }
  if (type.includes("coin") || key.endsWith("_coin") || key.endsWith("_turnover") || key.endsWith("_profit")) {
    return "coin";
  }
  return type || "number";
}

// 文档展示规则：百分比固定保留 2 位小数（截断补零）；整数展示整数；非整数展示 2 位小数（超出截断）。
function truncateTwoDecimals(value, { pad = false } = {}) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) {
    return "--";
  }
  const truncated = Math.trunc(numeric * 100) / 100;
  return truncated.toLocaleString("en-US", {
    maximumFractionDigits: 2,
    minimumFractionDigits: pad ? 2 : 0
  });
}

function formatRequirementRatio(value) {
  const numeric = Number(value);
  if (!Number.isFinite(numeric)) {
    return "--";
  }
  return `${truncateTwoDecimals(numeric * 100, { pad: true })}%`;
}

function formatRequirementValue(column, value) {
  if (isBlank(value)) {
    return "--";
  }
  const type = inferRequirementType(column);
  if (type === "money_minor") {
    const dollars = Number(value) / 100;
    if (!Number.isFinite(dollars)) {
      return "--";
    }
    return Number.isInteger(dollars) ? formatMoneyMinor(value) : `$${truncateTwoDecimals(dollars, { pad: true })}`;
  }
  if (type === "ratio") {
    return formatRequirementRatio(value);
  }
  if (type === "duration_ms") {
    return formatDurationMs(value);
  }
  if (type === "coin" || type === "count" || type === "integer") {
    const numeric = Number(value);
    // 平均值行的计数/金币可能出现小数，按"非整数展示2位小数（截断）"处理。
    if (Number.isFinite(numeric) && !Number.isInteger(numeric)) {
      return truncateTwoDecimals(numeric, { pad: true });
    }
    return formatCount(value);
  }
  const numeric = Number(value);
  if (Number.isFinite(numeric)) {
    return Number.isInteger(numeric) ? formatCount(numeric) : truncateTwoDecimals(numeric, { pad: true });
  }
  return String(value);
}

function csvRequirementValue(column, value) {
  if (isBlank(value)) {
    return "";
  }
  const type = inferRequirementType(column);
  if (type === "money_minor") {
    return Number(value) / 100;
  }
  if (type === "ratio") {
    return Number(value) * 100;
  }
  return value;
}

function requirementRowIdentity(row, sectionKey, index) {
  const parts = [row.app_code, row.stat_day, row.region_id].filter((part) => part !== undefined && part !== null && part !== "");
  return parts.length ? `${sectionKey}:${parts.join("|")}` : `${sectionKey}:${index}`;
}

function requirementAppName(row) {
  return row?.app_name || row?.app_code || "全部";
}

export function DataTableView() {
  const {
    derived,
    isLoading,
    isRequirementsLoading,
    loadRequirements,
    range,
    refreshToken,
    requirements,
    requirementsError,
    tableState,
    updateTableState
  } = useSocialBi();
  const [dimKey, setDimKey] = useState("appDaily");
  const [closedGroups, setClosedGroups] = useState(readClosedGroups);
  const [sort, setSort] = useState(null);
  const [requirementRole, setRequirementRole] = useState("all");
  const [requirementPayer, setRequirementPayer] = useState("all");
  const [requirementNewUser, setRequirementNewUser] = useState("all");
  const mode = tableState?.mode || TABLE_MODES[0].key;
  const requirementSection = tableState?.requirementSection || REQUIREMENT_SECTIONS[0].key;

  const dim = DIMENSIONS.find((item) => item.key === dimKey) || DIMENSIONS[0];
  const dimColumns = dim.dims.map((name) => DIM_COLUMNS[name]);
  const hasDay = dim.hasDay;
  const baseRows = derived?.[dim.key] || EMPTY_ROWS;
  const activeRequirementSection = requirementSectionConfig(requirementSection);
  const effectiveRequirementRole = activeRequirementSection.roleFilter ? requirementRole : "all";
  const effectiveRequirementPayer = activeRequirementSection.payerFilter ? requirementPayer : "all";
  const effectiveRequirementNewUser = activeRequirementSection.newUserFilter ? requirementNewUser : "all";
  const requirementData = useMemo(() => normalizeRequirementPayload(requirements, requirementSection), [requirements, requirementSection]);
  const requirementColumns = requirementData.columns;
  const requirementRows = requirementData.rows;
  const requirementTotal = requirementData.total;
  const requirementAverage = requirementData.average;
  const requirementGameData = useMemo(
    () => (requirementSection === "game" ? normalizeRequirementGamePayload(requirements) : { columns: [], rows: [] }),
    [requirements, requirementSection]
  );

  const visibleGroups = useMemo(() => METRIC_GROUPS.filter((group) => !closedGroups.includes(group.key)), [closedGroups]);
  const visibleMetrics = useMemo(() => visibleGroups.flatMap((group) => group.metrics), [visibleGroups]);

  // 排序列被列组开关隐藏时回退到维度默认排序。
  const activeSort = sort && visibleMetrics.includes(sort.key) ? sort : null;

  // 排序/合计每次渲染重算（行集千行量级可接受）；react-hooks 的 preserve-manual-memoization
  // 规则不允许在这里包 useMemo（依赖来自模块常量推导，编译器无法证明不可变）。
  const sortedRows = [...baseRows];
  // 日维度的分块 key：App×日按 App 分块，区域×日按 App+区域分块；
  // 块顺序沿用行集原始顺序（后端 master 的 App 顺序），保证与顶栏 App 筛选chip一致。
  const dayGroupKey = (row) =>
    dim.dims.includes("region") ? `${row.app_code ?? ""}|${row.region_id ?? ""}` : String(row.app_code ?? "");
  const dayGroupOrder = new Map();
  if (hasDay) {
    baseRows.forEach((row) => {
      const key = dayGroupKey(row);
      if (!dayGroupOrder.has(key)) {
        dayGroupOrder.set(key, dayGroupOrder.size);
      }
    });
  }
  if (activeSort) {
    sortedRows.sort((a, b) => compareMetric(a, b, activeSort.key, activeSort.dir));
  } else if (hasDay) {
    // 默认展示不按天全局倒序（那样多 App 会一天一行互相穿插）：先按分块聚拢，块内再按日期倒序。
    sortedRows.sort((a, b) => {
      const groupGap = (dayGroupOrder.get(dayGroupKey(a)) ?? 0) - (dayGroupOrder.get(dayGroupKey(b)) ?? 0);
      if (groupGap !== 0) {
        return groupGap;
      }
      if (a.stat_day !== b.stat_day) {
        return String(a.stat_day) < String(b.stat_day) ? 1 : -1;
      }
      return compareMetric(a, b, "recharge_usd_minor", "desc");
    });
  } else {
    sortedRows.sort((a, b) => compareMetric(a, b, "recharge_usd_minor", "desc"));
  }

  // 日维度的汇总跨天：余额快照类指标（金币数量/存量工资）只取最后一天，避免按天数放大。
  const totalRow = sortedRows.length ? aggregateMetricMaps(sortedRows, { acrossTime: hasDay }) : null;
  // 平均值行只在带日期的维度有意义：可加指标按覆盖天数取日均。
  const wideDayCount = hasDay ? new Set(sortedRows.map((row) => String(row.stat_day || "")).filter(Boolean)).size : 0;
  const averageRow = totalRow && hasDay && wideDayCount > 0 ? averageMetricMaps(sortedRows, wideDayCount) : null;
  const renderRows = sortedRows.slice(0, MAX_RENDER_ROWS);
  const isTruncated = sortedRows.length > MAX_RENDER_ROWS;
  const hasRestricted = baseRows.some((row) => row.restricted);
  const columnCount = dimColumns.length + visibleMetrics.length;

  useEffect(() => {
    if (mode !== "requirements") {
      return;
    }
    loadRequirements({
      newUserType: effectiveRequirementNewUser,
      payerType: effectiveRequirementPayer,
      section: requirementSection,
      userRole: effectiveRequirementRole
    });
  }, [effectiveRequirementNewUser, effectiveRequirementPayer, effectiveRequirementRole, loadRequirements, mode, refreshToken, requirementSection]);

  const handleDimChange = (key) => {
    setDimKey(key);
    setSort(null);
  };

  const handleRequirementSectionChange = (key) => {
    const nextSection = requirementSectionConfig(key);
    updateTableState({ requirementSection: nextSection.key });
    if (!nextSection.roleFilter) {
      setRequirementRole("all");
    }
    if (!nextSection.payerFilter) {
      setRequirementPayer("all");
    }
    if (!nextSection.newUserFilter) {
      setRequirementNewUser("all");
    }
  };

  const toggleGroup = (key) => {
    const next = closedGroups.includes(key) ? closedGroups.filter((item) => item !== key) : [...closedGroups, key];
    setClosedGroups(next);
    writeClosedGroups(next);
  };

  const handleSort = (key) => {
    setSort((current) => (current?.key === key ? { dir: current.dir === "desc" ? "asc" : "desc", key } : { dir: "desc", key }));
  };

  // 导出当前可见列与排序后的全部行：金额转美元、比例转百分数、缺失导出空串。
  const handleExport = () => {
    const header = [...dimColumns.map((column) => column.label), ...visibleMetrics.map((key) => metricLabel(key))];
    const rows = sortedRows.map((row) => [
      ...dimColumns.map((column) => column.value(row)),
      ...visibleMetrics.map((key) => {
        const value = metricChartValue(key, row[key]);
        return value === null ? "" : value;
      })
    ]);
    downloadCsv(`social-bi-${dim.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
  };

  const handleRequirementsExport = () => {
    const header = ["日期", "App", ...requirementColumns.map((column) => column.label)];
    const rows = [
      ...(requirementTotal ? [{ ...requirementTotal, app_name: "全部", stat_day: "汇总" }] : []),
      ...(requirementAverage ? [{ ...requirementAverage, app_name: "全部", stat_day: "平均值" }] : []),
      ...requirementRows
    ].map((row) => [
      row.stat_day || "--",
      requirementAppName(row),
      ...requirementColumns.map((column) => csvRequirementValue(column, row[column.key]))
    ]);
    downloadCsv(`social-bi-requirements-${activeRequirementSection.csvName}-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
  };

  const handleGameBreakdownExport = () => {
    const header = ["游戏", "日期", "App", ...requirementGameData.columns.map((column) => column.label)];
    const rows = requirementGameData.rows.map((row) => [
      requirementGameLabel(row),
      row.stat_day || "--",
      requirementAppName(row),
      ...requirementGameData.columns.map((column) => csvRequirementValue(column, row[column.key]))
    ]);
    downloadCsv(`social-bi-game-breakdown-${rangeLabel(range).replace(/\s+/g, "")}.csv`, header, rows);
  };

  let wideContent;
  if (isLoading && !baseRows.length) {
    wideContent = (
      <div className="sbi-dt-skeleton" aria-label="数据加载中">
        {SKELETON_WIDTHS.map((width) => (
          <span className="sbi-skeleton" key={width} style={{ width }} />
        ))}
      </div>
    );
  } else if (!baseRows.length) {
    wideContent = (
      <div className="sbi-empty">
        <strong>当前无数据</strong>
      </div>
    );
  } else if (!visibleMetrics.length) {
    wideContent = (
      <div className="sbi-empty">
        <strong>所有列组已隐藏</strong>
      </div>
    );
  } else {
    wideContent = (
      <div className="sbi-table-scroll sbi-dt-scroll">
        <table className="sbi-table sbi-dt-table">
          <thead>
            <tr>
              {dimColumns.map((column, index) => (
                <th className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label} rowSpan={2}>
                  {column.label}
                </th>
              ))}
              {visibleGroups.map((group) => (
                <th className="sbi-dt-group-th" colSpan={group.metrics.length} key={group.key}>
                  {group.label}
                </th>
              ))}
            </tr>
            <tr>
              {visibleMetrics.map((key) => {
                const isSorted = activeSort?.key === key;
                return (
                  <th
                    aria-sort={isSorted ? (activeSort.dir === "desc" ? "descending" : "ascending") : undefined}
                    className={isSorted ? "sbi-dt-metric-th is-sorted" : "sbi-dt-metric-th"}
                    key={key}
                    onClick={() => handleSort(key)}
                  >
                    <span className="sbi-metric-hint" title={metricTooltip(key)}>
                      {metricLabel(key)}
                    </span>
                    {isSorted ? <span className="sbi-dt-sort">{activeSort.dir === "desc" ? "↓" : "↑"}</span> : null}
                  </th>
                );
              })}
            </tr>
          </thead>
          <tbody>
            {totalRow ? (
              <tr className="is-total">
                {dimColumns.map((column, index) => (
                  <td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
                    {column.totalText}
                  </td>
                ))}
                {visibleMetrics.map((key) => (
                  <td key={key}>{formatMetric(key, totalRow[key])}</td>
                ))}
              </tr>
            ) : null}
            {averageRow ? (
              <tr className="is-total">
                {dimColumns.map((column, index) => (
                  <td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
                    {index === 0 ? "平均值" : column.key === "day" ? "--" : column.totalText}
                  </td>
                ))}
                {visibleMetrics.map((key) => (
                  <td key={key}>{formatMetric(key, averageRow[key])}</td>
                ))}
              </tr>
            ) : null}
            {renderRows.map((row, rowIndex) => {
              // 仅默认分块排序下标记块首行：指标排序是全局排序，没有分块语义。
              const isGroupStart =
                hasDay && !activeSort && rowIndex > 0 && dayGroupKey(renderRows[rowIndex - 1]) !== dayGroupKey(row);
              return (
                <tr className={isGroupStart ? "sbi-dt-group-start" : undefined} key={rowIdentity(row, dim.key, rowIndex)}>
                  {dimColumns.map((column, index) => (
                    <td className={index === 0 ? "is-left sbi-dt-sticky" : "is-left"} key={column.label}>
                      {column.key === "app" ? (
                        <SocialAppIdentity appCode={row.app_code} appName={row.app_name} />
                      ) : column.value(row)}
                    </td>
                  ))}
                  {visibleMetrics.map((key) => (
                    <td key={key}>{formatMetric(key, row[key])}</td>
                  ))}
                </tr>
              );
            })}
          </tbody>
          {isTruncated ? (
            <tfoot>
              <tr className="sbi-dt-note">
                <td colSpan={columnCount}>已截断，共 {sortedRows.length} 行，可导出 CSV 查看全部</td>
              </tr>
            </tfoot>
          ) : null}
        </table>
      </div>
    );
  }

  let requirementsContent;
  if (isRequirementsLoading && !requirementRows.length && !requirementTotal) {
    requirementsContent = (
      <div className="sbi-dt-skeleton" aria-label="数据需求加载中">
        {SKELETON_WIDTHS.map((width) => (
          <span className="sbi-skeleton" key={width} style={{ width }} />
        ))}
      </div>
    );
  } else if (requirementsError) {
    requirementsContent = (
      <div className="sbi-empty" role="alert">
        <strong>{requirementsError}</strong>
      </div>
    );
  } else if (!requirementRows.length && !requirementTotal) {
    requirementsContent = (
      <div className="sbi-empty">
        <strong>当前无数据</strong>
      </div>
    );
  } else {
    requirementsContent = (
      <>
      <div className="sbi-table-scroll sbi-dt-scroll">
        <table className="sbi-table sbi-dt-req-table">
          <thead>
            <tr>
              <th className="is-left sbi-dt-sticky">日期</th>
              <th className="is-left">App</th>
              {requirementColumns.map((column) => (
                <th key={column.key}>
                  <span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
                    {column.label}
                  </span>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {requirementTotal ? (
              <tr className="is-total">
                <td className="is-left sbi-dt-sticky">汇总</td>
                <td className="is-left">全部</td>
                {requirementColumns.map((column) => (
                  <td key={column.key}>{formatRequirementValue(column, requirementTotal[column.key])}</td>
                ))}
              </tr>
            ) : null}
            {requirementAverage ? (
              <tr className="is-total">
                <td className="is-left sbi-dt-sticky">平均值</td>
                <td className="is-left">全部</td>
                {requirementColumns.map((column) => (
                  <td key={column.key}>{formatRequirementValue(column, requirementAverage[column.key])}</td>
                ))}
              </tr>
            ) : null}
            {requirementRows.map((row, rowIndex) => (
              <tr key={requirementRowIdentity(row, activeRequirementSection.key, rowIndex)}>
                <td className="is-left sbi-dt-sticky">{row.stat_day || "--"}</td>
                <td className="is-left">
                  <SocialAppIdentity appCode={row.app_code} appName={requirementAppName(row)} />
                </td>
                {requirementColumns.map((column) => (
                  <td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>
      {requirementGameData.rows.length ? (
        <>
          <div className="sbi-card-header">
            <strong>各游戏明细</strong>
            <small>押注用户/同游戏次留/ARPPU · 不受身份与新用户筛选影响</small>
          </div>
          <div className="sbi-table-scroll sbi-dt-scroll">
            <table className="sbi-table sbi-dt-req-table">
              <thead>
                <tr>
                  <th className="is-left sbi-dt-sticky">游戏</th>
                  <th className="is-left">日期</th>
                  <th className="is-left">App</th>
                  {requirementGameData.columns.map((column) => (
                    <th key={column.key}>
                      <span className={column.tooltip ? "sbi-metric-hint" : ""} title={column.tooltip || undefined}>
                        {column.label}
                      </span>
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {requirementGameData.rows.map((row, rowIndex) => (
                  <tr
                    className={row.stat_day === "汇总" ? "is-total" : undefined}
                    key={`${row.app_code || ""}|${row.game_key || rowIndex}|${row.stat_day || rowIndex}`}
                  >
                    <td className="is-left sbi-dt-sticky">{requirementGameLabel(row)}</td>
                    <td className="is-left">{row.stat_day || "--"}</td>
                    <td className="is-left">
                      <SocialAppIdentity appCode={row.app_code} appName={requirementAppName(row)} />
                    </td>
                    {requirementGameData.columns.map((column) => (
                      <td key={column.key}>{formatRequirementValue(column, row[column.key])}</td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      ) : null}
      </>
    );
  }

  const rowCount = mode === "requirements" ? requirementRows.length : sortedRows.length;
  const content = mode === "requirements" ? requirementsContent : wideContent;
  const exportDisabled = mode === "requirements"
    ? (!requirementRows.length && !requirementTotal) || isRequirementsLoading
    : !sortedRows.length || !visibleMetrics.length;

  return (
    <section className="sbi-card">
      <div className="sbi-card-header">
        <strong>数据明细</strong>
        <small>
          {rangeLabel(range)} · 共 {rowCount} 行
        </small>
      </div>
      <div className="sbi-dt-tools">
        <div className="sbi-seg" role="radiogroup" aria-label="数据明细模式">
          {TABLE_MODES.map((item) => (
            <button
              aria-checked={mode === item.key}
              className={mode === item.key ? "is-active" : ""}
              key={item.key}
              onClick={() => updateTableState({ mode: item.key })}
              role="radio"
              type="button"
            >
              {item.label}
            </button>
          ))}
        </div>
        {mode === "wide" ? (
          <>
            <div className="sbi-seg" role="radiogroup" aria-label="明细维度">
              {DIMENSIONS.map((item) => (
                <button
                  aria-checked={dim.key === item.key}
                  className={dim.key === item.key ? "is-active" : ""}
                  key={item.key}
                  onClick={() => handleDimChange(item.key)}
                  role="radio"
                  type="button"
                >
                  {item.label}
                </button>
              ))}
            </div>
            <div className="sbi-dt-groups" role="group" aria-label="列组开关">
              <span className="sbi-dt-groups-label">列组</span>
              {METRIC_GROUPS.map((group) => {
                const isOpen = !closedGroups.includes(group.key);
                return (
                  <button
                    aria-pressed={isOpen}
                    className={isOpen ? "sbi-chip is-active" : "sbi-chip"}
                    key={group.key}
                    onClick={() => toggleGroup(group.key)}
                    title={isOpen ? "点击隐藏该组指标列" : "点击显示该组指标列"}
                    type="button"
                  >
                    {group.label}
                  </button>
                );
              })}
            </div>
            {hasRestricted ? <span className="sbi-badge">已按数据范围过滤</span> : null}
          </>
        ) : (
          <>
            <div className="sbi-dt-section-tabs" role="tablist" aria-label="数据需求分组">
              {REQUIREMENT_SECTIONS.map((section) => (
                <button
                  aria-selected={activeRequirementSection.key === section.key}
                  className={activeRequirementSection.key === section.key ? "is-active" : ""}
                  key={section.key}
                  onClick={() => handleRequirementSectionChange(section.key)}
                  role="tab"
                  type="button"
                >
                  {section.label}
                </button>
              ))}
            </div>
            {activeRequirementSection.roleFilter ? (
              <RequirementFilterGroup
                ariaLabel="用户身份"
                label="用户身份"
                options={USER_ROLE_OPTIONS}
                value={effectiveRequirementRole}
                onChange={setRequirementRole}
              />
            ) : null}
            {activeRequirementSection.payerFilter ? (
              <RequirementFilterGroup
                ariaLabel="付费身份"
                label="付费身份"
                options={PAYER_TYPE_OPTIONS}
                value={effectiveRequirementPayer}
                onChange={setRequirementPayer}
              />
            ) : null}
            {activeRequirementSection.newUserFilter ? (
              <RequirementFilterGroup
                ariaLabel="是否新用户"
                label="是否新用户"
                options={NEW_USER_OPTIONS}
                value={effectiveRequirementNewUser}
                onChange={setRequirementNewUser}
              />
            ) : null}
          </>
        )}
        <div className="sbi-dt-export">
          {mode === "requirements" && requirementGameData.rows.length ? (
            <Button
              onClick={handleGameBreakdownExport}
              size="small"
              startIcon={<FileDownloadOutlined />}
              variant="outlined"
            >
              导出游戏明细
            </Button>
          ) : null}
          <Button
            disabled={exportDisabled}
            onClick={mode === "requirements" ? handleRequirementsExport : handleExport}
            size="small"
            startIcon={<FileDownloadOutlined />}
            variant="outlined"
          >
            导出 CSV
          </Button>
        </div>
      </div>
      {content}
    </section>
  );
}

function RequirementFilterGroup({ ariaLabel, label, onChange, options, value }) {
  return (
    <div className="sbi-dt-filter-group">
      <span className="sbi-dt-groups-label">{label}</span>
      <div className="sbi-seg" role="radiogroup" aria-label={ariaLabel}>
        {options.map((option) => (
          <button
            aria-checked={value === option.value}
            className={value === option.value ? "is-active" : ""}
            key={option.value}
            onClick={() => onChange(option.value)}
            role="radio"
            type="button"
          >
            {option.label}
          </button>
        ))}
      </div>
    </div>
  );
}
