import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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';

/**
 * 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 月有回访记录的人 ──────────────────────
    // ⭐ **姓名直接来自回访表**(`task_director_name`,摄入的生产数据,本地实测 100% 有值)——
    //   不读 mock 登录用的 users.json:那份是**派生物**、只在 dev 存在,拿它当姓名源
    //   等于让生产功能依赖一个开发态文件。
    //
    // ⚠️ 但 id→姓名是**时间性的**,必须取"最近一次"那个名:
    //   本地实测 task_director_id=4090 在 2021 上半年是赵惠/杨柳/刘博学,
    //   2021-06 起一直是李宇琦(601 条)—— DW 侧这个 id 被换过人。
    //   取错会让主管把单派给"李宇琦",而界面上写着"赵惠" —— 比不显示姓名更糟。
    //   按最近一次取之后,101 个 id → 97 个名(剩下的是真重名,正常)。
    //   ⇒ 用 DISTINCT ON,不是 groupBy:Prisma 的 groupBy 表达不了
    //     "取 max(时间) 那一行的另一列"。
    const roster = await this.prisma.$queryRaw<
      Array<{ id: string; name: string | null; visits: number; lastAt: Date }>
    >(Prisma.sql`
      SELECT DISTINCT ON (rv.task_director_id)
             rv.task_director_id                                        AS "id",
             rv.task_director_name                                      AS "name",
             COUNT(*)      OVER (PARTITION BY rv.task_director_id)::int  AS "visits",
             MAX(rv.source_created_at) OVER (PARTITION BY rv.task_director_id) AS "lastAt"
      FROM patient_return_visits rv
      WHERE rv.host_id = ${scope.hostId}::uuid
        AND rv.tenant_id = ${scope.tenantId}
        AND rv.clinic_id = ${clinicId}
        AND rv.task_director_id IS NOT NULL
        -- ⛔ 不是 task_date,见类注释
        AND rv.source_created_at >= ${since}
      ORDER BY rv.task_director_id, rv.source_created_at DESC
    `);

    const ids = new Set<string>();
    for (const r of roster) ids.add(r.id);
    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.map((r) => [r.id, r]));

    // ⭐ 名册外被点名的人也要有名字:他们只是**本诊所近 N 月**没回访,
    //   不代表回访表里查不到(可能在别的诊所、或更早的时间)。
    //   不兜这一层,主管点名的人在确认单上就是一片空白 —— 那比不让他点更让人困惑。
    //   只在确实有人没解析出来时才发这一次查询。
    const unresolved = [...ids].filter((id) => !rosterById.has(id));
    const fallbackNames = unresolved.length
      ? await this.namesAnywhere(scope, unresolved)
      : new Map<string, string>();

    const agents: AgentInfo[] = [...ids].map((userId) => {
      const r = rosterById.get(userId);
      const inHand = inHandAll.get(userId) ?? 0;
      return {
        userId,
        // ⚠️ 主管显式点名、但名册里没有的人(实测 11 人只做召回不做回访)拿不到姓名 —— 返回 null。
        //   ⛔ 别去 mock 花名册兜底:那份是从**同一张回访表**派生的,同样查不到,
        //   只会给人"有兜底"的错觉。前端按 null 显示 id 前 8 位即可。
        name: r?.name ?? fallbackNames.get(userId) ?? null,
        inHand,
        inHandThisClinic: inHandHere.get(userId) ?? 0,
        recentVisits: r?.visits ?? 0,
        lastVisitAt: r?.lastAt?.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]} 为默认值(暂无历史数据反推)。` +
        `名册外的客服也可以指定。`,
    };
  }

  /**
   * 跨诊所、不限时间窗地查姓名 —— 只给「名册外被点名的人」兜底。
   * ⚠️ 同样取**最近一次**(id→姓名是时间性的,见 list 里的长注释)。
   */
  private async namesAnywhere(
    scope: TenantScopeContext,
    userIds: string[],
  ): Promise<Map<string, string>> {
    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(userIds)})
      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;
  }
}

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