import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { TreatmentInitiationRecallScenario } from './scenarios/treatment-initiation-recall.scenario';
import type { PlanScenarioPlugin, ScenarioHit, ScenarioScope } from './scenario.interface';

/**
 * PlanEngineService — 跑所有 scenario plugin,产 FollowupPlan + PlanReason
 *
 * 模式:**Plan 是 patient 级触达单元**,Reason 是 scenario 维度命中理由。
 * 同 patient 多 scenario 命中 → 合并到同一 active Plan(多条 reason);
 * 同 plan 内同 scenario 最多一条 reason(`@@unique([planId, scenario])` 兜底)。
 *
 * 入口:
 *   - runAllForHost({ hostId, tenantId, now }) — 批量,所有 patient 全扫
 *   - runForPatient({ ..., patientId })        — 单 patient(W3+ 单刷场景)
 *
 * Plan 状态机:
 *   - 无 active plan + 有 hits → 创建新 plan(version=1)
 *   - 有 active plan + hits 一致(reason set 同)→ no-op(只更新 priorityScore)
 *   - 有 active plan + hits 不同 → supersede 旧 + 创建新版本
 *   - 已有 assigned plan → 不动(避免抢正在跟进的客服)— v1 用 simple skip
 */
@Injectable()
export class PlanEngineService {
  private readonly logger = new Logger(PlanEngineService.name);
  private readonly scenarios: PlanScenarioPlugin[];

  constructor(
    private readonly prisma: PrismaService,
    private readonly initiation: TreatmentInitiationRecallScenario,
  ) {
    // v2.1:一期只跑潜在治疗新链召回(treatment_initiation_recall)
    // 链已完成召回(treatment_aftercare_recall)留后续
    this.scenarios = [initiation];
  }

  /**
   * 单 patient 重算(W2 兼容入口 — 走 runAllForHost 内置过滤)。
   */
  async recomputeForPatient(input: {
    hostId: string;
    tenantId: string;
    clinicId?: string;
    patientId: string;
  }): Promise<{
    plansCreated: number;
    plansClosed: number;
    /// 本次重算对该患者 plan 的结果(前端刷新据此提示/切换版本)
    outcome: 'created' | 'superseded' | 'unchanged' | 'suppressed' | 'closed' | 'none';
    /// 重算后该患者当前 active/assigned plan id(关闭/无 plan 时 null)
    currentPlanId: string | null;
  }> {
    // 真·单 patient:scenario SQL 带 patientId 收窄(只扫该患者),只 upsert 这一个 plan。
    // (旧版走 runAllForHost 全租户扫 + 写全员 plan,详情页单刷被拖到分钟级 → 现 O(1))
    const scope: ScenarioScope = {
      hostId: input.hostId,
      tenantId: input.tenantId,
      now: new Date(),
      patientId: input.patientId,
    };
    const hits: ScenarioHitWithKey[] = [];
    for (const sc of this.scenarios) {
      const scHits = await sc.selectHits(scope);
      for (const h of scHits) hits.push({ ...h, scenarioKey: sc.key });
    }
    // ⭐ 缺口1 修复:本轮 0 命中 = 该患者已无任何活信号(治完疗/诊断消失/被排除闸过滤)。
    //   旧版直接 return → 患者遗留的 active plan 永远残留在召回池(治完疗该结案却一直 active)。
    //   现在主动 supersede 关闭它那条 active(非 assigned)plan,让它退出召回池。
    //   - assigned 不动(客服正在跟进,关掉会打断)
    //   - superseded/completed/abandoned 已是终态,无需处理
    if (hits.length === 0) {
      const closed = await this.closeStaleActivePlan(scope, input.patientId);
      return {
        plansCreated: 0,
        plansClosed: closed ? 1 : 0,
        outcome: closed ? 'closed' : 'none',
        currentPlanId: null,
      };
    }

    const log = await this.prisma.planGenerationLog.create({
      data: {
        hostId: input.hostId,
        tenantId: input.tenantId,
        patientId: input.patientId,
        triggeredBy: 'patient_refresh',
        status: 'running',
        scenarioRunCount: this.scenarios.length,
      },
    });
    try {
      const result = await this.upsertPlan({ scope, patientId: input.patientId, hits });
      const created = result === 'created' || result === 'superseded' ? 1 : 0;
      await this.prisma.planGenerationLog.update({
        where: { id: log.id },
        data: { status: 'success', plansCreated: created, endedAt: new Date() },
      });
      // 重算后该患者当前 plan id(可能是新版本)→ 回给前端做"切到新版本"
      const current = await this.prisma.followupPlan.findFirst({
        where: {
          hostId: input.hostId,
          tenantId: input.tenantId,
          patientId: input.patientId,
          status: { in: ['active', 'assigned'] },
        },
        orderBy: { version: 'desc' },
        select: { id: true },
      });
      // upsert 路径自身会 supersede 旧版本(版本流),不算"关闭退池";plansClosed 只统计 0 命中关闭。
      return { plansCreated: created, plansClosed: 0, outcome: result, currentPlanId: current?.id ?? null };
    } catch (err) {
      await this.prisma.planGenerationLog.update({
        where: { id: log.id },
        data: {
          status: 'failed',
          errorMessage: err instanceof Error ? err.message : String(err),
          endedAt: new Date(),
        },
      });
      throw err;
    }
  }

