import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  ASSIGNMENT_ITEMS_HARD_LIMIT,
  Permission,
  PlanEventType,
  RELEASE_REASON_META,
  type AssignmentAgentStat,
  type AssignmentDetailResponse,
  type AssignmentSkipped,
  type CreateAssignmentRequest,
  type CreateAssignmentResponse,
  type ListAssignmentsResponse,
  type ReleaseReason,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { recordPlanEventsBulk } from './plan-event.recorder';
import { assertAssignable } from './claim-guard';
import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard';
import { timezoneToOffsetSuffix } from '../sync/assembler/field-mapper';

/**
 * PlanAssignmentService —— 门诊经理的**批次分配**写路径。
 *
 * 与 `PlanService.assign`(单条认领/指派)的分工:
 *   · PlanService.assign  —— 一次一条,认领语义,staff 也能调
 *   · 本服务              —— 一次几百条,批次语义,只有 PLAN_DISPATCH 能调
 *
 * ⛔ **不能靠循环调 PlanService.assign 实现**:那里每条自带一次 `findFirst` + 一个独立
 *   `$transaction`,500 条 ≈ 1500 次往返必炸;而且它写不了 assignment_id 等归因列。
 * ⛔ 反过来也不行(让单条 assign 委托批量):那会给每一次自助认领造一条垃圾批次行。
 *   两条路共用的只有判据 —— 收口在 `claim-guard.assertAssignable` 一个函数里。
 */

/** 一次 updateMany 的分片大小。PG bind 变量上限 32767,本表每行约 7 个变量 → 3500 才到顶,500 很安全。 */
const UPDATE_CHUNK = 500;

/// 已退回的行归到这个假 userId 下暂存 —— 退回后 assignee 已清空,摊到某个真人头上会摊错。
const RELEASED_BUCKET = '__released__';

/// 批次福利文本(attributes.benefit.text)。⚠️ 别在多处 `as any` 解 JSON,解错了不会报错
function readBenefitText(attributes: unknown): string | null {
  const a = attributes as { benefit?: { text?: string } } | null | undefined;
  const t = a?.benefit?.text;
  return typeof t === 'string' && t.trim() ? t : null;
}

@Injectable()
export class PlanAssignmentService {
  private readonly logger = new Logger(PlanAssignmentService.name);

  constructor(private readonly prisma: PrismaService) {}

