'use client';

import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { plansApi } from './plans-api';
import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react';
import {
  EXECUTION_OUTCOME_GROUP_META,
  EXECUTION_OUTCOME_META,
  PERSONA_TAG_FILTER_DIMS,
  Permission,
  type ExecutionOutcome,
  type ExecutionOutcomeGroup,
  type PlanListItem,
  type ReleaseReason,
  personaTagDimId,
  potentialTreatmentCardLabel,
} from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
import { actionTemplate } from '@/lib/action-url';
import { useHasPermission } from '@/hooks/use-permission';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { usePatientPicker, type PickerFilters } from './use-patient-picker';
import { ReleaseReasonDialog } from './release-reason-dialog';

/**
 * PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
 *
 * 吸收列表页核心要素(列表页将弃用):view tab / 搜索 / 排序 / 真实号码 /
 * ⭐筛选标签(治疗维度 / 属性维度…,同维 OR 跨维 AND,服务端 SQL;分层见 PERSONA_TAG_FILTER_DIMS)。
 * 不带 KPI 面板(按需求剔除)。点患者 → 路由切详情;本组件挂在 plans/layout(路由段之上),
 * 切患者不重挂、列表状态(筛选/滚动位置/已加载页)全保留。
 */

type View = PickerFilters['view'];
/**
 * '全部' tab 已下线(信息过载)。view='all' / 'pool' 的取数逻辑保留备用。
 *
 * 🔴 2026-08-10:**执行页只剩「我的」,召回池与「分配」整个撤掉**(产品定)。
 *
 * ── 演进 ────────────────────────────────────────────────────────
 * 2026-08(T16 原版):召回池只对主管可见 —— 那时主管和客服共用这一页,
 *   池子和分配只能挂在这儿。08-04 评审推翻了后半句,分配搬进了独立的主管工作台,
 *   这一页于是回到它唯一的职责:**把手上的任务打完**。
 * ⛔ 别再把池子/分配加回来:同一个动作两个入口,状态同步和归属口径都会跟着变复杂,
 *   而工作台那边的矩阵本来就是池子的视图(「分一批新的」)。
 *
 * ⚠️ 藏 tab **不等于**做到了 —— view 还有两条会自己滑到 pool 的路
 *   (下方的自动回落 effect、以及 /plans 落地页的兜底),必须一起堵,
 *   否则 tab 看不见但列表里躺着的还是整池,而且不会有任何报错。这两处都已改。
 */
const VIEW_TABS: Array<{ v: View; label: string }> = [{ v: 'mine', label: '我的' }];

/**
 * 左栏那一排筛选的可见性 —— 两个入口**分开控**(2026-08-04 产品走查先一起藏,
 * 随后单独放回「真」号码)。⛔ 不是删除,`PersonaTagFilter` 与 `phoneVerified`
 * 那条服务端谓词(plan.service.ts 的 `patientWhere.phoneVerified`)都一直留着。
 *
 * ⚠️ 藏的只是**入口**。`realPhoneOnly` / `tags` 两个 state 初值都是"不筛",
 *   所以藏起来之后请求里不会残留任何筛选条件 —— ⛔ 别改它们的初值,
 *   否则会出现"看不见的筛选"(列表少人而主管找不到原因)。
 */
/**
 * 「真」号码筛选(只看已核实的真实手机号)—— **隐藏**(2026-08-05 产品走查)。
 * ⚠️ 反复过两次:8-04 与标签筛选一起藏 → 8-05 单独放回 → 8-05 再次藏起。
 *   所以⛔ 别把这个开关连同按钮一起删掉,下次还要放回来。
 */
const PHONE_FILTER_VISIBLE: boolean = false;
/** 画像标签筛选 —— 仍隐藏 */
const TAG_FILTER_VISIBLE: boolean = false;

