// settings.jsx  v12  —  系统设置：指标字典 · 分析提醒 · 数据备份 · 安全可靠性
// 职责：读写 app.jsx 传入的全局配置（metricConfig / scoringAlgo / riskConfig）
// P1-D: ICCConfigSection — 力板指标 ICC 输入，持久化至 localStorage 'perf_v2_icc'，通过 DASHBOARD_DATA.ICC_USER 暴露
// P1-E: RiskTab — 四级交通灯（acwr/cmjDrop/mRSIDrop），替代旧双阈值结构

const { useState: sUseState, useRef: sUseRef, useMemo: sUseMemo, useEffect: sUseEffect } = React;

// ──────────────────────────────────────────────────────────────────────────
// Modal shell
// ──────────────────────────────────────────────────────────────────────────
function SettingsModal({ open, onClose, asPage, tab, onTabChange,
                         groups, onGroupsChange,
                         athletes, onAthletesChange,
                         seasons, currentSeason,
                         algo, onAlgoChange,
                         riskConfig, onRiskConfigChange,
                         trendConfig, onTrendConfigChange,
                         onResetToDefaults,
                         onDeleteAthlete, onCountAthleteData,
                         onClearForceSessions, forceStorageUsageBytes, forceSessionStores,
                         onForceBackupExport, onForceBackupImport, onForceTraceUsage,
                         legacyImportPlanner, onApplyLegacyImport, getLegacyImportMapping, onSaveLegacyImportMapping,
                         onSeasonsChange }) {
  if (!open && !asPage) return null;
  const tabs = [
    { id: 'account',  label: '账号与权限',   icon: 'user',     group: '工作区', description: '查看登录身份、工作区角色，并由管理员管理现有成员权限。' },
    { id: 'metrics',  label: '指标字典',     icon: 'sliders',  group: '指标字典', description: '管理指标名称、单位、方向与参考阈值；录入仍在测试页面完成。' },
    { id: 'scoring',  label: '评分与趋势',   icon: 'target',   group: '分析与提醒', description: '设置个体评分方式，以及团队趋势图的显示偏好。' },
    { id: 'insights', label: '洞察规则',     icon: 'star',     group: '分析与提醒', description: '管理重点指标和洞察规则的触发范围。' },
    { id: 'risk',     label: '提醒阈值',     icon: 'bolt',     group: '分析与提醒', description: '配置现有提醒阈值；这些提示不替代专业判断。' },
    { id: 'data',     label: '数据与备份',   icon: 'download', group: '数据与备份', description: '导入、导出、Force 专用备份，以及本地数据清理。' },
    { id: 'quality',  label: '可靠性与审计', icon: 'history', group: '安全与可靠性', description: '查看已录入数据覆盖率、异常值与测量可靠性。' },
  ];
  const activeTab = tabs.find(t => t.id === tab) || tabs[0];
  const tabGroups = [
    { label: '工作区', tabs: tabs.filter(t => t.group === '工作区') },
    { label: '指标字典', tabs: tabs.filter(t => t.group === '指标字典') },
    { label: '分析与提醒', tabs: tabs.filter(t => t.group === '分析与提醒') },
    { label: '数据与备份', tabs: tabs.filter(t => t.group === '数据与备份') },
    { label: '安全与可靠性', tabs: tabs.filter(t => t.group === '安全与可靠性') },
  ];

  return (
    <div onClick={asPage ? undefined : onClose} style={asPage ? {
      flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', background: 'var(--bg)', overflow: 'auto', padding: '18px 24px 40px',
    } : {
      position: 'fixed', inset: 0, background: 'rgba(5,8,12,.7)', backdropFilter: 'blur(4px)',
      zIndex: 100, display: 'grid', placeItems: 'center', padding: 20,
      animation: 'fade .2s ease both',
    }}>
      <div onClick={(e) => e.stopPropagation()} style={asPage ? {
        background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 12,
        width: '100%', maxWidth: 1420, margin: '0 auto', minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden',
      } : {
        background: 'var(--panel)', border: '1px solid var(--border-strong)',
        borderRadius: 12, width: 880, maxWidth: '100%', maxHeight: '90vh',
        boxShadow: 'var(--mac-shadow, 0 20px 60px rgba(0,0,0,.5))',
        display: 'flex', flexDirection: 'column', overflow: 'hidden',
        animation: 'fadeUp .25s ease both',
      }}>
        {/* header */}
        <div style={{
          padding: '14px 18px',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between',
          borderBottom: '1px solid var(--border)',
        }}>
          <div>
            <div style={{ fontSize: 10, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '.1em' }}>System settings</div>
            <h3 style={{ margin: 0, fontSize: 17, fontWeight: 650 }}>系统设置 · {activeTab.label}</h3>
            <div style={{ marginTop: 3, fontSize: 11.5, color: 'var(--muted)', lineHeight: 1.35, maxWidth: 620 }}>{activeTab.description}</div>
          </div>
          {!asPage && <button onClick={onClose} className="btn ghost" style={{ padding: 6 }}><Icon name="x" size={14}/></button>}
        </div>
        {/* body */}
        <div data-settings-ia-shell style={{
          flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: '240px minmax(0, 1fr)',
          background: 'var(--bg)', overflow: 'hidden',
        }}>
          <SettingsIARail
            activeTab={activeTab}
            tabGroups={tabGroups}
            onTabChange={onTabChange}
          />
          <div data-settings-ia-content style={{ minWidth: 0, overflow: 'auto', padding: '18px' }}>
            {tab === 'account'  && <AccountPermissionsTab athletes={athletes}/>}
            {tab === 'metrics'  && <MetricsTab groups={groups} onGroupsChange={onGroupsChange} athletes={athletes} onAthletesChange={onAthletesChange}/>}
            {tab === 'scoring'  && <ScoringTab algo={algo} onAlgoChange={onAlgoChange} trendConfig={trendConfig} onTrendConfigChange={onTrendConfigChange}/>}
            {tab === 'insights' && <InsightsTab groups={groups}/>}
            {tab === 'quality'  && <DataQualityTab groups={groups} athletes={athletes} seasons={seasons}/>}
            {tab === 'data' && <DataTab
              groups={groups} athletes={athletes} onAthletesChange={onAthletesChange}
              seasons={seasons} currentSeason={currentSeason}
              onResetToDefaults={onResetToDefaults} onDeleteAthlete={onDeleteAthlete} onCountAthleteData={onCountAthleteData}
              onClearForceSessions={onClearForceSessions} forceStorageUsageBytes={forceStorageUsageBytes} forceSessionStores={forceSessionStores}
              legacyImportPlanner={legacyImportPlanner} onApplyLegacyImport={onApplyLegacyImport}
              getLegacyImportMapping={getLegacyImportMapping} onSaveLegacyImportMapping={onSaveLegacyImportMapping}
              onForceBackupExport={onForceBackupExport} onForceBackupImport={onForceBackupImport} onForceTraceUsage={onForceTraceUsage}
            />}
            {tab === 'risk'     && <RiskTab    riskConfig={riskConfig} onRiskConfigChange={onRiskConfigChange}/>}
          </div>
        </div>
      </div>
    </div>
  );
}

const ACCOUNT_ROLE_COPY = {
  owner: ['所有者', '完整权限，并负责工作区归属。'],
  admin: ['管理员', '管理成员、数据与审核流程。'],
  scientist: ['运动科学家', '写入监控数据并确认审核结果。'],
  coach: ['教练', '维护运动员、日常记录与训练草案。'],
  viewer: ['只读成员', '仅查看获准的工作区记录。'],
};

const ACCOUNT_CAPABILITY_COPY = {
  'members.manage': '管理成员', 'athletes.write': '编辑运动员',
  'monitoring.write': '写入监控摘要', 'reviews.confirm': '确认审核结果', 'records.read': '读取记录',
  'audit.read': '查看审计记录', 'daily.write': '写入日常记录', 'drafts.write': '创建训练草案',
};

function AccountPermissionsTab({ athletes = [] }) {
  const runtime = window.AxisAuthRuntime;
  const context = runtime && runtime.getContext ? runtime.getContext() : null;
  const canManage = !!(runtime && runtime.can && runtime.can('members.manage') && context?.mode === 'cloud');
  const [members, setMembers] = sUseState([]);
  const [status, setStatus] = sUseState(canManage ? 'loading' : 'idle');
  const [message, setMessage] = sUseState('');
  const [syncing, setSyncing] = sUseState(false);
  const [syncMessage, setSyncMessage] = sUseState('');

  const loadMembers = React.useCallback(async () => {
    if (!canManage) return;
    setStatus('loading'); setMessage('');
    try { setMembers(await runtime.listMembers()); setStatus('ready'); }
    catch (error) { setStatus('error'); setMessage(String(error?.message || error)); }
  }, [canManage]);

  sUseEffect(() => { loadMembers(); }, [loadMembers]);
  if (!context) return <div className="settings-account-empty">登录上下文尚未就绪。</div>;

  const capabilities = runtime.capabilities ? runtime.capabilities() : [];
  const roleCopy = ACCOUNT_ROLE_COPY[context.role] || ACCOUNT_ROLE_COPY.viewer;
  async function changeRole(member, role) {
    setMessage('');
    try {
      await runtime.updateMemberRole(member.id, role);
      setMembers(current => current.map(row => row.id === member.id ? { ...row, role } : row));
      setMessage('成员角色已更新。');
    } catch (error) { setMessage(String(error?.message || error)); }
  }

  async function syncStructuredData() {
    if (!window.confirm('将当前花名册、人口学信息和已保存的数值指标结果同步到当前云端工作区。原始曲线、CSV、知识库和 AI 私有资料不会上传。继续？')) return;
    setSyncing(true); setSyncMessage('');
    try {
      const result = await runtime.syncStructuredSnapshot(athletes);
      setSyncMessage(`同步完成：${result.athleteCount} 名运动员，${result.metricCount} 条指标结果。`);
    } catch (error) { setSyncMessage(`同步失败：${String(error?.message || error)}`); }
    finally { setSyncing(false); }
  }

  return (
    <div className="settings-account" data-settings-account>
      <section className="settings-account-identity">
        <div className="settings-account-avatar" aria-hidden="true">{String(context.displayName || context.email || 'A').trim().slice(0, 1).toUpperCase()}</div>
        <div><span className="settings-account-kicker">当前身份</span><h4>{context.displayName || context.email}</h4><p>{context.email}</p></div>
        <div className="settings-account-workspace"><span>工作区</span><strong>{context.workspaceName}</strong><small>{context.mode === 'local' ? '仅本机模式' : 'Supabase 云端工作区'}</small></div>
      </section>
      <section className="settings-account-card">
        <header><div><span className="settings-account-kicker">当前角色</span><h4>{roleCopy[0]}</h4></div><span className="settings-role-badge">{context.role}</span></header>
        <p>{roleCopy[1]}</p>
        <div className="settings-capabilities" aria-label="当前角色能力">
          {capabilities.map(capability => <span key={capability}>{ACCOUNT_CAPABILITY_COPY[capability] || capability}</span>)}
        </div>
      </section>
      <section className="settings-account-card">
        <header><div><span className="settings-account-kicker">成员权限</span><h4>现有工作区成员</h4></div>{canManage && <button className="btn ghost" type="button" onClick={loadMembers}>刷新</button>}</header>
        {context.mode === 'local' && <p>本地模式没有远程成员。部署环境登录后才启用角色管理。</p>}
        {context.mode === 'cloud' && !canManage && <p>只有所有者或管理员可以调整成员角色。</p>}
        {status === 'loading' && <p role="status">正在读取成员…</p>}
        {status === 'error' && <p role="alert">无法读取成员：{message}</p>}
        {canManage && status === 'ready' && <div className="settings-member-list">
          {members.map(member => {
            const profile = member.profiles || {};
            const isSelf = member.user_id === context.userId;
            const choices = window.AxisPermissionModel.roles.filter(role => role !== 'owner' || member.role === 'owner');
            return <div className="settings-member-row" key={member.id}>
              <div><strong>{profile.display_name || profile.email || member.user_id}</strong><small>{profile.email || member.user_id}{isSelf ? ' · 当前账号' : ''}</small></div>
              <select aria-label={`设置 ${profile.display_name || profile.email || member.user_id} 的角色`} value={member.role}
                disabled={isSelf || member.role === 'owner'} onChange={event => changeRole(member, event.target.value)}>
                {choices.map(role => <option key={role} value={role}>{ACCOUNT_ROLE_COPY[role]?.[0] || role}</option>)}
              </select>
            </div>;
          })}
        </div>}
        {message && status !== 'error' && <p className="settings-account-message" role="status">{message}</p>}
        <p className="settings-account-footnote">MVP 不开放自助注册或伪装邀请。新账号由管理员在 Supabase 控制台预建，再在此分配角色。</p>
      </section>
      <section className="settings-account-card" data-settings-cloud-sync>
        <header><div><span className="settings-account-kicker">结构化同步</span><h4>运动员档案与指标结果</h4></div></header>
        <p>上传花名册、人口学信息及用户已保存的数值指标结果。原始力台曲线、CSV、知识库、AI 私有记忆与报告文件始终留在本机。</p>
        {context.mode === 'cloud' && runtime.can('athletes.write')
          ? <button type="button" className="btn" disabled={syncing} onClick={syncStructuredData}>{syncing ? '正在同步…' : '立即同步结构化数据'}</button>
          : <p className="settings-account-footnote">当前模式或角色不允许上传结构化数据。</p>}
        {syncMessage && <p className="settings-account-message" role="status">{syncMessage}</p>}
      </section>
      {context.mode === 'cloud' && <button type="button" className="btn ghost settings-signout" onClick={() => runtime.signOut()}>退出当前账号</button>}
    </div>
  );
}

