import { Injectable } from '@nestjs/common';
import { AGENT_CAPACITY_RANGE, type AgentInfo, type ListAgentsResponse } from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { loadMockUsers } from '../auth/mock-users';

/**
 * AgentRosterService —— 「这个诊所现在有哪些客服、各自手上压了多少」。
 *
 * ── 为什么名册和负载是**一个**接口不是两个 ────────────────────
 * 分配时问「还能吃多少」、跟踪时问「手上压了多少」,是同一份数据的两种读法。
 * 开两个端点必然口径漂移(一个算 assigned、另一个算 assigned+active),
 * 而漂了不会报错,只会让两个页面显示不同的数字然后没人说得清哪个对。
 *
 * ── 在岗判定 ────────────────────────────────────────────────
 * 「近 N 月有回访记录」,按 `source_created_at`(宿主侧创建时刻)。
 * ⛔⛔ **绝不能用 `task_date`** —— 那是**计划回访日**,含未来排程,
 *   生产实测最远 2033 年、DW 侧甚至 2121 年。拿它卡窗口会把早已离职的人判成在岗,
 *   而分配给一个离职的人不会报任何错,只会让那批单永远躺着。
 *
 * ⚠️ 名册是**建议来源,不是白名单**:实测有 11 个人只在登录侧有行为、回访数为 0
 *   (新入职 / 只做召回不做回访)。主管显式指定的人即使名册里查不到也必须能分,
 *   只在旁边给一个中性提示。挡人是主管的权力,不是名册的。
 */
@Injectable()
export class AgentRosterService {
  constructor(private readonly prisma: PrismaService) {}

  /**
   * @param clinicId  限定诊所(必填 —— 名册天然是诊所维度的)
   * @param months    在岗窗口,默认 12
   * @param extraUserIds 主管显式指定、但可能不在名册里的人 —— 一并返回并标注
   */
  async list(
    scope: TenantScopeContext,
    clinicId: string,
    opts: { months?: number; extraUserIds?: string[] } = {},
  ): Promise<ListAgentsResponse> {
    const months = opts.months ?? 12;
    const since = new Date();
    since.setMonth(since.getMonth() - months);

    // ── 名册:该诊所近 N 月有回访记录的人 ──────────────────────
    const roster = await this.prisma.patientReturnVisit.groupBy({
      by: ['taskDirectorId'],
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        clinicId,
        taskDirectorId: { not: null },
        // ⛔ 不是 taskDate,见类注释
        sourceCreatedAt: { gte: since },
      },
      _count: { _all: true },
      _max: { sourceCreatedAt: true },
    });

    const ids = new Set<string>();
    for (const r of roster) if (r.taskDirectorId) ids.add(r.taskDirectorId);
    for (const id of opts.extraUserIds ?? []) ids.add(id);
    if (ids.size === 0) return { clinicId, rosterMonths: months, agents: [], rosterNote: emptyNote(months) };

    // ── 负载:在手总量 ─────────────────────────────────────────
    // ⚠️ **跨诊所全量统计**,不按 clinicId 过滤 —— 容量说的是"这个人同时能跟进多少",
    //   是人的属性不是诊所的属性。实测 24% 的客服跨诊所,只算本诊所会把他们
    //   系统性地显示成"手上很空",然后被每个诊所各灌一轮。
    //   ⚠️ 这与 F4「写路径补 clinicIds 硬边界」方向相反,别顺手在这里也加诊所过滤:
    //   候选**患者**按诊所圈,客服**容量**跨诊所算,两个口径不同是有意的。
    const load = await this.prisma.followupPlan.groupBy({
      by: ['assigneeUserId'],
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        assigneeUserId: { in: [...ids] },
        status: 'assigned',
        supersededAt: null,
      },
      _count: { _all: true },
    });
    const inHandAll = new Map<string, number>();
    for (const l of load) if (l.assigneeUserId) inHandAll.set(l.assigneeUserId, l._count._all);

    // 本诊所在手(只用于展示拆分,不参与容量判断)
    const loadHere = await this.prisma.followupPlan.groupBy({
      by: ['assigneeUserId'],
      where: {
        hostId: scope.hostId,
        tenantId: scope.tenantId,
        assigneeUserId: { in: [...ids] },
        status: 'assigned',
        supersededAt: null,
        targetClinicId: clinicId,
      },
      _count: { _all: true },
    });
    const inHandHere = new Map<string, number>();
    for (const l of loadHere) if (l.assigneeUserId) inHandHere.set(l.assigneeUserId, l._count._all);

    const rosterById = new Map(roster.filter((r) => r.taskDirectorId).map((r) => [r.taskDirectorId!, r]));
    const nameById = mockNameIndex();

    const agents: AgentInfo[] = [...ids].map((userId) => {
      const r = rosterById.get(userId);
      const inHand = inHandAll.get(userId) ?? 0;
      return {
        userId,
        name: nameById.get(userId) ?? null,
        inHand,
        inHandThisClinic: inHandHere.get(userId) ?? 0,
        recentVisits: r?._count._all ?? 0,
        lastVisitAt: r?._max.sourceCreatedAt?.toISOString() ?? null,
        /// 名册里没有 = 近 N 月无回访记录。**不是"不能分"** —— 见类注释
        inRoster: r != null,
        // ⛔ **不返回 remaining**。容量「20-50」是一个**区间默认值**,不是这个人的真实上限;
        //   把它减出来会让助手说「李莉还能吃 38 个」—— 那是拿默认值做完了减法再当事实说出口,
        //   一句免责声明救不回来。返回区间 + 在手,减法留给主管(他知道谁在休假)。
        capacityRange: [...AGENT_CAPACITY_RANGE] as [number, number],
        capacityBasis: 'default' as const,
      };
    });

    // 在手少的排前面(铺平时先给谁的自然顺序);同值按 userId 保证**确定性** ——
    // 主管微调后重算两次必须看到同样的分法,否则他会以为系统在乱跳。
    agents.sort((a, b) => a.inHand - b.inHand || a.userId.localeCompare(b.userId));

    return {
      clinicId,
      rosterMonths: months,
      agents,
      // ⭐ 成品句子,给助手**照抄**用。T14 的落地方式是"提示词 + 工具返回值"双保险:
      //   LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。
      rosterNote:
        `在岗名册按「近 ${months} 个月有回访记录」近似判定,不代表系统确认在职;` +
        `容量上限 ${AGENT_CAPACITY_RANGE[0]}-${AGENT_CAPACITY_RANGE[1]} 为默认值(暂无历史数据反推)。` +
        `名册外的客服也可以指定。`,
    };
  }
}

function emptyNote(months: number): string {
  return `该诊所近 ${months} 个月无回访记录,名册为空 —— 可能是新开诊所或回访数据未接入;请主管直接指定客服。`;
}

/**
 * userId → 姓名。当前唯一来源是 mock 花名册(`data/<host>/users.json`,派生自回访表)。
 * ⚠️ PAC **没有 users 表**,而前端 `dictionary.users` 只覆盖当前登录人 ——
 * 所以姓名必须由**服务端**解析后随 payload 下发,不能指望前端自己翻。
 * 将来接了真 users 表,只换这个函数。
 */
function mockNameIndex(): Map<string, string> {
  const m = new Map<string, string>();
  for (const u of loadMockUsers()) m.set(u.externalId, u.name);
  return m;
}
