import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import {
  DAILY_CALLS_PER_AGENT,
  Permission,
  PlanEventType,
  REARRANGE_ITEMS_HARD_LIMIT,
  REARRANGE_SKIP_REASON_ZH,
  type RearrangeDraft,
  type RearrangeItem,
  type RearrangeSkipReason,
  type RearrangeSubmitRequest,
  type RearrangeSubmitResponse,
} from '@pac/types';
import { calcAge } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service';
import { endOfDayInHostTimezone } from './plan-assignment.service';
import { computeHeldSeconds, recordPlanEventsBulk } from './plan-event.recorder';
import { requirePermission, rejectSyntheticIdentity, type DispatchActor } from './dispatch-guard';
import { planLabelAnchorsSql, temperatureBucketCaseSql } from './reason-temperature.sql';

/**
 * PlanRearrangeService —— 主管**调整**:调整各客服此刻在手的单。
 *
 * ═══ 与「分配」的边界(⛔ 别把两条路缝在一起)═══════════════════════════
 * 分配是「从池子里挑一批人发下去」,调整是「已经在客服手上的这些,怎么调」。
 * 形状像,语义不通 —— 具体四条差异写在 `@pac/types` 的 `plan-rearrange.ts` 文件头。
 *
 * 🔴 **调整不建批次,单子的 `assignment_id` 一律不动。**
 *   动了原批的 `planned` 会静默缩水(schema.prisma 里 assignment_id 那段红字记过实测:
 *   某批分过 9 条,重分后 planned 显示 0)。所以调整改的只有三样:
 *   `assignee_user_id` / `assignment_expires_at` / 以及移出时的归属清空。
 *
 * 🔴 **移出⛔ 不写 `release` 事件。**
 *   批次详情的退回原因分布是 `for (e of batchEvents) if (e.event === 'release')` ——
 *   只按 assignment_id 过滤、**不看是谁干的**。挂 release 会同时污染那一批的
 *   退回原因分布、并给原客服记一笔他没做过的「退回」。⇒ 走 `rearrange_remove`。
 *
 * 🔴 **逐条落,落不上的如实回报。**
 *   ⛔ 不照抄分配 `create()` 的「数量对不上整体拒绝」:那边整批还没发出去,重来代价小;
 *   而调整时主管可能已经调了二十条,因为客服刚好打完一条就全部作废 —— 一次就废掉这个功能。
 */
@Injectable()
export class PlanRearrangeService {
  private readonly logger = new Logger(PlanRearrangeService.name);

  constructor(
    private readonly prisma: PrismaService,
    private readonly roster: AgentRosterService,
  ) {}

  // ═══════════════════════════════════════════════════════════
  // 取草稿(只读)
  // ═══════════════════════════════════════════════════════════

  /**
   * @param agentUserIds 主管点名的客服;不传 = 全景(全队在手)
   *
   * ⚠️ 人员集合一律走 `AgentRosterService`(与分配、团队状态同一个名册)——
   *   名册外的人手上的单本期**不管**(产品 2026-08-20 定)。
   *   ⛔ 别在这里另起一份人员口径:三处各写一套,主管会在三个界面上看到三个数。
   */
  async draft(
    scope: TenantScopeContext,
    clinicId: string,
    agentUserIds?: string[],
  ): Promise<RearrangeDraft> {
    const roster = await this.roster.list(scope, clinicId);
    const rosterIds = roster.agents.map((a) => a.userId);

    const wanted = agentUserIds?.map((s) => s.trim()).filter(Boolean) ?? [];
    const scoped = wanted.length > 0 ? rosterIds.filter((id) => wanted.includes(id)) : rosterIds;

    if (scoped.length === 0) {
      throw new NotFoundException(
        wanted.length > 0
          ? `点名的客服都不在「${clinicId}」的名册里,没有可调整的在手单。`
          : `「${clinicId}」的客服名册是空的(近 12 个月没有回访记录),没有可调整的在手单。`,
      );
    }

    /**
     * ⭐ 多取一条用来判断有没有被硬上限截断 —— `truncated > 0` 时界面必须说出来。
     *   ⛔ 静默截断 = 少掉的那些主管永远不知道(no silent cap)。
     */
    const rows = await this.prisma.followupPlan.findMany({
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        targetClinicId: clinicId,
        status: 'assigned',
        supersededAt: null,
        assigneeUserId: { in: scoped },
        ...(scope.sourceUnits.length
          ? { patient: { sourceUnit: { in: scope.sourceUnits } } }
          : {}),
      },
      select: {
        id: true,
        patientId: true,
        assigneeUserId: true,
        assignmentId: true,
        assignmentExpiresAt: true,
        snoozedUntil: true,
        patient: {
          select: {
            name: true,
            medicalRecordNumber: true,
            gender: true,
            birthDate: true,
            // ⚠️ 专属客服在 `preferences.dedicatedCs.id`(JSON)—— 与分配那边同源,
            //   ⛔ 别另起一套口径(那一列由宿主摄入覆盖,PAC 只读)
            preferences: true,
            profile: { select: { lastVisitDoctor: true } },
          },
        },
      },
      // 先按客服、再按最紧的时限 —— 主管展开一位客服时先看到快到期/已超期的
      orderBy: [{ assigneeUserId: 'asc' }, { assignmentExpiresAt: 'asc' }],
      take: REARRANGE_ITEMS_HARD_LIMIT + 1,
    });