function SettingsIARail({ activeTab, tabGroups, onTabChange }) {
  return (
    <aside data-settings-ia-rail style={{
      borderRight: '1px solid var(--border)',
      background: 'var(--panel)',
      padding: '14px 12px',
      overflow: 'auto',
    }}>
      <div style={{
        marginBottom: 14, paddingBottom: 10,
        borderBottom: '1px solid var(--border)',
      }} title="System controls, data operations, and legacy analysis configuration stay grouped here.">
        <div style={{
          fontSize: 10, color: 'var(--muted-2)', textTransform: 'uppercase',
          letterSpacing: '.08em', fontWeight: 600,
        }}>设置导航</div>
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {tabGroups.map(group => (
          <div key={group.label} data-settings-ia-group={group.label}>
            <div style={{
              padding: '0 8px 6px',
              fontSize: 10, color: 'var(--muted-2)', textTransform: 'uppercase',
              letterSpacing: '.07em', fontWeight: 650,
            }}>{group.label}</div>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
              {group.tabs.map(t => {
                const active = activeTab.id === t.id;
                return (
                  <button key={t.id} onClick={() => onTabChange(t.id)} title={t.description}
                    data-settings-ia-tab={t.id}
                    style={{
                      display: 'grid', gridTemplateColumns: '18px minmax(0,1fr)', alignItems: 'start', gap: 8,
                      width: '100%', minHeight: 48, textAlign: 'left', cursor: 'pointer',
                      padding: '8px', borderRadius: 6,
                      border: '1px solid transparent',
                      borderLeft: active ? '2px solid var(--accent)' : '2px solid transparent',
                      background: active ? 'var(--accent-soft)' : 'transparent',
                      color: active ? 'var(--text)' : 'var(--text-2)',
                      font: 'inherit', fontSize: 12.5, fontWeight: active ? 650 : 500,
                    }}>
                    <span style={{ color: active ? 'var(--accent-2)' : 'var(--muted)', display: 'inline-flex' }}>
                      <Icon name={t.icon} size={12}/>
                    </span>
                    <span style={{ minWidth: 0 }}>
                      <span style={{ display: 'block' }}>{t.label}</span>
                      <span style={{ display: 'block', marginTop: 2, fontSize: 10.5, lineHeight: 1.3, color: 'var(--muted)', fontWeight: 450 }}>{t.description}</span>
                    </span>
                  </button>
                );
              })}
            </div>
          </div>
        ))}
      </div>
    </aside>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Metrics tab — editable table of all metrics, grouped
// ──────────────────────────────────────────────────────────────────────────
function MetricsTab({ groups, onGroupsChange, athletes, onAthletesChange }) {
  const [selectedGroupId, setSelectedGroupId] = sUseState(groups[0]?.id || '');
  const [groupSearch, setGroupSearch] = sUseState('');
  const [metricSearch, setMetricSearch] = sUseState('');
  const [newGroupLabel, setNewGroupLabel] = sUseState('');
  const metricGridColumns = 'minmax(180px,1.45fr) minmax(72px,.55fr) minmax(130px,.85fr) minmax(140px,1fr) minmax(210px,1.45fr) 32px';
  const selectedGroupIndex = groups.findIndex(g => g.id === selectedGroupId);
  const selectedGroup = groups[selectedGroupIndex] || groups[0] || null;

  sUseEffect(() => {
    if (!groups.length) return;
    if (!groups.some(group => group.id === selectedGroupId)) setSelectedGroupId(groups[0].id);
  }, [groups, selectedGroupId]);

  const stripMetricValues = (metricIds) => {
    const ids = new Set(metricIds);
    onAthletesChange(athletes.map(athlete => ({
      ...athlete,
      seasons: Object.fromEntries(Object.entries(athlete.seasons || {}).map(([season, values]) => [
        season,
        Object.fromEntries(Object.entries(values || {}).filter(([metricId]) => !ids.has(metricId))),
      ])),
      ...(athlete.provenance ? {
        provenance: Object.fromEntries(Object.entries(athlete.provenance).map(([season, values]) => [
          season,
          Object.fromEntries(Object.entries(values || {}).filter(([metricId]) => !ids.has(metricId))),
        ])),
      } : {}),
    })));
  };
  const updateMetric = (gIdx, mIdx, patch) => {
    const next = groups.map((g, i) => {
      if (i !== gIdx) return g;
      return { ...g, metrics: g.metrics.map((m, j) => j === mIdx ? { ...m, ...patch } : m) };
    });
    onGroupsChange(next);
  };
  const removeMetric = (gIdx, mIdx) => {
    const removed = groups[gIdx].metrics[mIdx];
    if (!removed || !window.confirm(`删除指标“${removed.label}”？所有运动员历史记录中的该指标值也会被删除。`)) return;
    const next = groups.map((g, i) => i === gIdx ? { ...g, metrics: g.metrics.filter((_, j) => j !== mIdx) } : g);
    onGroupsChange(next);
    stripMetricValues([removed.id]);
  };
  const addMetric = (gIdx) => {
    const newId = `custom_${Date.now()}`;
    // No manual worst/avg/best — auto-computed once data is recorded.
    const newM = { id: newId, label: window.t('New metric'), unit: '', dir: 'higher', worst: 0, avg: 0, best: 0 };
    const next = groups.map((g, i) => i === gIdx ? { ...g, metrics: [...g.metrics, newM] } : g);
    onGroupsChange(next);
    // seed empty values
    onAthletesChange(athletes.map(a => ({
      ...a,
      seasons: Object.fromEntries(Object.entries(a.seasons).map(([s, v]) => [s, { ...v }])),
    })));
  };
  const renameGroup = (gIdx, label) => {
    onGroupsChange(groups.map((g, i) => i === gIdx ? { ...g, label } : g));
  };

  const addGroup = () => {
    const label = newGroupLabel.trim();
    if (!label) return;
    const id = `custom_group_${Date.now()}`;
    const accents = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#ef4444'];
    onGroupsChange([...groups, { id, label, accent: accents[groups.length % accents.length], metrics: [] }]);
    setSelectedGroupId(id);
    setNewGroupLabel('');
    setMetricSearch('');
  };

  const removeGroup = () => {
    if (!selectedGroup || groups.length <= 1) return;
    const metricIds = (selectedGroup.metrics || []).map(metric => metric.id);
    if (!window.confirm(`删除类别“${selectedGroup.label}”及其中 ${metricIds.length} 个指标？相关历史值也会被删除。`)) return;
    const next = groups.filter(group => group.id !== selectedGroup.id);
    onGroupsChange(next);
    stripMetricValues(metricIds);
    setSelectedGroupId(next[0]?.id || '');
    setMetricSearch('');
  };

  const filteredGroups = groups.filter(group =>
    !groupSearch.trim() || group.label.toLowerCase().includes(groupSearch.trim().toLowerCase())
  );
  const visibleMetrics = (selectedGroup?.metrics || []).map((metric, index) => ({ metric, index })).filter(({ metric }) => {
    const query = metricSearch.trim().toLowerCase();
    return !query || `${metric.label} ${metric.id} ${metric.unit || ''}`.toLowerCase().includes(query);
  });

  return (
    <div data-settings-metrics-workspace style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <Note>
        Edit each metric's label, unit, and direction. The normative
        <span className="mono"> worst / avg / best</span> range is computed automatically from the
        data you record — no need to set it by hand. Use <strong style={{ color: 'var(--text)' }}>Fixed
        Range</strong> when a metric has a clinical or sport-science reference threshold: values past
        the threshold are flagged with a red/green indicator on the athlete card.
      </Note>
      <MetricGovernanceNote />
      <div data-settings-metrics-split style={{
        display: 'grid', gridTemplateColumns: '250px minmax(760px, 1fr)', minHeight: 520,
        border: '1px solid var(--border)', borderRadius: 10, overflow: 'auto', background: 'var(--panel-2)',
      }}>
        <aside data-settings-metric-categories style={{
          minWidth: 0, borderRight: '1px solid var(--border)', background: 'var(--panel)',
          display: 'flex', flexDirection: 'column', position: 'sticky', left: 0, zIndex: 2,
        }}>
          <div style={{ padding: '14px 14px 10px', borderBottom: '1px solid var(--border)' }}>
            <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }}>
              <b style={{ fontSize: 13, color: 'var(--text)' }}>指标类别</b>
              <span className="mono" style={{ fontSize: 10, color: 'var(--muted)' }}>{groups.length} 类</span>
            </div>
            <input aria-label="搜索指标类别" value={groupSearch} onChange={e => setGroupSearch(e.target.value)} placeholder="搜索类别…"
              style={{ marginTop: 10, width: '100%', background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '7px 9px', color: 'var(--text)', fontSize: 12 }}/>
          </div>
          <div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 8 }}>
            {filteredGroups.map(group => {
              const active = selectedGroup?.id === group.id;
              return (
                <button key={group.id} data-settings-metric-category={group.id} onClick={() => { setSelectedGroupId(group.id); setMetricSearch(''); }}
                  style={{
                    width: '100%', display: 'grid', gridTemplateColumns: '5px minmax(0,1fr) auto', gap: 9, alignItems: 'center',
                    textAlign: 'left', padding: '9px 10px', border: '1px solid', borderColor: active ? 'var(--accent)' : 'transparent',
                    borderRadius: 7, background: active ? 'var(--accent-soft)' : 'transparent', color: 'var(--text)', cursor: 'pointer', font: 'inherit',
                  }}>
                  <span style={{ width: 5, height: 22, borderRadius: 4, background: group.accent || 'var(--accent)' }}/>
                  <span style={{ minWidth: 0 }}>
                    <span style={{ display: 'block', fontSize: 12.5, fontWeight: active ? 650 : 520, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{group.label}</span>
                    <span style={{ display: 'block', marginTop: 2, fontSize: 10, color: 'var(--muted)' }}>{group.metrics?.length || 0} 个指标</span>
                  </span>
                  <Icon name="chevRight" size={11}/>
                </button>
              );
            })}
          </div>
          <div style={{ padding: 10, borderTop: '1px solid var(--border)' }}>
            <div style={{ display: 'flex', gap: 6 }}>
              <input aria-label="新类别名称" value={newGroupLabel} onChange={e => setNewGroupLabel(e.target.value)} onKeyDown={e => e.key === 'Enter' && addGroup()} placeholder="新类别名称"
                style={{ minWidth: 0, flex: 1, background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '7px 8px', color: 'var(--text)', fontSize: 11.5 }}/>
              <button data-settings-add-category className="btn" disabled={!newGroupLabel.trim()} onClick={addGroup} title="新增类别" style={{ padding: '6px 8px' }}><Icon name="plus" size={12}/></button>
            </div>
          </div>
        </aside>

        <section data-settings-metric-detail style={{ minWidth: 760, background: 'var(--panel-2)' }}>
          {selectedGroup ? (
            <>
              <div style={{ padding: '12px 14px', display: 'grid', gridTemplateColumns: 'minmax(180px,1fr) minmax(220px,320px) auto auto', gap: 8, alignItems: 'center', background: 'var(--panel)', borderBottom: '1px solid var(--border)' }}>
                <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
                  <span style={{ width: 5, height: 24, borderRadius: 4, background: selectedGroup.accent || 'var(--accent)' }}/>
                  <input aria-label="类别名称" value={selectedGroup.label} onChange={e => renameGroup(selectedGroupIndex, e.target.value)}
                    style={{ minWidth: 0, flex: 1, background: 'transparent', border: 0, outline: 'none', color: 'var(--text)', fontSize: 14, fontWeight: 650 }}/>
                </div>
                <input aria-label="搜索具体指标" value={metricSearch} onChange={e => setMetricSearch(e.target.value)} placeholder="搜索名称、ID 或单位…"
                  style={{ width: '100%', background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '7px 9px', color: 'var(--text)', fontSize: 12 }}/>
                <button data-settings-add-metric className="btn primary" onClick={() => addMetric(selectedGroupIndex)}><Icon name="plus" size={12}/> 新增指标</button>
                <button data-settings-delete-category className="btn" disabled={groups.length <= 1} onClick={removeGroup} style={{ color: 'var(--neg)', borderColor: 'rgba(239,68,68,.3)' }}><Icon name="x" size={12}/> 删除类别</button>
              </div>
              <div data-settings-metric-grid="header" style={{
                display: 'grid', gridTemplateColumns: metricGridColumns, padding: '8px 14px', gap: 8,
                fontSize: 10, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--muted)',
              }}>
                <div>名称</div><div>单位</div><div>方向</div><div>自动范围</div><div>固定范围</div><div/>
              </div>
              {visibleMetrics.map(({ metric: m, index: mIdx }) => (
                <div key={m.id} data-settings-metric-grid="row" style={{
                  display: 'grid', gridTemplateColumns: metricGridColumns, padding: '7px 14px', gap: 8,
                  alignItems: 'center', borderTop: '1px solid var(--border)', background: 'var(--panel)',
                }}>
                  <div style={{ minWidth: 0 }}>
                    <CellInput value={m.label} onChange={(v) => updateMetric(selectedGroupIndex, mIdx, { label: v })}/>
                    <div className="mono" style={{ marginTop: 3, paddingLeft: 2, fontSize: 9.5, color: 'var(--muted-2)', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.id}</div>
                  </div>
                  <CellInput value={m.unit} onChange={(v) => updateMetric(selectedGroupIndex, mIdx, { unit: v })}/>
                  <CellSelect value={m.dir} onChange={(v) => updateMetric(selectedGroupIndex, mIdx, { dir: v })} options={[
                    { v: 'higher', label: window.t('↑ higher better') },
                    { v: 'lower', label: window.t('↓ lower better') },
                    { v: 'neutral', label: window.t('– neutral') },
                  ]}/>
                  <AutoRangeChip metric={m}/>
                  <FixedRangeCell metric={m} onChange={(fr) => updateMetric(selectedGroupIndex, mIdx, { fixedRange: fr })}/>
                  <button onClick={() => removeMetric(selectedGroupIndex, mIdx)} title="删除指标"
                    style={{ background: 'transparent', border: 0, color: 'var(--muted)', cursor: 'pointer', padding: 5, borderRadius: 4 }}
                    onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,.12)'; e.currentTarget.style.color = 'var(--neg)'; }}
                    onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--muted)'; }}><Icon name="x" size={12}/></button>
                </div>
              ))}
              {visibleMetrics.length === 0 && <div style={{ padding: 34, textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>该类别下没有匹配指标。可清除搜索或新增指标。</div>}
            </>
          ) : <div style={{ padding: 40, color: 'var(--muted)' }}>请先新增一个指标类别。</div>}
        </section>
      </div>
    </div>
  );
}

function MetricGovernanceNote() {
  return (
    <Note>
      <strong style={{ color: 'var(--text)' }}>Metric governance.</strong> Settings defines labels, units,
      direction, and reference ranges. Field Test captures observations. Reviewed evidence appears only
      after a captured source is reviewed by the sports scientist.
    </Note>
  );
}

const fmtNum = (x) => {
  if (x == null || isNaN(x)) return '—';
  if (Math.abs(x) >= 100) return Math.round(x).toString();
  if (Math.abs(x) >= 10)  return (Math.round(x * 10) / 10).toString();
  return (Math.round(x * 100) / 100).toString();
};

function AutoRangeChip({ metric }) {
  const hasData = metric.worst !== metric.best;
  if (!hasData) {
    return (
      <div style={{
        padding: '5px 10px',
        background: 'var(--panel)', border: '1px dashed var(--border)',
        borderRadius: 4, fontSize: 11, color: 'var(--muted)',
        textAlign: 'center', fontFamily: 'var(--font-mono)',
      }}>no data yet</div>
    );
  }
  return (
    <div className="mono" style={{
      display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2,
      padding: '4px 6px',
      background: 'var(--panel)', border: '1px solid var(--border)', borderRadius: 4,
      fontSize: 11,
    }}
      title="Auto-computed across all athletes × all measurement dates"
    >
      <span style={{ color: 'var(--neg)', textAlign: 'left' }}>{fmtNum(metric.worst)}</span>
      <span style={{ color: 'var(--text-2)', textAlign: 'center' }}>{fmtNum(metric.avg)}</span>
      <span style={{ color: 'var(--pos)', textAlign: 'right' }}>{fmtNum(metric.best)}</span>
    </div>
  );
}

function FixedRangeCell({ metric, onChange }) {
  const fr = metric.fixedRange;
  const enabled = !!fr;

  if (!enabled) {
    return (
      <button
        onClick={() => onChange({ threshold: metric.avg || 0, goodWhen: metric.dir === 'lower' ? 'below' : 'above' })}
        style={{
          background: 'transparent', border: '1px dashed var(--border)',
          borderRadius: 4, padding: '5px 10px',
          color: 'var(--muted)', fontSize: 11, cursor: 'pointer',
          textAlign: 'left',
        }}
        onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-2)'; }}
        onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--muted)'; }}
      >
        + Add reference threshold
      </button>
    );
  }

  const good = fr.goodWhen; // 'above' | 'below'
  return (
    <div style={{
      display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 4,
      alignItems: 'center',
    }}>
      <input
        data-se1-persisted-control="fixed-range-threshold"
        type="number"
        step="any"
        value={fr.threshold ?? 0}
        onChange={(e) => onChange({ ...fr, threshold: e.target.value === '' ? 0 : +e.target.value })}
        placeholder="Threshold"
        style={{
          background: 'var(--panel)', border: '1px solid var(--border)',
          borderRadius: 4, padding: '5px 8px',
          color: 'var(--text)', fontSize: 12,
          fontFamily: 'var(--font-mono)', textAlign: 'right',
          outline: 'none', minWidth: 0, width: '100%',
        }}
        title={`Threshold value in ${metric.unit || '—'}`}
        onFocus={(e) => e.target.style.borderColor = 'var(--accent)'}
        onBlur={(e) => e.target.style.borderColor = 'var(--border)'}
      />
      <button
        onClick={() => onChange({ ...fr, goodWhen: good === 'above' ? 'below' : 'above' })}
        title="Toggle which side of the threshold is good"
        style={{
          display: 'inline-flex', alignItems: 'center', gap: 6,
          padding: '4px 8px', borderRadius: 4,
          background: 'var(--panel)', border: '1px solid var(--border)',
          color: 'var(--text)', cursor: 'pointer',
          fontSize: 11, fontFamily: 'var(--font-mono)',
        }}
      >
        {good === 'above' ? (
          <>
            <span style={{ color: 'var(--neg)' }}>↓ red</span>
            <span style={{ color: 'var(--muted-2)' }}>·</span>
            <span style={{ color: 'var(--pos)' }}>↑ green</span>
          </>
        ) : (
          <>
            <span style={{ color: 'var(--pos)' }}>↓ green</span>
            <span style={{ color: 'var(--muted-2)' }}>·</span>
            <span style={{ color: 'var(--neg)' }}>↑ red</span>
          </>
        )}
      </button>
      <button
        onClick={() => onChange(null)}
        title="Remove fixed range"
        style={{
          background: 'transparent', border: 0, padding: 4,
          color: 'var(--muted)', cursor: 'pointer', borderRadius: 3,
          display: 'inline-flex', alignItems: 'center',
        }}
        onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--neg)'; }}
        onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted)'; }}
      ><Icon name="x" size={11}/></button>
    </div>
  );
}

const CellInput = ({ value, onChange, numeric }) => (
  <input
    value={value}
    onChange={(e) => onChange(e.target.value)}
    type={numeric ? 'number' : 'text'}
    step="any"
    style={{
      background: 'var(--panel)', border: '1px solid var(--border)',
      borderRadius: 4, padding: '5px 8px',
      color: 'var(--text)', fontSize: 12, outline: 'none',
      fontFamily: numeric ? 'var(--font-mono)' : 'inherit',
      textAlign: numeric ? 'right' : 'left',
      width: '100%', minWidth: 0,
    }}
    onFocus={(e) => e.target.style.borderColor = 'var(--accent)'}
    onBlur={(e) => e.target.style.borderColor = 'var(--border)'}
  />
);

const CellSelect = ({ value, onChange, options }) => (
  <select value={value} onChange={(e) => onChange(e.target.value)}
    style={{
      background: 'var(--panel)', border: '1px solid var(--border)',
      borderRadius: 4, padding: '5px 6px',
      color: 'var(--text)', fontSize: 12, outline: 'none',
      fontFamily: 'inherit', width: '100%',
    }}>
    {options.map(o => <option key={o.v} value={o.v}>{o.label}</option>)}
  </select>
);

// ──────────────────────────────────────────────────────────────────────────
// Scoring tab
// ──────────────────────────────────────────────────────────────────────────
const ALGOS = [
  {
    id: 'range',
    name: 'Range mapping',
    formula: 'score = (v − worst) / (best − worst) · 100',
    blurb: 'Linear projection of the athlete\'s value between user-defined worst & best benchmarks. Scores are stable across cohort changes. Best when normative benchmarks are well known.',
    pros: ['Stable across squad changes', 'Reflects absolute standards', 'Intuitive to explain'],
    cons: ['Sensitive to manually-set thresholds', 'Ignores squad distribution'],
  },
  {
    id: 'zscore',
    name: 'Z-score',
    formula: 'z = (v − μ) / σ ;  score = clamp((z + 2) / 4) · 100',
    blurb: 'Each value becomes its distance from the squad mean in standard deviations, then mapped so ±2σ covers 0–100. For metrics where lower is better, sign is flipped.',
    pros: ['Adapts to squad distribution', 'Statistically interpretable', 'Comparable across metrics'],
    cons: ['Shifts when squad changes', 'Requires n ≥ ~6 for stable σ'],
  },
  {
    id: 'percentile',
    name: 'Percentile rank',
    formula: 'score = rank(v) / N · 100   (within squad, current season)',
    blurb: 'Score is the share of teammates the athlete outperforms on a given metric. Easy to communicate ("90th percentile in sprint").',
    pros: ['Most intuitive', 'Robust to outliers', 'Ordinal'],
    cons: ['Loses magnitude of difference', 'Top performer is always 100'],
  },
  {
    id: 'swc',
    name: 'SWC (Smallest Worthwhile Change)',
    formula: 'SWC = 0.2 · σ;  score = clamp(0.5 + (v − μ) / (10·SWC)) · 100',
    blurb: 'Built on Hopkins\' practically meaningful change: 0.2 × between-athlete SD is the threshold of a real effect. Athletes are scored in multiples of SWC from the mean (±5 SWC covers 0–100).',
    pros: ['Sport-science standard', 'Aligns with practical thresholds', 'Highlights meaningful gaps'],
    cons: ['Same SD dependency as Z-score', 'Less intuitive for non-coaches'],
  },
  {
    id: 'modzscore',
    name: 'Modified Z (individual baseline)',
    formula: 'z = (v − T1_mean) / T1_SD  ·  score = clamp((z + 2) / 4) · 100',
    blurb: 'Uses each athlete\'s own first-season (T1) values as the personal baseline — not the squad mean. Scores reflect change from the individual\'s starting point, eliminating the bias introduced when the whole team improves together. Falls back to squad Z-score for athletes without T1 history.',
    pros: ['Individual-centred', 'Unaffected by squad-wide improvement', 'McGuigan 2017 standard'],
    cons: ['Requires T1 history (≥ 1 season)', 'Cross-athlete comparisons less direct'],
  },
];

