import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PlanEventType, PlanEventReason } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import { recordPlanEventsBulk, computeHeldSeconds } from './plan-event.recorder';

/**
 * AssignmentExpiryScheduler —— 分配单到期,系统收回池子。
 *
 * ── 为什么这件事必须做,而且默认开 ────────────────────────────
 * 时效是分配单的一部分(T11:不存在无限期批次),但**光写一个到期时刻不会让任何事发生**。
 * 不收回的后果不是"多了几条过期单",是**容量口径整体失效**:
 * 客服的「在手」只增不减,几批之后全员触顶,再分就分不下去 ——
 * 而这在主管看来就是分配功能坏了。
 *
 * 产品定调(2026-08):到期自动回池**也是给主管减负** —— 让他不必去追"这单还要不要"。
 * 主管在确认单上确认时效的那一下,就是对到期行为的预授权,不违 T8
 * (T8 要防的是"助手替主管做决定",不是"主管定好的规则到点执行")。
 *
 * ── 与既有 RecycleSchedulerService 的关系:两条互不干扰的路 ────
 *   · RecycleScheduler   看 `recycle_at`,是**认领**的 24h 兜底,生产**未启用**
 *   · 本服务             看 `assignment_expires_at`,是**分配**的时效,默认启用
 * 刻意不合并:两者的语义、开关、口径都不同,合了之后想单独关一边就得加分支,
 * 而那个分支迟早写错。
 */

/** 关掉的方法:PAC_ASSIGNMENT_EXPIRY=off。默认开(产品已定)。 */
function isEnabled(): boolean {
  return (process.env.PAC_ASSIGNMENT_EXPIRY ?? '').trim().toLowerCase() !== 'off';
}

/** 单轮上限 —— 防积压时一次性打爆事务;剩下的下一轮继续 */
const BATCH_LIMIT = 500;

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

  constructor(private readonly prisma: PrismaService) {}

  onModuleInit(): void {
    this.logger.log(
      isEnabled()
        ? '分配单到期自动回池:已启用(每 10 分钟扫一次);关闭设 PAC_ASSIGNMENT_EXPIRY=off'
        : '分配单到期自动回池:已关闭(PAC_ASSIGNMENT_EXPIRY=off)—— 在手量将只增不减',
    );
  }

  /**
   * @param at 判定时刻(默认此刻)。显式可注入是为了让判据可测 ——
   *   测试不必靠真实时钟凑时间差(高负载下事件循环被拖慢会越过阈值边界,产生间歇性假失败)。
   *   同款做法见 `sync-incremental.scheduler.reapStaleRunningLocks`,那条是踩过之后立的规矩。
   */
  @Cron(CronExpression.EVERY_10_MINUTES, { name: 'plan-assignment-expiry' })
  async runExpiry(at?: Date): Promise<void> {
    if (!isEnabled()) return;

    const now = at ?? new Date();
    const due = await this.prisma.followupPlan.findMany({
      where: {
        status: 'assigned',
        assignmentExpiresAt: { not: null, lt: now },
        supersededAt: null,
        // ⭐⭐ 照抄 RecycleScheduler 的守卫,理由完全相同:
        //   客服约了 6/10 回访、plan 已 snooze 到 6/10 —— 在那之前**绝不能**因到期被收走,
        //   否则客服丢了已经对患者承诺过的回访关系,6/10 一到单子还会被别人从池里捞走。
        //   回访日过后仍未处理,才允许收。
        //   ⚠️ 这条不是可选优化。漏了它,分配功能会主动破坏客服已经做出的承诺。
        OR: [{ snoozedUntil: null }, { snoozedUntil: { lte: now } }],
      },
      select: {
        id: true, hostId: true, tenantId: true, patientId: true,
        assigneeUserId: true, assignedAt: true,
      },
      take: BATCH_LIMIT,
    });
    if (due.length === 0) return;

    // 分片进事务:每片状态变更与账本同生共死,一片失败不影响其余
    const CHUNK = 200;
    let recycled = 0;
    for (let i = 0; i < due.length; i += CHUNK) {
      const chunk = due.slice(i, i + CHUNK);
      try {
        await this.prisma.$transaction(async (tx) => {
          const res = await tx.followupPlan.updateMany({
            // 带状态条件 → 并发下若客服刚好提交了执行/自己退回了,本次不生效(幂等)
            where: { id: { in: chunk.map((p) => p.id) }, status: 'assigned' },
            data: {
              status: 'active',
              assigneeUserId: null,
              assignedAt: null,
              recycleAt: null,
              assignmentExpiresAt: null,
              // ⛔ **不写 release_reason** —— 那一列只属于客服的处置。
              //   到期是"客服压根没动",不是"客服判断不该我做";混进去退回率的分子分母一起脏。
              //   到期的量单独从 plan_event_logs 按 reason 数。
              // ⛔ **不清 assignment_id / assigned_by / assign_strategy** —— 批次归因是历史事实,
              //   清了这批的分母就少一条,"分了 60 条其中 8 条到期没人动"就算不出来了。
              // ⛔ **绝不动 snoozedUntil** —— 与退回同一条纪律。
            },
          });
          recycled += res.count;
          await recordPlanEventsBulk(
            tx,
            chunk.map((p) => ({
              hostId: p.hostId,
              tenantId: p.tenantId,
              planId: p.id,
              patientId: p.patientId,
              event: PlanEventType.AUTO_RELEASE,
              assigneeUserId: null, // 释放后无人归属
              actorUserId: null, // 系统行为
              // ⭐ 必须在清空 assignedAt **之前**算(上面 findMany 取的就是清空前的值)
              heldSeconds: computeHeldSeconds(p.assignedAt, now),
              reason: PlanEventReason.ASSIGNMENT_EXPIRED,
            })),
          );
        });
      } catch (err) {
        this.logger.error(
          `分配到期回收失败(${chunk.length} 条): ${err instanceof Error ? err.message : err}`,
        );
      }
    }

    if (recycled > 0) {
      this.logger.log(
        `分配到期:${recycled} 条超期未处理的分配单已退回召回池(已记账本 reason=assignment_expired)` +
          (due.length === BATCH_LIMIT ? `;本轮已达单轮上限 ${BATCH_LIMIT},剩余下一轮继续` : ''),
      );
    }
  }
}
