'use client';

import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
  ChevronDown,
  ChevronLeft,
  ChevronRight,
  Flame,
  ListChecks,
  LogOut,
  PhoneCall,
  RefreshCw,
  Search,
  Waves,
  X,
} from 'lucide-react';
import type { ListPlansQuery, PlanListItem } from '@pac/types';
import { Permission, potentialTreatmentCardLabel } from '@pac/types';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { useHasPermission } from '@/hooks/use-permission';
import { ApiError } from '@/lib/api-client';
import { cn, formatGender, userDisplayName, roleNameZh } from '@/lib/utils';
import { plansApi } from './plans-api';
import { usePlansList } from './use-plans-list';
import { usePlanCounts } from './use-plan-counts';
import { ReasonLine } from '@/components/plan-detail/reason-line';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';

// ─────────────────────────────────────────────────────────────────
// 召回任务工作台(/plans)
// 设计来源:claude.ai/design TlY76ilunKQrB9T5JFJJ-g · Variant B 工作台 + 卡片网格
//
// 客服真实用户流:
//   1. 进来看 KPI(我的进行中 / 池中高优 / 我完成 / 召回池余量)
//   2. 切 tab:我的工单(默认) / 召回池 / 全部
//   3. 卡片网格 — 永久 checkbox + 行内单条 / 批量操作
//   4. 单条:认领(pool)/ 继续(我的 assigned)/ 详情(其他状态)/ 取消认领(我的 assigned 回池)
//   5. 多选:批量认领(pool 视图)+ 批量回收(我的视图)
// ─────────────────────────────────────────────────────────────────

type Density = 'comfy' | 'regular' | 'dense';
type SortKey = 'priority_desc' | 'priority_asc';
type View = NonNullable<ListPlansQuery['view']>;

// 一页条数固定,density(宽松/标准/紧凑)只影响间距/视觉/列数,不改分页条数
const PAGE_SIZE = 12;

// 三档密度的视觉 token —— 拉开差异:列数 + 卡片内距 + 字号 + 理由行数 + 间距
const DENSITY = {
  comfy: {
    grid: 'grid-cols-1 lg:grid-cols-2',
    gridGap: 'gap-4',
    pad: 'p-4',
    headGap: 'gap-3',
    title: 'text-[15.5px]',
    reason: 'text-[13px] line-clamp-3',
    foot: 'px-4 py-3',
  },
  regular: {
    grid: 'grid-cols-1 lg:grid-cols-2 xl:grid-cols-3',
    gridGap: 'gap-3',
    pad: 'p-3.5',
    headGap: 'gap-3',
    title: 'text-[14px]',
    reason: 'text-[12.5px] line-clamp-2',
    foot: 'px-3.5 py-2.5',
  },
  dense: {
    grid: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
    gridGap: 'gap-2',
    pad: 'p-2.5',
    headGap: 'gap-2',
    title: 'text-[12.5px]',
    reason: 'text-[11.5px] line-clamp-1',
    foot: 'px-2.5 py-1.5',
  },
} as const satisfies Record<Density, Record<string, string>>;

const STATUS_META: Record<string, { label: string; tone: string }> = {
  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' },
};

