import { PlanEngineService } from '../src/modules/plan/engine/plan-engine.service';
import type { ScenarioHit } from '../src/modules/plan/engine/scenario.interface';

/**
 * runAllForHost 批量化(2026-06)回归:
 *   bulk 预取 latest/snoozed/persona + 分块并发 upsert + PlanGenerationLog createMany。
 * 目标:证明批量路径在全部 5 种结局(create / unchanged / superseded / suppressed / stale-close)
 *      下与逐患者语义**等价**。用忠实内存 mock(按 where/orderBy/include 真实建模)端到端跑。
 */

const HOST = 'host-1';
const TENANT = 'tenant-1';
const SCEN = 'treatment_initiation_recall';

// id / evidence / signals:unchanged 分支的 reason 就地刷新要用(见 reason-refresh.ts)
type Reason = {
  scenario: string;
  subKey: string | null;
  id?: string;
  evidence?: unknown;
  signals?: unknown;
  closedReason?: string | null;
};
interface Plan {
  id: string;
  hostId: string;
  tenantId: string;
  patientId: string;
  version: number;
  status: string;
  priorityScore: number;
  targetClinicId?: string | null;
  snoozedUntil: Date | null;
  reasons: Reason[];
  assigneeUserId: string | null;
  assignedAt: Date | null;
  recycleAt: Date | null;
  contactAttempts: number;
  // 分配归因六列(2026-08)
  assignmentId: string | null;
  assignmentExpiresAt: Date | null;
  assignedBy: string | null;
  releaseReason: string | null;
  releaseNote: string | null;
  assignStrategy: string | null;
  // 不可逆决策快照五列(2026-08)—— ⚠️ 与 assignment_id **同生共死**,
  //   留下没有批次的孤儿快照会污染 T20 的因果分析
  selectionMode?: string | null;
  priorityScoreAtAssign?: number | null;
  dedicatedCsAtAssign?: string | null;
  dedicatedCsLastVisitAt?: Date | null;
  sourceConfidenceAtAssign?: number | null;
}

const inArr = (v: unknown): unknown[] | null =>
  v && typeof v === 'object' && Array.isArray((v as { in?: unknown[] }).in)
    ? (v as { in: unknown[] }).in
    : null;

