'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react';
import {
  ASSIGNMENT_EXPIRES_DAYS_DEFAULT,
  ASSIGNMENT_EXPIRES_DAYS_PRESETS,
  POTENTIAL_TREATMENT_CARD_LABEL,
  Permission,
  TEMPERATURE_META,
  type RearrangeDraft,
  type RearrangeEditOp,
  type RearrangeItem,
  type TemperatureValue,
} from '@pac/types';
import { Can } from '@/components/can';
import { rearrangeApi } from '@/components/plans/rearrange-api';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CopyableName, DaySelect } from './sheet-primitives';
import { cn, formatGender } from '@/lib/utils';
import type { DraftState } from './chat-blocks';

/**
 * 调整单 —— 调整各客服**在手**的单。
 *
 * ═══ 为什么是独立组件,而**外形必须跟确认单一模一样** ═══════════════
 * 产品 2026-08-20 定的两句话,缺一不可:
 *   ① 「调整单组件跟确认单独立,不要污染」—— 语义没有交集:
 *      没有福利(这一版的人来自若干不同批次)、没有待分配(人已经在手上)、
 *      没有"本批多大"(人数不是算出来的)。缝进一个组件就要给一半控件配
 *      `if (mode === 'rearrange') return null`,而那一半迟早有人漏判。
 *   ② 「交互展示需跟确认单保持一致,不用二次学习成本」—— 主管的手只学一遍:
 *      同一个折叠层级、同一个拖拽载荷(`text/plan-id`)、同一个 ✕、
 *      同一个时限下拉、同一个「展开名单」、同一个整宽确认按钮。
 * ⇒ **结构照抄、内容各归各的**。共用的只有没有业务语义的呈现原件
 *   (`sheet-primitives` 的 CopyableName / DaySelect)—— 抄成两份必然漂,
 *   而漂了的表现就是"两张单子手感不一样",正是 ② 要防的那件事。
 */

type Draft = {
  /** planId → 换给谁 */
  moves: Record<string, string>;
  /** planId → 新时限天数 */
  expiry: Record<string, number>;
  /** 被移出(收回池子)的 */
  removed: Set<string>;
};

const EMPTY: Draft = { moves: {}, expiry: {}, removed: new Set() };