  /**
   * 主管确认 → 批量落归属。**这是整条生产线上唯一的写动作**(T8)。
   *
   * 事务内五步,顺序不能换:
   *   ① 三道闸(权限自查 / 拒合成身份 / scope 绑定)
   *   ② 按 patientId 去重
   *   ③ 建批次头
   *   ④ 按客服分组的**带条件** updateMany(并发安全)
   *   ⑤ 一次性写归属账本
   */
  async create(
    scope: TenantScopeContext,
    actor: DispatchActor,
    dto: CreateAssignmentRequest,
  ): Promise<CreateAssignmentResponse> {
    // ── 闸 1 / 2:身份 ────────────────────────────────────────
    // 与 controller 上的 @RequirePermission 重复是有意的,见 dispatch-guard 文件头
    requirePermission(actor, Permission.PLAN_DISPATCH);
    rejectSyntheticIdentity(actor);

    // ── 幂等前置:同 requestId 已有批次 → 原样返回,一个字都不写 ──
    // 放在最前面而不是靠捕获唯一键冲突,是为了让**重放不产生任何副作用**
    // (真到了冲突那一步,前面的读查询已经发生了)。冲突兜底仍保留,见下方 catch。
    const existing = await this.findByRequestId(scope, dto.requestId);
    if (existing) return existing;

    if (dto.items.length > ASSIGNMENT_ITEMS_HARD_LIMIT) {
      throw new BadRequestException(`单次分配最多 ${ASSIGNMENT_ITEMS_HARD_LIMIT} 条`);
    }

    // ── 闸 3:scope 绑定 ─────────────────────────────────────
    // 诊所必须在本人可达范围内。空 clinicIds = 集团级,不限。
    if (scope.clinicIds.length > 0 && !scope.clinicIds.includes(dto.clinicId)) {
      throw new NotFoundException(`诊所 ${dto.clinicId} 不在你的数据范围内`);
    }

    // 同一 planId 在入参里出现两次 → 前端/模型的问题,直接按第一条算,不报错
    const byPlanId = new Map(dto.items.map((it) => [it.planId, it]));
    const planIds = [...byPlanId.keys()];

    // ⚠️ 时区**在事务外查一次**就够了。放进下面按条计算的循环里会变成每条一次查询
    //   (500 条 = 500 次往返,且全在事务里持锁)。
    const tz = await this.hostTimezone(scope.hostId);
    const expiresAt = endOfDayInHostTimezone(new Date(), dto.expiresInDays, tz);

    const rows = await this.prisma.followupPlan.findMany({
      where: {
        id: { in: planIds },
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        // 批次不跨诊所:只认本批指定诊所的单
        targetClinicId: dto.clinicId,
        supersededAt: null,
        ...(scope.sourceUnits.length
          ? { patient: { sourceUnit: { in: scope.sourceUnits } } }
          : {}),
      },
      select: {
        id: true, patientId: true, status: true,
        assigneeUserId: true, priorityScore: true,
      },
    });

    // ⭐ **数量对不上整体拒绝**,不是"能落几条落几条"。
    //   对不上只有两种可能:主管的确认单已经过期(单子被引擎重算 supersede 了),
    //   或者有人在试跨诊所越权。两种都不该让它落一半 —— 落一半的批次事后
    //   既解释不清也回不去,而主管看到的"成功 87/100"完全无从判断哪 13 条为什么没落。
    if (rows.length !== planIds.length) {
      const found = new Set(rows.map((r) => r.id));
      const missing = planIds.filter((id) => !found.has(id));
      throw new BadRequestException(
        `有 ${missing.length} 条计划不在可分配范围内(可能已被重算取代或不属于本诊所),` +
          `本次分配未执行。请让助手重新出确认单。`,
      );
    }

    // ── 去重 + 可分性判定 ────────────────────────────────────
    const skipped: AssignmentSkipped[] = [];

    // ⚠️ 按 patientId 去重:schema.prisma 注释里承诺的
    //   「一患者只一条活动 plan」partial UNIQUE **在 38 份迁移里根本不存在**(已逐份 grep)。
    //   不能当既有保障用,批量侧必须自己去重 —— 否则同一个人会被分两次,
    //   客服打开两条一模一样的单。同患者多条时留 priorityScore 最高的那条。
    const bestByPatient = new Map<string, (typeof rows)[number]>();
    for (const r of [...rows].sort((a, b) => b.priorityScore - a.priorityScore)) {
      if (bestByPatient.has(r.patientId)) {
        skipped.push({ planId: r.id, reason: 'duplicate_patient' });
        continue;
      }
      bestByPatient.set(r.patientId, r);
    }

    const applicable: Array<{ row: (typeof rows)[number]; item: (typeof dto.items)[number] }> = [];
    for (const row of bestByPatient.values()) {
      const item = byPlanId.get(row.id)!;
      const blocked = assertAssignable(row, item.assigneeUserId);
      if (blocked) {
        skipped.push({ planId: row.id, reason: blocked });
        continue;
      }
      applicable.push({ row, item });
    }

    if (applicable.length === 0) {
      // ⭐ 一条都落不上就**不建批次** —— 空批次会让跟踪报表出现一堆 0/0 的行,
      //   而"这批为什么是空的"事后完全查不到。
      throw new BadRequestException(
        `本批 ${dto.items.length} 条都无法分配(${summarizeSkipped(skipped)}),未创建批次。`,
      );
    }

    // ── 决策快照取数(事务外,只读)────────────────────────────
    // ⭐ 这三份数据都会被覆盖,分配当时不取就永久没了。宁可多三次只读查询,
    //   也不要留一列半年后才发现是空的快照。
    const patientIds = [...new Set(applicable.map(({ row }) => row.patientId))];
    const dedicatedByPatient = await this.dedicatedCsOf(patientIds);
    const lastVisitByCs = await this.lastVisitOf([...new Set([...dedicatedByPatient.values()])]);
    const confidenceByPlan = await this.headConfidenceOf(applicable.map(({ row }) => row.id));

    // ⭐ 一致性硬闸:`dedicated` 的字面意思就是"分给了他自己的专属客服",
    //   而服务端刚刚把当时的专属客服取到手 —— 这是**可验证的**,就不该放过去。
    //   不拦的后果:T20 按 assign_strategy 分组时,一批实际是铺平的单顶着 dedicated 的标签,
    //   算出来的"专属完成率"里混进了铺平的样本,而**没有任何报错**。
    //   ⚠️ 只拦这一个方向:spread_* 与 manual 的判定依赖容量与主管意图,服务端不知道,
    //   仍以调用方为准(错了也能靠 dedicated_cs_at_assign 快照事后重算,这正是那列的价值)。
    const mislabeled = applicable.filter(
      ({ row, item }) =>
        item.assignStrategy === 'dedicated' &&
        dedicatedByPatient.get(row.patientId) !== item.assigneeUserId,
    );
    if (mislabeled.length > 0) {
      throw new BadRequestException(
        `有 ${mislabeled.length} 条标记为「专属承接」,但承接人并不是该患者当前的专属客服。` +
          `请让助手重新出确认单(标记与事实不符会污染后续的策略效果分析)。`,
      );
    }

    const now = new Date();
    try {
      const result = await this.prisma.$transaction(
        async (tx) => {
          const head = await tx.planAssignment.create({
            data: {
              hostId: scope.hostId,
              tenantId: scope.tenantId,
              clinicId: dto.clinicId,
              createdBy: actor.userId,
              requestId: dto.requestId,
              criteria: dto.criteria as Prisma.InputJsonObject,
              attributes: (dto.attributes ?? undefined) as Prisma.InputJsonObject | undefined,
              expiresAt,
              status: 'confirmed',
            },
            select: { id: true, expiresAt: true },
          });

          // ⭐ **一条 SQL 打完全部 500 行**,而不是按客服分桶发多条 updateMany。
          //   换法的原因是快照列:priority_score_at_assign / dedicated_cs_at_assign 等
          //   **逐行不同**,updateMany 的 data 是全桶共用的,表达不了。
          //   逐行 update 则是 500 次往返。VALUES join 两个问题一起解决,
          //   而且 RETURNING 直接给出**哪些行真的落上了**,不必再回查一次去猜差额。
          //   bind 变量:每行 9 个 × 500 = 4,500,远低于 PG 上限 32,767。
          //
          // ⚠️ 走原生 SQL 的两个代价,都必须手工补上:
          //   ① Prisma 的 `@updatedAt` **不会触发** → 显式 SET updated_at
          //   ② where 条件要自己写全 —— 这里的 `status='active' AND assignee IS NULL
          //      AND superseded_at IS NULL` 就是并发安全的全部依据:
          //      从上面 findMany 到这一刻若有人抢先认领,该行不满足条件、不被更新,
          //      RETURNING 里也就没有它。
          const values = applicable.map(({ row, item }) => {
            const perItemExpires =
              item.expiresInDays != null
                ? endOfDayInHostTimezone(now, item.expiresInDays, tz)
                : head.expiresAt;
            const dcs = dedicatedByPatient.get(row.patientId) ?? null;
            return Prisma.sql`(
              ${row.id}::uuid, ${item.assigneeUserId}::text, ${perItemExpires}::timestamptz,
              ${item.assignStrategy}::text, ${dcs}::text,
              ${dcs ? (lastVisitByCs.get(dcs) ?? null) : null}::timestamptz,
              ${row.priorityScore}::double precision,
              ${confidenceByPlan.get(row.id) ?? null}::double precision,
              ${item.selectionMode ?? 'rank'}::text
            )`;
          });

          const appliedRows = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
            UPDATE followup_plans AS fp SET
              status                      = 'assigned',
              assignee_user_id            = v.assignee,
              assigned_at                 = ${now}::timestamptz,
              assigned_by                 = ${actor.userId}::text,
              assignment_id               = ${head.id}::uuid,
              assignment_expires_at       = v.expires_at,
              assign_strategy             = v.strategy,
              release_reason              = NULL,
              release_note                = NULL,
              dedicated_cs_at_assign      = v.dedicated_cs,
              dedicated_cs_last_visit_at  = v.dedicated_cs_last_visit,
              priority_score_at_assign    = v.priority_score,
              source_confidence_at_assign = v.source_confidence,
              selection_mode              = v.selection_mode,
              updated_at                  = ${now}::timestamptz
            FROM (VALUES ${Prisma.join(values)}) AS v(
              plan_id, assignee, expires_at, strategy,
              dedicated_cs, dedicated_cs_last_visit,
              priority_score, source_confidence, selection_mode
            )
            WHERE fp.id = v.plan_id
              AND fp.status = 'active'
              AND fp.assignee_user_id IS NULL
              AND fp.superseded_at IS NULL
            RETURNING fp.id
          `);

          const applied = appliedRows.map((r) => r.id);
          const appliedSet = new Set(applied);
          const lost = applicable.map(({ row }) => row.id).filter((id) => !appliedSet.has(id));

          if (applied.length === 0) {
            // 全被抢走 → 回滚,库里不留空批次(与上面 applicable=0 同一条纪律)
            throw new BadRequestException('本批计划在确认期间已全部被他人认领,未创建批次。');
          }

          // ⭐ 归属账本一次写完。走 recordPlanEventsBulk 而不是裸 createMany ——
          //   plan-event.recorder 文件头宣称自己是「PlanEventLog 的唯一写入口」,
          //   在别处写 createMany 当场破功。
          const rowById = new Map(applicable.map(({ row, item }) => [row.id, { row, item }]));
          for (let i = 0; i < applied.length; i += UPDATE_CHUNK) {
            await recordPlanEventsBulk(
              tx,
              applied.slice(i, i + UPDATE_CHUNK).map((id) => {
                const e = rowById.get(id)!;
                return {
                  hostId: scope.hostId,
                  tenantId: scope.tenantId,
                  planId: id,
                  patientId: e.row.patientId,
                  // 批次分配一律 assign(主管派的),不会是 claim —— 即使主管分给自己:
                  // 那也是"以主管身份派单",与他在池子里自己点认领是两回事
                  event: PlanEventType.ASSIGN,
                  assigneeUserId: e.item.assigneeUserId,
                  actorUserId: actor.userId,
                };
              }),
            );
          }

          return { head, applied: applied.length, lost };
        },
        // 500 条 × (updateMany + 事件) 的余量;maxWait 是等连接池的时间,不是执行时间
        { maxWait: 10_000, timeout: 30_000 },
      );

      for (const id of result.lost) skipped.push({ planId: id, reason: 'claimed_by_other' });
      this.logger.log(
        `批次 ${result.head.id} 已确认:落 ${result.applied} 条 / 跳过 ${skipped.length} 条,` +
          `发起人=${actor.userId} 诊所=${dto.clinicId}`,
      );
      return {
        assignmentId: result.head.id,
        duplicate: false,
        assigned: result.applied,
        skipped,
        expiresAt: result.head.expiresAt.toISOString(),
      };
    } catch (err) {
      // ⭐ 幂等兜底:两个并发请求带同一个 requestId,前面的预检都放行了,
      //   在这里撞唯一键。**不能抛错** —— 抛了模型会以为失败、换个参数重试一次,
      //   那才是真正的重复分配。回查已有批次原样返回。
      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
        const dup = await this.findByRequestId(scope, dto.requestId);
        if (dup) return dup;
      }
      throw err;
    }
  }

  /**
   * 批次列表 + 每批汇总。
   *
   * ⚠️ 计数**现算不落列**(T17):planned/inHand/released 都能从 followup_plans 一次
   * groupBy 出来。立柱看着省事,但批次一旦被退回/重算触碰,冗余列就开始漂,
   * 而漂了没有任何报错 —— 报表上少一条谁都不会发现。
   */
  async list(scope: TenantScopeContext, createdBy?: string): Promise<ListAssignmentsResponse> {
    const heads = await this.prisma.planAssignment.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
        ...(createdBy ? { createdBy } : {}),
      },
      orderBy: { createdAt: 'desc' },
      take: 50,
    });
    if (heads.length === 0) return { items: [] };

    const stats = await this.statsByAssignment(heads.map((h) => h.id));
    const names = await this.resolveNames(scope, heads.map((h) => h.createdBy));
    return {
      items: heads.map((h) => ({
        id: h.id,
        clinicId: h.clinicId,
        createdBy: h.createdBy,
        createdByName: names.get(h.createdBy) ?? null,
        criteria: (h.criteria ?? {}) as Record<string, unknown>,
        benefitText: readBenefitText(h.attributes),
        status: h.status,
        expiresAt: h.expiresAt.toISOString(),
        createdAt: h.createdAt.toISOString(),
        ...(stats.get(h.id) ?? { planned: 0, inHand: 0, released: 0, agents: 0 }),
      })),
    };
  }

  /** 单批全貌:按客服拆 + 退回原因分布 + 未动过的条数 */
  async detail(scope: TenantScopeContext, id: string): Promise<AssignmentDetailResponse> {
    const head = await this.prisma.planAssignment.findFirst({
      where: {
        id,
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        ...(scope.clinicIds.length ? { clinicId: { in: scope.clinicIds } } : {}),
      },
    });
    if (!head) throw new NotFoundException(`批次 ${id} 不存在`);

    const plans = await this.prisma.followupPlan.findMany({
      where: { assignmentId: id, supersededAt: null },
      select: {
        id: true, status: true, assigneeUserId: true,
        releaseReason: true, assignmentExpiresAt: true,
      },
    });

    const planIds = plans.map((p) => p.id);

    // ⭐ 退回后 `assignee_user_id` 已被清空,光看主表算不出"这条本来是谁的" ——
    //   于是退回的行会从所有人的名下消失,agentStats 各人 planned 加起来比批次 planned 少,
    //   主管一眼就看出对不上(实测 500 条退 1 条 → 加起来 499)。而"某人退了几条"
    //   恰恰是他最想看的那一列。
    //   账本里有:分配时写的 assign 事件带 assigneeUserId,且时间 >= 批次创建时刻,
    //   按 planId 取回来即可。这是 plan_event_logs 存在的意义 ——
    //   主表存当前值,历史归属只有账本留得住。
    // ⚠️ 取**最近一次** assign,不按时间窗过滤。
    //   曾经写成 `createdAt >= head.createdAt`(想排掉这条 plan 在**上一个**批次里的
    //   assign 事件),生产里事件与批次头同事务、时间必然在后,看不出问题;
    //   但那个条件本身很脆 —— 事件的 createdAt 若由调用方给(补数据、迁移、造数),
    //   或与批次头的 DB 时钟有毫秒级先后,整批已退回的单就会从所有人名下消失,
    //   agentStats 各人之和比 planned 少一大截,而**不报任何错**。
    //   (实测:seed 造的事件时间在 5 天前,199 条里 50 条当场蒸发。)
    //   正解:plan 当前的 assignment_id 就是本批,所以它**最近一次** assign 必然属于本批。
    const assignEvents = planIds.length
      ? await this.prisma.planEventLog.findMany({
          where: { planId: { in: planIds }, tenantId: scope.tenantId, event: PlanEventType.ASSIGN },
          select: { planId: true, assigneeUserId: true },
          orderBy: { createdAt: 'desc' },
        })
      : [];
    const originalOwner = new Map<string, string>();
    for (const e of assignEvents) {
      if (e.assigneeUserId && !originalOwner.has(e.planId)) originalOwner.set(e.planId, e.assigneeUserId);
    }

    const now = new Date();
    const byAgent = new Map<string, AssignmentAgentStat>();
    const reasonCount = new Map<string, number>();
    for (const p of plans) {
      // 当前归属优先;已退回(assignee 为 null)则回落到账本里的原始承接人。
      // 两者都没有(理论上不该发生:批次分配一定写了 assign 事件)→ 归到兜底桶,
      // 宁可显示一个「未归属」也不要静默摊到某个真人头上。
      const key = p.assigneeUserId ?? originalOwner.get(p.id) ?? RELEASED_BUCKET;
      let a = byAgent.get(key);
      if (!a) {
        a = { userId: key, name: null, planned: 0, inHand: 0, released: 0, overdue: 0 };
        byAgent.set(key, a);
      }
      a.planned++;
      if (p.status === 'assigned') {
        a.inHand++;
        if (p.assignmentExpiresAt && p.assignmentExpiresAt < now) a.overdue++;
      }
      if (p.releaseReason) {
        a.released++;
        reasonCount.set(p.releaseReason, (reasonCount.get(p.releaseReason) ?? 0) + 1);
      }
    }

    // 「未动过」= 分下去后客服**从未打开过详情页**。
    // ⚠️ 判据只能用 view 事件:plan_executions 回写率仅 11%(生产 65 认领 / 7 条结果),
    //   拿它当"动过没"会把 89% 打过电话的单误判成没动。contactAttempts 与它同源,同样不可用。
    const viewed = planIds.length
      ? await this.prisma.planEventLog.groupBy({
          by: ['planId'],
          where: { planId: { in: planIds }, event: PlanEventType.VIEW, tenantId: scope.tenantId },
        })
      : [];

    const names = await this.resolveNames(scope, [head.createdBy, ...byAgent.keys()]);
    for (const a of byAgent.values()) a.name = names.get(a.userId) ?? null;

    return {
      id: head.id,
      clinicId: head.clinicId,
      createdBy: head.createdBy,
      createdByName: names.get(head.createdBy) ?? null,
      criteria: (head.criteria ?? {}) as Record<string, unknown>,
      benefitText: readBenefitText(head.attributes),
      status: head.status,
      expiresAt: head.expiresAt.toISOString(),
      createdAt: head.createdAt.toISOString(),
      planned: plans.length,
      inHand: plans.filter((p) => p.status === 'assigned').length,
      released: plans.filter((p) => p.releaseReason != null).length,
      agents: [...byAgent.keys()].filter((k) => k !== RELEASED_BUCKET).length,
      agentStats: [...byAgent.values()].filter((a) => a.userId !== RELEASED_BUCKET),
      releaseReasons: [...reasonCount]
        .sort((a, b) => b[1] - a[1])
        .map(([reason, n]) => ({
          reason,
          labelZh: RELEASE_REASON_META[reason as ReleaseReason]?.labelZh ?? reason,
          n,
        })),
      // ⚠️ 「未动过」不是「没结果」:分母口径见 AssignmentDetailResponseSchema.untouched 的注释。
      //   只输出一个退回率百分比会让主管把"没人动"误读成"做得不错"。
      untouched: plans.length - viewed.length,
    };
  }

  /** 一次 groupBy 出多批的汇总,避免 N+1 */
  private async statsByAssignment(
    ids: string[],
  ): Promise<Map<string, { planned: number; inHand: number; released: number; agents: number }>> {
    const rows = await this.prisma.followupPlan.findMany({
      where: { assignmentId: { in: ids }, supersededAt: null },
      select: { assignmentId: true, status: true, assigneeUserId: true, releaseReason: true },
    });
    const out = new Map<string, { planned: number; inHand: number; released: number; agents: number }>();
    const agentSets = new Map<string, Set<string>>();
    for (const r of rows) {
      const k = r.assignmentId!;
      const cur = out.get(k) ?? { planned: 0, inHand: 0, released: 0, agents: 0 };
      cur.planned++;
      if (r.status === 'assigned') cur.inHand++;
      if (r.releaseReason) cur.released++;
      out.set(k, cur);
      if (r.assigneeUserId) {
        const s = agentSets.get(k) ?? new Set<string>();
        s.add(r.assigneeUserId);
        agentSets.set(k, s);
      }
    }
    for (const [k, v] of out) v.agents = agentSets.get(k)?.size ?? 0;
    return out;
  }

  /** 按幂等键回查已有批次(命中即视为重放) */
  private async findByRequestId(
    scope: TenantScopeContext,
    requestId: string,
  ): Promise<CreateAssignmentResponse | null> {
    const head = await this.prisma.planAssignment.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, requestId },
      select: { id: true, expiresAt: true },
    });
    if (!head) return null;
    const assigned = await this.prisma.followupPlan.count({
      where: { assignmentId: head.id, supersededAt: null },
    });
    return {
      assignmentId: head.id,
      duplicate: true,
      assigned,
      skipped: [],
      expiresAt: head.expiresAt.toISOString(),
    };
  }

  /**
   * 分配当时每个患者的专属客服。
   * ⚠️ 只此一刻取得到:`patients.preferences.dedicatedCs` 是摄入 upsert **覆盖**的当前值。
   */
  private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
    if (patientIds.length === 0) return new Map();
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: patientIds } },
      select: { id: true, preferences: true },
    });
    const out = new Map<string, string>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) out.set(r.id, id);
    }
    return out;
  }

  /**
   * 每个客服**最近一次回访的时刻**。
   *
   * ⚠️ 必须用 `source_created_at`,**绝不能用 `task_date`** —— 后者含未来排程
   * (生产实测最远 2033 年、DW 侧甚至 2121 年),拿它算"最近活跃"会把早已离职的人
   * 判成在岗。这条坑 schema.prisma 的 task_date 注释里已写过一次。
   *
   * ⚠️ 存**时刻**而不是"是否在岗":在岗判定是滚动窗口,今天在岗的人半年后回查会变离岗,
   * 存 boolean 就无法按分配当时的口径重算了。
   */
  private async lastVisitOf(csIds: string[]): Promise<Map<string, Date>> {
    if (csIds.length === 0) return new Map();
    const rows = await this.prisma.patientReturnVisit.groupBy({
      by: ['taskDirectorId'],
      where: { taskDirectorId: { in: csIds }, sourceCreatedAt: { not: null } },
      _max: { sourceCreatedAt: true },
    });
    const out = new Map<string, Date>();
    for (const r of rows) {
      if (r.taskDirectorId && r._max.sourceCreatedAt) out.set(r.taskDirectorId, r._max.sourceCreatedAt);
    }
    return out;
  }

  /**
   * 每条 plan 的**头部 reason** 的证据可信度(0.5 影像AI / 0.8 建议 / 1.0 诊断)。
   *
   * 头部 = priority_score 最高的那条 reason —— plan.priorityScore 就是对它们取 MAX,
   * 所以驱动排序的是这一条的分,可信度也该取这一条的。
   * ⚠️ 它是分里的 **2× 乘子**,不单独留一份,事后就分不清「按分选人」实际是不是
   * 在「按诊断来源选人」。
   */
  private async headConfidenceOf(planIds: string[]): Promise<Map<string, number>> {
    if (planIds.length === 0) return new Map();
    const rows = await this.prisma.planReason.findMany({
      where: { planId: { in: planIds } },
      select: { planId: true, priorityScore: true, breakdown: true },
      orderBy: { priorityScore: 'desc' },
    });
    const out = new Map<string, number>();
    for (const r of rows) {
      if (out.has(r.planId)) continue; // 已排序,第一条即头部
      const cf = (r.breakdown as { priority?: { confidenceFactor?: number } } | null)?.priority
        ?.confidenceFactor;
      if (typeof cf === 'number') out.set(r.planId, cf);
    }
    return out;
  }

  /**
   * userId → 姓名。**唯一来源是回访表 `task_director_name`**(摄入的生产数据)。
   *
   * ⚠️ 必须取**最近一次**的名:id→姓名是时间性的,本地实测同一个 id 在不同时期
   * 对应不同的人(DW 侧换过人)。取到旧名会让界面写着"赵惠"、实际派给了"李宇琦",
   * 比不显示姓名更糟。
   *
   * ⚠️ 为什么服务端解析而不是前端翻:PAC **没有 users 表**,前端的
   * `dictionary.users` 只覆盖当前登录人 —— 详情页「承接人」现在显示的就是一串 uuid。
   * 凡是返回 userId 的 payload 都该顺带把姓名带上。
   *
   * ⚠️ 这里是**读时解析**,不是快照:批次跟踪看的是"这个 id 现在是谁"。
   * 换人极罕见且不会发生在一个批次的几天窗口内,故不为它再立一列。
   */
  private async resolveNames(
    scope: TenantScopeContext,
    userIds: string[],
  ): Promise<Map<string, string>> {
    const ids = [...new Set(userIds)].filter(Boolean);
    if (ids.length === 0) return new Map();
    const rows = await this.prisma.$queryRaw<Array<{ id: string; name: string | null }>>(Prisma.sql`
      SELECT DISTINCT ON (rv.task_director_id)
             rv.task_director_id AS "id", rv.task_director_name AS "name"
      FROM patient_return_visits rv
      WHERE rv.host_id = ${scope.hostId}::uuid
        AND rv.tenant_id = ${scope.tenantId}
        AND rv.task_director_id IN (${Prisma.join(ids)})
      ORDER BY rv.task_director_id, rv.source_created_at DESC
    `);
    const out = new Map<string, string>();
    for (const r of rows) if (r.name) out.set(r.id, r.name);
    return out;
  }

  /** host 时区(缺省 Asia/Shanghai —— 现有宿主全在东八区,manifest 里也是必填项) */
  private async hostTimezone(hostId: string): Promise<string> {
    const host = await this.prisma.host.findUnique({
      where: { id: hostId },
      select: { pullConfig: true },
    });
    const tz = (host?.pullConfig as { timezone?: string } | null)?.timezone;
    return tz ?? 'Asia/Shanghai';
  }
}

function summarizeSkipped(skipped: AssignmentSkipped[]): string {
  const c = new Map<string, number>();
  for (const s of skipped) c.set(s.reason, (c.get(s.reason) ?? 0) + 1);
  const zh: Record<string, string> = {
    claimed_by_other: '已被他人认领',
    terminal: '已结案',
    duplicate_patient: '同一患者重复',
  };
  return [...c].map(([k, n]) => `${zh[k] ?? k} ${n} 条`).join('、');
}

/**
 * 「N 天后」→ **宿主当地日末**的绝对时刻。
 *
 * ⚠️ 为什么不是 `now + N*24h`:主管说「3 天有效」想的是"到后天下班",不是"到 72 小时后的
 * 此时此刻"。按小时算会让上午分的单和下午分的单在同一天的不同时刻过期,客服无法形成预期。
 *
 * ⚠️ 为什么必须显式带 offset:`new Date('2026-08-05T23:59:59.999')` 这种不带 tz 的写法
 * 在 Node 里按**本机时区**解析 —— 服务器跑在 UTC 就整整偏 8 小时。
 * 这正是 commit 4549817 修过的同一类坑,只是那次是纯日期。
 */
export function endOfDayInHostTimezone(base: Date, days: number, timezone: string): Date {
  const suffix = timezoneToOffsetSuffix(timezone); // '+08:00' | 'Z' | ...
  const offsetMin =
    suffix === 'Z'
      ? 0
      : (suffix.startsWith('-') ? -1 : 1) *
        (Number(suffix.slice(1, 3)) * 60 + Number(suffix.slice(4, 6)));
  // 把 UTC 时刻搬到"当地墙上时间"再读日期部分,才拿得到当地的今天是几号
  const local = new Date(base.getTime() + offsetMin * 60_000);
  const y = local.getUTCFullYear();
  const m = local.getUTCMonth();
  const d = local.getUTCDate() + days;
  const target = new Date(Date.UTC(y, m, d));
  const yyyy = target.getUTCFullYear();
  const mm = String(target.getUTCMonth() + 1).padStart(2, '0');
  const dd = String(target.getUTCDate()).padStart(2, '0');
  return new Date(`${yyyy}-${mm}-${dd}T23:59:59.999${suffix === 'Z' ? 'Z' : suffix}`);
}