/// 忠实内存 mock:findFirst/findMany 真的按 where(patientId/status 支持 in,snoozedUntil.gt)过滤、
/// 按 version desc 排序;update/updateMany/create/$transaction 真的改同一个 plans 数组。
function makeStore(
  seed: {
    plans?: Partial<Plan>[];
    personas?: { patientId: string; id: string }[];
    /// 客服打开过详情页的 planId(教条 T21 的「已动过」判据,归因该不该继承看它)
    viewedPlanIds?: string[];
  } = {},
) {
  let idc = 1000;
  const plans: Plan[] = (seed.plans ?? []).map((p, i) => ({
    id: p.id ?? `seed-${i}`,
    hostId: HOST,
    tenantId: TENANT,
    patientId: p.patientId!,
    version: p.version ?? 1,
    status: p.status ?? 'active',
    priorityScore: p.priorityScore ?? 50,
    targetClinicId: p.targetClinicId ?? null,
    snoozedUntil: p.snoozedUntil ?? null,
    reasons: p.reasons ?? [],
    assigneeUserId: p.assigneeUserId ?? null,
    assignedAt: p.assignedAt ?? null,
    recycleAt: p.recycleAt ?? null,
    contactAttempts: p.contactAttempts ?? 0,
    assignmentId: p.assignmentId ?? null,
    assignmentExpiresAt: p.assignmentExpiresAt ?? null,
    assignedBy: p.assignedBy ?? null,
    releaseReason: p.releaseReason ?? null,
    releaseNote: p.releaseNote ?? null,
    assignStrategy: p.assignStrategy ?? null,
    // 同 create:五列快照以前不进 seed,断言拿到 undefined 怎么写都过
    selectionMode: p.selectionMode ?? null,
    priorityScoreAtAssign: p.priorityScoreAtAssign ?? null,
    dedicatedCsAtAssign: p.dedicatedCsAtAssign ?? null,
    dedicatedCsLastVisitAt: p.dedicatedCsLastVisitAt ?? null,
    sourceConfidenceAtAssign: p.sourceConfidenceAtAssign ?? null,
  }));
  const personas = seed.personas ?? [];
  const logs: Array<Record<string, unknown>> = [];

  const matchPlan = (pl: Plan, where: Record<string, unknown>): boolean => {
    if (where.hostId && pl.hostId !== where.hostId) return false;
    if (where.tenantId && pl.tenantId !== where.tenantId) return false;
    if (where.patientId !== undefined) {
      const ins = inArr(where.patientId);
      if (ins) {
        if (!ins.includes(pl.patientId)) return false;
      } else if (pl.patientId !== where.patientId) return false;
    }
    if (where.status !== undefined) {
      const ins = inArr(where.status);
      if (ins) {
        if (!ins.includes(pl.status)) return false;
      } else if (pl.status !== where.status) return false;
    }
    const sn = where.snoozedUntil as { gt?: Date } | undefined;
    if (sn?.gt !== undefined) {
      if (!pl.snoozedUntil || !(pl.snoozedUntil > sn.gt)) return false;
    }
    return true;
  };

  const followupPlan = {
    findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
      const m = plans.filter((p) => matchPlan(p, where)).sort((a, b) => b.version - a.version);
      return m[0] ?? null;
    }),
    findMany: jest.fn(async ({ where }: { where: Record<string, unknown> }) =>
      plans
        .filter((p) => matchPlan(p, where))
        .sort((a, b) => a.patientId.localeCompare(b.patientId) || b.version - a.version),
    ),
    update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Plan> }) => {
      const p = plans.find((x) => x.id === where.id)!;
      Object.assign(p, data);
      return p;
    }),
    updateMany: jest.fn(async ({ where, data }: { where: { id: { in: string[] } }; data: Partial<Plan> }) => {
      const ids = where.id?.in ?? [];
      let count = 0;
      for (const p of plans)
        if (ids.includes(p.id)) {
          Object.assign(p, data);
          count++;
        }
      return { count };
    }),
    create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
      const rdata = (data.reasons as { createMany?: { data?: Array<{ scenario: string; subKey?: string }> } })
        ?.createMany?.data ?? [];
      const p: Plan = {
        id: `new-${idc++}`,
        hostId: data.hostId as string,
        tenantId: data.tenantId as string,
        patientId: data.patientId as string,
        version: data.version as number,
        status: data.status as string,
        priorityScore: data.priorityScore as number,
        targetClinicId: (data.targetClinicId as string) ?? null,
        snoozedUntil: null,
        reasons: rdata.map((r) => ({ scenario: r.scenario, subKey: r.subKey ?? null })),
        assigneeUserId: (data.assigneeUserId as string) ?? null,
        assignedAt: (data.assignedAt as Date) ?? null,
        recycleAt: (data.recycleAt as Date) ?? null,
        contactAttempts: (data.contactAttempts as number) ?? 0,
        assignmentId: (data.assignmentId as string) ?? null,
        assignmentExpiresAt: (data.assignmentExpiresAt as Date) ?? null,
        assignedBy: (data.assignedBy as string) ?? null,
        releaseReason: (data.releaseReason as string) ?? null,
        releaseNote: (data.releaseNote as string) ?? null,
        assignStrategy: (data.assignStrategy as string) ?? null,
        // ⚠️ 这五列以前没进 mock,于是"快照有没有被正确继承/清空"在单测里**根本看不见**
        //   (断言拿到的永远是 undefined,怎么写都过)。补上,别再留这种假绿。
        selectionMode: (data.selectionMode as string) ?? null,
        priorityScoreAtAssign: (data.priorityScoreAtAssign as number) ?? null,
        dedicatedCsAtAssign: (data.dedicatedCsAtAssign as string) ?? null,
        dedicatedCsLastVisitAt: (data.dedicatedCsLastVisitAt as Date) ?? null,
        sourceConfidenceAtAssign: (data.sourceConfidenceAtAssign as number) ?? null,
      };
      plans.push(p);
      return p;
    }),
  };

  const persona = {
    findFirst: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
      const ins = inArr(where.patientId);
      const cand = personas.filter((pe) =>
        ins ? ins.includes(pe.patientId) : pe.patientId === where.patientId,
      );
      return cand[0] ?? null;
    }),
    findMany: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
      const ins = (inArr(where.patientId) as string[]) ?? [];
      return personas.filter((pe) => ins.includes(pe.patientId));
    }),
  };

  const planGenerationLog = {
    create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
      const r = { id: `log-${idc++}`, ...data };
      logs.push(r);
      return r;
    }),
    update: jest.fn(async () => ({})),
    createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
      logs.push(...data);
      return { count: data.length };
    }),
  };

  // unchanged 分支的 reason 就地刷新会用到(见 reason-refresh.ts)
  const planReason = { update: jest.fn(async (_args: unknown) => ({})) };

  // 归属账本(2026-08 引擎补记账后引擎会往这里写):收集下来供断言
  const events: Array<Record<string, unknown>> = [];
  const planEventLog = {
    create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
      events.push(data);
      return data;
    }),
    /// 「客服打开过详情页」—— 归因该不该继续继承看它(教条 T21 的「已动过」判据)
    groupBy: jest.fn(async ({ where }: { where: { planId?: { in?: string[] } } }) => {
      const ids = new Set(where?.planId?.in ?? []);
      return (seed.viewedPlanIds ?? []).filter((id) => ids.has(id)).map((planId) => ({ planId }));
    }),
    findFirst: jest.fn(async ({ where }: { where: { planId?: string } }) =>
      (seed.viewedPlanIds ?? []).includes(where?.planId ?? '') ? { id: 'ev-1' } : null,
    ),
    createMany: jest.fn(async ({ data }: { data: Array<Record<string, unknown>> }) => {
      events.push(...data);
      return { count: data.length };
    }),
  };

  const prisma = {
    followupPlan,
    planReason,
    planEventLog,
    persona,
    planGenerationLog,
    // 最后到诊诊所查询(prefetchForBatch):本套件不测归属 → 返空 = 回退兜底 head.targetClinicId
    $queryRaw: jest.fn(async (): Promise<Array<{ patient_id: string; clinic_id: string }>> => []),
    $transaction: jest.fn(async (cb: (tx: unknown) => Promise<unknown>) =>
      cb({
        followupPlan: {
          update: followupPlan.update,
          updateMany: followupPlan.updateMany,
          create: followupPlan.create,
        },
        planReason: { update: planReason.update },
        planEventLog,
      }),
    ),
  };
  return { prisma, plans, logs, planReason, events };
}