function ScoringTab({ algo, onAlgoChange, trendConfig, onTrendConfigChange }) {
  const tc = trendConfig || { showDataLabels: false, showBenchmarkLine: false };
  const updateTrend = (patch) => onTrendConfigChange && onTrendConfigChange({ ...tc, ...patch });

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
      <Note>
        Choose how raw metric values are mapped onto a 0–100 score. The choice affects radar, group scores,
        leaderboard, and "Standout vs Squad" tile. <em style={{ color: 'var(--text-2)' }}>Range mapping</em> uses
        the thresholds you set per metric; the others derive everything from the current squad.
      </Note>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 12 }}>
        {ALGOS.map(a => {
          const active = a.id === algo;
          return (
            <button key={a.id} onClick={() => onAlgoChange(a.id)}
              style={{
                textAlign: 'left', cursor: 'pointer',
                background: active ? 'var(--accent-soft)' : 'var(--panel-2)',
                border: '1px solid',
                borderColor: active ? 'var(--accent)' : 'var(--border)',
                borderRadius: 8, padding: '14px 16px',
                color: 'inherit', font: 'inherit',
                display: 'flex', flexDirection: 'column', gap: 8,
                boxShadow: active ? '0 0 0 3px rgba(59,130,246,.15)' : 'none',
              }}>
              <div style={{ display:'flex', alignItems:'center', justifyContent:'space-between' }}>
                <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)', display: 'inline-flex', alignItems: 'center', gap: 7 }}>
                  {a.name}
                  {a.id === 'swc' && <span className="pill" style={{ fontSize: 9, color: 'var(--muted)' }}>仅个体视图</span>}
                </span>
                <div style={{
                  width: 16, height: 16, borderRadius: 999,
                  border: '2px solid',
                  borderColor: active ? 'var(--accent)' : 'var(--border-strong)',
                  display: 'grid', placeItems: 'center',
                }}>
                  {active && <div style={{ width: 7, height: 7, borderRadius: 99, background: 'var(--accent)' }}/>}
                </div>
              </div>
              <div className="mono" style={{
                fontSize: 11, padding: '6px 8px',
                background: 'rgba(0,0,0,.25)', borderRadius: 4,
                color: 'var(--text-2)',
              }}>{a.formula}</div>
              <div style={{ fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>{a.blurb}</div>
              <div style={{ display: 'flex', gap: 12, marginTop: 4 }}>
                <ProsConsList kind="pros" items={a.pros}/>
                <ProsConsList kind="cons" items={a.cons}/>
              </div>
            </button>
          );
        })}
      </div>

      {/* ── Trend Display options ── */}
      <Section title="Team Trend Display" icon="trend">
        <Note>
          Optional overlays for the <strong style={{ color: 'var(--text)' }}>Team Fitness Trend</strong> chart.
          These layers help coaches read absolute values and compare against reference levels at a glance.
        </Note>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
          <TrendToggleCard
            enabled={tc.showDataLabels}
            onToggle={() => updateTrend({ showDataLabels: !tc.showDataLabels })}
            title="Raw value labels"
            desc="Print the score value directly on every data point — no hover needed. Useful for reading absolute numbers in screenshots or reports."
            badge="Option 1"
          />
          <TrendToggleCard
            enabled={tc.showBenchmarkLine}
            onToggle={() => updateTrend({ showBenchmarkLine: !tc.showBenchmarkLine })}
            title="External benchmark line"
            desc="Draw a dashed reference line at score 50 (the normative midpoint in Range mapping). Shows whether the team is above or below an external population average."
            badge="Option 3"
          />
        </div>
      </Section>
    </div>
  );
}

function TrendToggleCard({ enabled, onToggle, title, desc, badge }) {
  return (
    <div style={{
      display: 'flex', alignItems: 'flex-start', gap: 14,
      padding: '12px 14px', borderRadius: 8,
      background: enabled ? 'var(--accent-soft)' : 'var(--panel-2)',
      border: `1px solid ${enabled ? 'var(--accent)' : 'var(--border)'}`,
      transition: 'all .15s',
    }}>
      <div style={{ flex: 1 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
          <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{title}</span>
          {badge && (
            <span className="pill" style={{
              background: enabled ? 'var(--accent-soft)' : 'var(--panel-hi)',
              color: enabled ? 'var(--accent-2)' : 'var(--muted)',
              borderColor: enabled ? 'rgba(59,130,246,.3)' : 'var(--border)',
            }}>{badge}</span>
          )}
        </div>
        <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.55 }}>{desc}</div>
      </div>
      {/* toggle switch */}
      <label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer', flexShrink: 0, marginTop: 2 }}>
        <span style={{ fontSize: 11, color: 'var(--muted)' }}>{enabled ? 'On' : 'Off'}</span>
        <div
          onClick={onToggle}
          style={{
            width: 36, height: 20, borderRadius: 999, position: 'relative', cursor: 'pointer',
            background: enabled ? 'var(--accent)' : 'var(--border-strong)',
            transition: 'background .15s',
          }}>
          <div style={{
            position: 'absolute', top: 2,
            left: enabled ? 18 : 2,
            width: 16, height: 16, borderRadius: 999,
            background: 'white',
            transition: 'left .15s',
            boxShadow: '0 1px 3px rgba(0,0,0,.3)',
          }}/>
        </div>
      </label>
    </div>
  );
}

const ProsConsList = ({ kind, items }) => (
  <div style={{ flex: 1 }}>
    <div style={{
      fontSize: 9, textTransform: 'uppercase', letterSpacing: '.1em',
      color: kind === 'pros' ? 'var(--pos)' : 'var(--neg)',
      marginBottom: 4,
    }}>{kind === 'pros' ? 'Pros' : 'Cons'}</div>
    <ul style={{ margin: 0, padding: '0 0 0 14px', fontSize: 11, color: 'var(--muted)', lineHeight: 1.55 }}>
      {items.map((it, i) => <li key={i}>{it}</li>)}
    </ul>
  </div>
);