    const truncated = Math.max(0, rows.length - REARRANGE_ITEMS_HARD_LIMIT);
    const kept = rows.slice(0, REARRANGE_ITEMS_HARD_LIMIT);
    const planIds = kept.map((r) => r.id);

    const [labelTemp, benefits] = await Promise.all([
      this.labelsAndTemperatureOf(planIds),
      this.benefitOf([...new Set(kept.map((r) => r.assignmentId).filter((v): v is string => !!v))]),
    ]);

    const now = new Date();
    const items: RearrangeItem[] = kept.map((r) => {
      const lt = labelTemp.get(r.id);
      return {
        planId: r.id,
        patientId: r.patientId,
        patientName: r.patient?.name ?? null,
        medicalRecordNumber: r.patient?.medicalRecordNumber ?? null,
        gender: r.patient?.gender ?? null,
        age: r.patient?.birthDate ? calcAge(r.patient.birthDate) : null,
        lastVisitDoctor: r.patient?.profile?.lastVisitDoctor ?? null,
        assigneeUserId: r.assigneeUserId!,
        dedicatedCsUserId: dedicatedOf(r.patient?.preferences),
        potentialTreatment: lt?.label ?? null,
        temperature: lt?.temperature ?? null,
        expiresAt: r.assignmentExpiresAt?.toISOString() ?? null,
        overdueDays: overdueDaysOf(r.assignmentExpiresAt, r.snoozedUntil, now),
        snoozedUntil: r.snoozedUntil?.toISOString() ?? null,
        fromAssignmentId: r.assignmentId,
        benefit: r.assignmentId ? (benefits.get(r.assignmentId) ?? null) : null,
      };
    });

    const byAgent = new Map<string, { inHand: number; overdue: number }>();
    for (const id of scoped) byAgent.set(id, { inHand: 0, overdue: 0 });
    for (const it of items) {
      const a = byAgent.get(it.assigneeUserId);
      if (!a) continue;
      a.inHand += 1;
      if (it.overdueDays != null) a.overdue += 1;
    }
    const nameOf = new Map(roster.agents.map((a) => [a.userId, a.name ?? null]));