function makeScenario(hits: ScenarioHit[]) {
  return { key: SCEN, selectHits: jest.fn(async () => hits) };
}
function hit(patientId: string, subKey: string, priorityScore = 50, targetClinicId?: string): ScenarioHit {
  return {
    patientId,
    patientExternalId: `ext-${patientId}`,
    reason: 'r',
    priorityScore,
    subKey,
    targetClinicId: targetClinicId ?? null,
    evidence: { factIds: ['f1'] },
  } as ScenarioHit;
}
function engine(prisma: unknown, scenario: unknown) {
  return new PlanEngineService(prisma as never, scenario as never);
}
const NOW = new Date('2026-06-02T00:00:00Z');

describe('runAllForHost 批量路径 — 5 种结局等价', () => {
  test('create:有命中无既有 plan → plansCreated=1 + 新 active plan(reason 正确)', async () => {
    const { prisma, plans } = makeStore({});
    const res = await engine(prisma, makeScenario([hit('pat-a', 'missing_tooth@whole')])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansCreated).toBe(1);
    const active = plans.filter((p) => p.patientId === 'pat-a' && p.status === 'active');
    expect(active).toHaveLength(1);
    expect(active[0]!.reasons.map((r) => r.subKey)).toEqual(['missing_tooth@whole']);
  });

  test('unchanged:既有 active 同 (scenario,subKey) → plansUnchanged=1,不升版本,仅刷分', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-old',
          patientId: 'pat-b',
          version: 1,
          status: 'active',
          priorityScore: 40,
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    const res = await engine(prisma, makeScenario([hit('pat-b', 'missing_tooth@whole', 55)])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansUnchanged).toBe(1);
    expect(res.plansSuperseded).toBe(0);
    expect(plans.filter((p) => p.patientId === 'pat-b')).toHaveLength(1); // 没升版本
    expect(plans.find((p) => p.id === 'p-old')!.priorityScore).toBe(55); // 刷分
  });

  // ⭐ 2026-07:(scenario,subKey) 没变、但 signals 语义变了(如缺牙按年龄排治疗新增
  //   focusCategory / patientAge)—— 原先走 unchanged 分支一个字都不改,存量 plan 永远修不好。
  test('unchanged + signals 语义变化 → reason 就地刷新,**不升版本、不动认领**', async () => {
    const { prisma, plans, planReason } = makeStore({
      plans: [
        {
          id: 'p-stale',
          patientId: 'pat-age',
          version: 3,
          status: 'assigned',
          assigneeUserId: 'u-cs',
          contactAttempts: 2,
          priorityScore: 50,
          reasons: [
            {
              id: 'r-stale',
              scenario: SCEN,
              subKey: 'missing_tooth@16',
              evidence: { factIds: ['f1'] },
              // 旧 signals:没有年龄适配字段
              signals: { subKey: 'missing_tooth', expectedCategories: ['implant', 'prosthodontic'] },
            },
          ],
        },
      ],
    });
    const h = hit('pat-age', 'missing_tooth@16', 50);
    // 新 signals 带上年龄适配结果
    (h as { signals?: unknown }).signals = {
      subKey: 'missing_tooth',
      expectedCategories: ['implant', 'prosthodontic'],
      focusCategory: 'prosthodontic',
      patientAge: 90,
    };
    const res = await engine(prisma, makeScenario([h])).runAllForHost({
      hostId: HOST, tenantId: TENANT, now: NOW,
    });

    expect(res.plansUnchanged).toBe(1);
    expect(res.plansSuperseded).toBe(0);
    expect(plans.filter((p) => p.patientId === 'pat-age')).toHaveLength(1); // 没升版本
    const p = plans.find((x) => x.id === 'p-stale')!;
    expect(p.version).toBe(3);                 // 版本号未动
    expect(p.status).toBe('assigned');         // 状态未动
    expect(p.assigneeUserId).toBe('u-cs');     // ⭐ 认领未丢
    expect(p.contactAttempts).toBe(2);         // 触达计数未清
    // reason 行被就地重写
    expect(planReason.update).toHaveBeenCalledTimes(1);
    const arg = planReason.update.mock.calls[0]![0] as {
      where: { id: string }; data: { signals: Record<string, unknown> };
    };
    expect(arg.where.id).toBe('r-stale');
    expect(arg.data.signals.focusCategory).toBe('prosthodontic');
    expect(arg.data.signals.patientAge).toBe(90);
  });

  test('⭐ unchanged + signals 完全一致 → 不写 reason(防每日重算全量重写)', async () => {
    const signals = {
      subKey: 'missing_tooth',
      expectedCategories: ['implant', 'prosthodontic'],
      focusCategory: 'prosthodontic',
      patientAge: 90,
      daysSince: 100,
    };
    const { prisma, planReason } = makeStore({
      plans: [
        {
          id: 'p-fresh', patientId: 'pat-same', version: 1, status: 'active', priorityScore: 50,
          reasons: [
            { id: 'r1', scenario: SCEN, subKey: 'missing_tooth@16',
              evidence: { factIds: ['f1'] }, signals },
          ],
        },
      ],
    });
    const h = hit('pat-same', 'missing_tooth@16', 50);
    // 只有天数往前走了一天 —— 语义没变
    (h as { signals?: unknown }).signals = { ...signals, daysSince: 101 };
    await engine(prisma, makeScenario([h])).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    expect(planReason.update).not.toHaveBeenCalled();
  });

  test('superseded:既有 active 不同 subKey → plansSuperseded=1,旧 superseded + 新 v2 active', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-old',
          patientId: 'pat-c',
          version: 1,
          status: 'active',
          reasons: [{ scenario: SCEN, subKey: 'caries@16' }],
        },
      ],
    });
    const res = await engine(prisma, makeScenario([hit('pat-c', 'missing_tooth@36')])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansSuperseded).toBe(1);
    expect(plans.find((p) => p.id === 'p-old')!.status).toBe('superseded');
    const v2 = plans.find((p) => p.patientId === 'pat-c' && p.version === 2);
    expect(v2?.status).toBe('active');
    expect(v2?.reasons.map((r) => r.subKey)).toEqual(['missing_tooth@36']);
  });

  test('suppressed:命中信号在 终态+未到期 抑制集 → plansSuppressed=1,不建新', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-term',
          patientId: 'pat-d',
          version: 1,
          status: 'completed',
          snoozedUntil: new Date('2026-12-01T00:00:00Z'),
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    const res = await engine(prisma, makeScenario([hit('pat-d', 'missing_tooth@whole')])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansSuppressed).toBe(1);
    expect(res.plansCreated).toBe(0);
    expect(plans.filter((p) => p.patientId === 'pat-d' && p.status === 'active')).toHaveLength(0);
  });

  test('stale-close:有 active 但本轮 0 命中 → plansClosed=1', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-stale',
          patientId: 'pat-e',
          version: 1,
          status: 'active',
          reasons: [{ scenario: SCEN, subKey: 'caries@16' }],
        },
      ],
    });
    const res = await engine(prisma, makeScenario([])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansClosed).toBe(1);
    expect(plans.find((p) => p.id === 'p-stale')!.status).toBe('superseded');
  });

  test('混合一批:create+unchanged+superseded+suppressed 并发跑 → 计数正确 + 每患一条 createMany 日志', async () => {
    const { prisma, plans, logs } = makeStore({
      plans: [
        { id: 'u-old', patientId: 'p2', status: 'active', priorityScore: 50, reasons: [{ scenario: SCEN, subKey: 'k@1' }] },
        { id: 's-old', patientId: 'p3', status: 'active', reasons: [{ scenario: SCEN, subKey: 'k@old' }] },
        {
          id: 't-term',
          patientId: 'p4',
          status: 'abandoned',
          snoozedUntil: new Date('2026-12-01T00:00:00Z'),
          reasons: [{ scenario: SCEN, subKey: 'k@4' }],
        },
      ],
    });
    const res = await engine(
      prisma,
      makeScenario([
        hit('p1', 'k@1'), // create
        hit('p2', 'k@1', 50), // unchanged
        hit('p3', 'k@new'), // superseded
        hit('p4', 'k@4'), // suppressed
      ]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    expect({ c: res.plansCreated, u: res.plansUnchanged, s: res.plansSuperseded, sup: res.plansSuppressed }).toEqual({
      c: 1,
      u: 1,
      s: 1,
      sup: 1,
    });
    expect(res.patientsHit).toBe(4);
    // PlanGenerationLog 走批量 createMany,每患一行(口径不变)
    expect(prisma.planGenerationLog.createMany).toHaveBeenCalled();
    expect(logs.filter((l) => ['p1', 'p2', 'p3', 'p4'].includes(l.patientId as string))).toHaveLength(4);
    // 无既有 active 且在 hitsByPatient 的患者不应被 stale-close 误关
    expect(res.plansClosed).toBe(0);
  });
});

