import type { Prisma } from '@prisma/client';
import { PlanEventType } from '@pac/types';
import type { PlanEventReasonValue } from '@pac/types';

/**
 * PlanEventLog 的**唯一写入口**。
 *
 * ── 为什么是应用层写,而不是数据库触发器 ──
 *   1. **触发器拿不到操作人**。DB 只看得见"某行变了",而 actor_user_id 在请求上下文里
 *      (JWT sub)。触发器要拿只能靠 session 变量(SET LOCAL app.user_id),那还是得应用
 *      配合,只是把依赖变成隐式的、更容易漏。本项目的 AsyncLocalStorage(tenant-context.ts)
 *      目前也只装 hostId/tenantId/sourceUnits,**不含 user**。
 *   2. 拿不到 actor 就**分不出 claim / assign** —— 而这正是这张表最核心的价值。
 *   3. **触发器会被运维脚本误触发**。任何一句
 *      `UPDATE followup_plans SET assignee_user_id=NULL ...`(数据修复、回滚、清理测试数据)
 *      都会凭空造出一条假的 release 事件,污染统计。应用层写则只记真实业务动作。
 *   4. Prisma 不管触发器 —— 得走 raw SQL 迁移,schema 里看不见,漂移/reset 时容易丢。
 *
 * ── 应用层写的弱点(得认)──
 *   靠自觉:将来若有人新加一条直接改 followup_plans 归属的代码路径,不会自动落账。
 *   缓解:① 写入点收口到本函数(找调用方一眼看全)
 *        ② 必须在**同一个事务**里调 —— 状态变更与账本同生共死,不会只成一半
 *        ③ 事件类型走 PlanEventType 枚举,编译期拦拼写错误
 *
 * @param tx **事务客户端**(prisma.$transaction 的回调参数)。刻意不接受裸 PrismaService:
 *           账本与状态变更必须原子,分开写就可能只落一半。
 */

/**
 * 只取事务客户端里 planEventLog 这一块 —— 调用方传 $transaction 的 tx 即可;
 * 单测传 mock 时 `as unknown as PlanEventLogWriter` 断言。
 */
export type PlanEventLogWriter = Pick<Prisma.TransactionClient, 'planEventLog'>;

/**
 * 批量写事件(每日全量重算 / 批量分配用)。
 *
 * 为什么要有:上面那句「本函数是唯一写入口」不能只是口号 —— 批量场景逐条 `create` 会打出
 * 上千次往返,写的人一定会绕过去直接 `createMany`,当场破功。给一个批量口子,
 * 纪律才守得住(**并且它内部就是 createMany,没有性能理由再绕**)。
 *
 * ⚠️ 调用方负责分片:PG 一条语句的 bind 变量上限 32767,本表每行 ~9 个变量 → 单批别超 3000。
 */
export function recordPlanEventsBulk(
  tx: PlanEventLogWriter,
  inputs: PlanEventInput[],
): Promise<unknown> {
  if (inputs.length === 0) return Promise.resolve(null);
  return tx.planEventLog.createMany({
    data: inputs.map((input) => ({
      hostId: input.hostId,
      tenantId: input.tenantId,
      planId: input.planId,
      patientId: input.patientId,
      event: input.event,
      assigneeUserId: input.assigneeUserId ?? null,
      actorUserId: input.actorUserId ?? null,
      heldSeconds: input.heldSeconds ?? null,
      reason: input.reason ?? null,
      assignmentId: input.assignmentId ?? null,
      operationId: input.operationId ?? null,
      details: input.details ?? undefined,
    })),
  });
}

export interface PlanEventInput {
  hostId: string;
  tenantId: string;
  planId: string;
  patientId: string;
  event: PlanEventType;
  /** 变更后的归属人;释放类事件传 null */
  assigneeUserId?: string | null;
  /** 操作人;系统行为(auto_release)传 null */
  actorUserId?: string | null;
  /** 归属持续秒数 —— 见 computeHeldSeconds 的注释 */
  heldSeconds?: number | null;
  /**
   * 简短原因(立柱,可直接过滤)。
   * ⚠️ 取值必须来自 [[PlanEventReasonValue]](系统原因 ∪ 客服退回原因)—— 不收裸 string,
   * 否则这一列会变成第二个「随手写字符串」的地方,而它正是退回原因分布的唯一数据源。
   */
  reason?: PlanEventReasonValue | null;
  /**
   * 这条事件发生在**哪一批**的执行过程中(见 schema 里 `assignment_id` 的整段说明)。
   *
   * ⚠️ 只有 assign / release / auto_release 该传。
   * ⛔ **claim 绝不能传** —— 自认领是从池子里自己捞的,而 plan 上那个 assignment_id
   *   可能是上一批留下的陈迹(退回/到期都刻意不清它),顶上去等于给旧批次凭空加人。
   */
  assignmentId?: string | null;
  /**
   * 这条事件属于**哪一次主管重排**(见 schema 里 `operation_id` 的说明)。
   *
   * ⚠️ 只有重排三件事该传:transfer / expiry_changed / rearrange_remove。
   * ⚠️ 与 `assignmentId` **不互斥**:重排不改归属批次,所以重排事件两列都有值 ——
   *   assignmentId 是这条单**原来那一批**,operationId 是这次重排。
   */
  operationId?: string | null;
  /** 事件专属细节(不按它查询的内容,如反馈文字) */
  details?: Prisma.InputJsonObject | null;
}

export function recordPlanEvent(tx: PlanEventLogWriter, input: PlanEventInput): Promise<unknown> {
  return tx.planEventLog.create({
    data: {
      hostId: input.hostId,
      tenantId: input.tenantId,
      planId: input.planId,
      patientId: input.patientId,
      event: input.event,
      assigneeUserId: input.assigneeUserId ?? null,
      actorUserId: input.actorUserId ?? null,
      heldSeconds: input.heldSeconds ?? null,
      reason: input.reason ?? null,
      assignmentId: input.assignmentId ?? null,
      operationId: input.operationId ?? null,
      // undefined 才让 Prisma 落 NULL;传 null 会被当成 JSON null 值
      details: input.details ?? undefined,
    },
  });
}

/**
 * 算归属持续秒数。
 * ⭐ 必须在把 assigned_at 清空**之前**调 —— 释放后该列即为 null,事后再也算不出来。
 * 这是 heldSeconds 需要落库(而非查询时现算)的全部理由。
 */
export function computeHeldSeconds(assignedAt: Date | null | undefined, now: Date): number | null {
  if (assignedAt == null) return null;
  return Math.max(0, Math.round((now.getTime() - assignedAt.getTime()) / 1000));
}