export function PatientPickerRail({
  currentPlanId,
  onNavigated,
}: {
  currentPlanId: string;
  /** 行点击跳转后回调(小屏抽屉模式用来收起) */
  onNavigated?: () => void;
}) {
  const router = useRouter();
  const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「退回」按钮门控(staff 也有,只能退自己的)

  // ⭐ 恒为 'mine' —— 执行页只剩这一个 view(见 VIEW_TABS)。
  //   ⚠️ setView 仍留着:'pool'/'all' 的取数逻辑没删,将来要放回 tab 时不用重接。
  const [view, setView] = useState<View>('mine');
  const [search, setSearch] = useState('');
  const [keyword, setKeyword] = useState(''); // 防抖后的值(真正上服务端)
  const composingRef = useRef(false); // 中文输入法组字中(true 时别拿拼音中间态"李石ming"去搜)
  // 排序固定优先级高→低(去掉可切换的排序控件后,顺序仍按优先级)
  const [sort] = useState<PickerFilters['sort']>('priority_desc');
  const [realPhoneOnly, setRealPhoneOnly] = useState(false);
  // 配了原始档案(宿主 VIEW_PATIENT)时,手机号在宿主档案页看 → 与身份卡手机号行同条件,
  // 隐藏"真手机号"筛选(PAC 侧为造数假号,按真伪筛选无意义)。
  const hasPatientArchive = !!actionTemplate('VIEW_PATIENT');
  const [clinics, setClinics] = useState<string[]>([]);
  const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value"
  /**
   * 待确认退回的那一条 —— ⭐ **退回必须先选原因**(T7),⛔ 不能点一下就走。
   * 原因分布是主管调整分配策略的输入;拿不到它,他只知道"退了 12 条",不知道该改什么。
   */
  const [pendingRelease, setPendingRelease] = useState<{ planId: string; name: string } | null>(null);
  const [recycling, setRecycling] = useState(false);

  const user = useAuthStore((st) => st.user);
  const clinicDict = user?.dictionary?.clinics; // 显示 plan 诊所名用(字典查表,非 scope)
  // 诊所名只在跨诊所权限下有信息量;口径同顶栏「N 个诊所」(visibleClinics)。
  // ⚠️ 用 `!== 1` 而非 `> 1`:字典未加载时会返回 0,按 `> 1` 会误隐藏集团级用户的诊所名。
  //   只在确知单诊所时隐藏,未知一律显示(理由同列表页 PlanCard)。
  const showClinic = visibleClinics(user).length !== 1;
  const clinicOptions = useMemo(() => visibleClinics(user), [user]);

  // 关键词防抖:手动排定,**不依赖 search 状态变化触发**。
  //   (若靠 useEffect([search]):组字结束时最终值"李石明"可能与组字中最后一次 onChange 同值 →
  //    React 不重渲染 → effect 不重跑 → 搜索永不发。这正是"中文搜不发请求"的坑。)
  //   规则:组字中(composingRef)onChange 不排;组字结束由 onCompositionEnd 直接排一次。
  const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const scheduleKeyword = (value: string) => {
    if (debounceRef.current) clearTimeout(debounceRef.current);
    debounceRef.current = setTimeout(() => setKeyword(value.trim()), 300);
  };
  useEffect(
    () => () => {
      if (debounceRef.current) clearTimeout(debounceRef.current);
    },
    [],
  );

  const filters: PickerFilters = useMemo(
    () => ({
      view,
      keyword: keyword || undefined,
      sort,
      phoneVerified: realPhoneOnly ? true : undefined,
      targetClinicIds: clinics.length ? clinics : undefined,
      personaTags: tags.size ? [...tags].join(',') : undefined,
      // ⛔ 初选的两轴(potentialTreatment / temperature)**不进这里** —— 见上方 cell 的注释。
      //   服务端仍支持这两个参数(plan.service.buildListWhere),只是前端不再自动带上。
    }),
    [view, keyword, sort, realPhoneOnly, clinics, tags],
  );
  const { items, total, loading, hasMore, error, loadMore, patchItem, removeItem } = usePatientPicker(filters);

  /**
   * 🔴 这里**曾经有一个「我的为空 → 自动回落召回池」**,2026-08-10 随召回池一起撤。
   *
   * 它是"藏入口"之外必须一起堵的那条路:tab 藏了、view 却被这段代码切成 pool,
   * 列表里躺着整池患者,而且不报任何错。现在执行页压根没有 pool 这个去处 ——
   * 「我的」空了就老老实实显示空态(任务由主管派发,自己不该去池子里捞人)。
   * ⛔ 别再加回来。
   */

  // 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。
  //   终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除,
  //   与认领/返池同款处理;all 视图仍显示全部 → 只改状态角标。
  const sync = usePlanSyncStore();
  useEffect(() => {
    if (!sync.planId) return;
    const terminal = sync.status === 'completed' || sync.status === 'abandoned';
    if (terminal && view !== 'all') {
      removeItem(sync.planId);
      return;
    }
    const patch: Partial<PlanListItem> = {};
    if (sync.status) patch.status = sync.status as PlanListItem['status'];
    if (sync.outcome) patch.lastOutcome = sync.outcome as ExecutionOutcome;
    if (Object.keys(patch).length) patchItem(sync.planId, patch);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sync.seq]);

  // ⭐ T16:认领入口已从前端全部撤掉(后端 POST :id/assign 保留,见 plans-api.recycle 旁注)。
  //   客服的单由主管派发;将来若放开客服主动性,是把入口加回来的事,不用改模型。

  /**
   * 🔴 这里**曾经有 `openMatrix` / `pickCell` 两段**(取池子矩阵 → 浮层 → 点一格交给助手),
   *   2026-08-10 随「分配」按钮一起撤。
   *
   * 那条生产线整体搬去了主管工作台的「分一批新的」——⛔ 别在这一页复活它:
   *   同一个动作两个入口,主管得先想用哪个,状态同步和归属口径也跟着复杂。
   */

  // 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
  //   返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
  //   不重拉,保滚动/筛选。
  const recycle = async (planId: string, name: string, reason: ReleaseReason, note?: string) => {
    setRecycling(true);
    try {
      await plansApi.recycle(planId, reason, note);
      if (view === 'mine') removeItem(planId);
      else patchItem(planId, { status: 'active', assigneeUserId: null });
      // 反向通知详情页:返池后要把闸重新上锁,否则还停在详情页的人仍能操作已交还的单
      usePlanSyncStore.getState().notifyClaim(planId, null);
      toast.success(`已退回 ${name}`);
      setPendingRelease(null);
    } catch (err) {
      toast.error(err instanceof Error ? err.message : '退回失败');
    } finally {
      setRecycling(false);
    }
  };

  // 触底加载
  const listRef = useRef<HTMLDivElement>(null);
  const onScroll = () => {
    const el = listRef.current;
    if (!el || loading || !hasMore) return;
    if (el.scrollHeight - el.scrollTop - el.clientHeight < 120) loadMore();
  };

  const toggleTag = (k: string) =>
    setTags((prev) => {
      const next = new Set(prev);
      if (next.has(k)) next.delete(k);
      else next.add(k);
      return next;
    });

  return (
    <aside className="flex h-full w-[320px] flex-none flex-col border-r bg-white">
      {/* view tabs */}
      <div className="flex flex-none gap-1 border-b px-2 pt-2">
        {VIEW_TABS.map((t) => (
          <button
            key={t.v}
            type="button"
            onClick={() => setView(t.v)}
            className={cn(
              'rounded-t-md px-3 py-1.5 text-[12.5px] transition-colors',
              view === t.v
                ? 'border-b-2 border-brand-600 font-semibold text-brand-700'
                : 'text-slate-500 hover:text-slate-800',
            )}
          >
            {t.label}
          </button>
        ))}
        <span
          className="ml-auto self-center pr-1 text-[10.5px] tabular-nums text-slate-400"
        >
          {total} 人
        </span>
      </div>

      {/* ⛔ 这里原来有一条「初选 xx · xx ✕」的条 —— 已撤(2026-08 产品走查)。
          它是列表被初选筛过的**告示牌**;列表既然不再跟着筛,告示牌也就没有对象了,
          留着反而像是"左栏此刻只剩这批人"。撤销初选的语义现在在助手那边(不采纳确认单即可)。

          ⚠️ 常驻的「把这 N 人移交助手」横幅同样已撤 —— 入口收敛到 tab 行的「分配」一个。 */}

      {/* 搜索 + 工具行 */}
      <div className="flex-none space-y-1.5 border-b p-2">
        <div className="relative">
          <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
          <input
            type="search"
            value={search}
            onChange={(e) => {
              const v = e.target.value;
              setSearch(v);
              // 组字中(拼音中间态)不搜;非组字(英文/数字/粘贴/原生清空)正常排定
              if (!composingRef.current) scheduleKeyword(v);
            }}
            onCompositionStart={() => {
              composingRef.current = true;
            }}
            onCompositionEnd={(e) => {
              // 组字完成(拼音→汉字上屏)→ 解锁,并用最终中文直接排一次搜索
              // (直接调 scheduleKeyword,不依赖 setSearch 是否引起重渲染)
              composingRef.current = false;
              const v = e.currentTarget.value;
              setSearch(v);
              scheduleKeyword(v);
            }}
            placeholder="姓名 / 手机 / 病历号"
            className="h-7 w-full rounded-md border bg-slate-50 pl-7 pr-2 text-[12px] outline-none placeholder:text-slate-400 focus:border-brand-300 focus:bg-white"
          />
        </div>
        {/* ⚠️ 外层条件要跟着两个开关走 —— 只判其中一个,另一个放开时整排还是不显示。
            `hasPatientArchive`(宿主自带患者档案)时号码整个不展示,「真」筛选也就没意义,
            这层守卫别顺手删(下面 PatientRow 的 `hidePhone` 是同一个判据)。 */}
        {((PHONE_FILTER_VISIBLE && !hasPatientArchive) || TAG_FILTER_VISIBLE) && (
          <div className="flex items-center gap-1.5">
            {PHONE_FILTER_VISIBLE && !hasPatientArchive && (
              <button
                type="button"
                onClick={() => setRealPhoneOnly((v) => !v)}
                title="只看已核实的真实手机号"
                aria-pressed={realPhoneOnly}
                className={cn(
                  'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 text-[11px] transition-colors',
                  realPhoneOnly
                    ? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
                    : 'bg-white text-slate-500 hover:bg-slate-50',
                )}
              >
                <PhoneCall className="h-3 w-3" />真
              </button>
            )}
            {TAG_FILTER_VISIBLE && (
              <PersonaTagFilter selected={tags} onToggle={toggleTag} onClear={() => setTags(new Set())} />
            )}
          </div>
        )}
        {clinicOptions.length > 1 && (
          <RailClinicFilter options={clinicOptions} selected={clinics} onChange={setClinics} />
        )}
        {/* 已选标签 chips */}
        {tags.size > 0 && (
          <div className="flex flex-wrap gap-1">
            {[...tags].map((kv) => {
              // ⚠️ 按**首个冒号**拆,且用 personaTagDimId 查维度(不是 d.key):
              //   source='patient' 的维度 key 为空串、筛选串用的是 id,按 key 查会查不到 →
              //   退化成显示原始 code(「18m_plus」而不是「18 个月以上」)。
              //   取值本身可能含冒号(医生姓名理论上),故不用 split 全拆。
              const i = kv.indexOf(':');
              const k = kv.slice(0, i);
              const v = kv.slice(i + 1);
              const dim = PERSONA_TAG_FILTER_DIMS.find((d) => personaTagDimId(d) === k);
              // 开集维度(医生)options 为空 → 取值本身就是中文姓名,直接显示
              const opt = dim?.options.find((o) => o.value === v);
              return (
                <button
                  key={kv}
                  type="button"
                  onClick={() => toggleTag(kv)}
                  className="inline-flex items-center gap-0.5 rounded-full bg-brand-50 px-1.5 py-0.5 text-[10.5px] text-brand-700 ring-1 ring-inset ring-brand-200 hover:bg-brand-100"
                  title={`${dim?.nameZh}:${opt?.zh}(点击移除)`}
                >
                  {opt?.zh ?? v}
                  <X className="h-2.5 w-2.5" />
                </button>
              );
            })}
          </div>
        )}
      </div>

      {/* 患者列表 */}
      <div ref={listRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto">
        {error && <div className="p-3 text-center text-[11.5px] text-rose-500">{error}</div>}
        {items.map((p) => (
          <PatientRow
            key={p.id}
            plan={p}
            view={view}
            active={p.id === currentPlanId}
            clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
            showClinic={showClinic}
            hidePhone={hasPatientArchive}
            onClick={() => {
              router.push(`/plans/${p.id}`);
              onNavigated?.();
            }}
            onRecycle={
              // 被认领(有 assignee)且非终态 → 可返池。不再只认 status='assigned':
              //   无自动回收后 plan 可能停在 active 但仍挂人名下,也要能退回池。
              canRecycle &&
              !!p.assigneeUserId &&
              p.status !== 'completed' &&
              p.status !== 'abandoned' &&
              p.status !== 'superseded'
                ? () => setPendingRelease({ planId: p.id, name: p.patient.name ?? '' })
                : undefined
            }
          />
        ))}
        {loading && (
          <div className="flex justify-center py-3">
            <Loader2 className="h-4 w-4 animate-spin text-slate-300" />
          </div>
        )}
        {!loading && items.length === 0 && !error && (
          <div className="p-6 text-center text-[11.5px] text-slate-400">没有匹配的患者</div>
        )}
        {/* data-pet-perch:助手宠物的栖息点(滚到列表底时它会跳上来蹲;按钮被压住也无妨,滚动加载兜底) */}
        {!loading && hasMore && (
          <button
            type="button"
            data-pet-perch
            onClick={loadMore}
            className="block w-full py-2 text-center text-[11px] text-brand-700 hover:bg-brand-50/50"
          >
            加载更多({items.length}/{total})
          </button>
        )}
      </div>
      {/* 退回原因 —— T7:退回是正常路径,但**必须说明原因** */}
      <ReleaseReasonDialog
        open={pendingRelease != null}
        patientName={pendingRelease?.name ?? ''}
        submitting={recycling}
        onCancel={() => setPendingRelease(null)}
        onConfirm={(reason, note) =>
          pendingRelease && void recycle(pendingRelease.planId, pendingRelease.name, reason, note)
        }
      />
    </aside>
  );
}

// ── 筛选标签 popover(多维度分组多选;维度与顺序见 PERSONA_TAG_FILTER_DIMS)────────
function PersonaTagFilter({
  selected,
  onToggle,
  onClear,
}: {
  selected: Set<string>;
  onToggle: (kv: string) => void;
  onClear: () => void;
}) {
  /// 当前悬停选项的说明,渲染在面板底部固定行(见下方注释:为什么不用浮层)
  const [hint, setHint] = useState<string | null>(null);
  /// 医生名单(开集维度的候选)—— 面板挂载时拉一次,失败就当空(输入框仍可用,只是没候选)。
  /// 后端缓存 6 小时,这里不再本地缓存。
  const [doctors, setDoctors] = useState<string[]>([]);
  useEffect(() => {
    let alive = true;
    plansApi
      .doctors()
      .then((r) => alive && setDoctors(r.doctors))
      .catch(() => undefined);
    return () => {
      alive = false;
    };
  }, []);
  return (
    <Popover>
      <PopoverTrigger asChild>
        <button
          type="button"
          className={cn(
            'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 text-[11px] transition-colors',
            selected.size > 0
              ? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
              : 'bg-white text-slate-500 hover:bg-slate-50',
          )}
        >
          <Filter className="h-3 w-3" />
          筛选标签{selected.size > 0 ? ` ${selected.size}` : ''}
        </button>
      </PopoverTrigger>
      <PopoverContent align="start" className="w-64 p-2">
        <div className="mb-1 flex items-center justify-between px-1">
          <span className="text-[11px] font-medium text-slate-500">按标签圈人</span>
          {selected.size > 0 && (
            <button type="button" onClick={onClear} className="text-[10.5px] text-brand-700 hover:underline">
              清空
            </button>
          )}
        </div>
        {/* 悬停某个选项时在底部解释它。「重要保持」「待激活」这类名字是算法口径的产物,
            光看名字猜不出边界(重要保持 vs 重要挽留差在哪?待激活 vs 沉睡客几个月分界?),
            客服只能凭感觉勾。说明文案在 persona-tag-filters.ts 的 hint 字段。 */}
        <div className="max-h-80 space-y-2 overflow-y-auto">
          {/* hidden 维度不进面板(业务下线的那几个);它们在 API / MCP 侧仍然有效,
              已保存的筛选链接不会失效 —— 见 persona-tag-filters.ts 的 hidden 注释。 */}
          {PERSONA_TAG_FILTER_DIMS.filter((d) => !d.hidden).map((dim) => (
            // ⚠️ key 用 personaTagDimId 不用 dim.key:三个维度共用 visit_recency 这个 key,
            //    用 key 会 React 重复 key 报错、并可能渲染错位。
            <div key={personaTagDimId(dim)}>
              <div className="px-1 py-0.5 text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
                {dim.nameZh}
              </div>
              {dim.dynamic ? (
                <DoctorPicker
                  dimId={personaTagDimId(dim)}
                  doctors={doctors}
                  selected={selected}
                  onToggle={onToggle}
                />
              ) : (
              <div className="flex flex-wrap gap-1 px-1 pb-1">
                {dim.options.map((o) => {
                  const kv = `${personaTagDimId(dim)}:${o.value}`;
                  const on = selected.has(kv);
                  return (
                    <button
                      key={kv}
                      type="button"
                      onClick={() => onToggle(kv)}
                      // 说明走面板底部的固定行,不用浮层:这些选项是用来**横向比较**的
                      // (重要保持 vs 重要挽留差在哪),浮层会盖住正要比的那几个。
                      onMouseEnter={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
                      onMouseLeave={() => setHint(null)}
                      onFocus={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
                      onBlur={() => setHint(null)}
                      className={cn(
                        'rounded-full border px-2 py-0.5 text-[11px] transition-colors',
                        on
                          ? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
                          : 'bg-white text-slate-600 hover:border-brand-200 hover:bg-brand-50/40',
                      )}
                    >
                      {o.zh}
                    </button>
                  );
                })}
              </div>
              )}
            </div>
          ))}
        </div>
        {/* 定高:hover 进出不抖动 */}
        <div className="mt-1 min-h-[30px] border-t px-1 pt-1.5 text-[10.5px] leading-relaxed">
          {hint ? (
            <span className="text-slate-600">{hint}</span>
          ) : (
            <span className="text-slate-300">悬停任一标签看它的含义</span>
          )}
        </div>
      </PopoverContent>
    </Popover>
  );
}

// ── 诊所筛选(popover 多选,样式与列表页 ClinicFilter 同源紧凑版)──────
function RailClinicFilter({
  options,
  selected,
  onChange,
}: {
  options: Array<{ id: string; name: string }>;
  selected: string[];
  onChange: (ids: string[]) => void;
}) {
  const sel = new Set(selected);
  const toggle = (id: string) => {
    const next = new Set(sel);
    if (next.has(id)) next.delete(id);
    else next.add(id);
    onChange([...next]);
  };
  return (
    <Popover>
      <PopoverTrigger asChild>
        <button
          type="button"
          className={cn(
            'inline-flex h-6 w-full items-center justify-between rounded-md border px-1.5 text-[11px] transition-colors',
            selected.length
              ? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
              : 'bg-white text-slate-500 hover:bg-slate-50',
          )}
        >
          <span className="truncate">
            {selected.length === 0
              ? '全部诊所'
              : selected.map((id) => options.find((o) => o.id === id)?.name ?? id).join('、')}
          </span>
          <ChevronDown className="h-3 w-3 flex-none text-slate-400" />
        </button>
      </PopoverTrigger>
      <PopoverContent align="start" className="max-h-72 w-56 overflow-y-auto p-1.5">
        <div className="mb-1 flex items-center justify-between border-b px-1.5 py-1">
          <span className="text-[10.5px] text-slate-400">按诊所筛选(可多选)</span>
          {selected.length > 0 && (
            <button type="button" onClick={() => onChange([])} className="text-[10.5px] text-brand-600 hover:underline">
              清空
            </button>
          )}
        </div>
        {options.map((o) => (
          <label key={o.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1.5 hover:bg-slate-50">
            <input
              type="checkbox"
              className="h-3.5 w-3.5 accent-brand-600"
              checked={sel.has(o.id)}
              onChange={() => toggle(o.id)}
            />
            <span className="truncate text-[12px] text-slate-700">{o.name}</span>
          </label>
        ))}
      </PopoverContent>
    </Popover>
  );
}

// ── 状态 pill(与列表页 STATUS_META 同款)─────────────────────
const STATUS_META: Record<string, { label: string; tone: string }> = {
  // ⭐ T16 后不再叫「待认领」:客服已经没有认领入口了,这个词会让他去找一个不存在的按钮。
  //   语义也变了 —— active 现在的含义是"在池子里等主管派",不是"等谁来领"。
  active: { label: '待分配', tone: 'bg-sky-100 text-sky-700' },
  assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
  completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
  abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
  superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' },
};

/**
 * 每个视图**自身定义**的那个状态 —— 药丸对这一行是零信息,不显示。
 *
 * 召回池的服务端基线就是 `status='active'`(见 poolBaseSql),所以那一栏**每一行**都写着
 * 「待分配」;「我的」里绝大多数行都是「进行中」。整列同一个词,读者第二行就不再看它了,
 * 却一直占着行尾最值钱的位置(退回按钮就在那儿)。
 *
 * ⛔ **不能因此把药丸整个删掉**:「我的」不带 status 过滤(`where.assigneeUserId = 我`),
 *   已完成 / 已放弃的单**也在这一栏里**,而那两个才是真要一眼看见的。
 *   删掉等于让客服分不出哪条已经结案。
 */
const DEFAULT_STATUS_OF_VIEW: Partial<Record<View, string>> = {
  pool: 'active', // 池子里的都是"待分配"
  mine: 'assigned', // 我名下的默认就是"进行中"
};

function StatusPill({
  status,
  view,
  reserveSpace,
}: {
  status: string;
  view: View;
  /**
   * 默认态是否仍要**占住宽度**。
   * ⚠️ 只有与「退回」按钮叠在同一 grid 格时才需要 —— 格宽取两者较大值,真删掉的话
   *   这一格 hover 前是 0 宽,按钮一冒出来整行就位移,而那个叠层机制存在的全部理由就是防这个。
   * ⛔ 召回池那栏**不要**开:那里压根没有按钮,留 42px 空白纯属白占,
   *   而这一列右侧正是手机号/诊所名要用的地方。
   */
  reserveSpace?: boolean;
}) {
  const m = STATUS_META[status] ?? { label: status, tone: 'bg-slate-100 text-slate-700' };
  const isDefault = DEFAULT_STATUS_OF_VIEW[view] === status;
  if (isDefault && !reserveSpace) return null;
  return (
    <span
      className={cn(
        'inline-block flex-none whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-medium',
        m.tone,
        isDefault && 'invisible',
      )}
      aria-hidden={isDefault}
    >
      {m.label}
    </span>
  );
}

// ── 回访结果角标(最近一次执行的分组:成功 / 不成功 / 保持)────────
// 分组 / 文案 / 色板全部取自 @pac/types 单一真理源(EXECUTION_OUTCOME_META)。
const OUTCOME_GROUP_TONE: Record<ExecutionOutcomeGroup, string> = {
  close: 'bg-emerald-100 text-emerald-700 ring-emerald-200', // 成功
  give_up: 'bg-rose-100 text-rose-700 ring-rose-200', // 不成功
  keep: 'bg-amber-100 text-amber-700 ring-amber-200', // 保持
};

function OutcomeTag({ outcome }: { outcome: string }) {
  const meta = EXECUTION_OUTCOME_META[outcome as ExecutionOutcome];
  if (!meta) return null;
  const group = EXECUTION_OUTCOME_GROUP_META[meta.group];
  return (
    <span
      title={`回访结果:${group.labelZh} · ${meta.labelZh}`}
      className={cn(
        'flex-none rounded px-1 text-[9.5px] font-medium leading-4 ring-1 ring-inset',
        OUTCOME_GROUP_TONE[meta.group],
      )}
    >
      {group.labelZh}
    </span>
  );
}

/**
 * 潜在治疗标签(画像 potential_treatment.labels)—— 与列表页 PlanCard 同款,尺寸小一号。
 * 刻意比状态药丸弱一档(无底色、细描边):这是"这人还有哪些没做"的背景信息,
 * 不该跟"这次为什么召"抢注意力。业务要求**全列不折叠**,容器自然换行。
 */
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
  if (!labels.length) return null;
  // 不折叠 —— 业务要求全列。「这人还有哪些没做」是客服决定要不要跟的依据,
  // 折成 +N 等于逼人多点一次;标签多的患者本来就是该优先跟的,让它换行占地方是对的。
  return (
    <>
      {labels.map((l) => (
        <span
          key={l}
          className="inline-block flex-none rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200"
        >
          {potentialTreatmentCardLabel(l)}
        </span>
      ))}
    </>
  );
}

/**
 * 开集维度(医生)的可搜索输入框。
 *
 * ⭐ 模糊只发生在**前端**:在已加载的名单里做子串匹配,选中后发**精确姓名**。
 *   后端不做 ILIKE —— 那会让 visit_recency 的偏索引失效,退回全分区扫
 *   (2026-07-29 的「圈人 20 秒」就是这么来的)。
 * 已选中的显示在上方,可点掉;输入为空时不铺全量名单(几百个医生会把面板撑爆)。
 */
function DoctorPicker({
  dimId,
  doctors,
  selected,
  onToggle,
}: {
  dimId: string;
  doctors: string[];
  selected: Set<string>;
  onToggle: (kv: string) => void;
}) {
  const [q, setQ] = useState('');
  const picked = [...selected].filter((kv) => kv.startsWith(`${dimId}:`));
  const matches = q.trim()
    ? doctors.filter((d) => d.includes(q.trim()) && !selected.has(`${dimId}:${d}`)).slice(0, 8)
    : [];
  return (
    <div className="space-y-1 px-1 pb-1">
      {picked.length > 0 && (
        <div className="flex flex-wrap gap-1">
          {picked.map((kv) => (
            <button
              key={kv}
              type="button"
              onClick={() => onToggle(kv)}
              title="点击移除"
              className="rounded-full border border-brand-300 bg-brand-50 px-2 py-0.5 text-[11px] font-medium text-brand-700"
            >
              {kv.slice(dimId.length + 1)} ×
            </button>
          ))}
        </div>
      )}
      <input
        value={q}
        onChange={(e) => setQ(e.target.value)}
        placeholder={doctors.length ? '输入医生姓名搜索' : '暂无医生名单'}
        className="w-full rounded border px-1.5 py-0.5 text-[11px] outline-none placeholder:text-slate-300 focus:border-brand-300"
      />
      {matches.length > 0 && (
        <div className="flex flex-wrap gap-1">
          {matches.map((d) => (
            <button
              key={d}
              type="button"
              onClick={() => {
                onToggle(`${dimId}:${d}`);
                setQ('');
              }}
              className="rounded-full border bg-white px-2 py-0.5 text-[11px] text-slate-600 hover:border-brand-200 hover:bg-brand-50/40"
            >
              {d}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

// ── 优先级(只留分数,10 分制)────────────────────────────────
//   业务 2026-07-30:去掉原来那排五个色点。分数本身已经带档位信息(还按档着色),
//   五个点是同一件事的第二种编码 —— 一行里两处讲优先级,反而要人对照着看。
//   档位色保留在数字上:极低/低 绿 → 中/高 琥珀 → 极高 玫红,扫一眼仍分得出轻重。
//   hover 的算分明细走外层 PriorityHover,不在这里。
function PriorityBar({ score }: { score: number }) {
  const pct = Math.max(0, Math.min(1, score / 100));
  const labelTone =
    pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500';
  // 展示值 = 排序键 / 10。⚠️ 别改成读 breakdown.raw:那是加权前的分项原始分,
  // 与列表排序用的 priorityScore 不同源,两处会对不上号。
  const disp = (score / 10).toFixed(2);
  return (
    <span
      className={cn('flex-none text-[10.5px] font-semibold tabular-nums', labelTone)}
      title={`优先级 ${disp} / 10`}
    >
      {disp}
    </span>
  );
}

// ── 患者行(紧凑单列卡)────────────────────────────────────
function PatientRow({
  plan: p,
  active,
  clinicName,
  showClinic,
  hidePhone,
  onClick,
  onRecycle,
  view,
}: {
  plan: PlanListItem;
  active: boolean;
  clinicName: string;
  /** 当前 tab —— 状态药丸要据此判断"这一行是不是本视图的默认态"(默认态不显示) */
  view: View;
  /** 跨诊所权限才显示诊所名(单诊所的人每行同一家 = 噪音);口径同顶栏「N 个诊所」 */
  showClinic: boolean;
  /** 宿主配了原始档案(VIEW_PATIENT)时隐藏手机号 —— 真号在宿主档案页,PAC 侧为造数假号。与详情页同条件。 */
  hidePhone: boolean;
  onClick: () => void;
  /** 已认领 plan 的「返池」回收动作(有 PLAN_RECYCLE 且非终态时传入;staff 只能退自己的,服务端兜底) */
  onRecycle?: () => void;
}) {
  const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={(e) => e.key === 'Enter' && onClick()}
      className={cn(
        'group block w-full cursor-pointer border-b px-3 py-2 text-left transition-colors',
        active ? 'bg-brand-50/70 ring-1 ring-inset ring-brand-200' : 'hover:bg-slate-50',
      )}
    >
      <div className="flex items-center gap-1.5">
        {p.lastOutcome && <OutcomeTag outcome={p.lastOutcome} />}
        <span className={cn('truncate text-[13px] font-semibold', active ? 'text-brand-800' : 'text-slate-900')}>
          {p.patient.name ?? p.patient.nameMasked ?? '—'}
        </span>
        {/* 顺序对齐详情页患者卡:姓名 → 病历号 → 性别·年龄。
            宿主不一定给病历号(jvs-dw 91% / friday 63%),缺就不占位 */}
        {p.patient.medicalRecordNumber && (
          <span className="min-w-0 truncate font-mono text-[10px] tabular-nums text-slate-400" title="病历号">
            {p.patient.medicalRecordNumber}
          </span>
        )}
        <span className="flex-none text-[10.5px] tabular-nums text-slate-500">
          {formatGender(p.patient.gender)} · {p.patient.age ?? '?'} 岁
        </span>
        {p.patient.phoneVerified && (
          <span className="flex-none rounded bg-brand-50 px-1 text-[9.5px] font-medium leading-4 text-brand-700 ring-1 ring-inset ring-brand-200">
            真
          </span>
        )}
        <span className="ml-auto flex-none">
          <PriorityHover score={p.priorityScore} breakdown={breakdown}>
            <span className="cursor-help">
              <PriorityBar score={p.priorityScore} />
            </span>
          </PriorityHover>
        </span>
      </div>
      {/* 第二行:潜在治疗(左) + 上次到诊·医生(右)。
          放在"手机/诊所/操作按钮"那行**之上** —— 客服扫列表是先看"这人有什么可谈"(治疗项 + 多久没来),
          确定要跟进了才看联系方式、按认领。两者都缺则整行不渲染,行高不虚占。 */}
      {(p.patient.potentialTreatments.length > 0 || p.patient.lastVisitAt) && (
        <div className="mt-0.5 flex items-start gap-1">
          <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
            <PotentialTreatmentChips labels={p.patient.potentialTreatments} />
          </span>
          {p.patient.lastVisitAt && (
            <span
              className="flex-none whitespace-nowrap text-[10px] leading-4 tabular-nums text-slate-400"
              title="上次到诊"
            >
              {p.patient.lastVisitAt}
              {p.patient.lastVisitDoctor && ` · ${p.patient.lastVisitDoctor}`}
            </span>
          )}
        </div>
      )}
      <div className="mt-0.5 flex items-center gap-1.5">
        {!hidePhone && (
          <span className="flex-none font-mono text-[10.5px] tabular-nums text-slate-400">
            {/* ⭐ 明文优先 —— 客服要照着这个拨,`138****2417` 拨不出去。
                ⚠️ 仍留 phoneMasked 兜底:老接口/缓存响应里可能没有 phone 字段。
                ⚠️ 配了原始档案的宿主整行不显示(hidePhone),两个都不会露。 */}
            {p.patient.phone ?? p.patient.phoneMasked ?? ''}
          </span>
        )}
        {/* 诊所名:单诊所权限的人每行都是同一家,纯噪音 → 隐藏;跨诊所才有区分价值 */}
        {clinicName && showClinic && (
          <span className="min-w-0 flex-1 truncate text-[10px] text-slate-400" title={clinicName}>
            {hidePhone ? '' : '· '}
            {clinicName}
          </span>
        )}
        <span className="ml-auto flex-none" />
        {(() => {
          // 动作按钮:已认领→「退回」(amber)。与状态药丸叠同 grid 格,槽位宽/高恒为较大值,
          // hover 只切 visibility(非 display)→ 悬停整行不重排、不位移。
          // ⭐ T16:原本这里是「认领 / 返池」二选一,认领入口已撤,只剩退回。
          //   ⚠️ grid 叠层机制保留 —— 它防的是 hover 时整行位移,与按钮有几个无关。
          const act = onRecycle
            ? { label: '退回', tone: 'bg-amber-500 hover:bg-amber-600', fn: onRecycle }
            : null;
          if (!act) return <StatusPill status={p.status} view={view} />;
          return (
            <span className="grid flex-none items-center justify-items-end">
              <span className="col-start-1 row-start-1 group-hover:invisible">
                <StatusPill status={p.status} view={view} reserveSpace />
              </span>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  act.fn();
                }}
                className={cn(
                  'invisible col-start-1 row-start-1 whitespace-nowrap rounded-md px-2 py-0.5 text-[10.5px] font-medium text-white group-hover:visible',
                  act.tone,
                )}
              >
                {act.label}
              </button>
            </span>
          );
        })()}
      </div>
    </div>
  );
}