describe('跟进诊所归属 = 患者最后到诊诊所(与诊断诊所解耦)', () => {
  test('有到诊记录 → plan.targetClinicId 取最后到诊诊所,而非触发诊断诊所', async () => {
    const { prisma, plans } = makeStore({});
    // 触发诊断落在 clinic-diag(如影像AI/顺路就诊那家);患者最后到诊在 clinic-home(主诊所)
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-x', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const active = plans.find((p) => p.patientId === 'pat-x' && p.status === 'active')!;
    expect(active.targetClinicId).toBe('clinic-home'); // ⭐ 归属跟人走,不跟诊断走
  });

  test('无任何到诊记录 → 回退触发诊断诊所(不置空)', async () => {
    const { prisma, plans } = makeStore({});
    prisma.$queryRaw = jest.fn(async () => []); // 查不到到诊
    await engine(
      prisma,
      makeScenario([hit('pat-y', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const active = plans.find((p) => p.patientId === 'pat-y' && p.status === 'active')!;
    expect(active.targetClinicId).toBe('clinic-diag'); // 兜底
  });

  test('reason 未变但最后到诊诊所变了 → 就地改归属,不升版本(修存量)', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-old',
          patientId: 'pat-z',
          version: 1,
          status: 'active',
          priorityScore: 50,
          targetClinicId: 'clinic-old', // 存量错分
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-z', clinic_id: 'clinic-new' }]);
    const res = await engine(
      prisma,
      makeScenario([hit('pat-z', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    expect(res.plansUnchanged).toBe(1); // reason 没变
    expect(plans.filter((p) => p.patientId === 'pat-z')).toHaveLength(1); // 没升版本
    expect(plans.find((p) => p.id === 'p-old')!.targetClinicId).toBe('clinic-new'); // ⭐ 就地改归属
  });

  test('unchanged + 已认领 + 归属漂移 → 顺带返池(防孤儿单:诊所隔离硬边界下原客服将出 scope)', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-assigned',
          patientId: 'pat-a',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-1',
          assignedAt: new Date('2026-07-01'),
          recycleAt: new Date('2026-08-01'),
          priorityScore: 50,
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-a', clinic_id: 'clinic-new' }]);
    const res = await engine(
      prisma,
      makeScenario([hit('pat-a', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    expect(res.plansUnchanged).toBe(1);
    const p = plans.find((x) => x.id === 'p-assigned')!;
    expect(p.targetClinicId).toBe('clinic-new');
    expect(p.status).toBe('active'); // ⭐ 返池
    expect(p.assigneeUserId).toBeNull();
  });

  test('升版本 + 已认领 + 归属漂移 → assigned 不继承,新版本返池', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-assigned2',
          patientId: 'pat-b',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-2',
          priorityScore: 50,
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit subKey 不同 → 升版本
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-b', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-b', 'missing_tooth@whole', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const v2 = plans.find((x) => x.patientId === 'pat-b' && x.version === 2)!;
    expect(v2.targetClinicId).toBe('clinic-new');
    expect(v2.status).toBe('active'); // ⭐ 不继承 assigned
    expect(v2.assigneeUserId).toBeNull();
  });

  test('升版本 + 已认领 + 归属未变 → assigned 照常继承(返池规则不误伤)', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-assigned3',
          patientId: 'pat-c',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-3',
          priorityScore: 50,
          targetClinicId: 'clinic-home',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-c', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-c', 'missing_tooth@whole', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const v2 = plans.find((x) => x.patientId === 'pat-c' && x.version === 2)!;
    expect(v2.status).toBe('assigned');
    expect(v2.assigneeUserId).toBe('staff-3'); // ⭐ 继承不回归
  });
});

// =============================================================
// 2026-08 · 引擎丢归属必须留账(召回分配 P0.5 / 教条 4.35)
// =============================================================
//
// 为什么这组断言值钱:引擎"悄悄把单从客服手里收走"是**没有任何报错**的行为 ——
// 主管分了 50 单,重算收回一部分,主管毫不知情;且这些单既没 release 也没 auto_release,
// 退回率的分母天生偏小。分配功能上线后这会直接体现为"分下去的单莫名其妙消失"。
describe('归属账本 — 引擎释放归属必须记 auto_release', () => {
  const ledger = (events: Array<Record<string, unknown>>) =>
    events.filter((e) => e.event === 'auto_release');

  test('⭐ unchanged + 诊所重归属收走归属 → 记 clinic_moved,持有时长算得出来', async () => {
    const { prisma, events } = makeStore({
      plans: [
        {
          id: 'p-x',
          patientId: 'pat-a',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-1',
          assignedAt: new Date(NOW.getTime() - 7200_000), // 2 小时前
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-a', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-a', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const rows = ledger(events);
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      planId: 'p-x',
      patientId: 'pat-a',
      reason: 'clinic_moved',
      assigneeUserId: null, // 释放后无人归属
      actorUserId: null, // 系统行为
    });
    // ⭐ heldSeconds 必须在清空 assignedAt **之前**算出来,清完就永远算不出来了
    expect(rows[0]!.heldSeconds).toBe(7200);
  });

  test('⭐ 升版本 + 归属不继承 → 记 clinic_moved,且 planId 记在**旧版本**上', async () => {
    const { prisma, events, plans } = makeStore({
      plans: [
        {
          id: 'p-y',
          patientId: 'pat-b',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-2',
          assignedAt: new Date(NOW.getTime() - 3600_000),
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }], // 与新 hit 不同 → 升版本
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-b', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-b', 'missing_tooth@whole', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const rows = ledger(events);
    expect(rows).toHaveLength(1);
    // 归属本来就挂在旧版上;记到新版会让"这个客服持有过哪些单"的回溯全部对不上
    expect(rows[0]!.planId).toBe('p-y');
    const v2 = plans.find((x) => x.patientId === 'pat-b' && x.version === 2)!;
    expect(rows[0]!.planId).not.toBe(v2.id);
    expect(rows[0]!.reason).toBe('clinic_moved');
  });

  test('⭐ 信号清零关闭已认领的单 → 记 signals_cleared', async () => {
    const { prisma, events } = makeStore({
      plans: [
        {
          id: 'p-z',
          patientId: 'pat-gone',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-3',
          assignedAt: new Date(NOW.getTime() - 1800_000),
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
        },
      ],
    });
    // 本轮该患者 0 命中 → 走批量收尾的关闭闸
    const res = await engine(prisma, makeScenario([])).runAllForHost({
      hostId: HOST,
      tenantId: TENANT,
      now: NOW,
    });
    expect(res.plansClosed).toBe(1);
    const rows = ledger(events);
    expect(rows).toHaveLength(1);
    expect(rows[0]).toMatchObject({
      planId: 'p-z',
      reason: 'signals_cleared',
      assigneeUserId: null,
      actorUserId: null,
    });
    expect(rows[0]!.heldSeconds).toBe(1800);
  });

  test('⭐⭐ 无人认领的单被关/被改 → **一条事件都不写**(否则每日全量会造事件洪峰)', async () => {
    const { prisma, events } = makeStore({
      plans: [
        // 无人认领 + 信号清零 → 关闭
        { id: 'p-n1', patientId: 'pat-n1', version: 1, status: 'active', reasons: [{ scenario: SCEN, subKey: 'x' }] },
        // 无人认领 + 诊所漂移 → 就地改
        {
          id: 'p-n2',
          patientId: 'pat-n2',
          version: 1,
          status: 'active',
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-n2', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-n2', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    expect(ledger(events)).toHaveLength(0);
  });

  test('🔴 归属被继承(诊所没变)→ 没有释放,不记账', async () => {
    const { prisma, events } = makeStore({
      plans: [
        {
          id: 'p-keep',
          patientId: 'pat-c',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-4',
          assignedAt: new Date(NOW.getTime() - 3600_000),
          targetClinicId: 'clinic-home',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-c', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-c', 'missing_tooth@whole', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    expect(ledger(events)).toHaveLength(0);
  });
});

// =============================================================
// 2026-08 · 分配归因守恒(召回分配 P1.2 / 契约 D-12)
// =============================================================
//
// 🔴 **不绿则 P2 的写路径不许上生产。**
//
// 为什么单开一组:归因列若挂在 carryAssignment 上,只有 `status='assigned'` 那一支能继承,
// 而**退回后的 plan 是 `status='active'`** —— 每一条被退回过的单,引擎升一次版本
// 就把批次归因连**分子带分母**一起抹掉,不报错、无告警,等发现时历史已经花了。
// 只测 assigned 那一支会漏掉这个,所以下面第 2 条才是真正的看门测试。
describe('分配归因守恒 — 升版本不得吞掉批次归属', () => {
  const seedAttrib = {
    assignmentId: 'batch-N',
    assignedBy: 'leader-1',
    assignStrategy: 'dedicated',
    assignmentExpiresAt: new Date('2026-08-10T00:00:00Z'),
  };

  test('① 已分配(assigned)+ 诊所未变 → 升版本后归因与归属全部守恒', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-1',
          patientId: 'pat-1',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-1',
          targetClinicId: 'clinic-home',
          // ⭐ 与新 hit 的 subKey 不同 → 升版本;但**缺口类型相同**(都是 caries,只是又坏一颗牙)
          //   → 还是同一张工单 → 归因必须守恒(见 plan-engine 的 carryAttribution)
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
          ...seedAttrib,
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-1', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-1', 'caries@11;36', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const v2 = plans.find((p) => p.patientId === 'pat-1' && p.version === 2)!;
    expect(v2.assignmentId).toBe('batch-N');
    expect(v2.assignedBy).toBe('leader-1');
    expect(v2.assignStrategy).toBe('dedicated');
    // 归属继承 → 在办期限也跟着继承
    expect(v2.assignmentExpiresAt).toEqual(seedAttrib.assignmentExpiresAt);
    expect(v2.assigneeUserId).toBe('staff-1');
  });

  test('🔴🔴 ② **退回后**(status=active)+ 有归因 → 批次**仍然看得到这条**(退回率不失真)', async () => {
    // 这一支是四份子方案全都漏掉的。原始担心是:carryAssignment 在这里是 false,
    // 归因若跟着它走就会归零 —— 而这恰恰是最需要统计的一类单(被退回的)。
    //
    // ⚠️ 2026-08 改判:担心成立,但**兜住它的不是"新版本继承归因"**。
    //   退回本身就是一次处置 → 按新判据「客服碰过就不继承」,新版本不该带归因;
    //   真正兜住退回率的是**旧行**:它带着 assignment_id + releaseReason 留在批次里,
    //   而跟踪按「按患者取最新版、不过滤 superseded」正好取到它。
    //   所以这里断言的是**结果**(批次看得到、原因还在),不是机制(v2 继承了)。
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-2',
          patientId: 'pat-2',
          version: 1,
          status: 'active', // ⭐ 退回后的状态,不是 assigned
          assigneeUserId: null,
          targetClinicId: 'clinic-home',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
          assignmentId: 'batch-N',
          assignedBy: 'leader-1',
          assignStrategy: 'spread',
          releaseReason: 'over_capacity',
          releaseNote: '手上还有 40 个',
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-2', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-2', 'caries@11;36', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const v1 = plans.find((p) => p.patientId === 'pat-2' && p.version === 1)!;
    const v2 = plans.find((p) => p.patientId === 'pat-2' && p.version === 2)!;
    // 分母:这条单**属于批次 N**,退回不改变这个历史事实 —— 记在旧行上
    expect(v1.assignmentId).toBe('batch-N');
    expect(v1.assignedBy).toBe('leader-1');
    expect(v1.assignStrategy).toBe('spread');
    // 分子:退回原因也要留着,否则「退回 5 条,原因分布如下」就只剩个数字
    expect(v1.releaseReason).toBe('over_capacity');
    expect(v1.releaseNote).toBe('手上还有 40 个');
    expect(v1.status).toBe('superseded'); // 跟踪取最新版时,带 batch-N 的只剩它
    // 新版本是**另一张工单**:客服已经对上一张做过处置,批次的账落定了
    expect(v2.assignmentId).toBeNull();
    expect(v2.releaseReason).toBeNull();
    expect(v2.status).toBe('active');
    expect(v2.assigneeUserId).toBeNull();
  });

  test('③ 诊所重归属导致不继承 → 归因守恒,但**在办期限清空**', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-3',
          patientId: 'pat-3',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-3',
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
          ...seedAttrib,
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-3', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-3', 'caries@11;36', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const v2 = plans.find((p) => p.patientId === 'pat-3' && p.version === 2)!;
    expect(v2.assignmentId).toBe('batch-N'); // 归因守恒
    expect(v2.assignedBy).toBe('leader-1');
    expect(v2.assigneeUserId).toBeNull(); // 归属释放
    // ⭐ 期限跟随归属:归属没了还留着期限,列就不自洽了(非空 ⟺ 有一次在办的分配)
    expect(v2.assignmentExpiresAt).toBeNull();
  });

  test('④ unchanged 分支的诊所重归属 → 就地改也只清期限,不清归因', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-4',
          patientId: 'pat-4',
          version: 1,
          status: 'assigned',
          assigneeUserId: 'staff-4',
          targetClinicId: 'clinic-old',
          reasons: [{ scenario: SCEN, subKey: 'missing_tooth@whole' }], // 与新 hit 相同 → unchanged
          ...seedAttrib,
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-4', clinic_id: 'clinic-new' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-4', 'missing_tooth@whole', 50, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const p = plans.find((x) => x.id === 'p-4')!;
    expect(p.assignmentId).toBe('batch-N');
    expect(p.assignedBy).toBe('leader-1');
    expect(p.assignStrategy).toBe('dedicated');
    expect(p.assigneeUserId).toBeNull();
    expect(p.assignmentExpiresAt).toBeNull();
  });

  test('⑤ 无归因的普通单升版本 → 六列都是 null,不凭空造归因', async () => {
    const { prisma, plans } = makeStore({
      plans: [
        {
          id: 'p-5',
          patientId: 'pat-5',
          version: 1,
          status: 'active',
          targetClinicId: 'clinic-home',
          reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
        },
      ],
    });
    prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-5', clinic_id: 'clinic-home' }]);
    await engine(
      prisma,
      makeScenario([hit('pat-5', 'missing_tooth@whole', 60, 'clinic-diag')]),
    ).runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });

    const v2 = plans.find((p) => p.patientId === 'pat-5' && p.version === 2)!;
    expect(v2.assignmentId).toBeNull(); // null = 自助认领,不是"未知"
    expect(v2.assignedBy).toBeNull();
    expect(v2.assignStrategy).toBeNull();
    expect(v2.releaseReason).toBeNull();
    expect(v2.assignmentExpiresAt).toBeNull();
  });
});

// =============================================================
// 2026-08 · 归因继承的**边界**:看客服碰过没,不看召回理由变没变
// =============================================================
//
// 产品裁决的推理链(值得完整记下来,因为第一版判据是错的):
//   「不继承应该是看客服有没处理过」
//   「召回一会儿出现一会儿不出现,也可能是被客服处理过所致」
//   「客服没处理过、召回变了,值得继承」
//   「该患者召回再次出现同一种诊断,也不代表客服没处理过」
//
// ⛔ 先做错的一版是拿「召回理由整组换掉」当判据,两个方向都会错:
//    ① 理由消失/重现**恰恰可能是客服处理过**造成的 → 该结账的反而被当成新工单
//    ② 同一诊断再次出现**不代表没处理过**        → 该结账的也继承了
//    理由的变化根本不携带"客服做没做事"的信息,它只反映患者临床状态在动。
describe('归因继承的边界 — 判据是「客服碰过没」', () => {
  const seedAttrib = {
    assignmentId: 'batch-N',
    assignedBy: 'leader-1',
    assignStrategy: 'dedicated',
    selectionMode: 'explore',
    priorityScoreAtAssign: 77,
  };
  const seedPlan = (over: Partial<Plan>): Partial<Plan> => ({
    patientId: 'pat-x', version: 1, status: 'assigned', assigneeUserId: 'staff-1',
    targetClinicId: 'clinic-home', reasons: [{ scenario: SCEN, subKey: 'caries@11' }],
    ...seedAttrib, ...over,
  });
  const run = async (seed: Parameters<typeof makeStore>[0], subKey: string) => {
    const store = makeStore(seed);
    store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
    await engine(store.prisma, makeScenario([hit('pat-x', subKey, 60, 'clinic-diag')]))
      .runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    return store.plans.find((p) => p.patientId === 'pat-x' && p.version === 2)!;
  };

  test('⭐⭐ 客服**没碰过** + 理由整组换掉(龋齿 → 缺牙)→ 照样**继承**', async () => {
    // 批次分的是**人**不是诊断:主管要知道的是"这 100 个人有多少被联系了"。
    // 这个人一次都没被联系过,账就还欠着 —— 理由怎么变都不改变这件事。
    const v2 = await run({ plans: [seedPlan({ id: 'p-a' })] }, 'missing_tooth@whole');
    expect(v2.assignmentId).toBe('batch-N');
    expect(v2.selectionMode).toBe('explore');
  });

  test('⭐⭐ 客服**打开过详情页**(view)→ 账落定,**不继承**', async () => {
    // ⭐ view 是主判据(教条 T21):生产实测 view 1,024 条 vs plan_executions 7 条。
    const v2 = await run({ plans: [seedPlan({ id: 'p-b' })], viewedPlanIds: ['p-b'] }, 'caries@11;36');
    expect(v2.assignmentId).toBeNull();
    // ⭐ 快照列必须一起归零:留下 selectionMode='explore' 却查不到批次的孤儿行,
    //   会污染 T20 的因果分析(探索组样本本来就小)
    expect(v2.selectionMode).toBeNull();
    expect(v2.priorityScoreAtAssign).toBeNull();
  });

  test('⭐ 写了回访结果(snoozedUntil)→ 不继承', async () => {
    const v2 = await run(
      { plans: [seedPlan({ id: 'p-c', snoozedUntil: new Date('2027-01-01') })] },
      'caries@11;36',
    );
    expect(v2.assignmentId).toBeNull();
  });

  test('⭐ 退回过(releaseReason)→ 不继承 —— 退回也是一种处置', async () => {
    const v2 = await run(
      { plans: [seedPlan({ id: 'p-d', status: 'active', assigneeUserId: null, releaseReason: 'over_capacity' })] },
      'caries@11;36',
    );
    expect(v2.assignmentId).toBeNull();
  });

  test('⭐ 提交过执行(contactAttempts)→ 不继承', async () => {
    const v2 = await run({ plans: [seedPlan({ id: 'p-e', contactAttempts: 2 })] }, 'caries@11;36');
    expect(v2.assignmentId).toBeNull();
  });

  test('⭐⭐ 不继承时批次**不会丢人**:旧行仍带着归因,跟踪取到的就是它', async () => {
    // 这条是上面几条能成立的前提。D-12 当初怕的「分母静默缩水」由它兜住 ——
    // 带 batch-N 的行只剩 v1,跟踪按患者取最新版正好取到,状态 superseded → 记「已处理」。
    const store = makeStore({ plans: [seedPlan({ id: 'p-f' })], viewedPlanIds: ['p-f'] });
    store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
    await engine(store.prisma, makeScenario([hit('pat-x', 'caries@11;36', 60, 'clinic-diag')]))
      .runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const v1 = store.plans.find((p) => p.id === 'p-f')!;
    expect(v1.assignmentId).toBe('batch-N'); // ⛔ 旧行的归因绝不能被清
    expect(v1.status).toBe('superseded');
  });

  test('⛔ 没进过批次的单不去查 view 事件 —— 全量重算会多出 44 万次查询', async () => {
    const store = makeStore({
      plans: [seedPlan({ id: 'p-g', assignmentId: null, assignedBy: null, selectionMode: null, priorityScoreAtAssign: null })],
    });
    store.prisma.$queryRaw = jest.fn(async () => [{ patient_id: 'pat-x', clinic_id: 'clinic-home' }]);
    await engine(store.prisma, makeScenario([hit('pat-x', 'caries@11;36', 60, 'clinic-diag')]))
      .runAllForHost({ hostId: HOST, tenantId: TENANT, now: NOW });
    const called = (store.prisma.planEventLog.groupBy as jest.Mock).mock.calls;
    for (const [args] of called) expect(args.where.planId.in).not.toContain('p-g');
  });
});