  /**
   * ⭐ 缺口1 helper:关闭该患者遗留的 active(非 assigned)plan(本轮 0 命中 → 退出召回池)。
   *
   * 关 status='active' **或 'assigned'** 的最新一条(2026-06 决策:缺口全消失 → 即使 assigned 也关,
   *   否则客服会拿"已解决"的理由白打;前端刷新时据此弹"已关闭"提示):
   *   - superseded/completed/abandoned 已是终态,不动
   * 关闭 = supersede(status='superseded' + supersededAt=now),不创建后继版本
   *   —— "无信号了"是终结,不是版本演进,故无新版本(版本流纪律:只有"换 reason set"才升版本)。
   * 返回是否真的关了一条(给调用方计数)。
   */
  private async closeStaleActivePlan(
    scope: ScenarioScope,
    patientId: string,
  ): Promise<boolean> {
    const latest = await this.prisma.followupPlan.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
      orderBy: { version: 'desc' },
      select: { id: true, status: true },
    });
    if (!latest || (latest.status !== 'active' && latest.status !== 'assigned')) return false;
    await this.prisma.followupPlan.update({
      where: { id: latest.id },
      data: { status: 'superseded', supersededAt: scope.now },
    });
    return true;
  }

  /**
   * 批量跑某 host 全 patient 的召回算法,产 plans。
   */
  async runAllForHost(input: {
    hostId: string;
    tenantId: string;
    now?: Date;
  }): Promise<EngineRunResult> {
    const startedAt = new Date();
    const now = input.now ?? new Date();
    const scope: ScenarioScope = {
      hostId: input.hostId,
      tenantId: input.tenantId,
      now,
    };

    // 1. 各 scenario 跑 selector,汇总 hits
    const hitsByPatient = new Map<string, ScenarioHitWithKey[]>();
    for (const sc of this.scenarios) {
      const hits = await sc.selectHits(scope);
      for (const h of hits) {
        const arr = hitsByPatient.get(h.patientId) ?? [];
        arr.push({ ...h, scenarioKey: sc.key });
        hitsByPatient.set(h.patientId, arr);
      }
    }

    let plansCreated = 0;
    let plansSuperseded = 0;
    let plansUnchanged = 0;
    const plansSkippedAssigned = 0; // assigned 不再 skip(保留返回字段=0,兼容旧结构)
    let plansSuppressed = 0;
    let plansClosed = 0;

    // 2. 批量写 plan。
    //   优化(2026-06):原"逐 patient 串行 + 每人 2 读(latest/snoozed)+ persona + 2 日志写",
    //     全 host(~13万)上 upsert 段 ~49min。改为:循环前一次性 **bulk 预取** latest/snoozed/persona
    //     → 逐患复用**同一** upsert 判定(结果等价)→ 写操作**分块并发** → PlanGenerationLog 收集后
    //     一次性 createMany(每患仍一行,审计/监控口径不变,只是写法从 12.5万次 → 几次)。
    //   注:selectHits 的全表扫不在本优化内(时间规则随时可改,每轮必须全量重选才正确)。
    const patientIds = [...hitsByPatient.keys()];
    const { latestByPatient, snoozedByPatient, personaByPatient } = await this.prefetchForBatch(
      scope,
      patientIds,
      now,
    );
    const EMPTY_SNOOZE = new Map<string, Date>();
    const logRows: Prisma.PlanGenerationLogCreateManyInput[] = [];
    const concurrency = Math.max(1, Number(process.env.PAC_PLAN_BATCH_CONCURRENCY) || 8);
    const entries = [...hitsByPatient.entries()];
    for (let i = 0; i < entries.length; i += concurrency) {
      await Promise.all(
        entries.slice(i, i + concurrency).map(async ([patientId, hits]) => {
          try {
            const result = await this.upsertPlan({
              scope,
              patientId,
              hits,
              prefetched: {
                latest: latestByPatient.get(patientId) ?? null,
                snoozedAnchors: snoozedByPatient.get(patientId) ?? EMPTY_SNOOZE,
                personaId: personaByPatient.get(patientId) ?? null,
              },
            });
            // 计数:JS 单线程,await 恢复后同步自增,chunk 内并发无交错
            if (result === 'created') plansCreated++;
            else if (result === 'superseded') plansSuperseded++;
            else if (result === 'unchanged') plansUnchanged++;
            else if (result === 'suppressed') plansSuppressed++;
            logRows.push({
              hostId: scope.hostId,
              tenantId: scope.tenantId,
              patientId,
              triggeredBy: 'cli:batch',
              status: 'success',
              scenarioRunCount: this.scenarios.length,
              plansCreated: result === 'created' || result === 'superseded' ? 1 : 0,
              startedAt,
              endedAt: new Date(),
            });
          } catch (err) {
            logRows.push({
              hostId: scope.hostId,
              tenantId: scope.tenantId,
              patientId,
              triggeredBy: 'cli:batch',
              status: 'failed',
              scenarioRunCount: this.scenarios.length,
              errorMessage: err instanceof Error ? err.message : String(err),
              startedAt,
              endedAt: new Date(),
            });
            this.logger.error(
              `plan upsert failed patient=${patientId}: ${err instanceof Error ? err.message : err}`,
            );
          }
        }),
      );
    }
    for (let i = 0; i < logRows.length; i += 5000) {
      await this.prisma.planGenerationLog.createMany({ data: logRows.slice(i, i + 5000) });
    }

    // 3. ⭐ 缺口1 修复:关闭"有 active plan 但本轮 0 命中"的患者的遗留 plan。
    //   旧版只遍历 hitsByPatient(有命中的患者)→ 治完疗变 0 命中的患者,其 plan 永远残留召回池。
    //   现在:跑完 upsert(已产生本轮新 active)后,扫该 (host, tenant) 全部 active+assigned plan,
    //   凡 patientId 不在本轮 hitsByPatient 的,supersede 关闭(退出召回池)。
    //   纪律:
    //     - 按 tenant 限定(隔离闸)
    //     - 关 status IN ('active','assigned')(2026-06 决策:缺口全消失即关,assigned 也关 —
    //       与 upsertPlan 注释 + closeStaleActivePlan 定向路径一致;此前批量路径漏改成只关 active,
    //       致已认领的误召计划在修复后仍残留旧理由 → 客服按错信息跟进。终态不动)
    //     - in-pool 的 assigned 不会被误关:其患者在 hitsByPatient,会被 has 命中跳过(理由已由 upsert 刷新+继承认领)
    //     - 不创建后继版本(无信号是终结,非版本演进)
    const activePlans = await this.prisma.followupPlan.findMany({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, status: { in: ['active', 'assigned'] } },
      select: { id: true, patientId: true },
    });
    const staleIds = activePlans
      .filter((pl) => !hitsByPatient.has(pl.patientId))
      .map((pl) => pl.id);
    if (staleIds.length > 0) {
      const res = await this.prisma.followupPlan.updateMany({
        where: { id: { in: staleIds } },
        data: { status: 'superseded', supersededAt: now },
      });
      plansClosed = res.count;
    }

    return {
      scenariosRun: this.scenarios.length,
      patientsHit: hitsByPatient.size,
      plansCreated,
      plansSuperseded,
      plansUnchanged,
      plansSkippedAssigned,
      plansSuppressed,
      plansClosed,
      durationMs: Date.now() - startedAt.getTime(),
    };
  }

  // ── plan upsert(单 patient,事务内 supersede + insert)──
  // prefetched:批量路径(runAllForHost)循环前一次性 bulk 取好 latest/snoozed/persona,
  //   逐患者复用**同一**判定逻辑但不再每人发 3 条读查询(结果等价,仅省往返)。
  //   单刷路径(recomputeForPatient)不传 → 照旧逐查,行为不变。
  private async upsertPlan(input: {
    scope: ScenarioScope;
    patientId: string;
    hits: ScenarioHitWithKey[];
    prefetched?: {
      latest: PlanWithReasons | null;
      snoozedAnchors: Map<string, Date>;
      personaId: string | null;
    };
  }): Promise<'created' | 'superseded' | 'unchanged' | 'suppressed'> {
    const { scope, patientId, hits, prefetched } = input;

    const latest = prefetched
      ? prefetched.latest
      : await this.prisma.followupPlan.findFirst({
          where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId },
          orderBy: { version: 'desc' },
          include: { reasons: true },
        });

    // ⭐ 设计决策(2026-06):assigned 不再整体 skip。
    //   理由:客服"正在执行"恰好撞上"正在重算"概率极低,为这点概率冻结内容、让客服拿过时理由白打,
    //   得不偿失。改为:照常重算,但**升新版本时继承分配**(assignee/assignedAt/recycleAt/contactAttempts
    //   带到新版)→ 客服不丢单、不被抢,只是召回理由刷新到最新;理由没变则只就地刷分(下面 unchanged 分支)。
    //   缺口全消失(0 命中)→ 即使 assigned 也关闭(见 closeStaleActivePlan)。

    // ⭐ 信号级抑制(召回闭环核心)— 抑制粒度 = 召回算法粒度 (scenario, subKey)。
    //   只压"已结案那条召回覆盖的诊断";患者新长的、不在已结案理由里的诊断照常召回(不受冷静期影响)。
    //   做法:取该患者所有"终态(completed/abandoned)+ 冷静期未到期"plan 的 reason (scenario, subKey)
    //         并集作抑制集,逐 hit 过滤掉命中抑制集的信号;剩下的(全新诊断)继续走下面生成逻辑。
    //   修复的 bug:旧版"整患者抑制" → 成功转化 K08 后,snooze 期内新长的 K02 也被压住不召(误伤新缺口)。
    //   多次结案也正确:查的是全部未到期终态 plan 的并集(不止 latest 一条)。
    const snoozedAnchors = prefetched
      ? prefetched.snoozedAnchors
      : await this.fetchSnoozedSignalKeys(scope, patientId, scope.now);
    const usableHits =
      snoozedAnchors.size === 0
        ? hits
        : hits.filter((h) => {
            const anchor = snoozedAnchors.get(`${h.scenarioKey}|${h.subKey ?? ''}`);
            if (!anchor) return true; // 不在抑制集 → 放行
            // ⭐ 时间锚逃逸:cluster 内存在"结案之后新发"的同类信号 → 视为新机会,放行复活。
            //   ("永久"抑制只对结案时已知的问题永久 —— 外院种植失败回院再诊断/多年后新发同类
            //    @whole 病种,不该被老 snooze 压死。)latest 缺省(旧 scenario 未提供)→ 维持全压。
            const latest = h.latestSignalOccurredAt;
            return latest != null && latest > anchor;
          });
    if (usableHits.length === 0) {
      // 当前所有活信号都在冷静期内(= 刚结案那批,且无结案后新发)→ 不生成新 plan
      return 'suppressed';
    }

    const newPriorityScore = Math.max(...usableHits.map((h) => h.priorityScore));

    // 比对 active plan 的 reason 是否变化
    // ⭐ W3 末修:比对维度 = (scenario, subKey) 二元组集合,**不能只看 scenario**
    // 旧 bug:加新 SUB_SCENARIOS(K01/K07/K00 等)后,oldScenarios={treatment_initiation_recall}
    //        跟 newScenarios={treatment_initiation_recall} 相等 → 判 unchanged → 新 subKey 丢弃
    //        实际只刷了 priorityScore,plan_reasons 残留老的 1 条 missing_tooth
    const isActive = latest && (latest.status === 'active' || latest.status === 'assigned');
    if (isActive) {
      const key = (sc: string, sk: string | null | undefined) => `${sc}|${sk ?? ''}`;
      const oldSubScenarios = new Set(latest.reasons.map((r) => key(r.scenario, r.subKey)));
      const newSubScenarios = new Set(usableHits.map((h) => key(h.scenarioKey, h.subKey)));
      const sameSubScenarios =
        oldSubScenarios.size === newSubScenarios.size &&
        [...oldSubScenarios].every((k) => newSubScenarios.has(k));
      if (sameSubScenarios) {
        // 只刷 priorityScore,不创新版本
        if (latest.priorityScore !== newPriorityScore) {
          await this.prisma.followupPlan.update({
            where: { id: latest.id },
            data: { priorityScore: newPriorityScore },
          });
        }
        return 'unchanged';
      }
    }

    // 需要新版本:supersede 旧 + 创建新
    const nextVersion = (latest?.version ?? 0) + 1;
    // ⭐ 若旧版是 assigned:新版本继承分配(客服不丢单、不被抢;只是理由换到最新)。
    //   contactAttempts 一并带过去 → 频控/熔断计数延续(执行明细行仍挂旧版本,符合版本流语义)。
    const carryAssignment = latest?.status === 'assigned';
    // head = 优先级最高的 hit(用于 plan 顶层字段:goal / recommendedRole / recommendedAt / recommendedChannel)
    const head = [...usableHits].sort((a, b) => b.priorityScore - a.priorityScore)[0]!;

    // 拉对应 patient 的最新 active persona(可空);批量路径用预取值,单刷照旧查
    const personaId = prefetched
      ? prefetched.personaId
      : (
          await this.prisma.persona.findFirst({
            where: { patientId, supersededAt: null },
            orderBy: { version: 'desc' },
            select: { id: true },
          })
        )?.id ?? null;

    await this.prisma.$transaction(async (tx) => {
      if (latest && (latest.status === 'active' || latest.status === 'assigned')) {
        await tx.followupPlan.update({
          where: { id: latest.id },
          data: { status: 'superseded', supersededAt: new Date() },
        });
      }
      await tx.followupPlan.create({
        data: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId,
          personaId: personaId ?? null,
          targetClinicId: head.targetClinicId ?? null,
          version: nextVersion,
          priorityScore: newPriorityScore,
          goal: head.goal ?? null,
          recommendedAt: head.recommendedAt ?? null,
          recommendedRole: head.recommendedRole ?? null,
          recommendedChannel: head.recommendedChannel ?? null,
          // assigned 继承:新版本保持 assigned + 原客服 + 原回收窗 + 触达计数;否则回池(active)
          status: carryAssignment ? 'assigned' : 'active',
          assigneeUserId: carryAssignment ? latest!.assigneeUserId : null,
          assignedAt: carryAssignment ? latest!.assignedAt : null,
          recycleAt: carryAssignment ? latest!.recycleAt : null,
          contactAttempts: carryAssignment ? latest!.contactAttempts : 0,
          reasons: {
            // W3 末改:plan_reasons 维度 = (plan, scenario, sub_key) — 每独立临床缺口一行,
            // 不再合并子规则(K08 缺牙 / K05 牙周 / K04 根管 是不同治疗体系,合并 reason
            // 长且生硬、evidence 粒度丢)。话术 / 摘要 / 执行回写仍 plan 级(展示细 / 触达粗)。
            // UNIQUE(plan_id, scenario, sub_key)约束保证同子规则不重复。
            createMany: {
              data: usableHits.map((h) => ({
                scenario: h.scenarioKey,
                subKey: h.subKey ?? null,
                priorityScore: h.priorityScore,
                reason: h.reason,
                evidence: {
                  factIds: h.evidence.factIds,
                } as Prisma.InputJsonValue,
                breakdown: h.priorityBreakdown
                  ? ({
                      priority: { ...h.priorityBreakdown },
                      subKey: h.subKey ?? null,
                    } as Prisma.InputJsonValue)
                  : Prisma.JsonNull,
                // 结构化召回信号(DB 存 raw enum,前端字典翻译富文本渲染)
                signals: h.signals
                  ? (h.signals as unknown as Prisma.InputJsonValue)
                  : Prisma.JsonNull,
                lifecycle: this.lifecycleFor(h.scenarioKey, h.subKey),
                source: 'algorithm',
              })),
            },
          },
        },
      });
    }, { maxWait: 30000, timeout: 60000 });

    return latest ? 'superseded' : 'created';
  }

  /**
   * 信号级抑制集(带时间锚)—— 该患者所有"终态(completed/abandoned)+ 冷静期未到期
   * (snoozedUntil>now)"plan 覆盖的召回理由 (scenario, subKey) → **结案锚点** Map。
   *
   * 语义:这些信号最近被召回处理过(成功转化/拒绝/外院/熔断…),在冷静期内不重复召回;
   *      但**不在此集合里的全新诊断不受影响**,照常生成召回(这是信号级抑制的核心,跟召回算法同粒度)。
   * ⭐ 时间锚(2026-07 修):同 key 也不一刀切 —— 结案锚点之后**新发**的同类信号(hit 的
   *   latestSignalOccurredAt > anchor)放行复活。修复的坑:外院/无效的"永久"snooze 会把
   *   结案后多年新发的同牙位/@whole 同类诊断也永久压死(粒度只到 key,无时间维度)。
   * 多次结案累积正确:取全部未到期终态 plan 的并集;同 key 多次结案取**最新**锚点。
   * key 格式 `${scenario}|${subKey ?? ''}`,跟 upsertPlan 过滤口径一致。
   */
  private async fetchSnoozedSignalKeys(
    scope: ScenarioScope,
    patientId: string,
    now: Date,
  ): Promise<Map<string, Date>> {
    const terminalPlans = await this.prisma.followupPlan.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        patientId,
        status: { in: ['completed', 'abandoned'] },
        snoozedUntil: { gt: now },
      },
      select: {
        updatedAt: true,
        reasons: { select: { scenario: true, subKey: true } },
        // 结案锚点 = 结案那次 execution 的 createdAt(事件表,不可变)。
        // ⚠️ 不能用 plan.updatedAt 当首选锚:召回反馈(👍/👎)等后续写操作会顶后 updatedAt,
        //    把"结案后、反馈前"新发的信号误压。updatedAt 仅作无 execution 时的兜底(理论不发生)。
        executions: {
          orderBy: { createdAt: 'desc' },
          take: 1,
          select: { createdAt: true },
        },
      },
    });
    return buildSnoozeAnchors(terminalPlans);
  }

  /**
   * 批量预取 —— runAllForHost 循环前一次性把每患者的 latest plan(含 reasons)/ snooze 抑制集 /
   * active persona id 取好,避免循环内每人发 3 条读查询(等价,仅省往返)。
   * 取数口径与 upsertPlan(findFirst orderBy version desc)/ fetchSnoozedSignalKeys(终态+未到期)
   * 严格对齐;IN 列表分块防参数超限。
   */
  private async prefetchForBatch(
    scope: ScenarioScope,
    patientIds: string[],
    now: Date,
  ): Promise<{
    latestByPatient: Map<string, PlanWithReasons>;
    snoozedByPatient: Map<string, Map<string, Date>>;
    personaByPatient: Map<string, string>;
  }> {
    const latestByPatient = new Map<string, PlanWithReasons>();
    const snoozedByPatient = new Map<string, Map<string, Date>>();
    const personaByPatient = new Map<string, string>();
    const CHUNK = 2000;
    for (let i = 0; i < patientIds.length; i += CHUNK) {
      const ids = patientIds.slice(i, i + CHUNK);
      // latest plan(含 reasons)— 每患者最高 version 那条(对齐 upsertPlan 的 orderBy version desc)
      const plans = await this.prisma.followupPlan.findMany({
        where: { hostId: scope.hostId, tenantId: scope.tenantId, patientId: { in: ids } },
        include: { reasons: true },
        orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
      });
      for (const p of plans) {
        if (p.patientId && !latestByPatient.has(p.patientId)) latestByPatient.set(p.patientId, p);
      }
      // snooze 抑制集(对齐 fetchSnoozedSignalKeys:终态 + 冷静期未到期 plan 的 reason → 结案锚点)
      const terminal = await this.prisma.followupPlan.findMany({
        where: {
          hostId: scope.hostId,
          tenantId: scope.tenantId,
          patientId: { in: ids },
          status: { in: ['completed', 'abandoned'] },
          snoozedUntil: { gt: now },
        },
        select: {
          patientId: true,
          updatedAt: true,
          reasons: { select: { scenario: true, subKey: true } },
          // 结案锚点 = 结案 execution.createdAt(不可变;updatedAt 会被召回反馈等后续写顶后,仅兜底)
          executions: { orderBy: { createdAt: 'desc' }, take: 1, select: { createdAt: true } },
        },
      });
      {
        const plansByPatient = new Map<string, typeof terminal>();
        for (const t of terminal) {
          if (!t.patientId) continue;
          const arr = plansByPatient.get(t.patientId) ?? [];
          arr.push(t);
          plansByPatient.set(t.patientId, arr);
        }
        for (const [pid, plans] of plansByPatient) {
          snoozedByPatient.set(pid, buildSnoozeAnchors(plans));
        }
      }
      // active persona id(每患最新 active 版本)
      const personas = await this.prisma.persona.findMany({
        where: { patientId: { in: ids }, supersededAt: null },
        orderBy: [{ patientId: 'asc' }, { version: 'desc' }],
        select: { id: true, patientId: true },
      });
      for (const pe of personas) {
        if (pe.patientId && !personaByPatient.has(pe.patientId)) {
          personaByPatient.set(pe.patientId, pe.id);
        }
      }
    }
    return { latestByPatient, snoozedByPatient, personaByPatient };
  }

  /// 子场景 → 生命周期类型(one_shot 短效 / recurring 长效)
  private lifecycleFor(scenario: string, subKey?: string): string {
    // 复查类长效(种植年度 / 牙周维护),其余短效
    if (subKey === 'implant_annual' || subKey === 'perio_maintenance') return 'recurring';
    return 'one_shot';
  }
}