// ──────────────────────────────────────────────────────────────────────────
// Data tab — import / export
// ──────────────────────────────────────────────────────────────────────────
// UX-01 / DELETE-01: preview-first batch hard delete.
// Step 1 selects the exact athlete scope; step 2 aggregates affected data and
// requires an exact generated phrase. Each athlete still goes through the
// authoritative cascade delete — no archive / soft-delete semantics.
function AthleteDeletePanel({ athletes, onDeleteAthlete, onCountAthleteData }) {
  const CATEGORY_LABELS = [
    ['forceSessions', 'Force sessions (CMJ/SJ/IMTP)'],
    ['forceReviews', 'Force session reviews'],
    ['assessmentReviews', 'Field-test (sprint) reviews'],
    ['manualMetricReviews', 'Manual metric reviews'],
    ['coachNotes', 'Report coach notes'],
    ['fieldRecords', 'Field-test records'],
    ['trainingLogs', 'Training logs'],
    ['injuries', 'Injury / rehab dossiers'],
    ['screenResults', 'Movement screen results'],
  ];
  const [selectedIds, setSelectedIds] = sUseState([]);
  const [stage, setStage] = sUseState('idle'); // idle → select → impact → deleting
  const [counts, setCounts] = sUseState(null);
  const [typedPhrase, setTypedPhrase] = sUseState('');
  const [result, setResult] = sUseState(null);
  const [query, setQuery] = sUseState('');
  const [groupFilter, setGroupFilter] = sUseState('');
  const [genderFilter, setGenderFilter] = sUseState('');

  const selectedSet = new Set(selectedIds);
  const targets = athletes.filter(athlete => selectedSet.has(athlete.id));
  const normalizedQuery = query.trim().toLowerCase();
  const groups = Array.from(new Set(athletes.map(athlete => athlete.group).filter(Boolean))).sort();
  const genders = Array.from(new Set(athletes.map(athlete => athlete.gender).filter(Boolean))).sort();
  const visibleAthletes = athletes.filter(athlete => {
    if (groupFilter && athlete.group !== groupFilter) return false;
    if (genderFilter && athlete.gender !== genderFilter) return false;
    if (!normalizedQuery) return true;
    return [athlete.name, athlete.id, athlete.group, athlete.position, athlete.sport]
      .some(value => String(value || '').toLowerCase().includes(normalizedQuery));
  });
  const visibleIds = visibleAthletes.map(athlete => athlete.id);
  const visibleSelectedCount = visibleIds.filter(id => selectedSet.has(id)).length;
  const allSelected = visibleIds.length > 0 && visibleSelectedCount === visibleIds.length;
  const confirmPhrase = `DELETE ${targets.length}`;
  const phraseMatches = targets.length > 0 && typedPhrase === confirmPhrase;

  const openPreview = () => {
    setSelectedIds([]);
    setTypedPhrase('');
    setResult(null);
    setCounts(null);
    setQuery('');
    setGroupFilter('');
    setGenderFilter('');
    setStage('select');
  };
  const cancel = () => {
    if (stage === 'deleting') return;
    setStage('idle');
    setSelectedIds([]);
    setTypedPhrase('');
    setCounts(null);
  };
  const toggleAthlete = (athleteId, checked) => {
    setSelectedIds(current => checked
      ? Array.from(new Set([...current, athleteId]))
      : current.filter(id => id !== athleteId));
  };
  const toggleAll = (checked) => {
    setSelectedIds(current => checked
      ? Array.from(new Set([...current, ...visibleIds]))
      : current.filter(id => !visibleIds.includes(id)));
  };
  const previewImpact = async () => {
    if (!targets.length) return;
    setTypedPhrase('');
    setCounts(null);
    setStage('impact');
    if (!onCountAthleteData) return;
    try {
      const perAthlete = await Promise.all(targets.map(target => onCountAthleteData(target.id)));
      const aggregate = {};
      [...CATEGORY_LABELS, ['programsUnassigned']].forEach(([key]) => {
        aggregate[key] = perAthlete.reduce((sum, item) => sum + Number(item?.[key] || 0), 0);
      });
      setCounts(aggregate);
    } catch {
      setCounts(null);
    }
  };
  const confirmDelete = async () => {
    if (!phraseMatches) return;
    const deleteTargets = [...targets];
    setStage('deleting');
    const deleted = [];
    const failed = [];
    for (const target of deleteTargets) {
      let report = null;
      try {
        report = onDeleteAthlete ? await onDeleteAthlete(target.id) : null;
      } catch (error) {
        report = { ok: false, failed: [{ step: 'delete', error: String(error?.message || error) }], completed: [] };
      }
      if (report && report.ok !== false) deleted.push(target);
      else failed.push({ target, report });
    }
    setResult({ deleted, failed });
    setStage('idle');
    setSelectedIds([]);
    setTypedPhrase('');
    setCounts(null);
  };

  return (
    <Section title="运动员删除" icon="x">
      <div data-se1-danger-zone style={{
        background: 'var(--panel-2)', border: '1px solid var(--border)',
        borderTop: '2px solid var(--neg)',
        borderRadius: 8, padding: '12px 14px',
      }}>
        <div style={{ fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5, marginBottom: 10 }}>
          先在预览框中选择要删除的运动员，再核对关联数据范围。系统会逐人执行完整级联删除；
          训练计划本身保留，仅解除运动员分配。
          <strong style={{ color: 'var(--neg)', marginLeft: 4 }}>硬删除无法撤销。</strong>
        </div>
        <button data-athlete-batch-delete-open className="btn settings-batch-action"
          disabled={!athletes.length} onClick={openPreview}
          style={{ minHeight: 40, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', opacity: athletes.length ? 1 : .5 }}>
          <Icon name="x" size={12}/> 选择并预览删除…
        </button>

        {result && (
          <div style={{ marginTop: 10, fontSize: 12, color: result.failed.length ? 'var(--neg)' : 'var(--text-2)', lineHeight: 1.5 }}>
            已删除 {result.deleted.length} 名运动员及其关联数据。
            {result.failed.length > 0 && ` ${result.failed.length} 名删除未完整完成：${result.failed.map(item => item.target.name).join('、')}。`}
          </div>
        )}
      </div>

      {stage !== 'idle' && (
        <div onClick={cancel} style={{
          position: 'fixed', inset: 0, background: 'rgba(5,8,12,.7)', backdropFilter: 'blur(4px)',
          zIndex: 200, display: 'grid', placeItems: 'center', padding: 20, animation: 'fade .2s ease both',
        }}>
          <div data-se1-danger-zone data-athlete-batch-delete-modal onClick={e => e.stopPropagation()} style={{
            background: 'var(--panel)', border: '1px solid var(--border-strong)',
            borderTop: '2px solid var(--neg)', borderRadius: 12,
            width: 680, maxWidth: '100%', maxHeight: '86vh', overflow: 'auto', padding: '18px 20px',
            boxShadow: 'var(--mac-shadow, 0 20px 60px rgba(0,0,0,.5))',
          }}>
            <div style={{ fontSize: 10, color: 'var(--neg)', textTransform: 'uppercase', letterSpacing: '.1em', marginBottom: 4 }}>Permanent hard delete</div>
            {stage === 'select' ? (
              <>
                <h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 600 }}>选择要删除的运动员</h3>
                <p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>
                  默认不选择任何人。勾选后先预览数据影响，不会立即删除。
                </p>
                <div data-athlete-delete-filters style={{ display: 'grid', gridTemplateColumns: 'minmax(180px,1fr) 150px 130px', gap: 8, marginBottom: 10 }}>
                  <input type="search" value={query} onChange={event => setQuery(event.target.value)}
                    placeholder="搜索姓名、ID、项目或位置" aria-label="搜索待删除运动员"
                    style={{ minHeight: 40, padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--panel-2)', color: 'var(--text)' }}/>
                  <select value={groupFilter} onChange={event => setGroupFilter(event.target.value)}
                    aria-label="按分组筛选待删除运动员"
                    style={{ minHeight: 40, padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--panel-2)', color: 'var(--text)' }}>
                    <option value="">全部分组</option>
                    {groups.map(group => <option key={group} value={group}>{group}</option>)}
                  </select>
                  <select value={genderFilter} onChange={event => setGenderFilter(event.target.value)}
                    aria-label="按性别筛选待删除运动员"
                    style={{ minHeight: 40, padding: '8px 10px', borderRadius: 6, border: '1px solid var(--border)', background: 'var(--panel-2)', color: 'var(--text)' }}>
                    <option value="">全部性别</option>
                    {genders.map(gender => <option key={gender} value={gender}>{gender}</option>)}
                  </select>
                </div>
                <div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 14 }}>
                  <label style={{ minHeight: 42, padding: '0 12px', display: 'flex', alignItems: 'center', gap: 10, background: 'var(--panel-2)', borderBottom: '1px solid var(--border)', fontWeight: 600, fontSize: 12 }}>
                    <input data-athlete-delete-select-all type="checkbox" checked={allSelected}
                      ref={node => { if (node) node.indeterminate = visibleSelectedCount > 0 && !allSelected; }}
                      onChange={event => toggleAll(event.target.checked)}/>
                    全选当前结果
                    <span className="mono" style={{ color: 'var(--muted)', fontWeight: 400 }}>{visibleSelectedCount}/{visibleAthletes.length} · 已选 {selectedIds.length}</span>
                  </label>
                  <div style={{ maxHeight: 360, overflowY: 'auto' }}>
                    {visibleAthletes.map((athlete, index) => (
                      <label key={athlete.id} data-athlete-batch-delete-row={athlete.id} style={{
                        minHeight: 48, padding: '6px 12px', display: 'grid',
                        gridTemplateColumns: '20px minmax(0,1fr) auto', alignItems: 'center', gap: 10,
                        borderBottom: index === visibleAthletes.length - 1 ? 0 : '1px solid var(--border)',
                        background: selectedSet.has(athlete.id) ? 'var(--panel-2)' : 'var(--panel)',
                      }}>
                        <input type="checkbox" checked={selectedSet.has(athlete.id)}
                          onChange={event => toggleAthlete(athlete.id, event.target.checked)}/>
                        <span style={{ minWidth: 0 }}>
                          <strong style={{ display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 12.5 }}>{athlete.name}</strong>
                          <span style={{ color: 'var(--muted)', fontSize: 10.5 }}>{athlete.group || '未分组'} · {athlete.position || '未设置位置'}</span>
                        </span>
                        <span className="mono" style={{ color: 'var(--muted)', fontSize: 10.5 }}>{athlete.id}</span>
                      </label>
                    ))}
                    {!visibleAthletes.length ? (
                      <div style={{ padding: 22, textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>没有符合筛选条件的运动员。</div>
                    ) : null}
                  </div>
                </div>
                <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
                  <button className="btn ghost settings-batch-action" onClick={cancel} style={{ minHeight: 40 }}>取消</button>
                  <button className="btn settings-batch-action" onClick={previewImpact} disabled={!targets.length}
                    style={{ minHeight: 40, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', opacity: targets.length ? 1 : .5 }}>
                    预览删除影响
                  </button>
                </div>
              </>
            ) : (
              <>
                <h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 600 }}>确认删除 {targets.length} 名运动员</h3>
                <p style={{ margin: '0 0 12px', fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>
                  {targets.map(target => target.name).join('、')}。以下数据将被永久删除，无法撤销。
                </p>
                <div style={{ border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', marginBottom: 14 }}>
                  {CATEGORY_LABELS.map(([key, label], index) => (
                    <div key={key} style={{
                      display: 'flex', justifyContent: 'space-between', alignItems: 'center',
                      minHeight: 36, padding: '6px 12px', fontSize: 12,
                      borderBottom: index === CATEGORY_LABELS.length - 1 ? 0 : '1px solid var(--border)',
                      background: 'var(--panel-2)',
                    }}>
                      <span style={{ color: 'var(--text-2)' }}>{label}</span>
                      <span className="mono" style={{ color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>
                        {counts == null ? '…' : (counts[key] || 0)}
                      </span>
                    </div>
                  ))}
                  <div style={{ padding: '7px 12px', fontSize: 11, color: 'var(--muted)', borderTop: '1px solid var(--border)' }}>
                    Training programs kept (athlete unassigned): <span className="mono">{counts == null ? '…' : (counts.programsUnassigned || 0)}</span>
                  </div>
                </div>
                <div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 6 }}>
                  输入 <strong data-athlete-batch-delete-phrase style={{ color: 'var(--text)' }}>{confirmPhrase}</strong> 确认：
                </div>
                <input data-se1-persisted-control="delete-athlete-confirm-phrase"
                  data-athlete-batch-delete-confirm value={typedPhrase}
                  onChange={event => setTypedPhrase(event.target.value)} autoFocus
                  placeholder={confirmPhrase} disabled={stage === 'deleting'}
                  style={{ width: '100%', minHeight: 40, padding: '8px 10px', borderRadius: 6, fontSize: 13,
                    border: '1px solid ' + (phraseMatches ? 'rgba(34,197,94,.5)' : 'var(--border)'),
                    background: 'var(--panel-2)', color: 'var(--text)', marginBottom: 14 }} />
                <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
                  <button className="btn ghost settings-batch-action" onClick={() => setStage('select')} disabled={stage === 'deleting'} style={{ minHeight: 40 }}>返回选择</button>
                  <div style={{ display: 'flex', gap: 8 }}>
                    <button className="btn ghost settings-batch-action" onClick={cancel} disabled={stage === 'deleting'} style={{ minHeight: 40 }}>取消</button>
                    <button className="btn settings-batch-action" onClick={confirmDelete}
                      disabled={!phraseMatches || stage === 'deleting'}
                      style={{ minHeight: 40, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', opacity: phraseMatches && stage !== 'deleting' ? 1 : .5 }}>
                      <Icon name="x" size={12}/> {stage === 'deleting' ? '正在删除…' : `永久删除 ${targets.length} 名运动员`}
                    </button>
                  </div>
                </div>
              </>
            )}
          </div>
        </div>
      )}
    </Section>
  );
}

// ── FORCE-TRACE M3-D · Force 数据备份/恢复（D3 合同）──
// A SEPARATE entry from the Research JSON import/export (whose format is untouched). Export =
// versioned pd-force-backup (manifest + f64le-base64 columns + SHA-256). Import = journaled
// replace-all over CMJ/SJ/IMTP sessions + CMJ traces ONLY — 失败可回滚、崩溃可恢复（跨存储引擎
// 无单事务原子性，如实表述）. Capacity numbers are advisory; the real write result is authoritative.
function ForceBackupSection({ onExport, onImport, onTraceUsage }) {
  const [status, setStatus] = React.useState(null);   // { kind:'ok'|'err'|'busy', msg }
  const [traceBytes, setTraceBytes] = React.useState(null);   // M3-D: trace usage display (contract)
  const fileRef = React.useRef(null);
  React.useEffect(() => {
    let dead = false;
    if (onTraceUsage) onTraceUsage().then((b) => { if (!dead) setTraceBytes(b); }).catch(() => {});
    return () => { dead = true; };
  }, [status]);   // refresh after import/export activity
  const doExport = async () => {
    setStatus({ kind: 'busy', msg: '正在打包备份…' });
    try {
      const { fileName, json } = await onExport();
      const blob = new Blob([json], { type: 'application/json' });
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob); a.download = fileName;
      document.body.appendChild(a); a.click(); a.remove();
      setStatus({ kind: 'ok', msg: '已导出 ' + fileName });
    } catch (e) { setStatus({ kind: 'err', msg: '导出失败：' + ((e && e.message) || e) }); }
  };
  const doImport = async (file) => {
    if (!file) return;
    if (!window.confirm('导入将【整体替换】当前的 CMJ/SJ/IMTP 会话与 CMJ 原始曲线（其他数据不受影响）。失败会自动回滚，浏览器崩溃后下次启动自动恢复。继续？')) return;
    setStatus({ kind: 'busy', msg: '正在校验并恢复…' });
    try {
      const text = await file.text();
      const r = await onImport(text);
      if (r.ok) setStatus({ kind: 'ok', msg: '恢复完成 · 会话与原始曲线已整体替换' + (r.advisory && r.advisory.freeBytes != null ? '（容量估计仅供参考）' : '') });
      else setStatus({ kind: 'err', msg: (r.error || '恢复失败') + (r.rolledBack ? ' · 已回滚，原数据未变' : r.journalRetained ? ' · 回滚未完成，下次启动将自动恢复' : '') });
    } catch (e) { setStatus({ kind: 'err', msg: '恢复失败：' + ((e && e.message) || e) }); }
    if (fileRef.current) fileRef.current.value = '';
  };
  return (
    <Section title="Force 数据备份 / 恢复" icon="download">
      <div data-force-backup style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8, padding: '12px 14px' }}>
        <div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 10 }}>
          备份 = CMJ/SJ/IMTP 会话 + CMJ 原始力曲线（版本化格式 · SHA-256 校验）。恢复为整体替换（仅上述数据）：
          失败可回滚、崩溃可恢复；与上方 Research JSON 导入导出相互独立。
          {traceBytes != null ? <span data-force-trace-usage> · 原始曲线当前占用 ~{(traceBytes / 1024).toFixed(0)} KB</span> : null}
        </div>
        <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
          <button className="btn" onClick={doExport}><Icon name="download" size={12}/> 导出 Force 备份</button>
          <button className="btn" onClick={() => fileRef.current && fileRef.current.click()}><Icon name="refresh" size={12}/> 导入 Force 备份（整体替换）</button>
          <input ref={fileRef} type="file" accept=".json,application/json" style={{ display: 'none' }}
            onChange={(e) => doImport(e.target.files && e.target.files[0])} />
          {status && (
            <span data-force-backup-status style={{ fontSize: 11, color: status.kind === 'err' ? 'var(--neg)' : status.kind === 'ok' ? 'rgba(52,211,153,.95)' : 'var(--muted)' }}>
              {status.msg}
            </span>
          )}
        </div>
      </div>
    </Section>
  );
}

function DataTab({ groups, athletes, onAthletesChange, seasons, currentSeason, onResetToDefaults, onDeleteAthlete, onCountAthleteData, onClearForceSessions, forceStorageUsageBytes, forceSessionStores, legacyImportPlanner, onApplyLegacyImport, getLegacyImportMapping, onSaveLegacyImportMapping, onForceBackupExport, onForceBackupImport, onForceTraceUsage }) {
  const rosterFileRef = sUseRef(null);
  const dataFileRef = sUseRef(null);
  const inlineEditorRef = sUseRef(null);
  const [status, setStatus] = sUseState(null);
  const [importPreview, setImportPreview] = sUseState(null);
  const [importSource, setImportSource] = sUseState(null);
  const [importMode, setImportMode] = sUseState('dataset');
  const [overwriteExisting, setOverwriteExisting] = sUseState(false);
  const [importApplying, setImportApplying] = sUseState(false);
  const [columnMappings, setColumnMappings] = sUseState({});
  const [selectedRosterRows, setSelectedRosterRows] = sUseState([]);
  const forceTypes = (window.FORCE_TEST_TYPES || ['cmj', 'sj', 'imtp']);
  const forceUsage = forceStorageUsageBytes || {};
  const forceStores = forceSessionStores || {};
  const sessionCountFor = (type) => Object.values(forceStores[type] || {}).reduce((sum, sessions) => sum + (Array.isArray(sessions) ? sessions.length : 0), 0);

  const rosterColumns = ['athlete_id', 'name', 'gender', 'birth_date', 'age', 'sport', 'group', 'position', 'jersey', 'dominant', 'country', 'height', 'weight'];
  const rosterValue = (athlete, column) => {
    if (column === 'athlete_id') return athlete.id;
    if (column === 'birth_date') return athlete.birthDate || '';
    if (column === 'age') return window.AthleteProfile?.resolveAge(athlete) ?? athlete.age ?? '';
    return athlete[column] ?? '';
  };
  const exportRosterCSV = () => {
    const rows = [rosterColumns.join(',')];
    athletes.forEach(athlete => rows.push(rosterColumns.map(column => csvEsc(rosterValue(athlete, column))).join(',')));
    triggerDownload(new Blob(['\ufeff' + rows.join('\n')], { type: 'text/csv;charset=utf-8' }), `axis-athlete-roster-${stampNow()}.csv`);
    setStatus({ kind: 'ok', msg: `已导出 ${athletes.length} 名运动员的花名册。` });
  };

  const exportRosterTemplate = () => {
    const example = ['', '示例姓名', 'Female', '2004-08-16', '', 'Basketball', 'First Team', 'Guard', '7', 'Right', 'CHN', '188', '82'];
    triggerDownload(new Blob(['\ufeff' + [rosterColumns.join(','), example.map(csvEsc).join(',')].join('\n')], { type: 'text/csv;charset=utf-8' }), 'axis-athlete-roster-template.csv');
    setStatus({ kind: 'ok', msg: '花名册模板已下载；姓名、性别以及出生日期/年龄（二选一）为必填，athlete_id 可选。' });
  };

  const exportXLSX = () => {
    const wb = window.XLSX.utils.book_new();

    // Athletes sheet
    const athletesRows = athletes.map(a => ({
      athlete_id: a.id,
      name: a.name,
      gender: a.gender,
      birth_date: a.birthDate,
      age: window.AthleteProfile?.resolveAge(a) ?? a.age,
      sport: a.sport,
      group: a.group,
      position: a.position,
      jersey: a.jersey,
      dominant: a.dominant,
      country: a.country,
      height: a.height,
      weight: a.weight,
    }));
    window.XLSX.utils.book_append_sheet(wb,
      window.XLSX.utils.json_to_sheet(athletesRows), 'Athletes');

    // Metrics sheet
    const metricsRows = [];
    groups.forEach(g => g.metrics.forEach(m => {
      metricsRows.push({
        metric_id: m.id, group: g.label, label: m.label,
        unit: m.unit, direction: m.dir,
        worst: m.worst, avg: m.avg, best: m.best,
      });
    }));
    window.XLSX.utils.book_append_sheet(wb,
      window.XLSX.utils.json_to_sheet(metricsRows), 'Metrics');

    // One sheet per season (wide format: athletes × metrics)
    seasons.forEach(season => {
      const rows = athletes.map(a => {
        const row = { athlete_id: a.id, name: a.name };
        groups.forEach(g => g.metrics.forEach(m => { row[m.id] = a.seasons[season] ? a.seasons[season][m.id] : null; }));
        return row;
      });
      window.XLSX.utils.book_append_sheet(wb,
        window.XLSX.utils.json_to_sheet(rows), sanitizeSheetName(season));
    });

    window.XLSX.writeFile(wb, `axis-performance-${stampNow()}.xlsx`);
    setStatus({ kind: 'ok', msg: `Exported ${athletes.length} athletes × ${metricsRows.length} metrics × ${seasons.length} seasons.` });
  };

  const exportCSV = () => {
    const headers = ['athlete_id', 'name', 'sport', 'group', 'position', 'season', ...groups.flatMap(g => g.metrics.map(m => m.id))];
    const lines = [headers.join(',')];
    athletes.forEach(a => {
      seasons.forEach(s => {
        const row = [a.id, csvEsc(a.name), csvEsc(a.sport || ''), csvEsc(a.group || ''), csvEsc(a.position), csvEsc(s)];
        groups.forEach(g => g.metrics.forEach(m => {
          const v = a.seasons[s] ? a.seasons[s][m.id] : '';
          row.push(v == null ? '' : v);
        }));
        lines.push(row.join(','));
      });
    });
    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
    triggerDownload(blob, `axis-performance-${stampNow()}.csv`);
    setStatus({ kind: 'ok', msg: `Exported CSV (${athletes.length * seasons.length} rows).` });
  };

  // Research-grade export: long-form CSV with derived stats per (athlete, metric, season).
  // Includes Z-score and percentile vs squad on that date — saves a lot of repeated
  // computation in downstream R/Python notebooks.
  const exportStatsCSV = () => {
    const D = window.DASHBOARD_DATA;
    const headers = [
      'athlete_id', 'name', 'position', 'age', 'season',
      'metric_id', 'group', 'metric_label', 'unit',
      'value', 'squad_n', 'squad_mean', 'squad_sd', 'z_score', 'percentile',
    ];
    const lines = [headers.join(',')];

    seasons.forEach(season => {
      groups.forEach(g => g.metrics.forEach(m => {
        // Compute squad stats for this (metric, season)
        const vals = athletes
          .map(a => a.seasons?.[season]?.[m.id])
          .filter(v => v != null && isFinite(v));
        if (vals.length === 0) return;
        const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
        const sd   = D ? D.stddev(vals) : 0;
        const sortedAsc = [...vals].sort((a, b) => a - b);

        athletes.forEach(a => {
          const v = a.seasons?.[season]?.[m.id];
          if (v == null || !isFinite(v)) return;
          const z = sd > 0 ? (v - mean) / sd : 0;
          // percentile (averaged for ties)
          let lt = 0, eq = 0;
          for (const x of sortedAsc) { if (x < v) lt++; else if (x === v) eq++; }
          const p01 = (lt + 0.5 * eq) / sortedAsc.length;
          const pct = m.dir === 'lower' ? 1 - p01 : p01;
          lines.push([
            a.id, csvEsc(a.name), csvEsc(a.position), a.age, csvEsc(season),
            m.id, csvEsc(g.label), csvEsc(m.label), csvEsc(m.unit || ''),
            v, vals.length, mean.toFixed(4), sd.toFixed(4),
            (m.dir === 'lower' ? -z : z).toFixed(4), (pct * 100).toFixed(2),
          ].join(','));
        });
      }));
    });

    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
    triggerDownload(blob, `axis-stats-long-${stampNow()}.csv`);
    setStatus({ kind: 'ok', msg: `Stats CSV: ${lines.length - 1} rows (long form, includes Z + percentile).` });
  };

  // Research-grade JSON: full hierarchy preserved for direct ingestion into
  // Python/R; includes computed squad stats per (metric, season) once so they
  // don't need to be recomputed downstream.
  const exportJSON = () => {
    const D = window.DASHBOARD_DATA;
    const out = {
      exported_at: new Date().toISOString(),
      schema_version: '1.0',
      seasons,
      groups: groups.map(g => ({
        id: g.id, label: g.label,
        metrics: g.metrics.map(m => ({
          id: m.id, label: m.label, unit: m.unit, dir: m.dir, direction: m.dir,
          worst: m.worst, avg: m.avg, best: m.best,
          computed: m.computed || undefined,
          fixedRange: m.fixedRange || undefined,
        })),
      })),
      athletes: athletes.map(a => ({
        id: a.id, name: a.name, sport: a.sport, group: a.group, position: a.position,
        jersey: a.jersey, gender: a.gender, birthDate: a.birthDate,
        age: window.AthleteProfile?.resolveAge(a) ?? a.age, dominant: a.dominant, country: a.country,
        height: a.height, weight: a.weight,
        seasons: a.seasons,
        provenance: a.provenance || undefined,
      })),
      squad_stats: (() => {
        const out = {};
        seasons.forEach(s => {
          out[s] = {};
          groups.forEach(g => g.metrics.forEach(m => {
            const vals = athletes
              .map(a => a.seasons?.[s]?.[m.id])
              .filter(v => v != null && isFinite(v));
            if (vals.length === 0) return;
            const mean = vals.reduce((sum, v) => sum + v, 0) / vals.length;
            const sd = D ? D.stddev(vals) : 0;
            const sorted = [...vals].sort((a, b) => a - b);
            out[s][m.id] = {
              n: vals.length, mean, sd,
              min: sorted[0], max: sorted[sorted.length - 1],
              median: sorted[Math.floor(sorted.length / 2)],
              swc: 0.2 * sd,
              values: vals,
            };
          }));
        });
        return out;
      })(),
    };
    const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
    triggerDownload(blob, `axis-research-${stampNow()}.json`);
    setStatus({ kind: 'ok', msg: 'Research JSON exported (athletes, metric catalog, dates, and squad stats; Force data is separate).' });
  };

  const exportTemplate = () => {
    // empty template — single season sheet with athlete_id / name / metric_id columns and blank values
    const wb = window.XLSX.utils.book_new();
    const headers = ['athlete_id', 'name', ...groups.flatMap(g => g.metrics.map(m => m.id))];
    const sample = athletes.slice(0, 3).map(a => {
      const row = { athlete_id: a.id, name: a.name };
      groups.forEach(g => g.metrics.forEach(m => { row[m.id] = ''; }));
      return row;
    });
    const ws = window.XLSX.utils.json_to_sheet(sample, { header: headers });
    window.XLSX.utils.book_append_sheet(wb, ws, sanitizeSheetName(currentSeason));
    // Also include metrics reference
    const metricsRows = [];
    groups.forEach(g => g.metrics.forEach(m => {
      metricsRows.push({ metric_id: m.id, group: g.label, label: m.label, unit: m.unit, direction: m.dir, worst: m.worst, avg: m.avg, best: m.best });
    }));
    window.XLSX.utils.book_append_sheet(wb,
      window.XLSX.utils.json_to_sheet(metricsRows), 'Metrics');
    window.XLSX.writeFile(wb, `axis-import-template.xlsx`);
    setStatus({ kind: 'ok', msg: 'Template downloaded. Fill in values per athlete, then import.' });
  };

  const buildLegacyPreview = (source, overwrite, mappings = columnMappings) => legacyImportPlanner.planLegacyImport({
    sheets: source.sheets,
    groups,
    athletes,
    seasons,
    currentSeason,
    sourceLabel: source.fileName,
    format: source.format,
    conflictPolicy: overwrite ? 'overwrite' : 'keep_existing',
    mappings: { metrics: mappings },
  });
  const rosterRows = importMode === 'roster' ? (importSource?.sheets?.[0]?.rows || []) : [];
  const selectedRosterSet = new Set(selectedRosterRows);
  const rosterCell = (row, aliases) => {
    const keys = Object.keys(row || {});
    const found = keys.find(key => aliases.includes(String(key).trim().toLowerCase()));
    return found == null || row[found] == null ? '' : String(row[found]).trim();
  };
  const rosterIdentity = (row, index) => rosterCell(row, ['athlete_id', 'id']) || `row-${index + 2}`;
  const rosterStatus = (row) => {
    const athleteId = rosterCell(row, ['athlete_id', 'id']);
    const name = rosterCell(row, ['name']).toLowerCase();
    return athletes.some(athlete => (athleteId && athlete.id === athleteId) || (name && String(athlete.name || '').trim().toLowerCase() === name))
      ? '更新' : '新增';
  };
  const sourceForRosterSelection = (source, selected) => ({
    ...source,
    sheets: [{
      ...source.sheets[0],
      rows: source.sheets[0].rows.filter((_, index) => selected.has(index)),
    }],
  });
  const updateRosterSelection = (nextSelection) => {
    const selected = new Set(nextSelection);
    setSelectedRosterRows(Array.from(selected).sort((a, b) => a - b));
    if (!importSource) return;
    const next = buildLegacyPreview(sourceForRosterSelection(importSource, selected), false, columnMappings);
    setImportPreview(next);
    setStatus({
      kind: !selected.size ? 'info' : next.blockers.length ? 'err' : 'info',
      msg: !selected.size
        ? '尚未选择要导入的运动员；数据不会写入。'
        : next.blockers.length
          ? `已选择 ${selected.size} 人，其中有 ${next.blockers.length} 个阻塞项；数据尚未写入。`
          : `已选择 ${selected.size} 人：新增 ${next.summary.createAthletes} 人、更新 ${next.summary.updateProfiles} 人；尚未写入。`,
    });
  };
  const toggleRosterRow = (index, checked) => {
    const next = new Set(selectedRosterRows);
    if (checked) next.add(index);
    else next.delete(index);
    updateRosterSelection(next);
  };
  const toggleAllRosterRows = (checked) => {
    updateRosterSelection(checked ? new Set(rosterRows.map((_, index) => index)) : new Set());
  };

  const onFile = (e, mode = 'dataset') => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const data = new Uint8Array(ev.target.result);
        const isCsv = /\.csv$/i.test(file.name);
        const normalizedCsv = isCsv ? window.DelimitedTextDecoder.toUtf8BomBytes(data) : null;
        const workbookInput = normalizedCsv ? normalizedCsv.bytes : data;
        const wb = window.XLSX.read(workbookInput, { type: 'array', cellDates: true, dateNF: 'yyyy-mm-dd' });
        let sheets = wb.SheetNames.map(name => ({
          name,
          rows: window.XLSX.utils.sheet_to_json(wb.Sheets[name], { defval: null, raw: false, dateNF: 'yyyy-mm-dd' }),
        }));
        if (mode === 'roster') {
          const athleteSheet = sheets.find(sheet => /^athletes$/i.test(String(sheet.name).trim())) || (sheets.length === 1 ? sheets[0] : null);
          if (!athleteSheet) throw new Error('花名册工作簿需包含 Athletes 工作表；也可直接上传单个 CSV。');
          sheets = [{ name: 'Athletes', rows: athleteSheet.rows }];
        }
        const source = {
          fileName: file.name,
          format: isCsv ? 'csv' : 'xlsx',
          sheets,
          signature: sheets.map(sheet => `${sheet.name}:${Array.from(new Set(sheet.rows.flatMap(row => Object.keys(row || {}).map(key => String(key).trim().toLowerCase())))).sort().join(',')}`).sort().join('|'),
        };
        const preset = getLegacyImportMapping?.(source.signature) || {};
        const parsed = buildLegacyPreview(source, false, preset);
        setImportSource(source);
        setImportMode(mode);
        setOverwriteExisting(false);
        setColumnMappings(preset);
        setSelectedRosterRows(mode === 'roster' ? sheets[0].rows.map((_, index) => index) : []);
        setImportPreview(parsed);
        setStatus({ kind: parsed.blockers.length ? 'err' : 'info', msg: parsed.blockers.length
          ? `发现 ${parsed.blockers.length} 个阻塞项；数据尚未写入。`
          : mode === 'roster'
            ? `花名册预览完成：新增 ${parsed.summary.createAthletes} 人、更新 ${parsed.summary.updateProfiles} 人；尚未写入。`
            : `预览完成：${parsed.summary.writeCells} 个值可写入；数据尚未写入。` });
      } catch (err) {
        console.error(err);
        setStatus({ kind: 'err', msg: 'Failed to parse: ' + err.message });
      }
    };
    reader.readAsArrayBuffer(file);
    e.target.value = '';
  };

  const applyImport = async () => {
    if (!importPreview || importPreview.blockers.length || importApplying
      || (importMode === 'roster' && selectedRosterRows.length === 0)) return;
    setImportApplying(true);
    let result;
    try { result = await onApplyLegacyImport(importPreview); }
    catch (error) { result = { committed: false, error: (error && error.message) || String(error) }; }
    setImportApplying(false);
    if (!result?.committed) {
      setStatus({ kind: 'err', msg: result?.rollbackFailed
        ? '导入失败且回滚失败；请重新加载并核对当前数据。'
        : `导入失败，原数据已保留。${result?.error ? ` ${result.error}` : ''}` });
      return;
    }
    const counts = result.receipt?.counts || importPreview.summary;
    if (importSource?.signature && Object.keys(columnMappings).length) onSaveLegacyImportMapping?.(importSource.signature, columnMappings);
    setStatus({ kind: 'ok', msg: importMode === 'roster'
      ? `花名册导入完成：新增 ${counts.createAthletes || 0} 人，更新 ${counts.updateProfiles || 0} 人。`
      : `导入完成：新增 ${counts.createAthletes || 0} 名运动员，写入 ${counts.writeCells || 0} 个值，覆盖 ${counts.overwriteCells || 0} 个值。` });
    setImportPreview(null); setImportSource(null); setSelectedRosterRows([]);
  };

  const changeOverwritePolicy = (checked) => {
    setOverwriteExisting(checked);
    if (!importSource) return;
    const next = buildLegacyPreview(importSource, checked, columnMappings);
    setImportPreview(next);
    setStatus({ kind: next.blockers.length ? 'err' : 'info', msg: next.blockers.length
      ? `发现 ${next.blockers.length} 个阻塞项；数据尚未写入。`
      : `预览已更新：${next.summary.writeCells} 个值可写入。` });
  };

  const mapLegacyColumn = (sourceColumn, metricId) => {
    const nextMappings = { ...columnMappings };
    if (metricId) nextMappings[sourceColumn] = metricId;
    else delete nextMappings[sourceColumn];
    setColumnMappings(nextMappings);
    const next = buildLegacyPreview(importSource, overwriteExisting, nextMappings);
    setImportPreview(next);
    setStatus({ kind: next.blockers.length ? 'err' : 'info', msg: next.blockers.length
      ? `仍有 ${next.blockers.length} 个阻塞项；数据尚未写入。`
      : `映射完成：${next.summary.writeCells} 个值可写入。` });
  };

  const unmappedColumns = importPreview
    ? Array.from(new Set(importPreview.blockers.filter(entry => entry.code === 'unknown_metric' && entry.sourceColumn).map(entry => entry.sourceColumn)))
    : [];
  const metricOptions = groups.flatMap(group => (group.metrics || []).map(metric => ({ ...metric, groupLabel: group.label })));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <div data-settings-data-command-center style={{
        border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden', background: 'var(--panel)',
      }}>
        <input ref={rosterFileRef} data-settings-import-input="roster" type="file" accept=".xlsx,.xls,.csv" onChange={e => onFile(e, 'roster')} style={{ display: 'none' }}/>
        <input ref={dataFileRef} data-settings-import-input="dataset" type="file" accept=".xlsx,.xls,.csv" onChange={e => onFile(e, 'dataset')} style={{ display: 'none' }}/>
        <div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'center' }}>
          <div>
            <div style={{ fontSize: 10, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--muted)' }}>Data operations</div>
            <h4 style={{ margin: '3px 0 0', fontSize: 15, color: 'var(--text)' }}>先选择你要管理的对象</h4>
            <p style={{ margin: '4px 0 0', fontSize: 11.5, lineHeight: 1.45, color: 'var(--muted)' }}>花名册只处理运动员基本资料；体测数据入口负责日期与指标值；完整导出用于迁移和外部分析。</p>
          </div>
          <div style={{ display: 'flex', gap: 14, flexShrink: 0 }}>
            <Stat2 label="运动员" value={athletes.length}/>
            <Stat2 label="测试日期" value={seasons.length}/>
            <Stat2 label="指标" value={groups.reduce((sum, group) => sum + (group.metrics?.length || 0), 0)}/>
          </div>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0,1fr))', gap: 0 }}>
          <DataCommandCard
            marker="roster"
            eyebrow="01 · Athlete roster"
            title="运动员花名册"
            description="新增或更新姓名、性别、出生日期/年龄、分组、位置和身高体重；不会改动体测值。"
            primaryLabel="导入花名册"
            onPrimary={() => rosterFileRef.current?.click()}
            actions={[
              { label: '导出当前花名册', onClick: exportRosterCSV },
              { label: '下载 CSV 模板', onClick: exportRosterTemplate },
            ]}/>
          <DataCommandCard
            marker="measurements"
            eyebrow="02 · Measurements"
            title="体测数据录入"
            description="批量导入长表或宽表；系统先做字段、单位和冲突预览，确认后才写入。"
            primaryLabel="导入体测数据"
            onPrimary={() => dataFileRef.current?.click()}
            actions={[
              { label: '下载录入模板', onClick: exportTemplate },
              { label: '打开在线数据表', onClick: () => inlineEditorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) },
            ]}/>
          <DataCommandCard
            marker="database"
            eyebrow="03 · Portable data"
            title="完整数据导出"
            description="包含花名册、指标目录与历次数据；Force 原始曲线仍使用下方专用备份。"
            primaryLabel="导出 Excel"
            onPrimary={exportXLSX}
            actions={[
              { label: '导出扁平 CSV', onClick: exportCSV },
              { label: '导出研究 JSON', onClick: exportJSON },
            ]}/>
        </div>
      </div>

      <Note>
        花名册每行必须包含姓名、性别，以及出生日期或年龄（两者只需一项；同时提供时出生日期优先并按当前日期计算年龄至 0.1 岁）。
        athlete_id 可选；有 ID 时优先按 ID 更新，无 ID 时按唯一姓名匹配或建立新档案。单个 CSV 会按 Athletes 表处理。体测导入支持 .xlsx、.xls、.csv 的长表和宽表，
        并沿用同一套预览、指标映射与原子写入规则。
      </Note>

      <Section title="Research export" icon="star">
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 10 }}>
          <ActionCard
            title="Stats CSV (long + Z + percentile)"
            desc="一行 = (运动员, 指标, 日期), 含 Z-score、百分位、队内均值/SD。可直接导入 R / pandas。"
            badge="Recommended"
            onClick={exportStatsCSV}/>
          <ActionCard
            title="Research JSON (非完整备份)"
            desc="运动员、指标目录、日期数据与预计算队内统计。Force 数据请使用下方专用备份。"
            onClick={exportJSON}/>
        </div>
      </Section>

      {status && (
        <div style={{
          padding: '10px 12px', borderRadius: 6, fontSize: 12,
          background: status.kind === 'err' ? 'rgba(239,68,68,.12)' : status.kind === 'ok' ? 'rgba(52,211,153,.12)' : 'var(--panel-2)',
          border: '1px solid',
          borderColor: status.kind === 'err' ? 'rgba(239,68,68,.3)' : status.kind === 'ok' ? 'rgba(52,211,153,.3)' : 'var(--border)',
          color: status.kind === 'err' ? 'var(--neg)' : status.kind === 'ok' ? 'var(--pos)' : 'var(--text-2)',
        }}>
          {status.msg}
        </div>
      )}

      {importPreview && (
        <Section title={importMode === 'roster' ? '花名册导入预览' : '体测数据导入预览'} icon="trend">
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, padding: 12, fontSize: 12, color: 'var(--text-2)' }}>
            <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0,1fr))', gap: 10 }}>
              <Stat2 label="新增运动员" value={importPreview.summary.createAthletes}/>
              <Stat2 label={importMode === 'roster' ? '更新资料' : '写入值'} value={importMode === 'roster' ? importPreview.summary.updateProfiles : importPreview.summary.writeCells}/>
              <Stat2 label={importMode === 'roster' ? '跳过行' : '覆盖值'} value={importMode === 'roster' ? importPreview.summary.skippedRows : importPreview.summary.overwriteCells}/>
              <Stat2 label="阻塞项" value={importPreview.blockers.length}/>
            </div>
            {importMode === 'roster' && (
              <div data-roster-import-preview style={{ marginTop: 12, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden', background: 'var(--panel)' }}>
                <label style={{
                  minHeight: 42, padding: '0 12px', display: 'grid',
                  gridTemplateColumns: '22px minmax(160px,1.3fr) minmax(90px,.65fr) minmax(130px,1fr) minmax(100px,.7fr) 58px',
                  alignItems: 'center', gap: 10, background: 'var(--panel-2)',
                  borderBottom: '1px solid var(--border)', fontSize: 10.5, fontWeight: 650, color: 'var(--text-2)',
                }}>
                  <input data-roster-import-select-all type="checkbox"
                    checked={rosterRows.length > 0 && selectedRosterRows.length === rosterRows.length}
                    ref={node => { if (node) node.indeterminate = selectedRosterRows.length > 0 && selectedRosterRows.length < rosterRows.length; }}
                    onChange={event => toggleAllRosterRows(event.target.checked)}/>
                  <span>全部 · 姓名</span>
                  <span>性别</span>
                  <span>出生日期 / 年龄</span>
                  <span>分组</span>
                  <span>动作</span>
                </label>
                <div style={{ maxHeight: 360, overflow: 'auto' }}>
                  {rosterRows.map((row, index) => {
                    const rowId = rosterIdentity(row, index);
                    const name = rosterCell(row, ['name']) || '未填写姓名';
                    const gender = rosterCell(row, ['gender', 'sex']) || '—';
                    const birthDate = rosterCell(row, ['birth_date', 'birthdate', 'dob']);
                    const age = rosterCell(row, ['age']);
                    const group = rosterCell(row, ['group']) || '未分组';
                    const checked = selectedRosterSet.has(index);
                    return (
                      <label key={`${rowId}-${index}`} data-roster-import-row={rowId} style={{
                        minHeight: 48, padding: '5px 12px', display: 'grid',
                        gridTemplateColumns: '22px minmax(160px,1.3fr) minmax(90px,.65fr) minmax(130px,1fr) minmax(100px,.7fr) 58px',
                        alignItems: 'center', gap: 10, fontSize: 11.5,
                        borderBottom: index === rosterRows.length - 1 ? 0 : '1px solid var(--border)',
                        background: checked ? 'var(--panel)' : 'var(--panel-2)', opacity: checked ? 1 : .62,
                      }}>
                        <input type="checkbox" checked={checked} onChange={event => toggleRosterRow(index, event.target.checked)}/>
                        <span style={{ minWidth: 0 }}>
                          <strong style={{ display: 'block', color: 'var(--text)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</strong>
                          <span className="mono" style={{ color: 'var(--muted)', fontSize: 9.5 }}>{rowId}</span>
                        </span>
                        <span>{gender}</span>
                        <span className="mono" style={{ fontVariantNumeric: 'tabular-nums' }}>{birthDate || (age ? `${age} 岁` : '—')}</span>
                        <span>{group}</span>
                        <span style={{ color: rosterStatus(row) === '新增' ? 'var(--pos)' : 'var(--muted)', fontWeight: 650 }}>{rosterStatus(row)}</span>
                      </label>
                    );
                  })}
                </div>
                <div style={{ minHeight: 38, padding: '7px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, borderTop: '1px solid var(--border)', color: 'var(--muted)', fontSize: 10.5 }}>
                  <span>未勾选的运动员不会进入导入计划，也不会写入系统。</span>
                  <strong className="mono" style={{ color: 'var(--text)', fontVariantNumeric: 'tabular-nums' }}>已选 {selectedRosterRows.length}/{rosterRows.length}</strong>
                </div>
              </div>
            )}
            {importMode !== 'roster' && <label style={{ marginTop: 12, display: 'flex', gap: 8, alignItems: 'center', color: 'var(--text)' }}>
              <input type="checkbox" checked={overwriteExisting} onChange={(e) => changeOverwritePolicy(e.target.checked)}/>
              明确使用导入值覆盖已有的同日同指标值（默认保留现有值）
            </label>}
            {unmappedColumns.length > 0 && (
              <div data-legacy-import-mapping style={{ marginTop: 12, padding: 10, border: '1px solid var(--border)', borderRadius: 6 }}>
                <b style={{ display: 'block', marginBottom: 8, color: 'var(--text)' }}>未识别列映射</b>
                {unmappedColumns.map(sourceColumn => (
                  <label key={sourceColumn} style={{ display: 'grid', gridTemplateColumns: 'minmax(120px, 1fr) minmax(220px, 2fr)', gap: 10, alignItems: 'center', marginTop: 6 }}>
                    <code>{sourceColumn}</code>
                    <select value={columnMappings[sourceColumn] || ''} onChange={(e) => mapLegacyColumn(sourceColumn, e.target.value)}>
                      <option value="">选择系统指标…</option>
                      {metricOptions.map(metric => <option key={metric.id} value={metric.id}>{metric.groupLabel} · {metric.label} ({metric.unit || '无单位'})</option>)}
                    </select>
                  </label>
                ))}
              </div>
            )}
            {importPreview.issues.length > 0 && (
              <div data-legacy-import-issues style={{ marginTop: 12, maxHeight: 220, overflow: 'auto', border: '1px solid var(--border)', borderRadius: 6 }}>
                {importPreview.issues.map((entry, index) => (
                  <div key={`${entry.code}-${index}`} style={{ padding: '8px 10px', borderBottom: index < importPreview.issues.length - 1 ? '1px solid var(--border)' : 0, display: 'grid', gridTemplateColumns: '70px 1fr', gap: 8 }}>
                    <b style={{ color: entry.severity === 'blocker' ? 'var(--neg)' : 'var(--warn, #d97706)', textTransform: 'uppercase', fontSize: 10 }}>{entry.severity === 'blocker' ? '阻塞' : '提示'}</b>
                    <span><code>{entry.location}</code> · {entry.message}</span>
                  </div>
                ))}
              </div>
            )}
            <div style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button data-settings-discard-import className="btn" onClick={() => { setImportPreview(null); setImportSource(null); setSelectedRosterRows([]); }}>{window.t('Discard')}</button>
              <button className="btn primary" disabled={importPreview.blockers.length > 0 || importApplying || (importMode === 'roster' && selectedRosterRows.length === 0)} onClick={applyImport}>
                {importApplying ? '正在写入…' : '确认写入'}
              </button>
            </div>
          </div>
        </Section>
      )}

      {/* ── Inline data editor ─────────────────────────────────────── */}
      <div ref={inlineEditorRef} data-settings-inline-data-entry style={{ scrollMarginTop: 18 }}>
        <InlineDataEditor
          athletes={athletes} onAthletesChange={onAthletesChange}
          groups={groups} seasons={seasons} currentSeason={currentSeason}
        />
      </div>

      {/* ── Force session storage ───────────────────────────────────── */}
      {onClearForceSessions && (
        <Section title="Force session storage" icon="bolt">
          <div data-se1-danger-zone style={{
            background: 'var(--panel-2)', border: '1px solid var(--border)',
            borderTop: '2px solid var(--neg)',
            borderRadius: 8, overflow: 'hidden',
          }}>
            <div style={{ padding: '10px 14px', fontSize: 11, color: 'var(--muted)', borderBottom: '1px solid var(--border)' }}>
              Stored force-plate analysis snapshots. Clearing removes saved browser sessions only; raw files on disk are untouched and can be re-uploaded.
            </div>
            {forceTypes.map(type => {
              const cfg = window.forceTestConfig?.(type) || { label: type.toUpperCase() };
              const used = forceUsage[type] || 0;
              const count = sessionCountFor(type);
              return (
                <div key={type} style={{
                  display: 'grid', gridTemplateColumns: '90px 1fr auto',
                  gap: 12, alignItems: 'center',
                  padding: '10px 14px',
                  borderBottom: type === forceTypes[forceTypes.length - 1] ? 0 : '1px solid rgba(15,23,42,.06)',
                }}>
                  <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text)' }}>{cfg.label}</div>
                  <div style={{ fontSize: 12, color: 'var(--text-2)' }}>
                    <span className="mono">{count}</span> sessions
                    <span className="mono" style={{ marginLeft: 8, color: 'var(--muted)' }}>~{(used / 1024).toFixed(1)} KB</span>
                  </div>
                  <button className="btn" onClick={() => {
                    if (window.confirm(`Clear all stored ${cfg.label} sessions for all athletes? This cannot be undone.`)) onClearForceSessions(type);
                  }} style={{ flexShrink: 0, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', whiteSpace: 'nowrap' }}>
                    <Icon name="x" size={12}/> Clear
                  </button>
                </div>
              );
            })}
            <div style={{ padding: '10px 14px', display: 'flex', justifyContent: 'flex-end', borderTop: '1px solid var(--border)' }}>
              <button className="btn" onClick={() => {
                if (window.confirm('Clear all stored CMJ, SJ, and IMTP sessions for all athletes? This cannot be undone.')) onClearForceSessions();
              }} style={{ borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)' }}>
                <Icon name="x" size={12}/> Clear all force data
              </button>
            </div>
          </div>
        </Section>
      )}

      {/* ── FORCE-TRACE M3-D: Force 数据备份/恢复（独立入口 · 与 Research JSON 导入导出互不影响） ── */}
      {onForceBackupExport && onForceBackupImport && (
        <ForceBackupSection onExport={onForceBackupExport} onImport={onForceBackupImport} onTraceUsage={onForceTraceUsage} />
      )}

      {/* ── Delete athlete (UX-01 / DELETE-01) ──────────────────────── */}
      {onDeleteAthlete && (
        <AthleteDeletePanel athletes={athletes} onDeleteAthlete={onDeleteAthlete} onCountAthleteData={onCountAthleteData} />
      )}

      {/* ── Reset ────────────────────────────────────────────────────── */}
      {onResetToDefaults && (
        <Section title="Reset" icon="x">
          <div data-se1-danger-zone style={{
            background: 'var(--panel-2)', border: '1px solid var(--border)',
            borderTop: '2px solid var(--neg)',
            borderRadius: 8, padding: '12px 14px',
            display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
          }}>
            <div style={{ fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>
              清除主要业务数据与测力台本地数据，并恢复内置示例。部分界面偏好可能保留。
              <strong style={{ color: 'var(--neg)', marginLeft: 4 }}>此操作不可撤销。</strong>
            </div>
            <button className="btn" onClick={() => {
              if (window.confirm('清除主要业务数据与测力台数据并恢复内置示例？此操作不可撤销。')) onResetToDefaults();
            }} style={{ flexShrink: 0, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', whiteSpace: 'nowrap' }}>
              <Icon name="x" size={12}/> Reset to defaults
            </button>
          </div>
        </Section>
      )}
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Inline Data Editor — spreadsheet-style table for editing raw values
// ──────────────────────────────────────────────────────────────────────────
function InlineDataEditor({ athletes, onAthletesChange, groups, seasons, currentSeason }) {
  const [editSeason, setEditSeason] = sUseState(currentSeason || seasons[0]);
  const [editCell, setEditCell] = sUseState(null); // { athleteId, metricId }
  const [editVal, setEditVal] = sUseState('');
  const [collapsed, setCollapsed] = sUseState(true);

  // sync season if currentSeason changes
  sUseEffect(() => {
    if (!currentSeason || currentSeason === editSeason) return;
    setEditSeason(currentSeason);
    setEditCell(null);
  }, [currentSeason]);

  const allMetrics = sUseMemo(() => groups.flatMap(g =>
    g.metrics.filter(m => !m.computed).map(m => ({ ...m, groupLabel: g.label, groupAccent: g.accent }))
  ), [groups]);
  const editableGroups = sUseMemo(() => groups
    .map(g => ({ ...g, metrics: g.metrics.filter(m => !m.computed) }))
    .filter(g => g.metrics.length), [groups]);

  const startEdit = (athleteId, metricId, currentVal) => {
    setEditCell({ athleteId, metricId });
    setEditVal(currentVal != null ? String(currentVal) : '');
  };

  const commitEdit = () => {
    if (!editCell) return;
    const { athleteId, metricId } = editCell;
    const numVal = editVal === '' ? undefined : +editVal;
    const nextAthletes = athletes.map(a => {
      if (a.id !== athleteId) return a;
      const seasonData = { ...(a.seasons[editSeason] || {}) };
      if (editVal === '' || isNaN(numVal)) {
        delete seasonData[metricId];
      } else {
        seasonData[metricId] = numVal;
      }
      return { ...a, seasons: { ...a.seasons, [editSeason]: seasonData } };
    });
    onAthletesChange(nextAthletes.map(a => window.DASHBOARD_DATA.recomputeLoadForAthlete(a)));
    setEditCell(null);
  };

  const handleKey = (e) => {
    if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); commitEdit(); }
    if (e.key === 'Escape') { setEditCell(null); }
  };

  return (
    <Section title="Edit Data" icon="sliders">
      {/* Header bar */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
        background: 'var(--panel-2)', border: '1px solid var(--border)',
        borderRadius: collapsed ? 8 : '8px 8px 0 0', padding: '10px 14px',
      }}>
        <div style={{ fontSize: 12, color: 'var(--text-2)', flex: 1, lineHeight: 1.4 }}>
          Click any cell to edit a value inline. <span style={{ color: 'var(--muted)' }}>Press Enter or Tab to confirm, Esc to cancel. Leave blank to clear.</span>
        </div>
        <select
          value={editSeason}
          onChange={e => { setEditSeason(e.target.value); setEditCell(null); }}
          style={{
            background: 'var(--panel)', border: '1px solid var(--border)',
            borderRadius: 4, padding: '4px 8px', color: 'var(--text)',
            fontSize: 12, fontFamily: 'var(--font-mono)',
          }}>
          {[...seasons].sort((a, b) => new Date(b) - new Date(a)).map(s => <option key={s} value={s}>{s}</option>)}
        </select>
        <button className="btn ghost" onClick={() => setCollapsed(c => !c)} style={{ padding: '4px 8px', fontSize: 11 }}>
          {collapsed ? 'Show table' : 'Collapse'}
        </button>
      </div>

      {!collapsed && (
        <div style={{
          border: '1px solid var(--border)', borderTop: 0, borderRadius: '0 0 8px 8px',
          overflowX: 'auto', background: 'var(--panel-2)',
        }}>
          <table style={{ borderCollapse: 'collapse', width: '100%', fontSize: 11, minWidth: 600 }}>
            <thead>
              {/* Group header row */}
              <tr>
                <th style={thStyle(true, 2)}>Athlete</th>
                {editableGroups.map(g => (
                  <th key={g.id} colSpan={g.metrics.length}
                    style={{ ...thStyle(), borderLeft: '2px solid var(--border-strong)', color: g.accent, textAlign: 'center', letterSpacing: '.06em' }}>
                    {g.label}
                  </th>
                ))}
              </tr>
              {/* Metric label row */}
              <tr>
                <th style={thStyle(true)}/>
                {allMetrics.map((m, i) => {
                  const isFirst = i === 0 || allMetrics[i-1]?.groupLabel !== m.groupLabel;
                  return (
                    <th key={m.id} title={`${m.label}${m.unit ? ' ('+m.unit+')' : ''}`}
                      style={{ ...thStyle(), borderLeft: isFirst ? '2px solid var(--border-strong)' : 'none',
                        color: 'var(--muted)', maxWidth: 64, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                      {m.label}{m.unit ? <span style={{ opacity: .5 }}> {m.unit}</span> : ''}
                    </th>
                  );
                })}
              </tr>
            </thead>
            <tbody>
              {athletes.map((a, ai) => (
                <tr key={a.id} style={{ background: ai % 2 ? 'transparent' : 'rgba(15,23,42,.03)' }}>
                  <td style={{ ...tdStyle(), fontWeight: 500, color: 'var(--text)', whiteSpace: 'nowrap', position: 'sticky', left: 0, background: ai%2 ? 'var(--panel-2)' : '#ffffff' }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                      <div style={{ width: 22, height: 22, borderRadius: 999, background: 'var(--panel-hi)', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, color: 'var(--text-2)', flexShrink: 0 }}>
                        {a.name.split(' ').map(p=>p[0]).join('').slice(0,2)}
                      </div>
                      <span style={{ maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.name}</span>
                    </div>
                  </td>
                  {allMetrics.map((m, mi) => {
                    const isFirst = mi === 0 || allMetrics[mi-1]?.groupLabel !== m.groupLabel;
                    const val = a.seasons[editSeason]?.[m.id];
                    const isEditing = editCell?.athleteId === a.id && editCell?.metricId === m.id;
                    return (
                      <td key={m.id} onClick={() => !isEditing && startEdit(a.id, m.id, val)}
                        style={{
                          ...tdStyle(),
                          borderLeft: isFirst ? '2px solid var(--border-strong)' : 'none',
                          cursor: isEditing ? 'default' : 'pointer',
                          background: isEditing ? 'rgba(59,130,246,.12)' : 'transparent',
                          minWidth: 60,
                        }}>
                        {isEditing ? (
                          <input
                            autoFocus
                            type="number" step="any"
                            value={editVal}
                            onChange={e => setEditVal(e.target.value)}
                            onBlur={commitEdit}
                            onKeyDown={handleKey}
                            style={{
                              width: '100%', background: 'transparent', border: 0,
                              color: 'var(--accent-2)', fontFamily: 'var(--font-mono)',
                              fontSize: 11, outline: 'none', textAlign: 'right', padding: '0 4px',
                            }}
                          />
                        ) : (
                          <span className="mono" style={{ color: val != null ? 'var(--text)' : 'var(--muted-2)', display: 'block', textAlign: 'right', padding: '0 4px' }}>
                            {val != null ? val : '—'}
                          </span>
                        )}
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </Section>
  );
}

const thStyle = (sticky = false, paddingX = 0) => ({
  padding: `6px ${paddingX || 8}px`,
  background: 'var(--panel)',
  color: 'var(--muted)',
  fontWeight: 500,
  fontSize: 10,
  textTransform: 'uppercase',
  letterSpacing: '.07em',
  borderBottom: '1px solid var(--border)',
  whiteSpace: 'nowrap',
  ...(sticky ? { position: 'sticky', left: 0, zIndex: 2 } : {}),
});

const tdStyle = () => ({
  padding: '5px 0',
  borderBottom: '1px solid rgba(15,23,42,.05)',
  verticalAlign: 'middle',
});

const Stat2 = ({ label, value }) => (
  <div>
    <div style={{ fontSize: 10, textTransform: 'uppercase', letterSpacing: '.08em', color: 'var(--muted)' }}>{label}</div>
    <div className="mono" style={{ fontSize: 18, fontWeight: 600, color: 'var(--text)' }}>{value}</div>
  </div>
);

function ActionCard({ title, desc, badge, onClick }) {
  return (
    <button onClick={onClick} style={{
      textAlign: 'left',
      background: 'var(--panel-2)', border: '1px solid var(--border)',
      borderRadius: 8, padding: '12px 14px',
      color: 'inherit', cursor: 'pointer',
      display: 'flex', flexDirection: 'column', gap: 6,
      transition: 'border-color .15s, transform .15s',
    }}
    onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--accent)'}
    onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--border)'}
    >
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
        <span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text)' }}>{title}</span>
        {badge && <span className="pill" style={{ background: 'var(--accent-soft)', color: 'var(--accent-2)', borderColor: 'rgba(59,130,246,.3)' }}>{badge}</span>}
      </div>
      <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5 }}>{desc}</div>
    </button>
  );
}

function DataCommandCard({ marker, eyebrow, title, description, primaryLabel, onPrimary, actions }) {
  return (
    <section data-settings-data-entry={marker} style={{
      minWidth: 0, padding: 16, borderRight: marker === 'database' ? 0 : '1px solid var(--border)',
      display: 'flex', flexDirection: 'column', gap: 10, background: 'var(--panel)',
    }}>
      <div style={{ fontSize: 9.5, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--muted)' }}>{eyebrow}</div>
      <div>
        <h5 style={{ margin: 0, fontSize: 14, color: 'var(--text)' }}>{title}</h5>
        <p style={{ margin: '5px 0 0', minHeight: 48, fontSize: 11, lineHeight: 1.5, color: 'var(--muted)' }}>{description}</p>
      </div>
      <button className="btn primary" onClick={onPrimary} style={{ justifyContent: 'center', width: '100%' }}>
        <Icon name={marker === 'roster' ? 'users' : 'download'} size={12}/> {primaryLabel}
      </button>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
        {(actions || []).map(action => (
          <button key={action.label} className="btn ghost" onClick={action.onClick} style={{ minWidth: 0, justifyContent: 'center', padding: '6px 7px', fontSize: 10.5 }}>
            {action.label}
          </button>
        ))}
      </div>
    </section>
  );
}

function Section({ title, icon, children }) {
  return (
    <div>
      <div style={{
        display: 'flex', alignItems: 'center', gap: 8,
        fontSize: 11, textTransform: 'uppercase', letterSpacing: '.1em',
        color: 'var(--muted)', marginBottom: 8,
      }}>
        <Icon name={icon} size={12}/> {title}
      </div>
      {children}
    </div>
  );
}

// UI-SE1: Note is the "explanatory copy" primitive. Per §3.9 (hover-first),
// its content is not a persistent block — it collapses to a hairline caption
// row with an info glyph, and the full text surfaces via native title-hover
// (and a click-to-pin popover for touch/no-hover access). No control or
// persisted value is ever routed through Note — those stay on-canvas.
function Note({ children }) {
  const [pinned, setPinned] = sUseState(false);
  return (
    <div data-se1-note style={{ position: 'relative' }}>
      <button
        type="button"
        data-se1-note-trigger
        onClick={() => setPinned(p => !p)}
        style={{
          display: 'flex', alignItems: 'center', gap: 6, width: '100%',
          padding: '4px 2px', background: 'transparent', border: 0,
          borderBottom: '1px solid var(--border)', cursor: 'help',
          font: 'inherit', textAlign: 'left', color: 'var(--muted)',
        }}
      >
        <Icon name="info" size={11}/>
        <span style={{ fontSize: 10.5, textTransform: 'uppercase', letterSpacing: '.06em' }}>
          {window.t('Details')}
        </span>
        <span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--muted-2)' }}>
          {pinned ? window.t('hide') : window.t('hover / click')}
        </span>
      </button>
      {pinned && (
        <div data-se1-note-body style={{
          marginTop: 6, padding: '10px 12px',
          background: 'var(--panel-2)', border: '1px solid var(--border)',
          borderRadius: 6,
          fontSize: 12, color: 'var(--text-2)', lineHeight: 1.55,
        }}>{children}</div>
      )}
      <div data-se1-note-hover className="se1-note-hover" style={{
        display: 'none',
        position: 'absolute', top: '100%', left: 0, zIndex: 6,
        marginTop: 4, padding: '10px 12px', width: 'min(420px, 90vw)',
        background: 'var(--panel-2)', border: '1px solid var(--border)',
        borderRadius: 6, boxShadow: '0 8px 24px rgba(15,23,42,.12)',
        fontSize: 12, color: 'var(--text-2)', lineHeight: 1.55,
      }}>{children}</div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Risk tab — configure alert thresholds for Squad Dashboard
// ──────────────────────────────────────────────────────────────────────────
// ── RiskTab: four-level traffic light (P1-E) ─────────────────────────────
// cfg shape: { enabled, acwr:{low,mod,high,vhigh}, cmjDrop:{amber,red}, mRSIDrop:{amber,red} }
function RiskTab({ riskConfig, onRiskConfigChange }) {
  const DEFAULT = {
    enabled: false,
    acwr:     { low: 0.8, mod: 1.3, high: 1.5, vhigh: 1.8 },
    cmjDrop:  { amber: 5,    red: 10   },
    mRSIDrop: { amber: 0.05, red: 0.10 },
  };
  const cfg = riskConfig && riskConfig.acwr ? riskConfig : DEFAULT;
  const [validationMessage, setValidationMessage] = sUseState('');
  const update = (patch) => onRiskConfigChange({ ...cfg, ...patch });
  const updateNested = (key, patch) => onRiskConfigChange({ ...cfg, [key]: { ...cfg[key], ...patch } });
  const updateOrdered = (group, key, value, order) => {
    const next = { ...cfg[group], [key]: value };
    const valid = Number.isFinite(value) && order.every((field, index) => (
      index === order.length - 1 || Number(next[field]) < Number(next[order[index + 1]])
    ));
    if (!valid) {
      setValidationMessage(group === 'acwr'
        ? 'ACWR 阈值必须保持 low < mod < high < vhigh。'
        : '注意阈值必须低于红色阈值。');
      return;
    }
    setValidationMessage('');
    updateNested(group, { [key]: value });
  };

  const NumField = ({ label, value, min, max, step, onChange, color = 'var(--text)' }) => (
    <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
      <span style={{ flex: 1, fontSize: 12, color: 'var(--text-2)' }}>{label}</span>
      <input
        data-se1-persisted-control="risk-threshold"
        type="number" min={min} max={max} step={step}
        value={value}
        onChange={(e) => onChange(+e.target.value)}
        style={{
          width: 72, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--border)',
          background: 'var(--panel-3)', color, fontFamily: 'var(--font-mono)',
          fontSize: 13, fontWeight: 600, textAlign: 'right',
        }}
      />
    </div>
  );

  // Visual ACWR zone bar
  const ZoneBar = () => {
    const a = cfg.acwr;
    const total = 2.2;
    const pct = v => Math.min(100, (v / total) * 100);
    const zones = [
      { from: 0,       to: a.low,   color: '#60a5fa', label: 'Under' },
      { from: a.low,   to: a.mod,   color: '#34d399', label: 'Optimal' },
      { from: a.mod,   to: a.high,  color: '#fbbf24', label: 'Caution' },
      { from: a.high,  to: a.vhigh, color: '#f97316', label: 'High' },
      { from: a.vhigh, to: total,   color: '#f87171', label: 'Danger' },
    ];
    return (
      <div style={{ marginTop: 8 }}>
        <div style={{ display: 'flex', height: 10, borderRadius: 6, overflow: 'hidden', gap: 1 }}>
          {zones.map((z, i) => (
            <div key={i} style={{
              flex: pct(z.to) - pct(z.from),
              background: z.color, opacity: .85,
            }}/>
          ))}
        </div>
        <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4, fontSize: 9, color: 'var(--muted)' }}>
          {[0, a.low, a.mod, a.high, a.vhigh].map((v, i) => (
            <span key={i}>{v.toFixed(2)}</span>
          ))}
        </div>
      </div>
    );
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
      <Note>
        四级交通灯系统——根据 ACWR、CMJ 跳跃高度和 mRSI 降幅对运动员进行风险分级。
        启用后，团队视图将在每位运动员行显示对应颜色标记。
      </Note>

      {/* Master toggle */}
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        padding: '12px 14px', borderRadius: 8,
        background: cfg.enabled ? 'rgba(248,113,113,.08)' : 'var(--panel-2)',
        border: `1px solid ${cfg.enabled ? 'rgba(248,113,113,.3)' : 'var(--border)'}`,
        transition: 'all .15s',
      }}>
        <div>
          <div style={{ fontSize: 13, fontWeight: 600, color: cfg.enabled ? 'var(--neg)' : 'var(--text)' }}>Risk Alerts</div>
          <div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 2 }}>
            在团队视图运动员行显示 🟢🟡🟠🔴 分级标记
          </div>
        </div>
        <div
          data-se1-persisted-control="risk-enabled-toggle"
          onClick={() => update({ enabled: !cfg.enabled })}
          style={{
            width: 36, height: 20, borderRadius: 999, position: 'relative', cursor: 'pointer',
            background: cfg.enabled ? 'var(--neg)' : 'var(--border-strong)',
            transition: 'background .15s',
          }}>
          <div style={{
            position: 'absolute', top: 2,
            left: cfg.enabled ? 18 : 2,
            width: 16, height: 16, borderRadius: 999,
            background: 'white', transition: 'left .15s',
            boxShadow: '0 1px 3px rgba(0,0,0,.3)',
          }}/>
        </div>
      </div>

      <div style={{ opacity: cfg.enabled ? 1 : .4, transition: 'opacity .2s', pointerEvents: cfg.enabled ? 'auto' : 'none', display: 'flex', flexDirection: 'column', gap: 14 }}>
        {validationMessage && <div role="alert" data-settings-risk-validation style={{
          padding: '9px 11px', borderRadius: 6, border: '1px solid color-mix(in srgb, var(--neg) 35%, transparent)',
          background: 'color-mix(in srgb, var(--neg) 8%, transparent)', color: 'var(--neg)', fontSize: 11.5,
        }}>{validationMessage}</div>}

        {/* ACWR thresholds */}
        <Section title="ACWR 阈值" icon="activity">
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5 }}>
              Acute:Chronic Workload Ratio 分区阈值。四条边界将 ACWR 分为五个区间（不足 / 最优 / 注意 / 高风险 / 极高风险）。
            </div>
            <NumField label="🔵 不足上限 (low)"   value={cfg.acwr.low}   min={0.5} max={1.0} step={0.05} onChange={v => updateOrdered('acwr', 'low', v, ['low', 'mod', 'high', 'vhigh'])} color="#60a5fa"/>
            <NumField label="🟢 最优上限 (mod)"   value={cfg.acwr.mod}   min={1.0} max={1.5} step={0.05} onChange={v => updateOrdered('acwr', 'mod', v, ['low', 'mod', 'high', 'vhigh'])} color="#34d399"/>
            <NumField label="🟠 注意上限 (high)"  value={cfg.acwr.high}  min={1.3} max={1.8} step={0.05} onChange={v => updateOrdered('acwr', 'high', v, ['low', 'mod', 'high', 'vhigh'])} color="#f97316"/>
            <NumField label="🔴 极高风险 (vhigh)" value={cfg.acwr.vhigh} min={1.5} max={2.5} step={0.05} onChange={v => updateOrdered('acwr', 'vhigh', v, ['low', 'mod', 'high', 'vhigh'])} color="#f87171"/>
            <ZoneBar/>
          </div>
        </Section>

        {/* CMJ drop thresholds */}
        <Section title="CMJ 跳高降幅" icon="trend">
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5 }}>
              相对运动员近4次均值的跳高（cm）百分比降幅触发预警。
            </div>
            <NumField label="🟡 Amber（注意）降幅 %" value={cfg.cmjDrop.amber} min={2} max={15} step={1} onChange={v => updateOrdered('cmjDrop', 'amber', v, ['amber', 'red'])} color="var(--warn)"/>
            <NumField label="🔴 Red（高风险）降幅 %"  value={cfg.cmjDrop.red}   min={5} max={25} step={1} onChange={v => updateOrdered('cmjDrop', 'red', v, ['amber', 'red'])} color="var(--neg)"/>
          </div>
        </Section>

        {/* mRSI drop thresholds */}
        <Section title="mRSI 降幅" icon="zap">
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8, padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
            <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.5 }}>
              相对运动员近4次均值的 mRSI 绝对降幅触发预警（Gathercole 2015: SWC ≈ 0.046）。
            </div>
            <NumField label="🟡 Amber（注意）降幅" value={cfg.mRSIDrop.amber} min={0.01} max={0.2} step={0.01} onChange={v => updateOrdered('mRSIDrop', 'amber', v, ['amber', 'red'])} color="var(--warn)"/>
            <NumField label="🔴 Red（高风险）降幅"  value={cfg.mRSIDrop.red}   min={0.05} max={0.4} step={0.01} onChange={v => updateOrdered('mRSIDrop', 'red', v, ['amber', 'red'])} color="var(--neg)"/>
          </div>
        </Section>

        <Note>
          各指标独立评级——显示所有触发条件中最严重的等级。至少需要4次历史力板 session 才能触发降幅预警。
        </Note>
      </div>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Insights tab — toggle rules on/off, edit numeric thresholds, pick starred
// metrics that the high-fanout rules will fire against.
// ──────────────────────────────────────────────────────────────────────────
function InsightsTab({ groups }) {
  const I = window.INSIGHTS;
  const [cfg, setCfg] = sUseState(() => I ? I.loadRuleConfig() : {});
  const [starred, setStarred] = sUseState(() => {
    try { return JSON.parse(localStorage.getItem('insight_starred_metrics')) || null; }
    catch { return null; }
  });
  const defaultStarred = I && Array.isArray(I.DEFAULT_STARRED) ? I.DEFAULT_STARRED : [];
  const effectiveStarred = starred && starred.length ? starred : defaultStarred;

  if (!I) {
    return <Note>{window.t('Insights engine not loaded. Reload the page.')}</Note>;
  }

  const persist = (next) => {
    setCfg(next);
    I.saveRuleConfig(next);
  };

  const toggleRule = (ruleId) => {
    const cur = cfg[ruleId] || {};
    const enabled = cur.enabled !== false;
    persist({ ...cfg, [ruleId]: { ...cur, enabled: !enabled } });
  };

  const updateParam = (ruleId, key, value) => {
    const cur = cfg[ruleId] || {};
    const params = { ...(cur.params || {}), [key]: value };
    persist({ ...cfg, [ruleId]: { ...cur, params } });
  };

  const resetRule = (ruleId) => {
    const next = { ...cfg };
    delete next[ruleId];
    persist(next);
  };

  const resetAll = () => {
    persist({});
    try { localStorage.removeItem('insight_starred_metrics'); } catch {}
    setStarred(defaultStarred);
  };

  const toggleStarredMetric = (mid) => {
    const next = effectiveStarred.includes(mid)
      ? effectiveStarred.filter(x => x !== mid)
      : [...effectiveStarred, mid];
    setStarred(next);
    try { localStorage.setItem('insight_starred_metrics', JSON.stringify(next)); } catch {}
  };

  const sevColor = { risk: 'var(--neg)', warn: 'var(--warn)', positive: 'var(--pos)', info: 'var(--accent-2)' };
  const sevIcon  = { risk: '🚨', warn: '⚠️', positive: '✨', info: '📅' };
  const rulesBySeverity = ['risk', 'warn', 'positive', 'info'].map(sev => ({
    sev,
    list: I.DEFAULT_RULES.filter(r => r.severity === sev),
  }));

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <Note>
        洞察规则决定哪些事件会出现在 <strong>Coach Mode</strong> 的"今日简报"里。
        高扇出规则（streak / PB / RFD 下降等）只对 <strong>starred metrics</strong> 触发，
        避免边缘指标污染。所有改动即时生效，存在浏览器 localStorage。
      </Note>

      {/* ── Starred metrics ─────────────────────────────────────────── */}
      <Section title="Starred Metrics" icon="target">
        <div style={{ fontSize: 11.5, color: 'var(--muted)', marginBottom: 10, lineHeight: 1.5 }}>
          选 <span style={{ color: '#fbbf24' }}>★</span> 标记的指标是关键性能指标。streak、PB、缓降、单次异常等规则只对它们触发。
        </div>
        <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
          {groups.map(g => g.metrics.map(m => {
            const on = effectiveStarred.includes(m.id);
            return (
              <button key={m.id} onClick={() => toggleStarredMetric(m.id)} style={{
                display: 'inline-flex', alignItems: 'center', gap: 5,
                padding: '4px 9px', borderRadius: 6, cursor: 'pointer',
                background: on ? 'rgba(251,191,36,.12)' : 'var(--panel-2)',
                border: '1px solid ' + (on ? 'rgba(251,191,36,.35)' : 'var(--border)'),
                color: on ? '#fbbf24' : 'var(--muted)',
                fontSize: 11, fontFamily: 'var(--font-sans)',
              }}>
                {on ? '★' : '☆'} {m.label}
                <span style={{ fontSize: 9, color: 'var(--muted-2)' }}>{m.unit}</span>
              </button>
            );
          }))}
        </div>
      </Section>

      {/* ── Rules by severity ───────────────────────────────────────── */}
      {rulesBySeverity.map(({ sev, list }) => list.length > 0 && (
        <Section key={sev} title={`${sevIcon[sev]} ${sev === 'risk' ? '风险类' : sev === 'warn' ? '预警类' : sev === 'positive' ? '积极类' : '提醒类'} 规则`} icon="bolt">
          <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
            {list.map(rule => {
              const ruleCfg = cfg[rule.id] || {};
              const enabled = ruleCfg.enabled !== false;
              const effective = { ...rule.defaults, ...(ruleCfg.params || {}) };
              const paramKeys = Object.keys(rule.defaults).filter(k => k !== 'watchedMetrics' && k !== 'rules');
              return (
                <div key={rule.id} style={{
                  background: 'var(--panel-2)', border: '1px solid var(--border)',
                  borderLeft: `3px solid ${sevColor[sev]}`,
                  borderRadius: 8, padding: '10px 14px',
                  opacity: enabled ? 1 : 0.55,
                }}>
                  <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: paramKeys.length ? 8 : 0 }}>
                    <button onClick={() => toggleRule(rule.id)} style={{
                      width: 36, height: 20, borderRadius: 10, cursor: 'pointer',
                      background: enabled ? sevColor[sev] : 'var(--border-strong)',
                      border: 'none', position: 'relative', padding: 0, flexShrink: 0,
                    }} title={enabled ? '点击禁用' : '点击启用'}>
                      <span style={{
                        position: 'absolute', top: 2, left: enabled ? 18 : 2,
                        width: 16, height: 16, borderRadius: '50%',
                        background: 'white', transition: 'left .15s',
                      }}/>
                    </button>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{ fontSize: 13, color: 'var(--text)', fontWeight: 500 }}>{rule.label}</div>
                      <div style={{ fontSize: 11, color: 'var(--muted)', lineHeight: 1.4, marginTop: 2 }}>{rule.description}</div>
                    </div>
                    {Object.keys(ruleCfg).length > 0 && (
                      <button onClick={() => resetRule(rule.id)} className="btn ghost" style={{ fontSize: 10.5, padding: '3px 7px', color: 'var(--muted)' }}>
                        ↺ 默认
                      </button>
                    )}
                  </div>
                  {paramKeys.length > 0 && enabled && (
                    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, paddingLeft: 46 }}>
                      {paramKeys.map(k => (
                        <ParamEditor key={k}
                          label={k}
                          value={effective[k]}
                          defaultValue={rule.defaults[k]}
                          onChange={(v) => updateParam(rule.id, k, v)}/>
                      ))}
                    </div>
                  )}
                </div>
              );
            })}
          </div>
        </Section>
      ))}

      {/* ── Reset ───────────────────────────────────────────────────── */}
      <Section title="Reset" icon="x">
        <div data-se1-danger-zone style={{
          background: 'var(--panel-2)', border: '1px solid var(--border)',
          borderTop: '2px solid var(--neg)',
          borderRadius: 8, padding: '12px 14px',
          display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
        }}>
          <div style={{ fontSize: 12, color: 'var(--text-2)' }}>
            恢复所有规则参数、starred 列表为内置默认值。
          </div>
          <button className="btn" onClick={() => {
            if (window.confirm('恢复所有洞察规则到默认状态？')) resetAll();
          }} style={{ flexShrink: 0, borderColor: 'rgba(239,68,68,.4)', color: 'var(--neg)', whiteSpace: 'nowrap' }}>
            <Icon name="x" size={12}/> Reset all rules
          </button>
        </div>
      </Section>
    </div>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// Data Quality tab — completeness matrix, missing data, suspected outliers,
// drift detection. Research-friendly data audit before analysis.
// ──────────────────────────────────────────────────────────────────────────
function DataQualityTab({ groups, athletes, seasons }) {
  const D = window.DASHBOARD_DATA;
  const allMetrics = groups.flatMap(g => g.metrics.map(m => ({ ...m, group: g.label, accent: g.accent })));
  const sortedSeasons = [...seasons].sort((a, b) => new Date(b) - new Date(a));

  // ── Completeness summary ──────────────────────────────────────────────
  const completeness = sUseMemo(() => {
    const totalCells = athletes.length * sortedSeasons.length * allMetrics.length;
    let filled = 0;
    athletes.forEach(a => sortedSeasons.forEach(s => allMetrics.forEach(m => {
      if (a.seasons?.[s]?.[m.id] != null) filled++;
    })));
    return { totalCells, filled, missing: totalCells - filled, pct: totalCells > 0 ? filled / totalCells * 100 : 0 };
  }, [athletes, sortedSeasons, allMetrics]);

  // ── Per-athlete completeness ──────────────────────────────────────────
  const athleteCompleteness = sUseMemo(() => {
    return athletes.map(a => {
      const total = sortedSeasons.length * allMetrics.length;
      let filled = 0;
      sortedSeasons.forEach(s => allMetrics.forEach(m => {
        if (a.seasons?.[s]?.[m.id] != null) filled++;
      }));
      const lastSession = sortedSeasons.find(s => a.seasons?.[s] && Object.keys(a.seasons[s]).length);
      return { athlete: a, total, filled, pct: total > 0 ? filled / total * 100 : 0, lastSession };
    }).sort((a, b) => a.pct - b.pct);
  }, [athletes, sortedSeasons, allMetrics]);

  // ── Per-date completeness ─────────────────────────────────────────────
  const dateCompleteness = sUseMemo(() => {
    return sortedSeasons.map(s => {
      const total = athletes.length * allMetrics.length;
      let filled = 0;
      athletes.forEach(a => allMetrics.forEach(m => {
        if (a.seasons?.[s]?.[m.id] != null) filled++;
      }));
      const athletesTested = athletes.filter(a => a.seasons?.[s] && Object.keys(a.seasons[s]).length).length;
      return { date: s, total, filled, athletesTested, pct: total > 0 ? filled / total * 100 : 0 };
    });
  }, [athletes, sortedSeasons, allMetrics]);

  // ── Outlier detection (|Z| > 3 vs squad on that date) ─────────────────
  const outliers = sUseMemo(() => {
    const out = [];
    sortedSeasons.forEach(s => {
      allMetrics.forEach(m => {
        const vals = athletes.map(a => ({ id: a.id, name: a.name, v: a.seasons?.[s]?.[m.id] }))
          .filter(x => x.v != null && isFinite(x.v));
        if (vals.length < 4) return;
        const mean = vals.reduce((sum, x) => sum + x.v, 0) / vals.length;
        const sd = D ? D.stddev(vals.map(x => x.v)) : 0;
        if (sd === 0) return;
        vals.forEach(x => {
          const z = (x.v - mean) / sd;
          if (Math.abs(z) > 3) {
            out.push({ date: s, metric: m, athleteId: x.id, athleteName: x.name, value: x.v, z });
          }
        });
      });
    });
    return out.sort((a, b) => Math.abs(b.z) - Math.abs(a.z));
  }, [athletes, sortedSeasons, allMetrics]);

  // ── Drift detection (mean squad value vs first → last date difference) ─
  const drift = sUseMemo(() => {
    if (sortedSeasons.length < 2) return [];
    const first = sortedSeasons[sortedSeasons.length - 1];
    const last = sortedSeasons[0];
    const driftRows = [];
    allMetrics.forEach(m => {
      const firstVals = athletes.map(a => a.seasons?.[first]?.[m.id]).filter(v => v != null && isFinite(v));
      const lastVals  = athletes.map(a => a.seasons?.[last]?.[m.id]).filter(v => v != null && isFinite(v));
      if (firstVals.length < 3 || lastVals.length < 3) return;
      const fMean = firstVals.reduce((s, v) => s + v, 0) / firstVals.length;
      const lMean = lastVals.reduce((s, v) => s + v, 0) / lastVals.length;
      const pctChange = fMean !== 0 ? (lMean - fMean) / Math.abs(fMean) * 100 : 0;
      if (Math.abs(pctChange) > 20) {  // arbitrary "interesting" threshold
        driftRows.push({ metric: m, firstMean: fMean, lastMean: lMean, pctChange, nFirst: firstVals.length, nLast: lastVals.length });
      }
    });
    return driftRows.sort((a, b) => Math.abs(b.pctChange) - Math.abs(a.pctChange));
  }, [athletes, sortedSeasons, allMetrics]);

  // CSV export of the audit
  const exportAudit = () => {
    const lines = [];
    lines.push('=== DATA QUALITY AUDIT ===');
    lines.push(`generated_at,${new Date().toISOString()}`);
    lines.push(`overall_completeness,${completeness.pct.toFixed(2)}%`);
    lines.push(`total_cells,${completeness.totalCells}`);
    lines.push(`missing_cells,${completeness.missing}`);
    lines.push('');
    lines.push('=== ATHLETE COMPLETENESS ===');
    lines.push('athlete_id,name,filled,total,pct,last_session');
    athleteCompleteness.forEach(r => lines.push(`${r.athlete.id},${csvEsc(r.athlete.name)},${r.filled},${r.total},${r.pct.toFixed(1)},${r.lastSession || ''}`));
    lines.push('');
    lines.push('=== DATE COMPLETENESS ===');
    lines.push('date,athletes_tested,filled,total,pct');
    dateCompleteness.forEach(r => lines.push(`${r.date},${r.athletesTested},${r.filled},${r.total},${r.pct.toFixed(1)}`));
    lines.push('');
    lines.push('=== OUTLIERS (|Z| > 3) ===');
    lines.push('date,metric_id,athlete,value,z_score');
    outliers.forEach(o => lines.push(`${o.date},${o.metric.id},${csvEsc(o.athleteName)},${o.value},${o.z.toFixed(2)}`));
    lines.push('');
    lines.push('=== DRIFT (>20% squad mean change first→last) ===');
    lines.push('metric_id,first_mean,last_mean,pct_change,n_first,n_last');
    drift.forEach(d => lines.push(`${d.metric.id},${d.firstMean.toFixed(2)},${d.lastMean.toFixed(2)},${d.pctChange.toFixed(1)},${d.nFirst},${d.nLast}`));
    const blob = new Blob([lines.join('\n')], { type: 'text/csv' });
    triggerDownload(blob, `axis-data-audit-${stampNow()}.csv`);
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      <Note>
        已录入数据覆盖率审计：这里按当前花名册、日期与指标组合进行探索性检查，不代表每个单元格都属于计划内必测项目。
        异常值和漂移只用于提示复核，不会自动修改数据。
      </Note>

      {/* Overall stats */}
      <Section title="已录入数据覆盖率" icon="target">
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0,1fr))', gap: 10 }}>
          <Stat2 label="Total cells" value={completeness.totalCells.toLocaleString()}/>
          <Stat2 label="Filled" value={completeness.filled.toLocaleString()}/>
          <Stat2 label="Missing" value={completeness.missing.toLocaleString()}/>
          <Stat2 label="Completeness" value={completeness.pct.toFixed(1) + '%'}/>
        </div>
        <div style={{ marginTop: 12 }}>
          <button className="btn primary" onClick={exportAudit}>
            <Icon name="download" size={12}/> 导出完整审计 CSV
          </button>
        </div>
      </Section>

      {/* Per-athlete completeness — sorted by worst */}
      <Section title="Athletes (按完整度排序，最差在前)" icon="users">
        <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
          <div style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 100px 120px', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
            <span>Athlete</span><span style={{ textAlign: 'right' }}>Filled</span><span style={{ textAlign: 'right' }}>Total</span><span style={{ textAlign: 'right' }}>Completeness</span><span>Last session</span>
          </div>
          {athleteCompleteness.slice(0, 20).map(r => {
            const sev = r.pct < 30 ? 'var(--neg)' : r.pct < 60 ? 'var(--warn)' : 'var(--pos)';
            return (
              <div key={r.athlete.id} style={{ display: 'grid', gridTemplateColumns: '1fr 80px 80px 100px 120px', gap: 8, padding: '6px 12px', borderBottom: '1px solid rgba(15,23,42,.05)', fontSize: 12, alignItems: 'center' }}>
                <span style={{ color: 'var(--text)' }}>{r.athlete.name} <span style={{ color: 'var(--muted)', fontSize: 10 }}>({r.athlete.position})</span></span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--text-2)' }}>{r.filled}</span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--muted)' }}>{r.total}</span>
                <span style={{ textAlign: 'right', display: 'inline-flex', alignItems: 'center', justifyContent: 'flex-end', gap: 6 }}>
                  <div style={{ width: 40, height: 6, background: 'var(--panel)', borderRadius: 3, overflow: 'hidden' }}>
                    <div style={{ width: r.pct + '%', height: '100%', background: sev }}/>
                  </div>
                  <span className="mono" style={{ color: sev, fontSize: 11, fontWeight: 600 }}>{r.pct.toFixed(0)}%</span>
                </span>
                <span className="mono" style={{ color: 'var(--muted)', fontSize: 11 }}>{r.lastSession || '—'}</span>
              </div>
            );
          })}
          {athleteCompleteness.length > 20 && (
            <div style={{ padding: '6px 12px', fontSize: 10, color: 'var(--muted-2)', textAlign: 'center' }}>
              +{athleteCompleteness.length - 20} more — export CSV to see all
            </div>
          )}
        </div>
      </Section>

      {/* Per-date completeness */}
      <Section title="Test dates (按时间倒序)" icon="history">
        <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
          <div style={{ display: 'grid', gridTemplateColumns: '140px 1fr 100px 100px 100px', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
            <span>Date</span><span/><span style={{ textAlign: 'right' }}>Athletes tested</span><span style={{ textAlign: 'right' }}>Cells filled</span><span style={{ textAlign: 'right' }}>Completeness</span>
          </div>
          {dateCompleteness.slice(0, 15).map(r => {
            const sev = r.pct < 30 ? 'var(--neg)' : r.pct < 60 ? 'var(--warn)' : 'var(--pos)';
            return (
              <div key={r.date} style={{ display: 'grid', gridTemplateColumns: '140px 1fr 100px 100px 100px', gap: 8, padding: '6px 12px', borderBottom: '1px solid rgba(15,23,42,.05)', fontSize: 12, alignItems: 'center' }}>
                <span className="mono" style={{ color: 'var(--text)' }}>{r.date}</span>
                <div style={{ height: 6, background: 'var(--panel)', borderRadius: 3, overflow: 'hidden' }}>
                  <div style={{ width: r.pct + '%', height: '100%', background: sev }}/>
                </div>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--text-2)' }}>{r.athletesTested}/{athletes.length}</span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--muted)' }}>{r.filled}/{r.total}</span>
                <span className="mono" style={{ textAlign: 'right', color: sev, fontWeight: 600 }}>{r.pct.toFixed(0)}%</span>
              </div>
            );
          })}
        </div>
      </Section>

      {/* Outliers */}
      <Section title={`Outliers (|Z| > 3)  ·  ${outliers.length} 条`} icon="bolt">
        {outliers.length === 0 ? (
          <Note>✓ 当前数据中没有 |Z| &gt; 3 的极端异常值。</Note>
        ) : (
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
            <div style={{ display: 'grid', gridTemplateColumns: '110px 1fr 1fr 100px 80px', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
              <span>Date</span><span>Metric</span><span>Athlete</span><span style={{ textAlign: 'right' }}>Value</span><span style={{ textAlign: 'right' }}>Z</span>
            </div>
            {outliers.slice(0, 30).map((o, i) => (
              <div key={i} style={{ display: 'grid', gridTemplateColumns: '110px 1fr 1fr 100px 80px', gap: 8, padding: '6px 12px', borderBottom: '1px solid rgba(15,23,42,.05)', fontSize: 12, alignItems: 'center' }}>
                <span className="mono" style={{ color: 'var(--muted)' }}>{o.date}</span>
                <span style={{ color: o.metric.accent || 'var(--text-2)' }}>{o.metric.label} <span style={{ color: 'var(--muted-2)', fontSize: 10 }}>({o.metric.unit})</span></span>
                <span style={{ color: 'var(--text)' }}>{o.athleteName}</span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--text)' }}>{o.value}</span>
                <span className="mono" style={{ textAlign: 'right', color: Math.abs(o.z) > 4 ? 'var(--neg)' : 'var(--warn)', fontWeight: 600 }}>{o.z > 0 ? '+' : ''}{o.z.toFixed(2)}σ</span>
              </div>
            ))}
            {outliers.length > 30 && (
              <div style={{ padding: '6px 12px', fontSize: 10, color: 'var(--muted-2)', textAlign: 'center' }}>
                +{outliers.length - 30} more — export CSV to see all
              </div>
            )}
          </div>
        )}
      </Section>

      {/* Drift */}
      <Section title={`Drift (>20% squad mean change from first to last date)  ·  ${drift.length} 个指标`} icon="trend">
        {drift.length === 0 ? (
          <Note>✓ 没有明显的群体均值漂移。</Note>
        ) : (
          <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 6, overflow: 'hidden' }}>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 100px 100px 100px', gap: 8, padding: '8px 12px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
              <span>Metric</span><span style={{ textAlign: 'right' }}>{window.t('First mean')}</span><span style={{ textAlign: 'right' }}>{window.t('Last mean')}</span><span style={{ textAlign: 'right' }}>Change</span>
            </div>
            {drift.map(d => (
              <div key={d.metric.id} style={{ display: 'grid', gridTemplateColumns: '1fr 100px 100px 100px', gap: 8, padding: '6px 12px', borderBottom: '1px solid rgba(15,23,42,.05)', fontSize: 12, alignItems: 'center' }}>
                <span style={{ color: d.metric.accent || 'var(--text-2)' }}>{d.metric.label}</span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--muted)' }}>{d.firstMean.toFixed(2)} <span style={{ fontSize: 9 }}>(n={d.nFirst})</span></span>
                <span className="mono" style={{ textAlign: 'right', color: 'var(--text)' }}>{d.lastMean.toFixed(2)} <span style={{ fontSize: 9 }}>(n={d.nLast})</span></span>
                <span className="mono" style={{ textAlign: 'right', color: d.pctChange > 0 ? 'var(--pos)' : 'var(--neg)', fontWeight: 600 }}>{d.pctChange > 0 ? '+' : ''}{d.pctChange.toFixed(1)}%</span>
              </div>
            ))}
          </div>
        )}
      </Section>

      {/* ICC inputs for MDC calculation (P1-D) */}
      <ICCConfigSection/>
    </div>
  );
}

