// dashboard/src/components/observation/UnifiedSearchPanel.tsx
//
// 多目标统一全源检索视图 —— 跨多个目标 × 多个 (source, product) 并发 cone 检索
//
// 两阶段流程：
//   ① 统一检索：三模式输入目标（坐标列表 / 天体名称列表 / CSV 导入）+ 源 chip 筛选
//      → POST /api/observation/unified/search → 按 (source, product) 分组展示候选源
//   ② 批量下载：勾选候选源 → 逐 (source, product) 组调用现有 POST /observation/download
//
// 与 ObservationPanel.SearchView 的区别：
//   - SearchView：单目标 + 单源 + 单产品（精细化参数）
//   - UnifiedSearchPanel：多目标 + 多源（广覆盖发现）
import { useState, useMemo, useCallback, useEffect } from 'react';
import {
  Globe,
  Search,
  Upload,
  CheckCircle2,
  AlertTriangle,
  Download,
  Loader2,
  MapPin,
  ListPlus,
} from 'lucide-react';
import { ObservationResultCard } from './ObservationResultCard';
import {
  SOURCE_THEME,
  PRODUCT_LABEL,
  type UnifiedSearchRequest,
  type UnifiedSearchResult,
  type SourceCandidateGroup,
  type TargetRequest,
  type ResolvedTarget,
  type ObservationBatchResult,
} from './constants';
import type { CapabilitySpec } from '../../types';

// ── 输入模式 ──
type InputMode = 'coordinates' | 'names' | 'csv';

interface UnifiedSearchPanelProps {
  capabilities: CapabilitySpec[];
  // 统一检索状态（来自 useObservation）
  unifiedResult: UnifiedSearchResult | null;
  unifiedSearching: boolean;
  unifiedError: string | null;
  runUnifiedSearch: (req: UnifiedSearchRequest) => Promise<void>;
  resolveNames: (names: string[]) => Promise<ResolvedTarget[]>;
  unifiedSelected: Set<string>;
  toggleUnifiedSelect: (key: string) => void;
  downloadUnifiedSelected: () => Promise<ObservationBatchResult[]>;
  downloading: boolean;
}

