'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
  ASSIGNMENT_EXPIRES_DAYS_PRESETS,
  AssignStrategy,
  REVOKE_WINDOW_MINUTES,
  Permission,
  type AgentOverride,
  type AssignmentProposal,
  type ProposedItem,
  type RevokeAssignmentResponse,
  type SheetEditOp,
} from '@pac/types';
import { Can } from '@/components/can';
import { assignmentsApi } from '@/components/plans/assignments-api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn, formatGender } from '@/lib/utils';

/**
 * 全景确认单 —— 主管**一次确认**就落库的那张卡片。
 *
 * ⚠️ 必须是**原生 React 组件**,不能用 `render_artifact`:
 * artifact 跑在 `sandbox="allow-scripts"` 的 iframe 里、CSP `connect-src 'none'`,
 * **卡片内不可能发出写请求**。分岔记牢:只读展示用 artifact,可交互用原生组件。
 *
 * ── 卡片上能做什么(2026-08-03 产品定)──────────────────────────
 * 四件**局部**操作,都不需要重新圈人、也不需要重跑落人算法:
 *   ① 逐条改时效(1-7 天)   ② 删掉某一条
 *   ③ 把某一条拖给别的客服  ④ 移除某个客服(连同他名下的条目)
 * ⛔ 换人群、改批次人数、按条件重新收敛 —— 一律回对话让助手重出一版。
 *   判据很简单:**要不要重跑服务端算法**。要,就回对话;不要,才放到卡片上。
 *
 * ⚠️ 这不违 T13「微调只有两项」—— 那条防的是"卡片变成第二个筛选器"。
 *   上面四件事都是对**已经算好的结果**做局部修正,不产生新的人群。
 *
 * ── 布局 ────────────────────────────────────────────────
 * 助手窗只有 400px 宽(最大化后更宽),所以**不用 table**(横向撑爆),用卡片列表;
 * 患者明细按客服折叠,展开才看 —— 兼顾"尽明细"与"一眼可确认"。
 */

/** 卡片本地状态下,一条召回计划的**生效**形态(叠加了主管的改动) */
interface EffectiveItem extends ProposedItem {
  /** 生效的经办人(拖拽后可能不是提案里的那个) */
  assignee: string;
  /** 生效时效(天) */
  days: number;
  /** 是否被主管拖到了别人名下 —— 落库时策略要改成 manual */
  moved: boolean;
}

/**
 * 精调表落库形态 = 提案带来的名额上限部分(原样带走)。
 *
 * ⚠️ 时效**不再进这里** —— 它已经细到"每一条召回计划"一级,再往 agentOverrides
 *   里塞一个"按客服的时效"就是两套口径打架(同一个人两处不同的值,听谁的?)。
 * ⚠️ 名额上限必须原样带走:少带一次,主管上次设的「李莉这批最多 5 条」
 *   就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
 */
function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string, AgentOverride> {
  const out: Record<string, AgentOverride> = {};
  for (const [userId, o] of Object.entries(fromSheet)) {
    if (o.maxThisBatch != null) out[userId] = { maxThisBatch: o.maxThisBatch };
  }
  return out;
}

/**
 * 时效选择 —— 1~7 天。用 shadcn `Select`(Radix),不用原生 `<select>`:
 * 原生下拉在深色/窄屏下样式不可控,而这个控件现在**每一条召回计划都有一个**,
 * 出现几百次,样式必须跟卡片其余部分一致。
 * ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
 */