// ── ICC Configuration (P1-D) ──────────────────────────────────────────────
// ICC values are persisted in localStorage under 'perf_v2_icc'.
// computeMDC(sd, icc) from data.js uses these at display time in force-plate panels.
const ICC_LS_KEY = 'perf_v2_icc';
const ICC_METRIC_DEFS = [
  { id: 'cmj_jh',         label: 'CMJ Jump Height',     unit: 'cm',   ref: 'Meylan 2015' },
  { id: 'cmj_mrsi',       label: 'CMJ mRSI',            unit: '',     ref: 'Gathercole 2015' },
  { id: 'cmj_peakPower',  label: 'CMJ Peak Power',      unit: 'W/kg', ref: 'Meylan 2015' },
  { id: 'sj_jh',         label: 'SJ Jump Height',      unit: 'cm',   ref: 'Suchomel 2015' },
  { id: 'sj_netImpulse', label: 'SJ Net Impulse',      unit: 'N·s',  ref: 'Suchomel 2015' },
  { id: 'imtp_peakForce',label: 'IMTP Peak Force',     unit: 'N',    ref: 'Brady 2018' },
  { id: 'imtp_f100',     label: 'IMTP Force @ 100ms',  unit: 'N',    ref: 'Brady 2018' },
  { id: 'imtp_f200',     label: 'IMTP Force @ 200ms',  unit: 'N',    ref: 'Brady 2018' },
];