export function UnifiedSearchPanel({
  capabilities,
  unifiedResult,
  unifiedSearching,
  unifiedError,
  runUnifiedSearch,
  resolveNames,
  unifiedSelected,
  toggleUnifiedSelect,
  downloadUnifiedSelected,
  downloading,
}: UnifiedSearchPanelProps) {
  // ── 统一检索的下载结果 ──
  const [downloadResults, setDownloadResults] = useState<
    ObservationBatchResult[] | null
  >(null);

  // 当重新检索结果变化时，重置下载结果
  useEffect(() => {
    setDownloadResults(null);
  }, [unifiedResult]);

  const handleDownload = useCallback(async () => {
    const r = await downloadUnifiedSelected();
    setDownloadResults(r);
  }, [downloadUnifiedSelected]);

  // ── 输入模式 ──
  const [inputMode, setInputMode] = useState<InputMode>('coordinates');
  // ── 坐标列表文本（每行 ra,dec[,radius] 或 ra dec [radius]）──
  const [coordsText, setCoordsText] = useState('');
  // ── 天体名称列表文本 ──
  const [namesText, setNamesText] = useState('');
  const [resolving, setResolving] = useState(false);
  const [resolved, setResolved] = useState<ResolvedTarget[]>([]);
  // ── CSV 导入 ──
  // ── 公共参数 ──
  const [defaultRadius, setDefaultRadius] = useState('0.1');
  const [perTargetLimit, setPerTargetLimit] = useState('1');

  // ── 可选择的检索源规格列表（展开所有的版本/子类型组合） ──
  const selectableSources = useMemo(() => {
    const list: {
      key: string;
      source: string;
      product: string;
      subtype?: string;
      release?: string;
      version?: string;
      label: string;
    }[] = [];

    for (const c of capabilities) {
      const subtypes = c.subtypes.length > 0 ? c.subtypes : [undefined];

      for (const subtype of subtypes) {
        const releases = c.releases.length > 0 ? c.releases : [undefined];

        for (const release of releases) {
          const versions =
            release &&
            c.release_versions &&
            c.release_versions[release] &&
            c.release_versions[release].length > 0
              ? c.release_versions[release]
              : [undefined];

          for (const version of versions) {
            const key = [
              c.source,
              c.product,
              subtype || '',
              release || '',
              version || '',
            ].join('|');

            // 构建用户友好标签，如：LAMOST 光谱 - lrs (DR11 v1.0)
            let label = `${SOURCE_THEME[c.source]?.label ?? c.source} ${PRODUCT_LABEL[c.product] ?? c.product}`;
            if (subtype) {
              label += ` - ${subtype}`;
            }
            const verParts: string[] = [];
            if (release) {
              verParts.push(release.toUpperCase());
            }
            if (version) {
              verParts.push(version);
            }
            if (verParts.length > 0) {
              label += ` (${verParts.join(' ')})`;
            }

            list.push({
              key,
              source: c.source,
              product: c.product,
              subtype,
              release,
              version,
              label,
            });
          }
        }
      }
    }
    return list;
  }, [capabilities]);

  // ── 源筛选：默认勾选逻辑 (望远镜、数据类型、子类型、发布版本全选，子版本只选最新) ──
  const defaultSelectedKeys = useMemo(() => {
    const groups = new Map<string, typeof selectableSources>();
    for (const s of selectableSources) {
      const comboKey = `${s.source}|${s.product}|${s.subtype || ''}|${s.release || ''}`;
      if (!groups.has(comboKey)) {
        groups.set(comboKey, []);
      }
      groups.get(comboKey)!.push(s);
    }

    const selected = new Set<string>();
    for (const items of groups.values()) {
      if (items.length === 1) {
        selected.add(items[0].key);
      } else {
        // 找出最新子版本的项
        let latestItem = items[0];
        for (let i = 1; i < items.length; i++) {
          const item = items[i];
          const vA = latestItem.version || '';
          const vB = item.version || '';
          const cmp = vB.localeCompare(vA, undefined, {
            numeric: true,
            sensitivity: 'base',
          });
          if (cmp > 0) {
            latestItem = item;
          }
        }
        selected.add(latestItem.key);
      }
    }
    return Array.from(selected);
  }, [selectableSources]);

  // ── 源筛选：全部可用组合 key ──
  const allKeys = useMemo(
    () => selectableSources.map((s) => s.key),
    [selectableSources]
  );
  const [selectedSources, setSelectedSources] = useState<Set<string> | null>(
    null
  );

  // 第一次加载完能力后默认选择最新的子版本以及所有发布版本
  useEffect(() => {
    if (selectedSources === null && defaultSelectedKeys.length > 0) {
      setSelectedSources(new Set(defaultSelectedKeys));
    }
  }, [defaultSelectedKeys, selectedSources]);

  // 同步选中的有效 Set
  const effectiveSelected = useMemo(() => {
    if (selectedSources === null) {
      return new Set(defaultSelectedKeys);
    }
    return selectedSources;
  }, [selectedSources, defaultSelectedKeys]);

  // 解析目标列表（坐标模式 / 名称模式已解析 / CSV）
  const targets: TargetRequest[] = useMemo(() => {
    if (inputMode === 'names') {
      // 名称模式：用 resolved 列表中成功的项
      return resolved
        .filter((r) => r.ra != null && r.dec != null)
        .map((r) => ({
          ra: r.ra!,
          dec: r.dec!,
          radius_deg: parseFloat(defaultRadius) || 0.1,
          label: r.name,
        }));
    }
    // coordinates / csv 模式：从文本解析
    return parseCoordsText(coordsText, parseFloat(defaultRadius) || 0.1);
  }, [inputMode, resolved, coordsText, defaultRadius]);

  // ── 执行检索 ──
  const handleSearch = useCallback(async () => {
    if (targets.length === 0) return;

    // 收集选中的 specs
    const selectedSpecs = selectableSources.filter((s) =>
      effectiveSelected.has(s.key)
    );

    const sources = selectedSpecs.map((s) => ({
      source: s.source,
      product: {
        product: s.product,
        subtype: s.subtype || undefined,
      },
      release: s.release || undefined,
      version: s.version || undefined,
    }));

    await runUnifiedSearch({
      targets,
      sources: sources.length === allKeys.length ? undefined : sources,
      per_target_limit: parseInt(perTargetLimit) || 1,
    });
  }, [
    targets,
    selectableSources,
    effectiveSelected,
    allKeys.length,
    perTargetLimit,
    runUnifiedSearch,
  ]);

  // ── 名称解析 ──
  const handleResolveNames = useCallback(async () => {
    const names = namesText
      .split(/[\s,;\n]+/)
      .map((s) => s.trim())
      .filter(Boolean);
    if (names.length === 0) return;
    setResolving(true);
    try {
      const results = await resolveNames(names);
      setResolved(results);
    } finally {
      setResolving(false);
    }
  }, [namesText, resolveNames]);

  // ── CSV 导入（前端纯解析，列名含 ra/dec/name/radius）──
  const handleCsvUpload = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        const text = String(reader.result ?? '');
        const parsed = parseCsvTargets(text, parseFloat(defaultRadius) || 0.1);
        // 把解析结果转成 coordsText 格式（统一走坐标文本流）
        const lines = parsed.map((t) =>
          [t.ra.toString(), t.dec.toString(), t.label].filter(Boolean).join(',')
        );
        setCoordsText(lines.join('\n'));
        setInputMode('coordinates');
      };
      reader.readAsText(file);
      e.target.value = ''; // 允许重复选同文件
    },
    [defaultRadius]
  );

  // ── 树节点展开状态 ──
  const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
  const toggleExpand = useCallback((nodeId: string) => {
    setExpandedNodes((prev) => {
      const next = new Set(prev);
      if (next.has(nodeId)) next.delete(nodeId);
      else next.add(nodeId);
      return next;
    });
  }, []);

  // 按产品和源多级树形分组的可选择检索源
  interface SourceTreeNode {
    id: string;
    label: string;
    level: number; // 0: source, 1: subtype, 2: release, 3: version
    children?: SourceTreeNode[];
    leafKeys: string[];
  }

  const sourcesByProduct = useMemo(() => {
    const map = new Map<string, SourceTreeNode[]>();

    for (const c of capabilities) {
      const product = c.product;
      if (!map.has(product)) {
        map.set(product, []);
      }
      const sourcesInProduct = map.get(product)!;

      const sourceNodeId = `${c.source}|${product}`;
      if (sourcesInProduct.some((node) => node.id === sourceNodeId)) {
        continue;
      }

      // 筛选该 (source, product) 的所有可勾选叶子节点 specs
      const matchingSources = selectableSources.filter(
        (s) => s.source === c.source && s.product === c.product
      );
      if (matchingSources.length === 0) continue;

      const sourceLabel = SOURCE_THEME[c.source]?.label ?? c.source;

      // 1. 按 subtype 分组
      const bySubtype = new Map<string | undefined, typeof selectableSources>();
      for (const ms of matchingSources) {
        const st = ms.subtype || undefined;
        if (!bySubtype.has(st)) bySubtype.set(st, []);
        bySubtype.get(st)!.push(ms);
      }

      const subtypeNodes: SourceTreeNode[] = [];
      for (const [st, stItems] of bySubtype.entries()) {
        const subtypeLabel = st || '';
        const subtypeNodeId = `${sourceNodeId}|${subtypeLabel}`;

        // 2. 按 release 分组
        const byRelease = new Map<
          string | undefined,
          typeof selectableSources
        >();
        for (const item of stItems) {
          const rel = item.release || undefined;
          if (!byRelease.has(rel)) byRelease.set(rel, []);
          byRelease.get(rel)!.push(item);
        }

        const releaseNodes: SourceTreeNode[] = [];
        for (const [rel, relItems] of byRelease.entries()) {
          const releaseLabel = rel ? rel.toUpperCase() : '';
          const releaseNodeId = `${subtypeNodeId}|${rel || ''}`;

          // 3. 按 version 分组
          const versionNodes: SourceTreeNode[] = [];
          for (const item of relItems) {
            if (item.version) {
              versionNodes.push({
                id: `${releaseNodeId}|${item.version}`,
                label: item.version,
                level: 3,
                leafKeys: [item.key],
              });
            }
          }

          if (versionNodes.length > 0) {
            releaseNodes.push({
              id: releaseNodeId,
              label: releaseLabel,
              level: 2,
              children: versionNodes,
              leafKeys: relItems.map((item) => item.key),
            });
          } else {
            releaseNodes.push({
              id: releaseNodeId,
              label: releaseLabel,
              level: 2,
              leafKeys: relItems.map((item) => item.key),
            });
          }
        }

        // 整理释放节点
        if (releaseNodes.length > 0) {
          const hasValidRelease = releaseNodes.some((rn) => rn.label !== '');
          if (hasValidRelease) {
            subtypeNodes.push({
              id: subtypeNodeId,
              label: subtypeLabel,
              level: 1,
              children: releaseNodes,
              leafKeys: stItems.map((item) => item.key),
            });
          } else {
            const directChildren = releaseNodes.flatMap(
              (rn) => rn.children || []
            );
            if (directChildren.length > 0) {
              subtypeNodes.push({
                id: subtypeNodeId,
                label: subtypeLabel,
                level: 1,
                children: directChildren,
                leafKeys: stItems.map((item) => item.key),
              });
            } else {
              subtypeNodes.push({
                id: subtypeNodeId,
                label: subtypeLabel,
                level: 1,
                leafKeys: stItems.map((item) => item.key),
              });
            }
          }
        }
      }

      // 整理源节点的子节点
      const hasValidSubtype = subtypeNodes.some((sn) => sn.label !== '');
      let finalChildren: SourceTreeNode[] | undefined = undefined;
      if (hasValidSubtype) {
        finalChildren = subtypeNodes;
      } else {
        const directChildren = subtypeNodes.flatMap((sn) => sn.children || []);
        if (directChildren.length > 0) {
          finalChildren = directChildren;
        }
      }

      sourcesInProduct.push({
        id: sourceNodeId,
        label: sourceLabel,
        level: 0,
        children: finalChildren,
        leafKeys: matchingSources.map((item) => item.key),
      });
    }

    return map;
  }, [capabilities, selectableSources]);

  // ── 渲染子孙层级节点 (Level 1, 2, 3) ──
  const renderNestedNode = useCallback(
    (node: SourceTreeNode) => {
      const isExpanded = expandedNodes.has(node.id);
      const hasChildren = node.children && node.children.length > 0;

      const allChecked = node.leafKeys.every((key) =>
        effectiveSelected.has(key)
      );
      const someChecked = node.leafKeys.some((key) =>
        effectiveSelected.has(key)
      );
      const isIndeterminate = someChecked && !allChecked;

      const handleCheckboxChange = (e: React.MouseEvent) => {
        e.stopPropagation();
        setSelectedSources((prev) => {
          const next = new Set(prev);
          if (allChecked) {
            node.leafKeys.forEach((k) => next.delete(k));
          } else {
            node.leafKeys.forEach((k) => next.add(k));
          }
          return next;
        });
      };

      const handleNodeClick = (e: React.MouseEvent) => {
        e.stopPropagation();
        if (hasChildren) {
          toggleExpand(node.id);
        }
      };

      return (
        <div key={node.id} className="flex flex-col select-none">
          <div
            onClick={handleNodeClick}
            className="flex items-center justify-between py-1 px-1.5 rounded hover:bg-sunken/45 transition-all cursor-pointer"
            style={{ paddingLeft: `${(node.level - 1) * 12}px` }}
          >
            <div className="flex items-center gap-2">
              {/* 子折叠箭头 */}
              {hasChildren ? (
                <span
                  className={`w-3.5 h-3.5 flex items-center justify-center text-[7px] text-tertiary transition-transform duration-200 ${
                    isExpanded ? 'rotate-90 text-blueprint' : ''
                  }`}
                >
                  ▶
                </span>
              ) : (
                <span className="w-3.5" />
              )}

              {/* 精美自定义复选框 */}
              <div
                onClick={handleCheckboxChange}
                className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all cursor-pointer ${
                  allChecked
                    ? 'bg-blueprint border-blueprint text-white'
                    : isIndeterminate
                      ? 'bg-blueprint/25 border-blueprint text-blueprint'
                      : 'border-default hover:border-secondary bg-surface'
                }`}
              >
                {allChecked && (
                  <svg className="w-2.5 h-2.5 fill-current" viewBox="0 0 20 20">
                    <path d="M0 11l2-2 5 5L18 3l2 2L7 18z" />
                  </svg>
                )}
                {isIndeterminate && (
                  <div className="w-1.5 h-0.5 bg-blueprint rounded-sm" />
                )}
              </div>

              {/* 子节点标签 */}
              <span className="text-[11px] font-bold text-tertiary hover:text-secondary transition-colors">
                {node.label || '默认'}
              </span>
            </div>
          </div>

          {/* 虚线连接子孙 */}
          {hasChildren && isExpanded && (
            <div className="flex flex-col gap-1 mt-1 border-l border-dashed border-subtle/50 ml-2.5 pl-1 animate-in fade-in duration-100">
              {node.children!.map((child) => renderNestedNode(child))}
            </div>
          )}
        </div>
      );
    },
    [expandedNodes, effectiveSelected, toggleExpand]
  );

  // ── 渲染顶层源卡片 (Level 0) ──
  const renderRootCard = useCallback(
    (node: SourceTreeNode) => {
      const isExpanded = expandedNodes.has(node.id);
      const hasChildren = node.children && node.children.length > 0;
      const sourceKey = node.id.split('|')[0];
      const theme = SOURCE_THEME[sourceKey] || {
        label: node.label,
        dot: 'bg-blueprint',
        badge: '',
      };

      const allChecked = node.leafKeys.every((key) =>
        effectiveSelected.has(key)
      );
      const someChecked = node.leafKeys.some((key) =>
        effectiveSelected.has(key)
      );
      const isIndeterminate = someChecked && !allChecked;

      const handleCheckboxChange = (e: React.MouseEvent) => {
        e.stopPropagation();
        setSelectedSources((prev) => {
          const next = new Set(prev);
          if (allChecked) {
            node.leafKeys.forEach((k) => next.delete(k));
          } else {
            node.leafKeys.forEach((k) => next.add(k));
          }
          return next;
        });
      };

      const handleNodeClick = () => {
        if (hasChildren) {
          toggleExpand(node.id);
        }
      };

      return (
        <div
          key={node.id}
          className={`relative group select-none flex flex-col rounded-lg border transition-all duration-200 ${
            allChecked
              ? 'bg-surface border-blueprint/60 shadow-sm'
              : someChecked
                ? 'bg-surface border-blueprint/35 shadow-sm'
                : 'bg-sunken/15 border-subtle hover:border-default hover:bg-sunken/25'
          }`}
        >
          {/* 紧凑卡片头部行 */}
          <div
            onClick={handleNodeClick}
            className="flex items-center justify-between p-2 cursor-pointer select-none"
          >
            <div className="flex items-center gap-2">
              {/* 精美主复选框 */}
              <div
                onClick={handleCheckboxChange}
                className={`w-3.5 h-3.5 rounded border flex items-center justify-center transition-all cursor-pointer ${
                  allChecked
                    ? 'bg-blueprint border-blueprint text-white'
                    : isIndeterminate
                      ? 'bg-blueprint/20 border-blueprint text-blueprint'
                      : 'border-default hover:border-secondary bg-surface'
                }`}
              >
                {allChecked && (
                  <svg className="w-2.5 h-2.5 fill-current" viewBox="0 0 20 20">
                    <path d="M0 11l2-2 5 5L18 3l2 2L7 18z" />
                  </svg>
                )}
                {isIndeterminate && (
                  <div className="w-1.5 h-0.5 bg-blueprint rounded-sm" />
                )}
              </div>

              {/* 源中文/英文名 */}
              <span className="text-[11px] font-bold text-secondary tracking-wide">
                {theme.label}
              </span>
            </div>

            {/* 指示微标和折叠指示 */}
            <div className="flex items-center gap-1">
              <span className="text-[9px] font-semibold text-tertiary px-1 py-0.5 rounded bg-sunken scale-90">
                {node.leafKeys.length} 项
              </span>
              {hasChildren && (
                <span
                  className={`w-3.5 h-3.5 flex items-center justify-center text-[9px] text-tertiary transition-transform duration-300 ${
                    isExpanded ? 'rotate-180 text-secondary' : 'rotate-0'
                  }`}
                >
                  ▼
                </span>
              )}
            </div>
          </div>

          {/* 悬浮树形结构展示框 */}
          {hasChildren && isExpanded && (
            <div
              onClick={(e) => e.stopPropagation()}
              className="absolute left-0 right-0 top-full mt-1 z-50 bg-surface border border-default rounded-lg shadow-xl p-2.5 flex flex-col gap-1.5 animate-in fade-in slide-in-from-top-1 duration-150 max-h-60 overflow-y-auto"
            >
              <div className="text-[10px] text-tertiary font-extrabold pb-1 border-b border-subtle/50 mb-1">
                {theme.label} 选项配置
              </div>
              {node.children!.map((child) => renderNestedNode(child))}
            </div>
          )}
        </div>
      );
    },
    [expandedNodes, effectiveSelected, toggleExpand, renderNestedNode]
  );

  // ── 统一递归渲染树入口 ──
  const renderTreeNode = useCallback(
    (node: SourceTreeNode) => {
      if (node.level === 0) {
        return renderRootCard(node);
      }
      return renderNestedNode(node);
    },
    [renderRootCard, renderNestedNode]
  );

  // ── 源筛选控制 ──
  const selectAllSources = useCallback(
    () => setSelectedSources(new Set(allKeys)),
    [allKeys]
  );
  const clearSources = useCallback(() => setSelectedSources(new Set()), []);

  return (
    <div className="space-y-5">
      {/* ── 统一检索表单卡片 ── */}
      <div className="console-panel rounded-lg p-5 space-y-4">
        {/* 输入模式切换 */}
        <div className="flex items-center justify-between flex-wrap gap-3 pb-3 border-b border-subtle select-none">
          <div className="flex items-center gap-2 text-xs font-bold">
            <span className="text-secondary">检索模式：</span>
            <div className="flex items-center gap-1 bg-sunken p-1 rounded-lg w-fit">
              <ModeTab
                active={inputMode === 'coordinates'}
                onClick={() => setInputMode('coordinates')}
                icon={<MapPin className="w-3.5 h-3.5" />}
                label="坐标列表"
              />
              <ModeTab
                active={inputMode === 'names'}
                onClick={() => setInputMode('names')}
                icon={<ListPlus className="w-3.5 h-3.5" />}
                label="天体名称"
              />
              <ModeTab
                active={inputMode === 'csv'}
                onClick={() => setInputMode('csv')}
                icon={<Upload className="w-3.5 h-3.5" />}
                label="CSV 导入"
              />
            </div>
          </div>
        </div>

        {/* ── 目标输入区 ── */}
        <div className="space-y-3">
          {inputMode === 'coordinates' && (
            <div className="space-y-1.5 flex flex-col">
              <label className="text-xs font-bold text-secondary block">
                坐标列表（每行：ra,dec 或 ra,dec,radius；支持空格/逗号分隔）
              </label>
              <textarea
                value={coordsText}
                onChange={(e) => setCoordsText(e.target.value)}
                placeholder={'10.6847,41.2687,M31\n40.0,-5.0'}
                rows={5}
                className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono resize-y"
              />
              <p className="text-[11px] text-tertiary font-semibold">
                已识别 {targets.length} 个目标
              </p>
            </div>
          )}

          {inputMode === 'names' && (
            <div className="space-y-1.5 flex flex-col">
              <label className="text-xs font-bold text-secondary block">
                天体名称列表（每行一个名称，如 M31 / NGC 1068 / GD 358）
              </label>
              <textarea
                value={namesText}
                onChange={(e) => setNamesText(e.target.value)}
                placeholder={'M31\nNGC 1068\nGD 358'}
                rows={5}
                className="w-full px-2.5 py-2 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono resize-y"
              />
              <div className="flex items-center gap-2">
                <button
                  onClick={handleResolveNames}
                  disabled={resolving || !namesText.trim()}
                  className="btn-console px-3 py-1.5 text-xs font-semibold rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
                >
                  {resolving ? (
                    <Loader2 className="w-3.5 h-3.5 animate-spin" />
                  ) : (
                    <Search className="w-3.5 h-3.5" />
                  )}
                  解析坐标
                </button>
                <span className="text-[11px] text-tertiary font-medium">
                  通过 CDS SESAME 解析（带本地缓存）
                </span>
              </div>
              {resolved.length > 0 && (
                <div className="mt-2 border border-default rounded overflow-hidden">
                  <table className="w-full text-[11px]">
                    <thead className="bg-sunken text-secondary">
                      <tr className="font-bold border-b border-default/60">
                        <th className="text-left p-1.5">名称</th>
                        <th className="text-left p-1.5">RA</th>
                        <th className="text-left p-1.5">Dec</th>
                        <th className="text-left p-1.5">光谱型</th>
                        <th className="text-left p-1.5">V</th>
                        <th className="text-left p-1.5">状态</th>
                      </tr>
                    </thead>
                    <tbody>
                      {resolved.map((r) => (
                        <tr
                          key={r.name}
                          className="border-t border-subtle hover:bg-sunken/40"
                        >
                          <td className="p-1.5 font-mono">{r.name}</td>
                          <td className="p-1.5 font-mono">
                            {r.ra?.toFixed(4) ?? '—'}
                          </td>
                          <td className="p-1.5 font-mono">
                            {r.dec?.toFixed(4) ?? '—'}
                          </td>
                          <td className="p-1.5">{r.spectral_type ?? '—'}</td>
                          <td className="p-1.5">
                            {r.v_magnitude?.toFixed(2) ?? '—'}
                          </td>
                          <td className="p-1.5">
                            {r.error ? (
                              <span className="text-danger">{r.error}</span>
                            ) : (
                              <CheckCircle2 className="w-3.5 h-3.5 text-success" />
                            )}
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                  <p className="text-[11px] text-secondary p-1.5 bg-sunken font-medium">
                    成功解析 {resolved.filter((r) => !r.error).length} /{' '}
                    {resolved.length}
                  </p>
                </div>
              )}
            </div>
          )}

          {inputMode === 'csv' && (
            <div className="space-y-1.5 flex flex-col">
              <label className="text-xs font-bold text-secondary block">
                导入 CSV 文件（列名含 ra/dec，可选 name/radius；首行为表头）
              </label>
              <input
                type="file"
                accept=".csv,.tsv,.txt"
                onChange={handleCsvUpload}
                className="text-xs file:mr-3 file:py-1.5 file:px-3 file:rounded-md file:border file:border-default file:text-xs file:font-bold file:bg-sunken file:text-content hover:file:bg-surface transition-all"
              />
              <p className="text-[11px] text-tertiary font-medium">
                导入后会自动转换为坐标列表显示（VOTable 暂不支持，请先转 CSV）
              </p>
            </div>
          )}
        </div>

        {/* ── 公共参数 ── */}
        <div className="flex flex-wrap gap-6 pt-2 border-t border-subtle">
          <Field label="默认检索半径 (°)">
            <input
              type="number"
              step="0.01"
              min="0.0001"
              max="30"
              value={defaultRadius}
              onChange={(e) => setDefaultRadius(e.target.value)}
              className="w-36 px-2.5 py-1.5 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
            />
          </Field>
          <Field label="单源命中上限">
            <input
              type="number"
              min="1"
              max="500"
              value={perTargetLimit}
              onChange={(e) => setPerTargetLimit(e.target.value)}
              className="w-36 px-2.5 py-1.5 rounded-md bg-sunken border border-default text-content placeholder-tertiary focus:outline-none focus:border-blueprint focus:bg-surface transition-all text-xs font-mono"
            />
          </Field>
        </div>

        {/* ── 源筛选 chip 组 ── */}
        <div className="space-y-3 pt-3 border-t border-subtle">
          <div className="flex items-center justify-between">
            <label className="text-xs font-bold text-secondary">
              检索源筛选（{effectiveSelected.size}/{allKeys.length} 个选择）
            </label>
            <div className="flex items-center gap-1.5">
              <button
                onClick={selectAllSources}
                className="btn-console px-2 py-0.5 rounded text-[10px] font-bold bg-blueprint/15 text-blueprint border-blueprint/20 hover:bg-blueprint/25 transition-all"
              >
                全选
              </button>
              <button
                onClick={clearSources}
                className="btn-console px-2 py-0.5 rounded text-[10px] font-bold bg-sunken border border-default text-secondary hover:bg-surface hover:text-content transition-all"
              >
                清空
              </button>
            </div>
          </div>
          <div className="space-y-4">
            {Array.from(sourcesByProduct.entries()).map(([product, nodes]) => (
              <div
                key={product}
                className="flex flex-col gap-2.5 pb-3.5 border-b border-subtle/30 last:border-0"
              >
                <span className="text-[11px] font-extrabold text-secondary shrink-0 uppercase tracking-wider">
                  {PRODUCT_LABEL[product] ?? product}
                </span>
                <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 flex-1 mt-1">
                  {nodes.map((node) => renderTreeNode(node))}
                </div>
              </div>
            ))}
          </div>
        </div>

        {/* ── 执行检索 ── */}
        <div className="flex items-center gap-3 pt-3 border-t border-subtle">
          <button
            onClick={handleSearch}
            disabled={unifiedSearching || targets.length === 0}
            className="btn-console btn-console-primary px-4 py-2 text-xs font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
          >
            {unifiedSearching ? (
              <Loader2 className="w-4 h-4 animate-spin" />
            ) : (
              <Globe className="w-4 h-4" />
            )}
            统一检索 {targets.length > 0 && `(${targets.length} 目标)`}
          </button>
          {unifiedResult && (
            <span className="text-xs text-secondary font-semibold">
              共 {unifiedResult.groups.length} 个源组合，
              {unifiedResult.total_candidates} 个候选源
            </span>
          )}
        </div>
      </div>

      {/* ── 错误 ── */}
      {unifiedError && (
        <div className="flex items-center gap-2 p-3 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 rounded-lg text-xs text-rose-700 dark:text-rose-300">
          <AlertTriangle className="w-4 h-4 shrink-0" />
          {unifiedError}
        </div>
      )}

      {/* ── 名称解析失败提示 ── */}
      {unifiedResult?.resolve_failures &&
        unifiedResult.resolve_failures.length > 0 && (
          <div className="p-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg text-xs text-amber-700 dark:text-amber-300 space-y-1">
            {unifiedResult.resolve_failures.map((f, i) => (
              <div key={i}>
                <strong>{f.target_label}</strong>: {f.error}
              </div>
            ))}
          </div>
        )}

      {/* ── 结果区：按 (source, product) 分组的候选源表 ── */}
      {unifiedResult && unifiedResult.groups.length > 0 && (
        <UnifiedResultGroups
          groups={unifiedResult.groups}
          unifiedSelected={unifiedSelected}
          toggleUnifiedSelect={toggleUnifiedSelect}
          onDownload={handleDownload}
          downloading={downloading}
        />
      )}

      {/* ── 下载区 ── */}
      {unifiedResult && unifiedResult.groups.length > 0 && (
        <UnifiedDownloadBar
          selectedCount={unifiedSelected.size}
          onDownload={handleDownload}
          downloading={downloading}
          results={downloadResults}
        />
      )}
    </div>
  );
}

// ════════════════════════════════════════════════════════════
// 子组件：结果分组表
// ════════════════════════════════════════════════════════════

function UnifiedResultGroups({
  groups,
  unifiedSelected,
  toggleUnifiedSelect,
  onDownload,
  downloading,
}: {
  groups: SourceCandidateGroup[];
  unifiedSelected: Set<string>;
  toggleUnifiedSelect: (key: string) => void;
  onDownload: () => void;
  downloading: boolean;
}) {
  const totalCandidatesCount = groups.reduce(
    (acc, g) => acc + g.candidates.length,
    0
  );

  const allKeys = useMemo(() => {
    return groups.flatMap((g) => {
      const groupKeyPrefix = [
        g.source,
        g.product.product,
        g.product.subtype ?? '',
        g.release ?? '',
        g.version ?? '',
      ].join('#');
      return g.candidates.map((c) => `${groupKeyPrefix}#${c.source_id}`);
    });
  }, [groups]);

  const isAllAllSelected = useMemo(() => {
    return allKeys.length > 0 && allKeys.every((k) => unifiedSelected.has(k));
  }, [allKeys, unifiedSelected]);

  const selectAllAll = () => {
    if (isAllAllSelected) {
      allKeys.forEach((key) => {
        if (unifiedSelected.has(key)) {
          toggleUnifiedSelect(key);
        }
      });
    } else {
      allKeys.forEach((key) => {
        if (!unifiedSelected.has(key)) {
          toggleUnifiedSelect(key);
        }
      });
    }
  };

  return (
    <div className="space-y-4">
      {/* 全局所有源控制面板 */}
      {groups.length > 0 && (
        <div className="console-panel rounded-lg p-3 bg-surface border border-subtle shadow-sm flex flex-col sm:flex-row sm:items-center justify-between gap-2.5 select-none animate-in fade-in duration-300">
          <div className="flex items-center gap-2">
            <span className="font-extrabold text-[10px] text-content uppercase tracking-wider">
              全部搜索候选源
            </span>
            <span className="text-[9px] font-bold bg-sunken px-2 py-0.5 rounded-full border border-subtle text-secondary">
              已选 {unifiedSelected.size} 项 / 共 {totalCandidatesCount} 项
            </span>
          </div>
          <div className="flex items-center gap-2.5">
            <button
              onClick={selectAllAll}
              className={`px-3 py-1 rounded-full text-[10px] font-bold border transition-all cursor-pointer ${
                isAllAllSelected
                  ? 'bg-amber-500/10 text-amber-700 border-amber-500/30 hover:bg-amber-500/20 dark:text-amber-300 dark:border-amber-500/40'
                  : 'bg-blueprint text-white border-blueprint hover:opacity-90'
              }`}
            >
              {isAllAllSelected ? '取消全选所有源' : '全选所有源'}
            </button>
            <span className="text-border-subtle h-4 w-[1px] bg-border-subtle hidden sm:inline" />
            <button
              onClick={onDownload}
              disabled={downloading || unifiedSelected.size === 0}
              className="btn-console bg-emerald-600 border-emerald-600 text-white hover:bg-emerald-500 hover:border-emerald-500 px-3 py-1 text-[10px] font-bold rounded-full disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer transition-all"
            >
              {downloading ? (
                <Loader2 className="w-3.5 h-3.5 animate-spin" />
              ) : (
                <Download className="w-3.5 h-3.5" />
              )}
              下载所选 ({unifiedSelected.size})
            </button>
          </div>
        </div>
      )}

      {groups.map((g) => {
        const theme = SOURCE_THEME[g.source] ?? SOURCE_THEME.lamost;
        const productLabel =
          PRODUCT_LABEL[g.product.product] ?? g.product.product;
        const groupKeyPrefix = [
          g.source,
          g.product.product,
          g.product.subtype ?? '',
          g.release ?? '',
          g.version ?? '',
        ].join('#');
        const groupSelected = g.candidates.filter((c) =>
          unifiedSelected.has(`${groupKeyPrefix}#${c.source_id}`)
        ).length;
        const allSelected =
          groupSelected === g.candidates.length && g.candidates.length > 0;
        return (
          <div
            key={groupKeyPrefix}
            className="console-panel rounded-lg overflow-hidden border border-subtle bg-surface"
          >
            {/* 分组头 */}
            <div className="flex items-center justify-between px-3.5 py-2 bg-sunken border-b border-subtle">
              <div className="flex items-center gap-2">
                <span
                  className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-bold ${theme.badge}`}
                >
                  <span className={`w-1.5 h-1.5 rounded-full ${theme.dot}`} />
                  {theme.label}
                </span>
                <span className="text-xs font-semibold text-secondary">
                  {productLabel}
                  {g.product.subtype && (
                    <span className="text-tertiary ml-1">
                      /{g.product.subtype}
                    </span>
                  )}
                  {(g.release || g.version) && (
                    <span className="text-blueprint ml-1 font-bold">
                      (
                      {[g.release?.toUpperCase(), g.version]
                        .filter(Boolean)
                        .join(' ')}
                      )
                    </span>
                  )}
                </span>
                <span className="text-[11px] text-secondary">
                  {g.candidates.length} 个候选源
                </span>
              </div>
              <button
                onClick={() => {
                  // 全选/取消该组
                  const keys = g.candidates.map(
                    (c) => `${groupKeyPrefix}#${c.source_id}`
                  );
                  const allIn = keys.every((k) => unifiedSelected.has(k));
                  keys.forEach((k) => {
                    if (allIn === unifiedSelected.has(k))
                      toggleUnifiedSelect(k);
                  });
                }}
                className={`px-2 py-0.5 rounded text-[9px] font-bold border transition-all cursor-pointer ${
                  allSelected
                    ? 'bg-blueprint/10 text-blueprint border-blueprint/30 hover:bg-blueprint/20'
                    : 'bg-surface text-secondary border-subtle hover:bg-sunken hover:border-strong'
                }`}
              >
                {allSelected ? '取消全选' : '全选该源'}
              </button>
            </div>
            {/* 候选源表 */}
            <div className="overflow-x-auto">
              <table className="w-full text-[11px]">
                <thead className="text-secondary select-none">
                  <tr className="bg-surface/50 border-b border-subtle">
                    <th className="text-left p-2.5 w-10"></th>
                    <th className="text-left p-2.5">source_id</th>
                    <th className="text-left p-2.5">标签</th>
                    <th className="text-left p-2.5">RA</th>
                    <th className="text-left p-2.5">Dec</th>
                    <th className="text-left p-2.5">距离</th>
                  </tr>
                </thead>
                <tbody>
                  {g.candidates.map((c) => {
                    const key = `${groupKeyPrefix}#${c.source_id}`;
                    const checked = unifiedSelected.has(key);
                    return (
                      <tr
                        key={c.source_id}
                        onClick={() => toggleUnifiedSelect(key)}
                        className={`border-b border-subtle last:border-b-0 hover:bg-sunken transition-colors cursor-pointer select-none ${
                          checked ? 'bg-blueprint/[0.02]' : 'bg-surface'
                        }`}
                      >
                        <td className="p-2.5">
                          <input
                            type="checkbox"
                            checked={checked}
                            readOnly
                            className="w-3.5 h-3.5 accent-blueprint pointer-events-none"
                          />
                        </td>
                        <td className="p-2.5 font-mono text-content">
                          {c.source_id}
                        </td>
                        <td className="p-2.5 text-secondary max-w-32 truncate">
                          {c.label || '—'}
                        </td>
                        <td className="p-2.5 font-mono text-secondary">
                          {c.ra != null ? c.ra.toFixed(4) : '—'}
                        </td>
                        <td className="p-2.5 font-mono text-secondary">
                          {c.dec != null ? c.dec.toFixed(4) : '—'}
                        </td>
                        <td className="p-2.5 font-mono text-secondary">
                          {c.distance != null
                            ? `${c.distance.toFixed(4)}°`
                            : '—'}
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ════════════════════════════════════════════════════════════
// 子组件：下载条
// ════════════════════════════════════════════════════════════

function UnifiedDownloadBar({
  selectedCount,
  onDownload,
  downloading,
  results,
}: {
  selectedCount: number;
  onDownload: () => void;
  downloading: boolean;
  results: ObservationBatchResult[] | null;
}) {
  return (
    <div className="space-y-3">
      <div className="flex items-center gap-3 p-3 bg-sunken rounded-lg">
        <button
          onClick={onDownload}
          disabled={downloading || selectedCount === 0}
          className="btn-console bg-emerald-600 border-emerald-600 text-white hover:bg-emerald-500 hover:border-emerald-500 px-4 py-2 text-xs font-bold rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 cursor-pointer transition-all"
        >
          {downloading ? (
            <Loader2 className="w-4 h-4 animate-spin" />
          ) : (
            <Download className="w-4 h-4" />
          )}
          下载选中 ({selectedCount})
        </button>
        {results && results.length > 0 && (
          <span className="text-xs text-secondary font-bold">
            完成 {results.length} 组下载
          </span>
        )}
      </div>
      {/* 多组结果卡片横向展示 */}
      {results && results.length > 0 && (
        <div className="space-y-3 animate-in fade-in duration-300">
          {results.map((r, i) => (
            <ObservationResultCard key={`${r.source}-${i}`} result={r} />
          ))}
        </div>
      )}
    </div>
  );
}

// ════════════════════════════════════════════════════════════
// 辅助组件与函数
// ════════════════════════════════════════════════════════════

function ModeTab({
  active,
  onClick,
  icon,
  label,
}: {
  active: boolean;
  onClick: () => void;
  icon: React.ReactNode;
  label: string;
}) {
  return (
    <button
      onClick={onClick}
      className={`px-3 py-1.5 rounded-md text-xs font-bold flex items-center gap-1.5 transition-all ${
        active
          ? 'bg-surface text-content shadow-xs'
          : 'text-secondary hover:text-content'
      }`}
    >
      {icon}
      {label}
    </button>
  );
}

function Field({
  label,
  children,
}: {
  label: string;
  children: React.ReactNode;
}) {
  return (
    <div className="flex items-center gap-2">
      <label className="text-[11px] font-semibold text-secondary whitespace-nowrap">
        {label}
      </label>
      {children}
    </div>
  );
}

/** 解析坐标列表文本（每行 ra,dec[,radius] 或 ra dec [radius]）*/
function parseCoordsText(text: string, defaultRadius: number): TargetRequest[] {
  const targets: TargetRequest[] = [];
  for (const rawLine of text.split('\n')) {
    const line = rawLine.trim();
    if (!line || line.startsWith('#')) continue;
    // 支持逗号或空白分隔
    const parts = line.split(/[\s,]+/).filter(Boolean);
    if (parts.length < 2) continue;
    const ra = parseFloat(parts[0]);
    const dec = parseFloat(parts[1]);
    if (isNaN(ra) || isNaN(dec)) continue;
    const radius = parts.length >= 3 ? parseFloat(parts[2]) : defaultRadius;
    const label = parts.length >= 4 ? parts.slice(3).join(' ') : undefined;
    targets.push({
      ra,
      dec,
      radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
      label,
    });
  }
  return targets;
}

/** 解析 CSV 文件文本（首行表头，列名含 ra/dec，可选 name/radius）*/
function parseCsvTargets(text: string, defaultRadius: number): TargetRequest[] {
  const lines = text
    .split('\n')
    .map((l) => l.trim())
    .filter(Boolean);
  if (lines.length < 2) return [];
  // 解析表头
  const headers = lines[0].split(/[,;\t]/).map((h) => h.trim().toLowerCase());
  const colIndex = (names: string[]): number => {
    for (const n of names) {
      const i = headers.indexOf(n);
      if (i >= 0) return i;
    }
    return -1;
  };
  const raCol = colIndex(['ra', 'raj', 'ra_deg', 'ra_degrees']);
  const decCol = colIndex(['dec', 'dej', 'dec_deg', 'dec_degrees', 'de']);
  if (raCol < 0 || decCol < 0) return [];
  const nameCol = colIndex(['name', 'object', 'obj', 'target', 'designation']);
  const radiusCol = colIndex(['radius', 'radius_deg', 'r']);

  const targets: TargetRequest[] = [];
  for (let i = 1; i < lines.length; i++) {
    const cells = lines[i].split(/[,;\t]/).map((c) => c.trim());
    const ra = parseFloat(cells[raCol]);
    const dec = parseFloat(cells[decCol]);
    if (isNaN(ra) || isNaN(dec)) continue;
    const radius =
      radiusCol >= 0 ? parseFloat(cells[radiusCol]) : defaultRadius;
    const label = nameCol >= 0 ? cells[nameCol] : undefined;
    targets.push({
      ra,
      dec,
      radius_deg: isNaN(radius) ? defaultRadius : Math.max(0.0001, radius),
      label: label || undefined,
    });
  }
  return targets;
}