function DaySelect({
  value,
  disabled,
  onChange,
  compact,
}: {
  value: number;
  disabled?: boolean;
  onChange: (days: number) => void;
  /** 条目行用紧凑版(只显示数字,省掉"天"字省宽度) */
  compact?: boolean;
}) {
  return (
    <Select
      value={String(value)}
      disabled={disabled}
      onValueChange={(v) => onChange(Number(v))}
    >
      <SelectTrigger
        className={cn(
          'h-6 gap-1 px-1.5 text-[11px]',
          compact ? 'w-[54px]' : 'w-[76px]',
        )}
      >
        <SelectValue />
      </SelectTrigger>
      {/* z 层级已在 ui/select.tsx 统一抬到 70(助手窗是 z-60,见那里的层级约定) */}
      <SelectContent>
        {ASSIGNMENT_EXPIRES_DAYS_PRESETS.map((d) => (
          <SelectItem key={d} value={String(d)} className="text-[12px]">
            {d} 天
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  );
}

export function AssignmentConfirmSheet({
  requestId,
  sheet,
  state,
  assignmentId,
  onConfirmed,
  edits,
  onEditApplied,
}: {
  requestId: string;
  sheet: AssignmentProposal;
  state: 'pending' | 'confirmed' | 'cancelled';
  assignmentId?: string;
  /** 落库成功 → 交给外层把卡片切终态 + 往消息流注入一条文本(补模型记忆) */
  onConfirmed: (assignmentId: string, summary: string) => void;
  /**
   * 助手下发的语义编辑指令队列(edit_assignment_sheet)。
   * ⚠️ 由**本组件**去匹配姓名 —— 模型手里没有 planId,那是故意的。
   */
  edits?: { seq: number; ops: SheetEditOp[] }[];
  /** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
  onEditApplied?: (summary: string) => void;
}) {
  /**
   * 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
   * 改它 = 把所有**没有单独设过**的条目一起改;单独设过的不动(下面 dayByPlan 优先)。
   */
  const [expiresInDays, setExpiresInDays] = useState(sheet.expiresInDays);
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [open, setOpen] = useState<Set<string>>(new Set());

  /// 逐条时效覆盖(planId → 天)。⚠️ 时效**落到每一条召回计划**,不是按客服
  const [dayByPlan, setDayByPlan] = useState<Record<string, number>>({});
  /// 拖拽改派(planId → 新经办人)。落库时这些条的策略改成 manual(主管指定)
  const [moveByPlan, setMoveByPlan] = useState<Record<string, string>>({});
  /// 主管删掉的条目
  const [dropped, setDropped] = useState<Set<string>>(new Set());
  /// 待确认移除的客服(AlertDialog 用;移除 = 连同他名下条目一起移出本批)
  const [pendingRemove, setPendingRemove] = useState<string | null>(null);
  /// 拖拽悬停中的目标客服(高亮用)
  const [dragOver, setDragOver] = useState<string | null>(null);
  /**
   * 批次福利(T4)—— 挂在**批次**上不挂个人,是**话术勾子 + 归因标签**。
   * v1 不核销、不接宿主卡券:落库进 `attributes.benefit.text`,生成话术时作为事实输入进 prompt。
   * ⚠️ 卡片上**只读**:文案由主管口述、助手写进来(set_benefit)——
   *   在 400px 的卡片里放一个自由文本框,主管十有八九会写成一段带承诺的话,
   *   而福利话术有硬护栏(不得追加条件/期限/承诺),那道闸在生成侧,不在这里。
   */
  const [benefit, setBenefit] = useState<string>('');
  /**
   * ── 撤销整批(限时 30 分钟)────────────────────────────────
   *
   * ⚠️ 语义是「**手滑 / 分错人**的补救」,不是"改主意重新调度" ——
   *   改主意应该走「客服退回」,那条路有完整的原因记录(T7)。所以限时。
   * ⚠️ 它是 **UX 摩擦不是安全边界**:主管本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
   *   30 分钟后照样能逐条收回来。⛔ 别基于"过了窗口就锁死了"去设计别的东西。
   * ⚠️ 窗口按**确认那一刻**算(confirmedAt),⛔ 不能用提案生成时间:
   *   主管可能盯着确认单改了二十分钟才点确认,那二十分钟不该算进他的补救窗口。
   */
  const [confirmedAt, setConfirmedAt] = useState<number | null>(null);
  const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
  const [revoking, setRevoking] = useState(false);
  const [confirmRevoke, setConfirmRevoke] = useState(false);
  /// 每 30 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒)
  const [, setRevokeTick] = useState(0);
  useEffect(() => {
    if (!confirmedAt || revoked) return;
    const i = setInterval(() => setRevokeTick((t) => t + 1), 30_000);
    return () => clearInterval(i);
  }, [confirmedAt, revoked]);
  const canRevoke =
    assignmentId != null &&
    confirmedAt != null &&
    Date.now() - confirmedAt < REVOKE_WINDOW_MINUTES * 60_000;

  const doRevoke = async () => {
    if (!assignmentId) return;
    setRevoking(true);
    try {
      const res = await assignmentsApi.revoke(assignmentId);
      setRevoked(res);
      setConfirmRevoke(false);
      // ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句),
      //   既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了"
      onEditApplied?.(res.note);
    } catch (e) {
      setError(e instanceof Error ? e.message : '撤销失败');
      setConfirmRevoke(false);
    } finally {
      setRevoking(false);
    }
  };
  /**
   * 🔴 拖拽时的**自动滚动**。
   *
   * 助手窗里一屏只放得下三四个客服,而 HTML5 DnD **不会**自己滚容器 ——
   * 主管拖着一条想丢给下面的人,拖到边缘就卡住了,只能先滚一段再拖,基本等于不可用。
   * 这里在拖动过程中监听指针的 Y 位置:进入上/下 `EDGE` 像素带就按 rAF 持续滚。
   * ⚠️ 滚的是**离条目最近的可滚动祖先**(助手窗的消息区),不是 window ——
   *   写死 window 在最大化/非最大化两种形态下会滚错东西。
   */
  const scrollerRef = useRef<HTMLElement | null>(null);
  const autoScroll = useRef<{ dir: number; raf: number } | null>(null);
  const stopAutoScroll = () => {
    if (autoScroll.current) cancelAnimationFrame(autoScroll.current.raf);
    autoScroll.current = null;
  };
  const onDragOverAnywhere = (e: React.DragEvent) => {
    const sc = scrollerRef.current;
    if (!sc) return;
    const r = sc.getBoundingClientRect();
    const EDGE = 56; // 边缘感应带
    const dir = e.clientY < r.top + EDGE ? -1 : e.clientY > r.bottom - EDGE ? 1 : 0;
    if (dir === 0) return stopAutoScroll();
    if (autoScroll.current?.dir === dir) return; // 同方向already在滚
    stopAutoScroll();
    const step = () => {
      sc.scrollTop += dir * 12;
      autoScroll.current = { dir, raf: requestAnimationFrame(step) };
    };
    autoScroll.current = { dir, raf: requestAnimationFrame(step) };
  };

  const done = state === 'confirmed';
  /// 助手撤的整批(state 由 tool_result 切过来);本地撤的走 revoked
  const cancelled = state === 'cancelled';
  const readOnly = done || cancelled || submitting;

  /**
   * 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时效。
   * ⭐ 所有展示都从这里派生(条数、按客服分组、确认按钮上的数字),
   *   ⛔ 别再直接用 `sheet.byAgent` —— 它是**提案时**的分组,拖过一条就对不上了。
   */
  const items = useMemo<EffectiveItem[]>(
    () =>
      sheet.items
        .filter((i) => !dropped.has(i.planId))
        .map((i) => ({
          ...i,
          assignee: moveByPlan[i.planId] ?? i.assigneeUserId,
          days: dayByPlan[i.planId] ?? expiresInDays,
          moved: moveByPlan[i.planId] != null && moveByPlan[i.planId] !== i.assigneeUserId,
        })),
    [sheet.items, dropped, moveByPlan, dayByPlan, expiresInDays],
  );

  /** 按客服分组(顺序沿用提案里的客服顺序;拖过去的新客服排在后面) */
  const groups = useMemo(() => {
    const byUser = new Map<string, EffectiveItem[]>();
    for (const it of items) {
      const arr = byUser.get(it.assignee) ?? [];
      arr.push(it);
      byUser.set(it.assignee, arr);
    }
    const order = [
      ...sheet.byAgent.map((a) => a.userId).filter((u) => byUser.has(u)),
      ...[...byUser.keys()].filter((u) => !sheet.byAgent.some((a) => a.userId === u)),
    ];
    const nameOf = new Map(sheet.byAgent.map((a) => [a.userId, a.name]));
    const inHandOf = new Map(sheet.byAgent.map((a) => [a.userId, a.inHandBefore]));
    return order.map((userId) => ({
      userId,
      name: nameOf.get(userId) ?? null,
      inHandBefore: inHandOf.get(userId) ?? 0,
      list: byUser.get(userId)!,
    }));
  }, [items, sheet.byAgent]);

  const confirm = async () => {
    setSubmitting(true);
    setError(null);
    try {
      const res = await assignmentsApi.create({
        // ⭐ requestId **原样回传**服务端铸造的那个 —— 幂等键。
        //   前端自己生成挡不住 SSE 重连与重试。
        requestId,
        clinicId: sheet.clinicId,
        // 初筛条件快照:批次要能解释"当时按什么圈的"
        criteria: {
          potentialTreatment: sheet.potentialTreatment,
          candidateTotal: sheet.candidateTotal,
          target: sheet.target,
          selectionNote: sheet.selectionNote,
          // ⚠️ 存的是**基数**不是 target —— 候选不够时 target 被压低,那是这一批的偶然事实。
          //   存 target 的话,主管点一次 44 人的小格子,以后所有批次就永远是 44 人。
          //   ⚠️ 同时 target 也在(上面)—— 沿用时两个键都认,改版不丢"沿用"。
          batchSize: sheet.batchSize,
          expiresInDays,
          agentOverrides: carryOverrides(sheet.agentOverrides),
          // 主管在卡片上做过的局部修正 —— 事后要能解释"这批为什么跟助手算的不一样"
          manualEdits: {
            dropped: [...dropped],
            moved: Object.keys(moveByPlan).length,
            perItemExpiry: Object.keys(dayByPlan).length,
          },
        },
        // T4:福利挂批次(话术勾子 + 归因标签);空串 = 不带福利,⛔ 别落一个空 benefit 对象
        ...(benefit.trim() ? { attributes: { benefit: { text: benefit.trim() } } } : {}),
        expiresInDays,
        // ⚠️ 落库的是**卡片当前值**,不是模型原提案。
        //   逐条 expiresInDays 只在**与批次不同**时才带:一样还带等于把批次时效冻进每条,
        //   将来批次层面改时效(撤销重发之类)就改不动这些单子了。
        items: items.map((it) => ({
          planId: it.planId,
          assigneeUserId: it.assignee,
          // ⭐ 拖过的标 manual(主管指定)—— 不标的话事后分析会把它算成算法的选择,
          //   而 T20 要反推的正是"算法选得准不准"
          assignStrategy: it.moved ? AssignStrategy.MANUAL : it.assignStrategy,
          selectionMode: it.selectionMode,
          ...(it.days !== expiresInDays ? { expiresInDays: it.days } : {}),
        })),
      });
      const summary =
        `已确认分配:批次 #${res.assignmentId.slice(0, 8)} · ` +
        (sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') +
        `${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
        (dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
        (res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
      setConfirmedAt(Date.now());
      onConfirmed(res.assignmentId, summary);
    } catch (e) {
      setError(e instanceof Error ? e.message : '确认失败');
    } finally {
      setSubmitting(false);
    }
  };

  /** 移除某个客服 = 他名下的条目**一并移出本批** */
  const removeAgent = (userId: string) => {
    setDropped((s) => {
      const n = new Set(s);
      for (const it of items) if (it.assignee === userId) n.add(it.planId);
      return n;
    });
    setPendingRemove(null);
  };

  const removingGroup = groups.find((g) => g.userId === pendingRemove);

  /**
   * 🔴 执行助手下发的**语义编辑指令**。
   *
   * 模型只会说「把杨丽华移出这批」——**姓名由这里去匹配**(它没有 planId,那是故意的)。
   * ⚠️ 匹配不到 / 匹配到多个都要**如实回一句**,⛔ 不许静默跳过:
   *   模型看不到卡片,静默失败会让它跟主管说"已经移除了",而那个人还在。
   * ⚠️ `appliedRef` 记已执行到第几批 —— 组件每次重渲染 edits 都在,不挡就会重复执行。
   */
  const appliedRef = useRef(0);
  useEffect(() => {
    if (!edits?.length) return;
    const fresh = edits.filter((e) => e.seq > appliedRef.current);
    if (!fresh.length) return;
    appliedRef.current = Math.max(...edits.map((e) => e.seq));

    const notes: string[] = [];
    // ⚠️ 用 items(生效条目)匹配,不是 sheet.items —— 已经删掉的人不该再被"找到"
    const findPatients = (q: string) => {
      const k = q.trim();
      return items.filter((i) => i.patientName === k || i.medicalRecordNumber === k);
    };
    const findAgent = (q: string) => {
      const k = q.trim();
      return groups.find((g) => g.name === k || g.userId === k) ?? null;
    };

    for (const { ops } of fresh) {
      for (const op of ops) {
        if (op.action === 'remove_agent' || (op.action === 'set_expiry' && op.agent)) {
          const name = op.action === 'remove_agent' ? op.agent : op.agent!;
          const g = findAgent(name);
          if (!g) {
            notes.push(`没找到客服「${name}」,跳过`);
            continue;
          }
          // 🔴 同名歧义:实测「张悦」既是本批客服、又是本批患者,模型选了 remove_agent,
          //   于是"把张悦移出确认单"变成了把**客服**张悦连同他名下 20 条一起移除。
          //   ⛔ 这种时候不许猜 —— 猜错动的是 20 条,而主管以为只动了 1 条。
          if (findPatients(name).length > 0) {
            notes.push(
              `「${name}」既是本批客服、又是本批患者,没有执行 —— 请说清是「客服 ${name}」还是患者`,
            );
            continue;
          }
          if (op.action === 'remove_agent') {
            setDropped((sset) => {
              const n = new Set(sset);
              for (const it of g.list) n.add(it.planId);
              return n;
            });
            notes.push(`已移除客服「${g.name ?? name}」及其 ${g.list.length} 条`);
          } else {
            setDayByPlan((m) => {
              const n = { ...m };
              for (const it of g.list) n[it.planId] = op.days;
              return n;
            });
            notes.push(`「${g.name ?? name}」名下 ${g.list.length} 条时效改为 ${op.days} 天`);
          }
          continue;
        }
        if (op.action === 'set_benefit') {
          setBenefit(op.text);
          notes.push(op.text.trim() ? `本批福利设为「${op.text}」` : '已撤掉本批福利');
          continue;
        }
        if (op.action === 'set_expiry' && !op.patient) {
          setExpiresInDays(op.days);
          notes.push(`整批时效改为 ${op.days} 天`);
          continue;
        }
        // 以下都是"针对某个患者"
        const who = op.action === 'set_expiry' ? op.patient! : op.patient;
        const hit = findPatients(who);
        if (hit.length === 0) {
          notes.push(`确认单里没有「${who}」,跳过`);
          continue;
        }
        if (hit.length > 1) {
          // ⛔ 同名时不猜:猜错等于把不该动的人动了,而主管看不出来
          notes.push(`有 ${hit.length} 位叫「${who}」,没有执行 —— 请用病历号指定`);
          continue;
        }
        const it = hit[0]!;
        if (op.action === 'remove_patient') {
          setDropped((sset) => new Set(sset).add(it.planId));
          notes.push(`已从本批移除「${it.patientName ?? who}」`);
        } else if (op.action === 'move_patient') {
          const to = findAgent(op.toAgent);
          if (!to) {
            notes.push(`没找到客服「${op.toAgent}」,「${who}」未改派`);
            continue;
          }
          setMoveByPlan((m) => ({ ...m, [it.planId]: to.userId }));
          notes.push(`「${it.patientName ?? who}」改派给「${to.name ?? op.toAgent}」`);
        } else {
          setDayByPlan((m) => ({ ...m, [it.planId]: op.days }));
          notes.push(`「${it.patientName ?? who}」时效改为 ${op.days} 天`);
        }
      }
    }
    if (notes.length) onEditApplied?.(`确认单已更新:${notes.join(';')}。`);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [edits]);

  /// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用
  const rootRef = useRef<HTMLDivElement>(null);
  useEffect(() => {
    let el = rootRef.current?.parentElement ?? null;
    while (el) {
      const oy = getComputedStyle(el).overflowY;
      if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) break;
      el = el.parentElement;
    }
    scrollerRef.current = el;
  }, []);

  return (
    <Card
      ref={rootRef}
      onDragOver={onDragOverAnywhere}
      onDragEnd={stopAutoScroll}
      onDrop={stopAutoScroll}
      className="overflow-hidden border-brand-100 text-[12px] shadow-sm"
    >
      {/* ① 汇总 */}
      <div className="flex items-center gap-2 border-b border-slate-100 bg-brand-50/50 px-3 py-2">
        <Users className="h-3.5 w-3.5 flex-none text-brand-600" />
        <span className="font-semibold text-slate-800">
          拟分 {items.length} 人 · {groups.length} 位客服
        </span>
        {dropped.size > 0 && (
          <Badge variant="secondary" className="text-[10px] font-normal">
            已移除 {dropped.size}
          </Badge>
        )}
        {sheet.unplaced > 0 && (
          <Badge variant="secondary" className="bg-amber-100 text-[10px] font-normal text-amber-700">
            {sheet.unplaced} 人分不下去
          </Badge>
        )}
        {/* 福利:有才显示。⚠️ 必须看得见 —— 它会进这批人的话术,主管点确认前有权知道说了什么 */}
        {benefit.trim() && (
          <Badge
            variant="secondary"
            className="max-w-[40%] truncate bg-emerald-50 text-[10px] font-normal text-emerald-700"
            title={benefit}
          >
            福利 · {benefit}
          </Badge>
        )}
        {/* ⭐ 批次时效放**右上角**:它是"这一整批"的设置,和左边"这一整批多少人"同一层级,
            放在底部微调区会让人以为它跟下面的逐条时效是一回事。
            ⚠️ 文案是「N 天**后自动退回**」而不是光一个「时效」——
            主管要知道到期会发生什么(单子回池),不然这个数对他没有意义。 */}
        <div className="ml-auto flex flex-none items-center gap-1 text-[10.5px] text-slate-500">
          <DaySelect value={expiresInDays} disabled={readOnly} onChange={setExpiresInDays} />
          <span>后自动退回</span>
        </div>
      </div>

      {/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
      <CardContent className="p-0">
        <div className="divide-y divide-slate-50">
          {groups.map((g) => {
            const isOpen = open.has(g.userId);
            return (
              <div
                key={g.userId}
                // ⭐ 整个客服块都是投放区(不只是标题行):展开后列表很长,
                //   拖到中间松手是最自然的动作,只认标题行会让人反复试
                onDragOver={(e) => {
                  if (readOnly) return;
                  e.preventDefault();
                  setDragOver(g.userId);
                }}
                onDragLeave={() => setDragOver((u) => (u === g.userId ? null : u))}
                onDrop={(e) => {
                  e.preventDefault();
                  setDragOver(null);
                  const planId = e.dataTransfer.getData('text/plan-id');
                  if (planId) setMoveByPlan((m) => ({ ...m, [planId]: g.userId }));
                }}
                className={cn(dragOver === g.userId && 'bg-brand-50/60 ring-1 ring-inset ring-brand-300')}
              >
                <div className="flex w-full items-center gap-2 px-3 py-2 hover:bg-slate-50">
                  <button
                    type="button"
                    onClick={() =>
                      setOpen((s) => {
                        const n = new Set(s);
                        if (n.has(g.userId)) n.delete(g.userId);
                        else n.add(g.userId);
                        return n;
                      })
                    }
                    className="flex min-w-0 flex-1 items-center gap-2 text-left"
                  >
                    <ChevronRight
                      className={cn(
                        'h-3 w-3 flex-none text-slate-400 transition-transform',
                        isOpen && 'rotate-90',
                      )}
                    />
                    {/* 姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,卡片会撑爆 */}
                    <span className="truncate font-medium text-slate-700">
                      {g.name ?? `#${g.userId.slice(0, 8)}`}
                    </span>
                    <span className="flex-none tabular-nums text-slate-500">
                      {g.list.length} 条
                      <span className="ml-1 text-[10.5px] text-slate-400">
                        (在手 {g.inHandBefore} → {g.inHandBefore + g.list.length})
                      </span>
                    </span>
                  </button>
                  {!readOnly && (
                    <Button
                      variant="ghost"
                      size="icon"
                      title="把这位客服移出本批(他名下的条目一并移除)"
                      onClick={() => setPendingRemove(g.userId)}
                      className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
                    >
                      <X className="h-3.5 w-3.5" />
                    </Button>
                  )}
                </div>

                {isOpen && (
                  <ul className="space-y-px bg-slate-50/60 px-2 pb-2">
                    {g.list.map((i) => (
                      <li
                        key={i.planId}
                        draggable={!readOnly}
                        onDragStart={(e) => {
                          e.dataTransfer.setData('text/plan-id', i.planId);
                          e.dataTransfer.effectAllowed = 'move';
                        }}
                        className={cn(
                          'flex items-center gap-1.5 rounded px-1.5 py-1 hover:bg-white',
                          !readOnly && 'cursor-grab active:cursor-grabbing',
                        )}
                      >
                        {!readOnly && (
                          <GripVertical className="h-3 w-3 flex-none text-slate-300" />
                        )}
                        {/* ⭐ 姓名优先;宿主没给姓名才退回 id 前缀 */}
                        <span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
                          {i.patientName ?? `#${i.patientId.slice(0, 8)}`}
                          {i.medicalRecordNumber && (
                            <span className="ml-1 font-mono text-[10px] text-slate-400">
                              {i.medicalRecordNumber}
                            </span>
                          )}
                          {/* 性别·年龄 —— 与患者详情卡片同一种写法(formatGender 归一宿主的各种取值) */}
                          {(i.gender || i.age != null) && (
                            <span className="ml-1 text-[10px] tabular-nums text-slate-400">
                              {formatGender(i.gender)}·{i.age ?? '?'} 岁
                            </span>
                          )}
                        </span>
                        {/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。
                            没标的就是"不是他的老客户",反而更好读。 */}
                        {i.assignStrategy === AssignStrategy.DEDICATED && !i.moved && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-brand-50 px-1 text-[10px] font-normal text-brand-700"
                          >
                            专属
                          </Badge>
                        )}
                        {i.moved && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-violet-50 px-1 text-[10px] font-normal text-violet-700"
                          >
                            指定
                          </Badge>
                        )}
                        {i.selectionMode === 'explore' && (
                          <Badge
                            variant="secondary"
                            className="flex-none bg-violet-100 px-1 text-[10px] font-normal text-violet-600"
                          >
                            探索
                          </Badge>
                        )}
                        {/* ⭐ 时效**逐条**:落库时写进这条 plan 的 assignment_expires_at */}
                        <DaySelect
                          compact
                          value={i.days}
                          disabled={readOnly}
                          onChange={(d) => setDayByPlan((m) => ({ ...m, [i.planId]: d }))}
                        />
                        {!readOnly && (
                          <Button
                            variant="ghost"
                            size="icon"
                            title="本批不分这个人"
                            onClick={() => setDropped((s) => new Set(s).add(i.planId))}
                            className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600"
                          >
                            <X className="h-3 w-3" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            );
          })}
        </div>
      </CardContent>

      {/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
          ⚠️ 措辞不能写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
          (水位法没轮到)或被精调成 0 名额。 */}
      {sheet.skippedAgents.length > 0 && (
        <div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">
          本批未分到:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
        </div>
      )}

      {/* ⛔ 这里原来有一块「人数 本批 340 人 / 要改说『这批 680 人』/ 时效为首次默认…」——
          撤掉(2026-08-03 走查)。人数是**只读**的,而只读信息不该占一块跟可操作区一样重的位置;
          它想说的两件事都已经有更好的落点:
            · 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
            · 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */}

      {/* ④ 确认 */}
      <div className="border-t border-slate-100 px-3 py-2">
        {error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
        {cancelled && !revoked ? (
          // 助手撤的:本地没有三个数,只说状态 —— 具体数字助手在消息里已经原话转述过
          <span className="text-[11.5px] font-medium text-slate-500">这批已撤销</span>
        ) : done ? (
          <div className="flex items-center gap-2">
            <span className="flex items-center gap-1.5 text-[11.5px] font-medium text-emerald-700">
              <Check className="h-3.5 w-3.5" />
              已分配
              {/* ⛔ 不显示批次号:`#9a9fc222` 是 uuid 前 8 位,对主管没有任何意义
                  (他不会拿它去查什么),只是占位置 + 制造"我是不是该记下来"的负担。
                  ⚠️ **模型那边照旧拿得到完整 id** —— 它走 list_assignment_batches,
                  与界面显不显示无关。 */}
            </span>
            {revoked ? (
              // 撤销结果**如实三个数**,⛔ 不能只说"已撤销":客服打开过的单收不回来,
              // 只报一个数会让主管以为整批都回来了
              <span className="text-[11px] text-slate-500">
                已撤销 {revoked.revoked} 条
                {revoked.skippedTouched > 0 && ` · ${revoked.skippedTouched} 条客服已看过,未收回`}
                {revoked.alreadyReleased > 0 && ` · ${revoked.alreadyReleased} 条本就不在手上`}
              </span>
            ) : (
              canRevoke && (
                <Button
                  variant="ghost"
                  size="sm"
                  disabled={revoking}
                  onClick={() => setConfirmRevoke(true)}
                  className="h-6 gap-1 px-2 text-[11px] text-slate-500 hover:text-rose-600"
                >
                  {revoking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Undo2 className="h-3 w-3" />}
                  撤销这批
                </Button>
              )
            )}
          </div>
        ) : (
          <Can
            perm={Permission.PLAN_DISPATCH}
            fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
          >
            <Button
              disabled={submitting || items.length === 0}
              onClick={() => void confirm()}
              className="w-full gap-1.5"
            >
              {submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
              确认分配 {items.length} 条
            </Button>
          </Can>
        )}
      </div>

      {/* 撤销整批前的二次确认。
          ⚠️ 措辞必须先说「**客服已经打开过的收不回来**」—— 撤销几乎总是部分成功,
          主管以为"点了就全回来了"的话,他会停止补救(比如不再逐条去打招呼),
          而那几条其实还挂在别人手上。 */}
      <AlertDialog open={confirmRevoke} onOpenChange={(o) => !o && setConfirmRevoke(false)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>撤销这批分配?</AlertDialogTitle>
            <AlertDialogDescription>
              这批 {items.length} 条会退回召回池。
              <span className="font-medium text-slate-700">
                客服已经打开过的单不会被收回
              </span>
              —— 他可能已经联系了患者,收回等于把那通电话抹掉。撤销窗口是确认后{' '}
              {REVOKE_WINDOW_MINUTES} 分钟;过了就走「客服退回」逐条处理。
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={revoking}>不撤了</AlertDialogCancel>
            <Button disabled={revoking} onClick={() => void doRevoke()} className="gap-1.5">
              {revoking && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
              确认撤销
            </Button>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* 移除客服前的二次确认 —— ⚠️ 必须说清"他名下的条目会一起移出",
          否则主管以为只是把人从名单上划掉,回头发现少了 20 个患者 */}
      <AlertDialog open={pendingRemove != null} onOpenChange={(o) => !o && setPendingRemove(null)}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>
              把「{removingGroup?.name ?? removingGroup?.userId.slice(0, 8)}」移出本批?
            </AlertDialogTitle>
            <AlertDialogDescription>
              他名下的 {removingGroup?.list.length ?? 0} 条会
              <span className="font-medium text-slate-700">一并移出本批</span>
              (患者留在池子里,下批再说)。想把这些人交给别人,先把条目拖过去,
              或者回对话说一句让助手重出一版。
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>取消</AlertDialogCancel>
            <AlertDialogAction onClick={() => pendingRemove && removeAgent(pendingRemove)}>
              移出本批
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </Card>
  );
}