type ScenarioHitWithKey = ScenarioHit & { scenarioKey: string };
/// 批量预取/upsert 用:plan 含 reasons(对齐 upsertPlan 的 include:{reasons:true})
type PlanWithReasons = Prisma.FollowupPlanGetPayload<{ include: { reasons: true } }>;

export interface EngineRunResult {
  scenariosRun: number;
  patientsHit: number;
  plansCreated: number;
  plansSuperseded: number;
  plansUnchanged: number;
  plansSkippedAssigned: number;
  plansSuppressed: number;
  plansClosed: number;
  durationMs: number;
}

/**
 * 终态 plan → (scenario|subKey) → 结案锚点 Map(纯函数,fetchSnoozedSignalKeys / prefetchForBatch 共用)。
 * 锚点 = 结案 execution.createdAt(事件表,不可变);无 execution(理论不发生)兜底 plan.updatedAt。
 * 同 key 多次结案取**最新**锚 —— 最近一次人为处置才是"此后新发才算新机会"的分界线。
 */
export function buildSnoozeAnchors(
  terminalPlans: Array<{
    updatedAt?: Date;
    reasons: Array<{ scenario: string; subKey: string | null }>;
    executions?: Array<{ createdAt: Date }>;
  }>,
): Map<string, Date> {
  // 锚点解析不到(旧数据/未 select 关联)→ 远未来哨兵:latest > anchor 永不成立 = 永不逃逸,
  // 严格等价旧的"key 命中即压"行为(宁可多压,不误放)。
  const FAR_FUTURE = new Date(8640000000000000);
  const anchors = new Map<string, Date>();
  for (const plan of terminalPlans) {
    const anchor = plan.executions?.[0]?.createdAt ?? plan.updatedAt ?? FAR_FUTURE;
    for (const r of plan.reasons) {
      const key = `${r.scenario}|${r.subKey ?? ''}`;
      const prev = anchors.get(key);
      if (!prev || anchor > prev) anchors.set(key, anchor);
    }
  }
  return anchors;
}