function ICCConfigSection() {
  const defaults = window.DASHBOARD_DATA?.ICC_DEFAULTS || {};
  const [icc, setIcc] = sUseState(() => {
    try { const v = localStorage.getItem(ICC_LS_KEY); return v ? JSON.parse(v) : {}; } catch { return {}; }
  });
  const update = (id, val) => {
    const next = { ...icc, [id]: val };
    setIcc(next);
    try { localStorage.setItem(ICC_LS_KEY, JSON.stringify(next)); } catch {}
    // Expose on DASHBOARD_DATA so force-plate panels can read it at render time
    if (window.DASHBOARD_DATA) window.DASHBOARD_DATA.ICC_USER = next;
  };

  // Keep DASHBOARD_DATA.ICC_USER in sync on mount
  sUseState(() => {
    if (window.DASHBOARD_DATA) window.DASHBOARD_DATA.ICC_USER = icc;
  });

  return (
    <Section title="ICC / MDC Configuration" icon="shield">
      <Note>
        MDC₉₅ = SD × √(1 − ICC) × 2.77（Weir 2005）。
        以下 ICC 值将用于各力板分析面板的 MDC 计算。默认值来自文献，可根据自测数据修改。
      </Note>
      <div style={{ background: 'var(--panel-2)', border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px', gap: 0, padding: '8px 14px', borderBottom: '1px solid var(--border)', fontSize: 10, color: 'var(--muted)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '.06em' }}>
          <span>Metric</span>
          <span style={{ textAlign: 'center' }}>文献默认</span>
          <span style={{ textAlign: 'center' }}>当前值</span>
          <span style={{ textAlign: 'center' }}>来源</span>
        </div>
        {ICC_METRIC_DEFS.map(m => {
          const defVal = defaults[m.id] ?? '—';
          const curVal = icc[m.id] ?? defVal;
          const isCustom = icc[m.id] != null && icc[m.id] !== defVal;
          return (
            <div key={m.id} style={{
              display: 'grid', gridTemplateColumns: '1fr 90px 90px 90px',
              gap: 0, padding: '7px 14px',
              borderBottom: '1px solid rgba(15,23,42,.05)',
              alignItems: 'center', fontSize: 12,
            }}>
              <span style={{ color: 'var(--text-2)' }}>
                {m.label}
                {m.unit && <span style={{ color: 'var(--muted)', fontSize: 10, marginLeft: 4 }}>({m.unit})</span>}
              </span>
              <span className="mono" style={{ textAlign: 'center', color: 'var(--muted)' }}>{typeof defVal === 'number' ? defVal.toFixed(2) : defVal}</span>
              <div style={{ display: 'flex', justifyContent: 'center' }}>
                <input
                  data-se1-persisted-control="icc-value"
                  type="number" min={0} max={1} step={0.01}
                  value={typeof curVal === 'number' ? curVal : defVal}
                  onChange={(e) => update(m.id, Math.max(0, Math.min(0.999, +e.target.value)))}
                  style={{
                    width: 60, padding: '3px 6px', borderRadius: 5, fontSize: 12,
                    border: `1px solid ${isCustom ? 'var(--accent)' : 'var(--border)'}`,
                    background: isCustom ? 'rgba(59,130,246,.07)' : 'var(--panel)',
                    color: 'var(--text)', fontFamily: 'var(--font-mono)', textAlign: 'right',
                  }}
                />
              </div>
              <span style={{ textAlign: 'center', fontSize: 10, color: 'var(--muted-2)' }}>{m.ref}</span>
            </div>
          );
        })}
        <div style={{ padding: '8px 14px' }}>
          <button
            className="btn ghost"
            style={{ fontSize: 11 }}
            onClick={() => {
              setIcc({});
              try { localStorage.removeItem(ICC_LS_KEY); } catch {}
              if (window.DASHBOARD_DATA) window.DASHBOARD_DATA.ICC_USER = {};
            }}
          >
            恢复文献默认值
          </button>
        </div>
      </div>
    </Section>
  );
}