export function RearrangeSheet({
  operationId,
  sheet,
  state,
  edits,
  streaming,
  onEditApplied,
  onConfirmed,
  onCompose,
}: {
  operationId: string;
  sheet: RearrangeDraft;
  state: DraftState;
  edits?: { seq: number; ops: RearrangeEditOp[] }[];
  streaming?: boolean;
  /** 改完之后把**实际结果**回一句进对话(模型看不到卡片,不回它就会替系统撒谎) */
  onEditApplied?: (summary: string, modelText?: string) => void;
  onConfirmed?: (summary: string, modelFacts: string) => void;
  /** 兜底:拖点改覆盖不到的,把焦点落到输入框(⛔ 不预填任何话) */
  onCompose?: (draft: string) => void;
}) {
  const readOnly = state !== 'active';
  const superseded = state === 'superseded';

  const [draft, setDraft] = useState<Draft>(EMPTY);
  const [bodyOpen, setBodyOpen] = useState(false);
  const [open, setOpen] = useState<Set<string>>(new Set());
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [done, setDone] = useState<string | null>(null);
  /// 正被拖过的那一组 —— 高亮落点,主管才知道松手会掉到谁那儿
  const [dragOver, setDragOver] = useState<string | null>(null);

  /**
   * 拖到边缘自动滚 —— 与确认单**同一份实现**(逐行照抄,⛔ 别自己另写一套手感)。
   *
   * 🔴 没有它,名单一长主管就"够不着":要把患者拖给屏幕外的客服,得先松手、
   *   滚一段、再拖一次 —— 而松手那一下就已经落到别人头上了。
   * ⚠️ 滚的是**外层**那个滚动容器(从卡片往上找第一个真的能滚的祖先),
   *   ⛔ 不是卡片自己 —— 所以上面 CardContent 不能再套一层 overflow。
   */
  const rootRef = useRef<HTMLDivElement>(null);
  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; // 同方向已经在滚
    stopAutoScroll();
    const step = () => {
      sc.scrollTop += dir * 12;
      autoScroll.current = { dir, raf: requestAnimationFrame(step) };
    };
    autoScroll.current = { dir, raf: requestAnimationFrame(step) };
  };
  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;
  }, []);

  const nameOf = useMemo(
    () => new Map(sheet.agents.map((a) => [a.userId, a.name ?? a.userId])),
    [sheet.agents],
  );
  const zhAgent = (id: string) => nameOf.get(id) ?? `#${id.slice(0, 8)}`;
  const zhPatient = (planId: string) => {
    const it = sheet.items.find((x) => x.planId === planId);
    return it?.patientName ?? `#${planId.slice(0, 8)}`;
  };

  const ownerOf = useCallback(
    (it: RearrangeItem) => draft.moves[it.planId] ?? it.assigneeUserId,
    [draft.moves],
  );

  const live = useMemo(
    () => sheet.items.filter((i) => !draft.removed.has(i.planId)),
    [sheet.items, draft.removed],
  );

  /** 调整后每位客服手上有哪些 —— ⚠️ 空的客服也要留一行(他可能刚被搬空) */
  const groups = useMemo(() => {
    const m = new Map<string, RearrangeItem[]>();
    for (const a of sheet.agents) m.set(a.userId, []);
    for (const it of live) {
      const owner = ownerOf(it);
      if (!m.has(owner)) m.set(owner, []);
      m.get(owner)!.push(it);
    }
    return [...m.entries()].map(([userId, list]) => ({
      userId,
      list,
      before: sheet.agents.find((a) => a.userId === userId)?.inHand ?? 0,
    }));
  }, [live, ownerOf, sheet.agents]);

  const changed =
    Object.keys(draft.moves).length + Object.keys(draft.expiry).length + draft.removed.size;

  // ── 手工改动回一句进对话 —— 与确认单同一套(模型看不到卡片)──────
  const echoQueue = useRef<string[]>([]);
  const [echoSeq, setEchoSeq] = useState(0);
  const echo = useCallback((summary: string) => {
    echoQueue.current.push(summary);
    setEchoSeq((n) => n + 1);
  }, []);
  useEffect(() => {
    if (echoSeq === 0 || echoQueue.current.length === 0) return;
    const text = echoQueue.current.join('；');
    echoQueue.current = [];
    onEditApplied?.(text);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [echoSeq]);

  // ── 助手的语义指令队列 ────────────────────────────────────
  /**
   * ⭐ 与确认单同一条路:助手推**语义**指令,由这里拿手上这份名单去匹配执行。
   *   ⛔ 别让服务端解析姓名 —— 名单只有界面手里有(它含几百个 planId,不进模型上下文)。
   * ⚠️ 按 `seq` 只执行新增的那一批:主管连着说两句时两批都要生效。
   */
  const appliedSeq = useRef(0);
  useEffect(() => {
    if (readOnly || !edits?.length) return;
    const fresh = edits.filter((e) => e.seq > appliedSeq.current);
    if (fresh.length === 0) return;
    appliedSeq.current = Math.max(...fresh.map((e) => e.seq));

    const notes: string[] = [];
    setDraft((prev) => {
      const next: Draft = {
        moves: { ...prev.moves },
        expiry: { ...prev.expiry },
        removed: new Set(prev.removed),
      };
      for (const { ops } of fresh) {
        for (const op of ops) {
          const picked = selectItems(sheet, next, op);
          if (picked.length === 0) {
            notes.push(`「${describeSelect(op)}」没找到对应的单,跳过`);
            continue;
          }
          if (op.action === 'remove') {
            for (const it of picked) next.removed.add(it.planId);
            notes.push(`移出 ${picked.length} 条`);
          } else if (op.action === 'set_expiry' && op.days != null) {
            for (const it of picked) next.expiry[it.planId] = op.days;
            notes.push(`${picked.length} 条时限改成 ${op.days} 天`);
          } else if (op.action === 'move') {
            const pool = (op.to?.agents ?? [])
              .map((n) => sheet.agents.find((a) => (a.name ?? '') === n.trim())?.userId)
              .filter((v): v is string => !!v);
            const candidates = pool.length > 0 ? pool : sheet.agents.map((a) => a.userId);
            const spread = balance(picked, candidates, sheet, next);
            if (spread.length === 0) {
              notes.push('没有可以接手的客服,跳过');
              continue;
            }
            for (const [planId, to] of spread) next.moves[planId] = to;
            const who = [...new Set(spread.map(([, to]) => zhAgent(to)))];
            notes.push(`${spread.length} 条换给了 ${who.join('、')}`);
          }
        }
      }
      return next;
    });
    if (notes.length > 0) echo(notes.join(';') + '。');
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [edits, readOnly]);

  // ── 确认 ─────────────────────────────────────────────────
  const confirm = async () => {
    if (submitting || readOnly || changed === 0) return;
    setSubmitting(true);
    setError(null);
    try {
      const r = await rearrangeApi.submit({
        operationId,
        clinicId: sheet.clinicId,
        moves: Object.entries(draft.moves).map(([planId, toUserId]) => ({ planId, toUserId })),
        expiry: Object.entries(draft.expiry).map(([planId, expiresInDays]) => ({
          planId,
          expiresInDays,
        })),
        removes: [...draft.removed].map((planId) => ({ planId })),
      });
      setDone(r.note);
      // 团队状态 / 批次 / 矩阵都跟着重拉 —— 在手量当场变了
      useAssignmentSyncStore.getState().notifyChanged();
      onConfirmed?.(
        r.note,
        JSON.stringify({
          调整已生效: !r.duplicate,
          改派: r.moved,
          改时限: r.expiryChanged,
          移出: r.removed,
          没落上的条数: r.skipped.length,
          结果原话: r.note,
        }),
      );
    } catch (e) {
      setError(e instanceof Error ? e.message : '调整没有成功');
    } finally {
      setSubmitting(false);
    }
  };

  const scopeText =
    sheet.scope === 'all'
      ? '全队在手'
      : `${sheet.scopedAgentUserIds.map(zhAgent).join('、')} 手上的`;

  return (
    <Card
      ref={rootRef}
      onDragOver={onDragOverAnywhere}
      onDragEnd={stopAutoScroll}
      onDrop={stopAutoScroll}
      className={
        superseded
          ? 'overflow-hidden border-slate-200 text-[12px] opacity-60 shadow-none'
          : 'overflow-hidden border-brand-100 text-[12px] shadow-sm'
      }
    >
      {/* ⭐ 作废横幅钉在**卡片顶部** —— 单子可以很长,底下那句主管看不到 */}
      {superseded && (
        <div className="border-b border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] font-medium text-amber-800">
          这一版已被新的一版取代，不能确认 —— 当前有效的是下面最新的那一张
        </div>
      )}

      {/* ① 汇总 —— 与确认单同一行样式(必须 flex-wrap,窄窗会装不下) */}
      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b 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">
          <span className="whitespace-nowrap">{scopeText} {live.length} 条</span>
          <span className="whitespace-nowrap"> · {groups.filter((g) => g.list.length > 0).length} 位客服</span>
        </span>
        {sheet.overdueTotal > 0 && (
          <Badge
            variant="secondary"
            className="flex-none whitespace-nowrap bg-amber-100 text-[10px] font-normal text-amber-700"
          >
            超期 {sheet.overdueTotal}
          </Badge>
        )}
        {changed > 0 && (
          <Badge variant="secondary" className="flex-none whitespace-nowrap text-[10px] font-normal">
            已改 {changed} 条
          </Badge>
        )}
        {draft.removed.size > 0 && (
          <Badge variant="secondary" className="flex-none whitespace-nowrap text-[10px] font-normal">
            已移出 {draft.removed.size}
          </Badge>
        )}
        {/*
          🔴 **右上角⛔ 不放"一次改全部"的时限控件**(产品 2026-08-20 走查拿掉)。
          确认单那个位置放的是**这一整批的时限** —— 那一批还没发出去,它是个默认值,
          改它是无害的。而这一版的条目**已经在客服手上、各有各的到期时刻**:
          一次改全部等于把 286 条的截止日一起推平,而卡片上「已改 N 条」当场变成 290,
          主管完全看不出自己动了什么(实测就是这么显示的)。
          ⇒ 时限只留**逐条**那一个。要成批改,让他说一句(助手走 set_expiry,选谁由他定)。
        */}
      </div>

      {/* ①.1 这张单能怎么改 —— 与确认单同一段位置、同一种写法 */}
      {!readOnly && (
        <div className="bg-slate-50/70 px-3 py-2 text-[11.5px] leading-relaxed text-slate-500">
          展开客服看名单 · <span className="text-slate-700">拖动患者</span>换给别人 ·
          点 <span className="text-slate-700">×</span> 移出(收回池子) · 每行可单独改时限。
          {onCompose && (
            <>
              {' '}想自定义谁换给谁等更多操作，直接{' '}
              <button
                type="button"
                onClick={() => onCompose('')}
                className="rounded border border-dashed border-slate-300 px-1.5 py-0.5 text-[11px] leading-4 text-slate-500 transition-colors hover:border-brand-400 hover:text-brand-700"
              >
                我自己说
              </button>
            </>
          )}
        </div>
      )}

      {/* ⛔ 静默截断 = 让主管以为"这就是全部" */}
      {sheet.truncated > 0 && (
        <div className="border-b border-amber-100 bg-amber-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-amber-900">
          还有 <span className="font-medium">{sheet.truncated}</span> 条没装进这一版（单次最多
          500 条）—— 这一版调完再出一版接着调。
        </div>
      )}

      {/*
        ② 名单(收起体)—— 与确认单一样,只有名单进得来。
        ⚠️ 与确认单**逐类对齐**:`p-0` + `divide-y`,⛔ 不给它自己的 max-h/overflow。
        🔴 内嵌滚动会把拖拽自动滚动打断 —— `scrollerRef` 找的是**外层**那个滚动容器,
          名单自己再滚一层,拖到边缘时滚的是外层、而目标客服在内层看不见的地方,
          那正是"放置不到不可见的地方"这个投诉的成因。
      */}
      {bodyOpen && (
        <CardContent className="p-0">
          <div className="divide-y">
          {groups.map((g) => {
            const isOpen = open.has(g.userId);
            const after = g.list.length;
            const need = Math.max(1, Math.ceil(after / sheet.dailyCalls));
            const overdue = g.list.filter((r) => r.overdueDays != null).length;
            /**
             * 🔴 **放置区是整组,⛔ 不只是抬头那一行**(产品 2026-08-20 走查)。
             * 展开之后患者名单那一片在视觉上就属于这位客服,主管自然会往那儿放 ——
             * 只把抬头做成落点,他拖到名单里松手什么都不会发生,而且没有任何提示。
             * ⇒ `onDragOver` / `onDrop` 挂在**外层**,名单是它的子节点,冒泡上来照样命中。
             */
            return (
              <div
                key={g.userId}
                onDragOver={(e) => {
                  if (readOnly) return;
                  e.preventDefault();
                  e.dataTransfer.dropEffect = 'move';
                  setDragOver(g.userId);
                }}
                onDragLeave={() => setDragOver((u) => (u === g.userId ? null : u))}
                onDrop={(e) => {
                  if (readOnly) return;
                  e.preventDefault();
                  setDragOver(null);
                  const planId = e.dataTransfer.getData('text/plan-id');
                  if (!planId) return;
                  setDraft((p) => ({ ...p, moves: { ...p.moves, [planId]: g.userId } }));
                  echo(`调整单已更新:「${zhPatient(planId)}」换给了${zhAgent(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;
                      })
                    }
                    aria-label={isOpen ? '收起' : '展开'}
                    className="flex-none"
                  >
                    <ChevronRight
                      className={cn(
                        'h-3 w-3 text-slate-400 transition-transform',
                        isOpen && 'rotate-90',
                      )}
                    />
                  </button>
                  {/* ⚠️ 姓名是可点击复制的 <button>,⛔ 不能套进展开那个 button(button 嵌 button 非法) */}
                  <span className="min-w-0 truncate font-medium text-slate-700">
                    <CopyableName name={zhAgent(g.userId)} />
                  </span>
                  <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 text-left"
                  >
                    <span className="flex-none tabular-nums text-slate-500">
                      {after} 条
                      {/* ⚠️ 只在**真的变了**时才写前后 —— 没变的行写「15 → 15」是噪音 */}
                      {after !== g.before && (
                        <span className="ml-1 text-[10.5px] text-slate-400">
                          (在手 {g.before} → {after})
                        </span>
                      )}
                    </span>
                  </button>
                  {overdue > 0 && (
                    <span className="flex-none tabular-nums text-[10.5px] text-amber-700">
                      超期 {overdue}
                    </span>
                  )}
                  <span
                    className="flex-none tabular-nums text-[10.5px] text-slate-400"
                    title={`按每人每天 ${sheet.dailyCalls} 通算：手上共 ${after} 条 ≈ ${need} 天`}
                  >
                    约 {need} 天
                  </span>
                  {!readOnly && after > 0 && (
                    <Button
                      variant="ghost"
                      size="icon"
                      title="把他手上这些全部移出(收回池子)"
                      onClick={() => {
                        const ids = g.list.map((x) => x.planId);
                        setDraft((p) => {
                          const n = new Set(p.removed);
                          for (const id of ids) n.add(id);
                          return { ...p, removed: n };
                        });
                        echo(`调整单已更新:${zhAgent(g.userId)}手上 ${ids.length} 条全部移出。`);
                      }}
                      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.length === 0 && (
                      <li className="px-1.5 py-1 text-[11.5px] text-slate-400">
                        这一版调完他手上没有了
                      </li>
                    )}
                    {g.list.map((i) => (
                      <PatientRow
                        key={i.planId}
                        item={i}
                        readOnly={readOnly}
                        days={draft.expiry[i.planId] ?? null}
                        moved={draft.moves[i.planId] != null}
                        dedicated={i.dedicatedCsUserId === i.assigneeUserId}
                        onExpiry={(d) => {
                          setDraft((p) => ({ ...p, expiry: { ...p.expiry, [i.planId]: d } }));
                          echo(
                            `调整单已更新:「${zhPatient(i.planId)}」这条时限单独改成 ${d} 天。`,
                          );
                        }}
                        onRemove={() => {
                          setDraft((p) => {
                            const n = new Set(p.removed);
                            n.add(i.planId);
                            return { ...p, removed: n };
                          });
                          echo(`调整单已更新:「${zhPatient(i.planId)}」移出,收回池子。`);
                        }}
                      />
                    ))}
                  </ul>
                )}
              </div>
            );
          })}
          </div>
        </CardContent>
      )}

      {/* ②.5 移出的那些 —— 可撤回。⚠️ ⛔ 不进收起体:他刚点的 ✕ 必须看得见 */}
      {draft.removed.size > 0 && !readOnly && (
        <div className="border-t bg-slate-50 px-3 py-2">
          <p className="mb-1 text-[11px] text-slate-500">
            移出 {draft.removed.size} 条 —— 确认后回到召回池，谁都不挂着。点一下撤回：
          </p>
          <div className="flex flex-wrap gap-1.5">
            {[...draft.removed].map((planId) => (
              <button
                key={planId}
                type="button"
                onClick={() => {
                  setDraft((p) => {
                    const n = new Set(p.removed);
                    n.delete(planId);
                    return { ...p, removed: n };
                  });
                  echo(`调整单已更新:「${zhPatient(planId)}」撤回移出。`);
                }}
                className="rounded border bg-white px-1.5 py-0.5 text-[11px] text-slate-600 transition-colors hover:border-brand-400 hover:text-brand-700"
              >
                {zhPatient(planId)} ↩
              </button>
            ))}
          </div>
        </div>
      )}

      {/* ③ 展开 / 收起名单 —— 整宽一条,紧贴确认按钮上方(位置与确认单一致) */}
      <button
        type="button"
        onClick={() => setBodyOpen((v) => !v)}
        aria-expanded={bodyOpen}
        className="flex w-full items-center justify-center gap-1 border-t bg-slate-50/70 py-1.5 text-[11.5px] text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700"
      >
        <ChevronDown className={cn('h-3.5 w-3.5 transition-transform', bodyOpen && 'rotate-180')} />
        {bodyOpen
          ? '收起名单'
          : `展开名单（${groups.filter((g) => g.list.length > 0).length} 位客服 · ${live.length} 条）`}
      </button>

      {/* ④ 确认 */}
      <div className="border-t px-3 py-2">
        {error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
        {done ? (
          <p className="text-[11.5px] text-slate-700">{done}</p>
        ) : superseded ? (
          <span className="text-[11.5px] font-medium text-amber-700">
            这一版已被新的一版取代 —— 请看下面最新的那张
          </span>
        ) : readOnly ? (
          <span className="text-[11.5px] text-slate-400">这一版已经结束，只作回顾</span>
        ) : (
          <Can
            perm={Permission.PLAN_DISPATCH}
            fallback={<p className="text-[11px] text-slate-400">你没有分配权限,请让主管确认</p>}
          >
            <Button
              disabled={submitting || changed === 0 || streaming}
              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" />
              )}
              {changed === 0 ? '还没有任何改动' : `确认调整 ${changed} 条`}
            </Button>
          </Can>
        )}
      </div>
    </Card>
  );
}

// ─────────────────────────────────────────────────────────
// 一行患者 —— 字段顺序与确认单逐列对齐(姓名 · 病历号 · 性别年龄 · 医生 · 角标 · 时限 · ✕)
// ─────────────────────────────────────────────────────────

function PatientRow({
  item,
  days,
  moved,
  dedicated,
  readOnly,
  onExpiry,
  onRemove,
}: {
  item: RearrangeItem;
  days: number | null;
  moved: boolean;
  /** 这条现在正在**这位患者的专属客服**手上 —— 拖走就是拆一段老关系 */
  dedicated: boolean;
  readOnly: boolean;
  onExpiry: (days: number) => void;
  onRemove: () => void;
}) {
  const treat = item.potentialTreatment
    ? (POTENTIAL_TREATMENT_CARD_LABEL[item.potentialTreatment] ?? item.potentialTreatment)
    : null;
  const temp = item.temperature
    ? TEMPERATURE_META[item.temperature as TemperatureValue]?.zh
    : null;
  const snoozed = item.snoozedUntil != null && new Date(item.snoozedUntil) > new Date();
  /** 还剩几天 —— 已超期 / 没有时限时为 null(那两种由左边各自的角标说清) */
  const left =
    item.expiresAt != null && item.overdueDays == null
      ? Math.max(1, Math.ceil((new Date(item.expiresAt).getTime() - Date.now()) / 86_400_000))
      : null;

  return (
    <li
      draggable={!readOnly}
      onDragStart={(e) => {
        e.dataTransfer.setData('text/plan-id', item.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" />}
      <span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
        <CopyableName name={item.patientName ?? `#${item.patientId.slice(0, 8)}`} />
        {item.medicalRecordNumber && (
          <span className="ml-1 font-mono text-[10px] text-slate-400">
            {item.medicalRecordNumber}
          </span>
        )}
        {(item.gender || item.age != null) && (
          <span className="ml-1 text-[10px] tabular-nums text-slate-400">
            {formatGender(item.gender)}·{item.age ?? '?'} 岁
          </span>
        )}
      </span>

      {/* 这条是什么机会 / 患者末诊落在哪一档 —— 调整单独有(确认单那一版整格同一档,不用逐条写) */}
      {treat && <span className="flex-none text-[10px] text-slate-400">{treat}</span>}
      {temp && <span className="flex-none text-[10px] text-slate-400">{temp}</span>}

      {/* 医生 —— 与确认单同一种写法。⚠️ 调整单只给「最近就诊」那一列(口径见 schema 里的红字) */}
      {item.lastVisitDoctor && (
        <span className="flex-none text-[10px] text-slate-400">主治 {item.lastVisitDoctor}</span>
      )}

      {/* 🔴 约了下次回访的要标出来:挪走 = 把已经约好的人从原客服手里拿走 */}
      {snoozed && (
        <Badge
          variant="secondary"
          className="flex-none bg-emerald-50 px-1 text-[10px] font-normal text-emerald-700"
        >
          已约下次
        </Badge>
      )}
      {item.overdueDays != null ? (
        <Badge
          variant="secondary"
          className="flex-none whitespace-nowrap bg-amber-50 px-1 text-[10px] font-normal tabular-nums text-amber-700"
        >
          超期 {item.overdueDays} 天
        </Badge>
      ) : (
        left != null && (
          <span className="flex-none whitespace-nowrap text-[10px] tabular-nums text-slate-400">
            剩 {left} 天
          </span>
        )
      )}
      {/*
        ⛔ 只标「专属」,不标"不是他的老客户" —— 没标的就是不是,反而更好读(同确认单)。
        ⚠️ 判据是「**现在**正在他的专属客服手上」,换过人之后这条就不成立了 ——
          所以 `!moved`。换走之后改标「已换人」,并在 title 里补一句他拆掉了什么。
      */}
      {dedicated && !moved && (
        <Badge
          variant="secondary"
          className="flex-none bg-brand-50 px-1 text-[10px] font-normal text-brand-700"
        >
          专属
        </Badge>
      )}
      {/* ⚠️ 与确认单的「指定」同一支紫 —— 都表示"主管手工点过这一条" */}
      {moved && (
        <Badge
          variant="secondary"
          className="flex-none bg-violet-50 px-1 text-[10px] font-normal text-violet-700"
          title={dedicated ? '原来在这位患者的专属客服手上' : undefined}
        >
          已换人
        </Badge>
      )}
      {item.benefit && (
        <Badge
          variant="secondary"
          className="max-w-[22%] flex-none truncate bg-slate-100 px-1 text-[10px] font-normal text-slate-500"
          title={`这条所属的那一批挂着福利：${item.benefit}`}
        >
          福利 · {item.benefit}
        </Badge>
      )}

      {/*
        ⭐ 时限**逐条**:落库写进这条 plan 的 assignment_expires_at。
        🔴 未改动时显示的是**这条还剩几天**,⛔ 不是一个凭空的默认值 ——
          确认单那边整批同一个时限,拿默认值显示是对的;这一版的条目来自若干不同批次,
          各有各的到期时刻,显示成同一个数就是在说谎。
        ⚠️ 剩余天数落在 1~7 之外(已超期、或当初设得很长)时下拉装不下,回落到系统默认;
          真实状态由左边那个「超期 N 天 / 剩 N 天」负责说清 —— 那一处永远是准的。
      */}
      <DaySelect
        compact
        value={days ?? (left != null && ASSIGNMENT_EXPIRES_DAYS_PRESETS.includes(left) ? left : ASSIGNMENT_EXPIRES_DAYS_DEFAULT)}
        disabled={readOnly}
        onChange={onExpiry}
      />
      {!readOnly && (
        <Button
          variant="ghost"
          size="icon"
          title="移出(收回池子)"
          onClick={onRemove}
          className="h-5 w-5 flex-none text-slate-300 hover:text-rose-600"
        >
          <X className="h-3 w-3" />
        </Button>
      )}
    </li>
  );
}

// ─────────────────────────────────────────────────────────
// 助手指令 → 具体条目
// ─────────────────────────────────────────────────────────

/** 一条 `select` 落到哪些单上 —— ⚠️ 已经被移出的不再参与 */
function selectItems(sheet: RearrangeDraft, draft: Draft, op: RearrangeEditOp): RearrangeItem[] {
  const alive = sheet.items.filter((i) => !draft.removed.has(i.planId));
  const ownerOf = (i: RearrangeItem) => draft.moves[i.planId] ?? i.assigneeUserId;
  switch (op.select.group) {
    case 'all':
      return alive;
    case 'overdue':
      return alive.filter((i) => i.overdueDays != null);
    case 'agent': {
      const id = sheet.agents.find((a) => (a.name ?? '') === op.select.agent?.trim())?.userId;
      return id ? alive.filter((i) => ownerOf(i) === id) : [];
    }
    case 'patients': {
      const keys = (op.select.patients ?? []).map((s) => s.trim()).filter(Boolean);
      return alive.filter((i) =>
        keys.some((k) => i.patientName === k || i.medicalRecordNumber === k),
      );
    }
    default:
      return [];
  }
}

/** 「谁手上少先给谁」—— 与服务端落人同一个思路(水位法),⛔ 不随机 */
function balance(
  picked: RearrangeItem[],
  candidates: string[],
  sheet: RearrangeDraft,
  draft: Draft,
): Array<[string, string]> {
  const ownerOf = (i: RearrangeItem) => draft.moves[i.planId] ?? i.assigneeUserId;
  const load = new Map<string, number>();
  for (const c of candidates) load.set(c, 0);
  for (const i of sheet.items) {
    if (draft.removed.has(i.planId)) continue;
    const o = ownerOf(i);
    if (load.has(o)) load.set(o, load.get(o)! + 1);
  }
  const out: Array<[string, string]> = [];
  for (const it of picked) {
    const from = ownerOf(it);
    // ⚠️ 不能把人"换给他自己" —— 那不是一次改派
    const pool = candidates.filter((c) => c !== from);
    if (pool.length === 0) continue;
    // 同水位按 userId 打破平局 —— 确定性:同一张单每次算出来都一样
    pool.sort((x, y) => load.get(x)! - load.get(y)! || (x < y ? -1 : 1));
    const to = pool[0]!;
    load.set(from, Math.max(0, (load.get(from) ?? 1) - 1));
    load.set(to, (load.get(to) ?? 0) + 1);
    out.push([it.planId, to]);
  }
  return out;
}

/** 一条指令没匹配上时,回给主管/模型的那半句 —— 说清「你指的是哪些」 */
function describeSelect(op: RearrangeEditOp): string {
  switch (op.select.group) {
    case 'all':
      return '这一版全部';
    case 'overdue':
      return '已超期的';
    case 'agent':
      return `${op.select.agent ?? ''} 手上的`;
    case 'patients':
      return (op.select.patients ?? []).join('、');
    default:
      return '这些';
  }
}