export function PlansListApp() {
  const user = useAuthStore((s) => s.user);
  const canViewAll = useHasPermission(Permission.PLAN_VIEW_ALL);
  const canAssign = useHasPermission(Permission.PLAN_ASSIGN);
  const canRecycle = useHasPermission(Permission.PLAN_RECYCLE);

  const [density, setDensity] = useState<Density>('regular');
  const [sort, setSort] = useState<SortKey>('priority_desc');
  const [q, setQ] = useState('');
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const [refreshToken, setRefreshToken] = useState(0);

  const pageSize = PAGE_SIZE;
  const { state, query, setQuery, refresh } = usePlansList({
    view: 'mine',
    page: 1,
    pageSize,
    sort, // 初始 query 带默认 sort,避免挂载时"先无 sort 拉一次、再带 sort 拉一次"的双请求
  });

  const { counts, reload: reloadCounts } = usePlanCounts(refreshToken);

  const view = (query.view ?? 'mine') as View;

  const refreshAll = () => {
    refresh();
    setRefreshToken((t) => t + 1);
    void reloadCounts();
  };

  // W3 末:keyword / sort 改服务端(usePlansList → /pac/v1/plans?keyword=&sort=),
  // 跨全表搜索 + 跨页排序;前端不再 .filter / .sort 本页 items
  // - keyword 300ms debounce 防抖,避免每键一次请求
  // - sort 变化立刻 setQuery
  useEffect(() => {
    const trimmed = q.trim();
    const handle = setTimeout(() => {
      setQuery({ keyword: trimmed || undefined });
    }, 300);
    return () => clearTimeout(handle);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [q]);
  useEffect(() => {
    setQuery({ sort });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sort]);

  const visible: PlanListItem[] = state.status === 'ready' ? state.data.items : [];

  const toggleSelect = (id: string) =>
    setSelected((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  const togglePageSelect = () => {
    const visibleIds = visible.map((p) => p.id);
    const allOn = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
    setSelected((prev) => {
      const next = new Set(prev);
      if (allOn) visibleIds.forEach((id) => next.delete(id));
      else visibleIds.forEach((id) => next.add(id));
      return next;
    });
  };
  const clearSelected = () => setSelected(new Set());

  const handleClaimOne = async (plan: PlanListItem) => {
    if (!user) return;
    try {
      await plansApi.assign(plan.id, user.sub);
      // 成功不弹 toast —— refreshAll 后该行就地变为"已认领",列表本身即反馈
      refreshAll();
    } catch (e) {
      toast.error('认领失败', { description: errMsg(e) });
    }
  };
  const handleRecycleOne = async (plan: PlanListItem) => {
    try {
      await plansApi.recycle(plan.id);
      // 成功不弹 toast —— refreshAll 后该行回到池中,列表本身即反馈
      refreshAll();
    } catch (e) {
      toast.error('回收失败', { description: errMsg(e) });
    }
  };
  const handleBatchClaim = async () => {
    if (!user || !selected.size) return;
    const ids = Array.from(selected);
    const results = await Promise.allSettled(ids.map((id) => plansApi.assign(id, user.sub)));
    const ok = results.filter((r) => r.status === 'fulfilled').length;
    const fail = results.length - ok;
    if (ok) toast.success(`批量认领 ${ok} 条`, { description: fail ? `失败 ${fail} 条` : undefined });
    if (!ok && fail) toast.error('批量认领全部失败');
    clearSelected();
    refreshAll();
  };
  const handleBatchRecycle = async () => {
    if (!selected.size) return;
    const ids = Array.from(selected);
    const results = await Promise.allSettled(ids.map((id) => plansApi.recycle(id)));
    const ok = results.filter((r) => r.status === 'fulfilled').length;
    const fail = results.length - ok;
    if (ok) toast.success(`已回收 ${ok} 条`, { description: fail ? `失败 ${fail} 条` : undefined });
    if (!ok && fail) toast.error('批量回收全部失败');
    clearSelected();
    refreshAll();
  };

  // 切换 view / status / q / sort 时回到第一页 + 清空选择(常识)
  // ⭐ 切 view 时清空 status — 不同 view 状态可选项不同(pool 无 filter,mine 不可选 active)
  //   不清会出现 mine + status=active 这种空查询的怪状态
  const setView = (v: View) => {
    setQuery({ view: v, status: undefined, page: 1 });
    clearSelected();
  };
  const setStatus = (s: ListPlansQuery['status'] | undefined) => {
    setQuery({ status: s, page: 1 });
    clearSelected();
  };
  // 诊所多选筛选 — 选项 = 当前用户可见诊所(clinicIds 受限范围;集团级空→回退字典全部)。状态存 query.targetClinicIds
  const clinicOptions = useMemo(() => visibleClinics(user), [user]);
  const selectedClinics = query.targetClinicIds ?? [];
  const setClinics = (ids: string[]) => {
    setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
    clearSelected();
  };
  // 只看真实号码(外部对照表核实过;测试库其余为造数假号)
  const realPhoneOnly = query.phoneVerified === true;
  const toggleRealPhone = () => {
    setQuery({ phoneVerified: realPhoneOnly ? undefined : true, page: 1 });
    clearSelected();
  };

  return (
    <div className="flex h-full min-h-screen flex-col bg-slate-50">
      <PageHeader user={user} mineAssigned={counts.mineAssigned} poolHi={counts.pool} onRefresh={refreshAll} />

      <div className="grid flex-none grid-cols-2 gap-3 border-b border-slate-100 bg-slate-50 px-5 py-3 md:grid-cols-4">
        <KpiCard tone="amber" icon={<ListChecks className="h-4 w-4" />} label="我的进行中" value={counts.mineAssigned} sub={`共 ${counts.mine} 个工单`} />
        <KpiCard tone="rose" icon={<Flame className="h-4 w-4" />} label="召回池余量" value={counts.pool} sub="池中待领取" />
        <KpiCard tone="emerald" icon={<ListChecks className="h-4 w-4" />} label="我完成" value={counts.mineCompleted} sub={successRateText(counts)} />
        <KpiCard tone="brand" icon={<Waves className="h-4 w-4" />} label="全部计划" value={counts.all} sub="活跃 + 历史" />
      </div>

      <div className="flex-none bg-white">
        <ViewTabs current={view} counts={counts} canViewAll={canViewAll} onChange={setView} />
      </div>

      <FilterBar
        view={view}
        q={q}
        setQ={setQ}
        status={query.status}
        setStatus={setStatus}
        sort={sort}
        setSort={setSort}
        clinicOptions={clinicOptions}
        selectedClinics={selectedClinics}
        setClinics={setClinics}
        realPhoneOnly={realPhoneOnly}
        onToggleRealPhone={toggleRealPhone}
        density={density}
        setDensity={setDensity}
        allSelected={visible.length > 0 && visible.every((p) => selected.has(p.id))}
        someSelected={visible.some((p) => selected.has(p.id))}
        onTogglePage={togglePageSelect}
      />

      <BatchBar
        count={selected.size}
        view={view}
        canAssign={canAssign}
        canRecycle={canRecycle}
        onClaim={handleBatchClaim}
        onRecycle={handleBatchRecycle}
        onClear={clearSelected}
      />

      <div className="flex-1 overflow-y-auto p-4">
        {state.status === 'loading' && <PlaceholderCard text="加载中…" />}

        {state.status === 'error' && (
          <Card>
            <CardContent className="space-y-2 py-10 text-center">
              <div className="text-sm text-rose-600">无法加载召回池</div>
              <div className="text-xs text-muted-foreground">code={state.code ?? '?'} · {state.message}</div>
              <Button variant="outline" size="sm" onClick={refreshAll}>重试</Button>
            </CardContent>
          </Card>
        )}

        {state.status === 'ready' && visible.length === 0 && (
          <div className="py-20 text-center text-slate-500">
            <div className="mb-2 text-3xl">🌿</div>
            <p className="text-[13px]">{q ? '无匹配结果(尝试清空搜索)' : '本视图暂无任务'}</p>
          </div>
        )}

        {state.status === 'ready' && visible.length > 0 && (
          <div className={cn('grid', DENSITY[density].gridGap, DENSITY[density].grid)}>
            {visible.map((p) => (
              <PatientPlanCard
                key={p.id}
                plan={p}
                me={user?.sub ?? ''}
                view={view}
                density={density}
                selected={selected.has(p.id)}
                onSelect={() => toggleSelect(p.id)}
                canAssign={canAssign}
                canRecycle={canRecycle}
                onClaim={() => handleClaimOne(p)}
                onUnclaim={() => handleRecycleOne(p)}
              />
            ))}
          </div>
        )}
      </div>

      {state.status === 'ready' && (
        <Pagination
          page={state.data.page}
          pageSize={state.data.pageSize}
          total={state.data.total}
          onPage={(p) => {
            setQuery({ page: p });
            clearSelected();
          }}
        />
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// PageHeader — PAC logo + 标题 + me + 刷新
// ─────────────────────────────────────────────────────────────────

function PageHeader({
  user,
  mineAssigned,
  poolHi,
  onRefresh,
}: {
  user: ReturnType<typeof useAuthStore.getState>['user'];
  mineAssigned: number;
  poolHi: number;
  onRefresh: () => void;
}) {
  const greeting = mineAssigned > 0 ? `今天好,还有 ${mineAssigned} 个待打` : poolHi > 0 ? `池中还有 ${poolHi} 个待领取` : '今天好';
  return (
    <header className="flex flex-none items-center justify-between gap-3 border-b border-slate-100 bg-white px-5 py-3">
      <div className="flex min-w-0 items-center gap-3">
        <div className="inline-flex items-center gap-2">
          <span className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-brand-600 text-[12px] font-bold text-white">PAC</span>
          <span className="text-[13px] font-semibold text-slate-900">疗效保障</span>
        </div>
        <span className="text-slate-300">/</span>
        <div className="min-w-0">
          <h1 className="truncate text-[15px] font-semibold leading-tight text-slate-900">召回任务工作台</h1>
          <p className="mt-0.5 truncate text-[11px] text-slate-500">{greeting}</p>
        </div>
      </div>
      <div className="flex flex-none items-center gap-3">
        <Button variant="outline" size="sm" onClick={onRefresh} className="h-8">
          <RefreshCw className="h-3.5 w-3.5" /> 刷新
        </Button>
        <div className="hidden text-right text-[11.5px] leading-tight text-slate-500 md:block">
          <div className="font-medium text-slate-700">{userDisplayName(user)}</div>
          <div className="nums">{user?.clinicIds?.length ?? 0} 个诊所 · {roleNameZh(user?.role)}</div>
        </div>
        <span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-brand-400 to-brand-600 text-[12px] font-bold text-white">
          {userDisplayName(user).charAt(0).toUpperCase()}
        </span>
        <Button
          variant="ghost"
          size="icon"
          className="h-8 w-8 text-slate-400 hover:text-rose-600"
          title="退出登录"
          aria-label="退出登录"
          onClick={() => {
            useAuthStore.getState().clear(); // 清 token → AuthGate 自动弹回登录(mock/SSO 同此口径)
            // 不弹 toast —— 立即跳回登录页本身即反馈
          }}
        >
          <LogOut className="h-4 w-4" />
        </Button>
      </div>
    </header>
  );
}

// ─────────────────────────────────────────────────────────────────
// KPI Card
// ─────────────────────────────────────────────────────────────────

const TONE_BG: Record<string, string> = {
  amber: 'bg-amber-50 text-amber-700 ring-amber-200',
  rose: 'bg-rose-50 text-rose-700 ring-rose-200',
  emerald: 'bg-emerald-50 text-emerald-700 ring-emerald-200',
  brand: 'bg-brand-50 text-brand-700 ring-brand-200',
};

function KpiCard({
  tone,
  icon,
  label,
  value,
  sub,
}: {
  tone: keyof typeof TONE_BG;
  icon: React.ReactNode;
  label: string;
  value: number;
  sub?: string;
}) {
  const T = TONE_BG[tone] ?? '';
  return (
    <div className="flex items-center gap-3 rounded-lg border border-slate-100 bg-white px-3 py-2.5">
      <span className={cn('inline-flex h-9 w-9 flex-none items-center justify-center rounded-lg ring-1 ring-inset', T)}>
        {icon}
      </span>
      <div className="min-w-0 flex-1">
        <div className="text-[11px] leading-tight text-slate-500">{label}</div>
        <div className="mt-0.5 flex items-baseline gap-1.5">
          <span className={cn('text-[22px] font-bold leading-none nums', T.split(' ')[1] ?? '')}>{value}</span>
          {sub && <span className="truncate text-[10.5px] text-slate-400">{sub}</span>}
        </div>
      </div>
    </div>
  );
}

function successRateText(c: { mineCompleted: number; mine: number }): string {
  if (c.mine === 0) return '暂无数据';
  return `转化率 ${Math.round((c.mineCompleted / c.mine) * 100)}%`;
}

// ─────────────────────────────────────────────────────────────────
// View Tabs
// ─────────────────────────────────────────────────────────────────

function ViewTabs({
  current,
  counts,
  canViewAll,
  onChange,
}: {
  current: View;
  counts: { mine: number; pool: number; all: number };
  canViewAll: boolean;
  onChange: (v: View) => void;
}) {
  const tabs: Array<{ v: View; label: string; hint: string; gated?: boolean }> = [
    { v: 'mine', label: '我的工单', hint: '已分配给我的(active+assigned)' },
    { v: 'pool', label: '召回池', hint: '待领取(active+未分配)' },
    { v: 'all', label: '全部', hint: '全状态', gated: !canViewAll },
  ];
  return (
    <div className="border-b border-slate-100">
      <div className="flex items-center px-3">
        {tabs.map((t) => {
          const active = current === t.v;
          const count = counts[t.v];
          if (t.gated) {
            return (
              <button
                key={t.v}
                disabled
                title="需要 PLAN_VIEW_ALL 权限"
                className="px-4 py-2 text-[13px] text-slate-300 cursor-not-allowed"
              >
                {t.label}
              </button>
            );
          }
          return (
            <button
              key={t.v}
              onClick={() => onChange(t.v)}
              title={t.hint}
              className={cn(
                'relative inline-flex items-center gap-1.5 px-4 py-2.5 text-[13px] transition-colors',
                active ? 'font-semibold text-slate-900' : 'text-slate-500 hover:text-slate-900',
              )}
            >
              {t.label}
              <span
                className={cn(
                  'inline-flex h-[18px] min-w-[20px] items-center justify-center rounded px-1 text-[10.5px] font-bold nums',
                  active ? 'bg-brand-600 text-white' : 'bg-slate-100 text-slate-600',
                )}
              >
                {count}
              </span>
              {active && <span className="absolute -bottom-px left-3 right-3 h-[2px] rounded-full bg-brand-600" />}
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// FilterBar — 搜索 / 状态 / 排序 / 密度 / 全选本页
// ─────────────────────────────────────────────────────────────────

/// 诊所多选筛选(Popover + 复选框)。选项来自 token dictionary.clinics(用户可见诊所)。
function ClinicFilter({
  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]);
  };
  const label = selected.length === 0 ? '全部诊所' : `诊所 · ${selected.length}`;
  return (
    <Popover>
      <PopoverTrigger asChild>
        <button className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-100 bg-white px-3 text-xs hover:border-slate-300">
          <span className={cn(selected.length ? 'font-medium text-slate-900' : 'text-slate-600')}>{label}</span>
          <ChevronDown className="h-3.5 w-3.5 text-slate-400" />
        </button>
      </PopoverTrigger>
      <PopoverContent align="start" className="w-56 max-h-72 overflow-y-auto p-1.5">
        <div className="mb-1 flex items-center justify-between border-b border-slate-100 px-1.5 py-1">
          <span className="text-[10.5px] text-slate-400">按诊所筛选(可多选)</span>
          {selected.length > 0 && (
            <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>
  );
}

function FilterBar({
  view,
  q, setQ, status, setStatus,
  sort, setSort, density, setDensity,
  clinicOptions, selectedClinics, setClinics,
  realPhoneOnly, onToggleRealPhone,
  allSelected, someSelected, onTogglePage,
}: {
  view: View;
  q: string;
  setQ: (v: string) => void;
  status: ListPlansQuery['status'] | undefined;
  setStatus: (s: ListPlansQuery['status'] | undefined) => void;
  sort: SortKey;
  setSort: (s: SortKey) => void;
  density: Density;
  setDensity: (d: Density) => void;
  clinicOptions: Array<{ id: string; name: string }>;
  selectedClinics: string[];
  setClinics: (ids: string[]) => void;
  realPhoneOnly: boolean;
  onToggleRealPhone: () => void;
  allSelected: boolean;
  someSelected: boolean;
  onTogglePage: () => void;
}) {
  // 按 view 决定状态可选 — 跟后端 view filter 口径对齐(plan.service.ts)
  //   pool:  WHERE status=active AND assigneeUserId IS NULL → 全部都是"待认领",status filter 无意义,隐藏
  //   mine:  WHERE assigneeUserId=user → 可能 assigned/completed/abandoned,不会有 active
  //   all:   无 view 过滤,全状态可见
  const statusOptions: Array<{ value: string; label: string }> | null =
    view === 'pool'
      ? null // pool 隐藏 status filter(语义上等价"待认领")
      : view === 'mine'
        ? [
            { value: '__all', label: '全部我的' },
            { value: 'assigned', label: '进行中' },
            { value: 'completed', label: '已完成' },
            { value: 'abandoned', label: '已放弃' },
          ]
        : [
            { value: '__all', label: '全部状态' },
            { value: 'active', label: '待认领' },
            { value: 'assigned', label: '已认领' },
            { value: 'completed', label: '已完成' },
            { value: 'abandoned', label: '已放弃' },
          ];
  return (
    <div className="flex flex-none flex-wrap items-center gap-2 border-b border-slate-100 bg-white px-5 py-2.5">
      <div className="relative">
        <Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
        <Input
          compact
          type="search"
          placeholder="搜索 姓名 / 手机 / 外部 ID"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          className="w-64 pl-8"
        />
      </div>
      {statusOptions && (
        <Select value={status ?? '__all'} onValueChange={(v) => setStatus(v === '__all' ? undefined : (v as ListPlansQuery['status']))}>
          <SelectTrigger className="h-8 w-32 text-xs"><SelectValue placeholder="状态" /></SelectTrigger>
          <SelectContent>
            {statusOptions.map((o) => (
              <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}
      {clinicOptions.length > 1 && (
        <ClinicFilter options={clinicOptions} selected={selectedClinics} onChange={setClinics} />
      )}
      <Select value={sort} onValueChange={(v) => setSort(v as SortKey)}>
        <SelectTrigger className="h-8 w-44 text-xs"><SelectValue placeholder="排序" /></SelectTrigger>
        <SelectContent>
          <SelectItem value="priority_desc">优先级 高 → 低</SelectItem>
          <SelectItem value="priority_asc">优先级 低 → 高</SelectItem>
        </SelectContent>
      </Select>
      {/* 只看真实号码(外部对照表核实;测试库其余为造数假号) */}
      <button
        type="button"
        onClick={onToggleRealPhone}
        title="只看已核实的真实手机号"
        className={cn(
          'inline-flex h-8 items-center gap-1 rounded-md border px-2.5 text-xs transition-colors',
          realPhoneOnly
            ? 'border-brand-300 bg-brand-50 font-medium text-brand-700'
            : 'border-slate-100 bg-white text-slate-600 hover:bg-slate-50',
        )}
      >
        <PhoneCall className="h-3.5 w-3.5" />
        真实号码
      </button>
      <div className="inline-flex items-center rounded-md bg-slate-100 p-0.5">
        {(['comfy', 'regular', 'dense'] as const).map((d) => (
          <button
            key={d}
            onClick={() => setDensity(d)}
            className={cn(
              'rounded px-2 py-1 text-[11.5px] transition-colors',
              density === d ? 'bg-white font-semibold text-slate-900 shadow-sm' : 'text-slate-500 hover:text-slate-800',
            )}
            title={`密度 · ${d}`}
          >
            {{ comfy: '宽松', regular: '标准', dense: '紧凑' }[d]}
          </button>
        ))}
      </div>
      <div className="ml-auto flex items-center gap-2">
        <label className="inline-flex cursor-pointer select-none items-center gap-1.5">
          <input
            type="checkbox"
            className="h-4 w-4 accent-brand-600 cursor-pointer"
            checked={allSelected}
            ref={(el) => {
              if (el) el.indeterminate = !allSelected && someSelected;
            }}
            onChange={onTogglePage}
          />
          <span className="text-[11.5px] text-slate-600">全选本页</span>
        </label>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// BatchBar — 浮出
// ─────────────────────────────────────────────────────────────────

function BatchBar({
  count, view, canAssign, canRecycle,
  onClaim, onRecycle, onClear,
}: {
  count: number;
  view: View;
  canAssign: boolean;
  canRecycle: boolean;
  onClaim: () => void;
  onRecycle: () => void;
  onClear: () => void;
}) {
  if (!count) return null;
  return (
    <div className="flex flex-none items-center gap-3 border-y border-brand-200 bg-brand-50/80 px-5 py-2 backdrop-blur">
      <span className="text-[12px] text-brand-800">
        已选 <span className="nums font-bold">{count}</span> 条
      </span>
      <span className="text-slate-300">·</span>
      {view !== 'mine' && canAssign && (
        <Button size="sm" className="h-7" onClick={onClaim}>批量认领</Button>
      )}
      {view !== 'pool' && canRecycle && (
        <Button size="sm" variant="outline" className="h-7" onClick={onRecycle}>批量回收</Button>
      )}
      <Button size="sm" variant="ghost" className="ml-auto h-7 text-slate-500 hover:text-slate-700" onClick={onClear}>
        <X className="h-3.5 w-3.5" /> 取消选择
      </Button>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────
// PatientPlanCard — 富卡片
// ─────────────────────────────────────────────────────────────────

function PatientPlanCard({
  plan, me, view, density, selected, onSelect,
  canAssign, canRecycle, onClaim, onUnclaim,
}: {
  plan: PlanListItem;
  me: string;
  view: View;
  density: Density;
  selected: boolean;
  onSelect: () => void;
  canAssign: boolean;
  canRecycle: boolean;
  onClaim: () => void;
  onUnclaim: () => void;
}) {
  const p = plan;
  const clinicDict = useAuthStore((s) => s.user?.dictionary?.clinics);
  // 诊所名只在「跨诊所权限」下才有信息量:单诊所的人每张卡都是同一家,纯噪音。
  // ⚠️ 判据用 `!== 1` 而不是 `> 1`:visibleClinics 在诊所字典尚未拉到时会返回 0
  //   (集团级用户 clinicIds 为空 → 回退全字典 → 字典空则 0),按 `> 1` 会把集团级用户
  //   也误隐藏。只在**确知是单诊所**时才隐藏,未知一律显示 —— 多显示一个字段是小事,
  //   把跨诊所的人看不到诊所归属是大事。
  const showClinic = useAuthStore((s) => visibleClinics(s.user).length) !== 1;
  const isMine = p.assigneeUserId === me;
  const isPool = p.status === 'active' && !p.assigneeUserId;
  const isClosed = p.status === 'completed' || p.status === 'abandoned';
  const displayName = p.patient.name ?? p.patient.nameMasked ?? p.patient.externalId;

  const d = DENSITY[density];
  const pad = d.pad;
  const titleSize = d.title;

  return (
    <article
      className={cn(
        'group relative flex flex-col rounded-lg border bg-white transition-all',
        selected ? 'border-brand-400 ring-2 ring-brand-100' : 'border-slate-100 hover:border-slate-300 hover:shadow-sm',
        isClosed && 'opacity-75',
      )}
    >
      {/* 头:复选 + 患者 + 优先级 */}
      <div className={cn('flex items-start', d.headGap, pad, 'pb-2')}>
        <span
          className="flex-none cursor-pointer pt-0.5"
          onClick={(e) => {
            e.stopPropagation();
            onSelect();
          }}
        >
          <input
            type="checkbox"
            className="h-4 w-4 accent-brand-600 cursor-pointer"
            checked={selected}
            onChange={onSelect}
            onClick={(e) => e.stopPropagation()}
          />
        </span>
        <div className="min-w-0 flex-1">
          <div className="flex min-w-0 flex-wrap items-center gap-1.5">
            <span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span>
            {/* 顺序对齐详情页患者卡:姓名 → 病历号 → 性别·年龄。
                宿主不一定给病历号(jvs-dw 91% / friday 63%),缺就整块不占位 */}
            {p.patient.medicalRecordNumber && (
              <span className="truncate font-mono text-[10.5px] text-slate-400 nums" title="病历号">
                {p.patient.medicalRecordNumber}
              </span>
            )}
            <span className="text-[10.5px] text-slate-500 nums">
              {formatGender(p.patient.gender)} · {p.patient.age ?? '?'} 岁
            </span>
          </div>
          <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
            <span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span>
            {p.patient.phoneVerified && (
              <span
                title="真实手机号(外部对照表已核实)"
                className="inline-flex items-center rounded bg-brand-50 px-1 text-[10px] font-medium leading-4 text-brand-700 ring-1 ring-inset ring-brand-200"
              >
                真
              </span>
            )}
            {/* 诊所名:单诊所权限的人每张卡都是同一家,纯噪音 → 隐藏;跨诊所才有区分价值 */}
            {p.targetClinicId && showClinic && (
              <>
                <span className="text-slate-300">·</span>
                <span className="truncate">{clinicDict?.[p.targetClinicId] ?? p.targetClinicId}</span>
              </>
            )}
          </div>
        </div>
        <PriorityHover
          score={p.priorityScore}
          breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
        >
          <span className="inline-flex items-center gap-1 cursor-help group/score">
            <PriorityBar score={p.priorityScore} />
            <svg className="w-3 h-3 text-slate-400 opacity-0 group-hover/score:opacity-100 transition-opacity" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
              <circle cx="12" cy="12" r="10" />
              <path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
            </svg>
          </span>
        </PriorityHover>
      </div>

      {/* 中:scenario + status */}
      <div className={cn('flex flex-wrap items-center gap-1.5', pad, 'pb-2 pt-0')}>
        <ScenarioChip label={p.scenarioLabel} scenario={p.scenario} />
        <StatusPill status={p.status} />
        {/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项,
            比下面的召回理由更宽 —— 理由只是这次值得召回的那条。全列不折叠。 */}
        <PotentialTreatmentChips labels={p.patient.potentialTreatments} />
      </div>

      {/* 召回理由富文本(跟详情页 WhyCard 同源 ReasonLine):后端给 signals raw JSON,前端字典翻译。
          列表只展示 primary(MAX priorityScore)一条,卡片保持紧凑;多 reason 全量在详情页看。 */}
      <div className={cn('leading-snug text-slate-700', d.reason, pad, 'pb-3 pt-0')}>
        {/* primary = reasons[0](后端按 priorityScore desc 返回 → 最高优先级);其余 +N hover 展示 */}
        {p.reasons[0] ? <ReasonLine reason={p.reasons[0]} /> : <span>{p.inclusionReason}</span>}
        {p.reasons.length > 1 && (
          <HoverCard openDelay={120} closeDelay={80}>
            <HoverCardTrigger asChild>
              <button
                onClick={(e) => e.stopPropagation()}
                className="ml-1.5 align-middle rounded px-1 text-[10.5px] text-slate-400 hover:text-brand-700 hover:bg-slate-100 cursor-default"
              >
                +{p.reasons.length - 1}
              </button>
            </HoverCardTrigger>
            <HoverCardContent align="start" sideOffset={6} className="w-80 p-3">
              <p className="mb-1.5 text-[11px] font-medium text-slate-500">
                其余 {p.reasons.length - 1} 项潜在治疗
              </p>
              <ul className="space-y-1.5 text-[12px] leading-relaxed text-slate-700">
                {p.reasons.slice(1).map((r) => (
                  <li key={r.id} className="flex gap-1.5">
                    <span className="mt-[2px] flex-none text-rose-400">•</span>
                    <div className="min-w-0 flex-1">
                      <ReasonLine reason={r} />
                    </div>
                  </li>
                ))}
              </ul>
            </HoverCardContent>
          </HoverCard>
        )}
      </div>

      {/* 底:meta + 操作 */}
      <div className={cn('mt-auto flex items-center justify-between gap-2 border-t border-slate-100', d.foot)}>
        <div className="flex min-w-0 items-center gap-2.5 text-[11px] text-slate-600">
          {/* 上次到诊 + 那一次的医生(同源于同一次就诊,不会日期/医生错配)。
              医生名只有 emr_record 带,取不到就只显示日期,不拿医生 id 顶替。 */}
          {p.patient.lastVisitAt && (
            <span className="truncate nums" title="上次到诊">
              上次 {p.patient.lastVisitAt}
              {p.patient.lastVisitDoctor && (
                <span className="text-slate-500"> · {p.patient.lastVisitDoctor}</span>
              )}
            </span>
          )}
          {p.assigneeUserId && view === 'all' && (
            <span className="truncate font-mono text-[10.5px] text-slate-500">承接 {p.assigneeUserId}</span>
          )}
        </div>
        <div className="flex flex-none items-center gap-1.5">
          {isPool && canAssign && (
            <button
              onClick={onClaim}
              className="rounded-md bg-brand-600 px-2.5 py-1 text-[12px] font-semibold text-white transition-colors hover:bg-brand-700"
            >
              认领 →
            </button>
          )}
          {isMine && p.status === 'assigned' && (
            <>
              {canRecycle && (
                <button
                  onClick={onUnclaim}
                  className="rounded-md px-2 py-1 text-[11.5px] text-slate-500 hover:bg-slate-100 hover:text-slate-800"
                >
                  取消认领
                </button>
              )}
              <Link
                href={`/plans/${p.id}`}
                className="rounded-md bg-slate-900 px-2.5 py-1 text-[12px] font-semibold text-white hover:bg-slate-800"
              >
                继续 →
              </Link>
            </>
          )}
          {!isPool && !(isMine && p.status === 'assigned') && (
            <Link
              href={`/plans/${p.id}`}
              className="rounded-md border border-slate-100 px-2.5 py-1 text-[12px] text-slate-700 hover:bg-slate-50"
            >
              详情 →
            </Link>
          )}
        </div>
      </div>
    </article>
  );
}

// ─────────────────────────────────────────────────────────────────
// 原子组件
// ─────────────────────────────────────────────────────────────────

function PriorityBar({ score }: { score: number }) {
  const pct = Math.max(0, Math.min(1, score / 100));
  const filled = Math.max(1, Math.round(pct * 5));
  const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
  const labelTone =
    pct >= 0.8 ? 'text-rose-700'
      : pct >= 0.6 ? 'text-amber-700'
      : pct >= 0.4 ? 'text-amber-700'
      : pct >= 0.2 ? 'text-emerald-700'
      : 'text-slate-500';
  // 10 分制(两位小数)= 排序键 score / 10。
  // ⚠️ 不要改回读 breakdown.raw:那会让「显示的数」和「排序用的数」分成两条源,
  //   2026-07-29 就是这么出的乱序 bug(排序用 0-100 整数、显示用 0-10 两位小数)。
  //   现在 score 由 priority-scorer 保证 = raw × 10,除以 10 即还原展示值,单一真理源。
  const disp = (score / 10).toFixed(2);
  return (
    <span
      className="inline-flex flex-none items-center gap-1"
      title={`优先级 ${disp} / 10`}
    >
      <span className="inline-flex items-center gap-0.5">
        {Array.from({ length: 5 }).map((_, i) => (
          <span key={i} className={cn('h-2 w-[7px] rounded-sm', i < filled ? colors[i] : 'bg-slate-200')} />
        ))}
      </span>
      <span className={cn('text-[10.5px] font-semibold nums', labelTone)}>{disp}</span>
    </span>
  );
}

function StatusPill({ status }: { status: string }) {
  const m = STATUS_META[status] ?? { label: status, tone: 'bg-slate-100 text-slate-700' };
  return <span className={cn('inline-block rounded px-1.5 py-0.5 text-[10.5px] font-medium', m.tone)}>{m.label}</span>;
}

/**
 * 潜在治疗标签(画像 potential_treatment.labels)。
 *
 * 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召",
 * 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。
 * 全列不折叠(业务口径):标签多的患者本来就该优先跟,让它换行占地方是对的。
 */
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
  if (!labels.length) return null;
  // 不折叠(与 rail 同口径,见 patient-picker-rail 注释):全列出来,让容器自然换行。
  return (
    <>
      {labels.map((l) => (
        <span
          key={l}
          className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200"
        >
          {potentialTreatmentCardLabel(l)}
        </span>
      ))}
    </>
  );
}

function ScenarioChip({ label, scenario }: { label: string; scenario: string }) {
  const tone =
    scenario === 'treatment_initiation_recall'
      ? 'bg-rose-50 text-rose-700 ring-rose-200'
      : 'bg-brand-50 text-brand-700 ring-brand-200';
  return <span className={cn('inline-block rounded px-1.5 py-0.5 text-[10.5px] font-medium ring-1 ring-inset', tone)}>{label}</span>;
}

// ─────────────────────────────────────────────────────────────────
// Pagination
// ─────────────────────────────────────────────────────────────────

function Pagination({
  page, pageSize, total, onPage,
}: {
  page: number;
  pageSize: number;
  total: number;
  onPage: (p: number) => void;
}) {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  return (
    <div className="flex flex-none items-center justify-between border-t border-slate-100 bg-white px-5 py-2.5 text-[11.5px] text-slate-500">
      <div>
        共 <b className="nums font-semibold text-slate-800">{total}</b> 条 · 第 <b className="nums">{page}</b>/<span className="nums">{totalPages}</span> 页
      </div>
      <div className="flex items-center gap-1">
        <Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onPage(page - 1)} className="h-7">
          <ChevronLeft className="h-3.5 w-3.5" /> 上一页
        </Button>
        <Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPage(page + 1)} className="h-7">
          下一页 <ChevronRight className="h-3.5 w-3.5" />
        </Button>
      </div>
    </div>
  );
}

function PlaceholderCard({ text }: { text: string }) {
  return (
    <Card>
      <CardContent className="py-10 text-center text-sm text-muted-foreground">{text}</CardContent>
    </Card>
  );
}

// ─────────────────────────────────────────────────────────────────
// helpers
// ─────────────────────────────────────────────────────────────────

function errMsg(e: unknown): string {
  if (e instanceof ApiError) return `code=${e.code} · ${e.message}`;
  return e instanceof Error ? e.message : String(e);
}