function ParamEditor({ label, value, defaultValue, onChange }) {
  const isNumber = typeof defaultValue === 'number';
  const inputStyle = {
    width: 72, padding: '3px 6px', fontSize: 11.5,
    background: 'var(--panel)', border: '1px solid var(--border)',
    borderRadius: 4, color: 'var(--text)', textAlign: 'right',
    fontFamily: 'var(--font-mono)',
  };
  return (
    <label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--muted)' }}>
      <span style={{ color: 'var(--text-2)', fontFamily: 'var(--font-mono)' }}>{label}</span>
      {isNumber ? (
        <input type="number" value={value ?? defaultValue}
          step={Number.isInteger(defaultValue) ? 1 : 0.1}
          onChange={e => {
            const v = e.target.value;
            if (v === '') return onChange(defaultValue);
            const n = parseFloat(v);
            if (!isNaN(n)) onChange(n);
          }}
          style={inputStyle}/>
      ) : (
        <span style={{ color: 'var(--muted-2)', fontStyle: 'italic', fontSize: 10 }}>(复杂参数 — 暂不可编辑)</span>
      )}
      <span style={{ color: 'var(--muted-2)', fontSize: 10 }}>默认 {String(defaultValue)}</span>
    </label>
  );
}

// ──────────────────────────────────────────────────────────────────────────
// helpers
// ──────────────────────────────────────────────────────────────────────────
const csvEsc = (s) => {
  const str = String(s ?? '');
  return /[",\n]/.test(str) ? `"${str.replace(/"/g,'""')}"` : str;
};
const triggerDownload = (blob, filename) => {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename;
  document.body.appendChild(a); a.click(); a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const stampNow = () => {
  const d = new Date();
  return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
};
const sanitizeSheetName = (s) => s.replace(/[\\/?*\[\]:]/g, '_').slice(0, 31);

Object.assign(window, { SettingsModal });