    return {
      operationId: randomUUID(),
      clinicId,
      scope: wanted.length > 0 ? 'agents' : 'all',
      scopedAgentUserIds: wanted.length > 0 ? scoped : [],
      agents: scoped.map((userId) => ({
        userId,
        name: nameOf.get(userId) ?? null,
        inHand: byAgent.get(userId)?.inHand ?? 0,
        overdue: byAgent.get(userId)?.overdue ?? 0,
      })),
      items,
      total: items.length,
      overdueTotal: items.filter((i) => i.overdueDays != null).length,
      truncated,
      dailyCalls: DAILY_CALLS_PER_AGENT,
    };
  }

  /**
   * 每条单的「什么机会 + 患者末诊落在哪一档」。
   *
   * ⭐ 复用矩阵那条路(`planLabelAnchorsSql` + `temperatureBucketCaseSql`)——
   *   ⛔ 别在这儿另算一套:调整单上写「2–3 年」而矩阵上是「1–2 年」,主管无从判断谁对。
   * ⚠️ 一条单可能有多个标签(一个患者两种潜在治疗),但**温度只有一个** ——
   *   锚点是患者末诊、边界是固定天数,与标签无关(见 reason-temperature 的 BOUNDS)。
   *   所以这里按 plan 聚合,标签取字典序第一个当代表(界面只展示一个)。
   */
  private async labelsAndTemperatureOf(
    planIds: string[],
  ): Promise<Map<string, { label: string | null; temperature: string | null }>> {
    const out = new Map<string, { label: string | null; temperature: string | null }>();
    if (planIds.length === 0) return out;

    const filter = Prisma.sql`fp.id IN (${Prisma.join(planIds.map((i) => Prisma.sql`${i}::uuid`))})`;
    const bucket = temperatureBucketCaseSql(
      Prisma.sql`max(la.hot_until)`,
      Prisma.sql`max(la.warm_until)`,
      Prisma.sql`max(la.anchor_at)`,
    );
    const rows = await this.prisma.$queryRaw<
      Array<{ plan_id: string; label: string | null; temperature: string | null }>
    >(Prisma.sql`
      WITH la AS (${planLabelAnchorsSql(filter)})
      SELECT la.plan_id, min(la.label) AS label, ${bucket} AS temperature
      FROM la GROUP BY la.plan_id`);

    for (const r of rows) out.set(r.plan_id, { label: r.label, temperature: r.temperature });
    return out;
  }

  /** 批次挂的福利 —— ⛔ 只读。调整跨批次,改福利会破坏「这批的效果 = 这个福利的效果」。 */
  private async benefitOf(assignmentIds: string[]): Promise<Map<string, string>> {
    const out = new Map<string, string>();
    if (assignmentIds.length === 0) return out;
    const rows = await this.prisma.planAssignment.findMany({
      where: { id: { in: assignmentIds } },
      select: { id: true, attributes: true },
    });
    for (const r of rows) {
      const text = (r.attributes as { benefit?: { text?: string } } | null)?.benefit?.text;
      if (typeof text === 'string' && text.trim()) out.set(r.id, text);
    }
    return out;
  }

  // ═══════════════════════════════════════════════════════════
  // 提交(主管手动确认之后)
  // ═══════════════════════════════════════════════════════════

  async submit(
    scope: TenantScopeContext,
    actor: DispatchActor,
    dto: RearrangeSubmitRequest,
  ): Promise<RearrangeSubmitResponse> {
    // ── 闸:身份(与分配同一份判据,⛔ 别另写)────────────────────
    requirePermission(actor, Permission.PLAN_DISPATCH);
    rejectSyntheticIdentity(actor);

    // ── 幂等:这次调整已经落过 → 一个字都不写 ──────────────────
    // 放最前面而不是靠唯一键冲突:让重放**不产生任何副作用**(与分配 create 同一条纪律)。
    const already = await this.prisma.planEventLog.findFirst({
      where: { hostId: scope.hostId, tenantId: scope.tenantId, operationId: dto.operationId },
      select: { id: true },
    });
    if (already) {
      return {
        operationId: dto.operationId,
        duplicate: true,
        moved: 0,
        expiryChanged: 0,
        removed: 0,
        skipped: [],
        note: '这次调整此前已经提交过,本次没有任何改动。',
      };
    }

    // ── 闸:诊所在不在数据范围内 ──────────────────────────────
    if (scope.clinicIds.length > 0 && !scope.clinicIds.includes(dto.clinicId)) {
      throw new NotFoundException(`诊所 ${dto.clinicId} 不在你的数据范围内`);
    }

    const moveBy = new Map(dto.moves.map((m) => [m.planId, m.toUserId]));
    const expiryBy = new Map(dto.expiry.map((e) => [e.planId, e.expiresInDays]));
    const removeSet = new Set(dto.removes.map((r) => r.planId));

    /**
     * 移出与改派/改时限**不能落在同一条单上** —— 那是调用方的 bug,不是并发。
     * ⛔ 不"贴心地"挑一个执行:两种意图相反(一个留在人手上、一个收回池子),
     *   猜错了主管完全看不出来。
     */
    const conflict = [...removeSet].filter((id) => moveBy.has(id) || expiryBy.has(id));
    if (conflict.length > 0) {
      throw new BadRequestException(
        `有 ${conflict.length} 条同时被要求「移出」和「改派/改时限」,意图矛盾,本次未执行。`,
      );
    }

    const planIds = [...new Set([...moveBy.keys(), ...expiryBy.keys(), ...removeSet])];
    if (planIds.length === 0) {
      throw new BadRequestException('这次调整一条改动都没有,未执行。');
    }
    if (planIds.length > REARRANGE_ITEMS_HARD_LIMIT) {
      throw new BadRequestException(`单次调整最多 ${REARRANGE_ITEMS_HARD_LIMIT} 条`);
    }

    const rows = await this.prisma.followupPlan.findMany({
      where: {
        id: { in: planIds },
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        targetClinicId: dto.clinicId,
        ...(scope.sourceUnits.length
          ? { patient: { sourceUnit: { in: scope.sourceUnits } } }
          : {}),
      },
      select: {
        id: true,
        patientId: true,
        status: true,
        assigneeUserId: true,
        assignedAt: true,
        assignmentId: true,
        assignmentExpiresAt: true,
        supersededAt: true,
      },
    });
    const byId = new Map(rows.map((r) => [r.id, r]));

    const skipped: RearrangeSubmitResponse['skipped'] = [];
    const skip = (planId: string, op: 'move' | 'expiry' | 'remove', reason: RearrangeSkipReason) =>
      skipped.push({ planId, op, reason });

    /** 这条单此刻还能不能动 —— 三种 op 共用的前置判断 */
    const liveOf = (planId: string, op: 'move' | 'expiry' | 'remove') => {
      const r = byId.get(planId);
      if (!r) {
        skip(planId, op, 'out_of_scope');
        return null;
      }
      if (r.supersededAt != null || r.status === 'superseded') {
        skip(planId, op, 'superseded');
        return null;
      }
      if (r.status === 'completed' || r.status === 'abandoned') {
        skip(planId, op, 'already_done');
        return null;
      }
      if (r.status !== 'assigned' || r.assigneeUserId == null) {
        skip(planId, op, 'not_in_hand');
        return null;
      }
      return r;
    };

    type Live = NonNullable<ReturnType<typeof liveOf>>;
    const moves: Array<{ row: Live; to: string }> = [];
    for (const [planId, to] of moveBy) {
      const row = liveOf(planId, 'move');
      if (!row) continue;
      if (row.assigneeUserId === to) {
        skip(planId, 'move', 'same_assignee');
        continue;
      }
      moves.push({ row, to });
    }

    const expiries: Array<{ row: Live; days: number }> = [];
    for (const [planId, days] of expiryBy) {
      const row = liveOf(planId, 'expiry');
      if (row) expiries.push({ row, days });
    }

    const removes: Live[] = [];
    for (const planId of removeSet) {
      const row = liveOf(planId, 'remove');
      if (row) removes.push(row);
    }

    if (moves.length + expiries.length + removes.length === 0) {
      throw new BadRequestException(
        `这 ${planIds.length} 条都动不了(${summarizeSkipped(skipped)}),本次未执行。`,
      );
    }

    const tz = await this.hostTimezone(scope.hostId);
    const now = new Date();

    const result = await this.prisma.$transaction(async (tx) => {
      const events: Parameters<typeof recordPlanEventsBulk>[1] = [];
      const base = {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        operationId: dto.operationId,
      };

      // ── ① 改派 ────────────────────────────────────────────
      // ⭐ 乐观并发:`assignee_user_id = v.from_user` 是全部依据 —— 从上面 findMany
      //   到这一刻若有人抢先改派/退回,该行不满足条件、不被更新,RETURNING 里就没有它。
      let movedIds: string[] = [];
      if (moves.length > 0) {
        const values = moves.map(
          ({ row, to }) =>
            Prisma.sql`(${row.id}::uuid, ${row.assigneeUserId!}::text, ${to}::text)`,
        );
        const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
          UPDATE followup_plans AS fp SET
            assignee_user_id = v.to_user,
            updated_at       = ${now}::timestamptz
          FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, from_user, to_user)
          WHERE fp.id = v.plan_id
            AND fp.status = 'assigned'
            AND fp.superseded_at IS NULL
            AND fp.assignee_user_id = v.from_user
          RETURNING fp.id`);
        movedIds = applied.map((a) => a.id);
        const ok = new Set(movedIds);
        for (const { row } of moves) if (!ok.has(row.id)) skip(row.id, 'move', 'assignee_changed');
        for (const { row, to } of moves) {
          if (!ok.has(row.id)) continue;
          events.push({
            ...base,
            planId: row.id,
            patientId: row.patientId,
            event: PlanEventType.TRANSFER,
            // 变更**后**的归属人 —— 与 assign 同语义
            assigneeUserId: to,
            actorUserId: actor.userId,
            // ⭐ 原客服进 details:列上只有"变更后",而"从谁那儿来"事后补不回来
            details: { from: row.assigneeUserId },
            // ⭐ 仍是**原来那一批** —— 调整不改归属批次(见文件头红字)
            assignmentId: row.assignmentId,
          });
        }
      }

      // ── ② 改时限 ──────────────────────────────────────────
      let expiredIds: string[] = [];
      if (expiries.length > 0) {
        const values = expiries.map(
          ({ row, days }) =>
            Prisma.sql`(${row.id}::uuid, ${endOfDayInHostTimezone(now, days, tz)}::timestamptz)`,
        );
        const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
          UPDATE followup_plans AS fp SET
            assignment_expires_at = v.expires_at,
            updated_at            = ${now}::timestamptz
          FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, expires_at)
          WHERE fp.id = v.plan_id
            AND fp.status = 'assigned'
            AND fp.superseded_at IS NULL
          RETURNING fp.id`);
        expiredIds = applied.map((a) => a.id);
        const ok = new Set(expiredIds);
        for (const { row } of expiries) if (!ok.has(row.id)) skip(row.id, 'expiry', 'not_in_hand');
        for (const { row, days } of expiries) {
          if (!ok.has(row.id)) continue;
          events.push({
            ...base,
            planId: row.id,
            patientId: row.patientId,
            event: PlanEventType.EXPIRY_CHANGED,
            // 归属没变 —— 改派同批发生时这里记的是**改派后**那个人
            assigneeUserId: moveBy.get(row.id) ?? row.assigneeUserId,
            actorUserId: actor.userId,
            details: {
              from: row.assignmentExpiresAt?.toISOString() ?? null,
              to: endOfDayInHostTimezone(now, days, tz).toISOString(),
              days,
            },
            assignmentId: row.assignmentId,
          });
        }
      }

      // ── ③ 移出(收回池子)──────────────────────────────────
      let removedIds: string[] = [];
      if (removes.length > 0) {
        const values = removes.map(
          (row) => Prisma.sql`(${row.id}::uuid, ${row.assigneeUserId!}::text)`,
        );
        /**
         * 清空哪些列与客服退回(`PlanService.recycle`)**逐列对齐**:
         *   ⛔ 不清 assignment_id / assigned_by / assign_strategy —— 那是"这单来自哪批"
         *      这个历史事实,也是批次分母;
         *   ⛔ 绝不动 snoozed_until —— 移出是"换个人来做",不是"这人别召了";
         *   ⛔ 不写 release_reason —— 那一列是**客服**的「我为什么不接这单」,
         *      主管的调度动作往里写会污染退回原因。
         */
        const applied = await tx.$queryRaw<Array<{ id: string }>>(Prisma.sql`
          UPDATE followup_plans AS fp SET
            status                = 'active',
            assignee_user_id      = NULL,
            assigned_at           = NULL,
            recycle_at            = NULL,
            assignment_expires_at = NULL,
            updated_at            = ${now}::timestamptz
          FROM (VALUES ${Prisma.join(values)}) AS v(plan_id, from_user)
          WHERE fp.id = v.plan_id
            AND fp.status = 'assigned'
            AND fp.superseded_at IS NULL
            AND fp.assignee_user_id = v.from_user
          RETURNING fp.id`);
        removedIds = applied.map((a) => a.id);
        const ok = new Set(removedIds);
        for (const row of removes) if (!ok.has(row.id)) skip(row.id, 'remove', 'assignee_changed');
        for (const row of removes) {
          if (!ok.has(row.id)) continue;
          events.push({
            ...base,
            planId: row.id,
            patientId: row.patientId,
            event: PlanEventType.REARRANGE_REMOVE,
            assigneeUserId: null, // 收回后无人归属
            actorUserId: actor.userId,
            heldSeconds: computeHeldSeconds(row.assignedAt, now),
            details: { from: row.assigneeUserId },
            assignmentId: row.assignmentId,
          });
        }
      }

      await recordPlanEventsBulk(tx, events);
      return { moved: movedIds.length, expiryChanged: expiredIds.length, removed: removedIds.length };
    });

    const note =
      `调整已生效:` +
      [
        result.moved ? `改派 ${result.moved} 条` : '',
        result.expiryChanged ? `改时限 ${result.expiryChanged} 条` : '',
        result.removed ? `移出 ${result.removed} 条` : '',
      ]
        .filter(Boolean)
        .join('、') +
      (skipped.length > 0 ? `。另有 ${skipped.length} 条没动:${summarizeSkipped(skipped)}` : '。');

    this.logger.log(
      `调整 ${dto.operationId}:改派 ${result.moved} / 改时限 ${result.expiryChanged} / ` +
        `移出 ${result.removed} / 跳过 ${skipped.length},操作人=${actor.userId}`,
    );

    return { operationId: dto.operationId, duplicate: false, ...result, skipped, note };
  }

  /** 与 PlanAssignmentService 同一份回落(宿主没配就按上海)—— 时限一律按宿主当地日末 */
  private async hostTimezone(hostId: string): Promise<string> {
    const host = await this.prisma.host.findUnique({
      where: { id: hostId },
      select: { pullConfig: true },
    });
    return (host?.pullConfig as { timezone?: string } | null)?.timezone ?? 'Asia/Shanghai';
  }
}

/** `patients.preferences.dedicatedCs.id` —— 与 PlanAssignmentService.dedicatedCsOf 同一条路径 */
function dedicatedOf(preferences: unknown): string | null {
  const id = (preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
  return typeof id === 'string' && id ? id : null;
}

/**
 * 已经超期几天 —— 未超期返回 null。
 *
 * 🔴 「且没约下次回访」这半句不能少,与团队状态表 / 批次超期**同一份判据**:
 *   客服打了电话、约好了下次的单会一直"过期"下去,但那不是没处置。
 *   ⛔ 三处判据不许分家 —— 分了主管会在三个界面上看到三个数,而且不报错。
 */
export function overdueDaysOf(
  expiresAt: Date | null,
  snoozedUntil: Date | null,
  now: Date,
): number | null {
  if (expiresAt == null || expiresAt >= now) return null;
  if (snoozedUntil != null && snoozedUntil > now) return null;
  return Math.floor((now.getTime() - expiresAt.getTime()) / 86_400_000);
}

/** 跳过原因汇总成一句人话 —— ⛔ 界面与助手都念服务端这一句,别各拼一份 */
export function summarizeSkipped(skipped: RearrangeSubmitResponse['skipped']): string {
  const count = new Map<RearrangeSkipReason, number>();
  for (const s of skipped) count.set(s.reason, (count.get(s.reason) ?? 0) + 1);
  return [...count]
    .map(([reason, n]) => `${REARRANGE_SKIP_REASON_ZH[reason]} ${n} 条`)
    .join('、');
}
