import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { AgentInfo, 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
 *   (新入职 / 只做召回不做回访)。主管显式指定的人即使名册里查不到也必须能分,
 *   只在旁边给一个中性提示。挡人是主管的权力,不是名册的。
 *
 * ── 归属:在这儿干活的人,不是「碰过这儿一次」的人 ──────────────
 * 🔴 2026-08-16 加的量闸。此前只要「近 12 月在本诊所有 ≥1 条回访」就算在岗,
 *   测试服上海世纪公园因此显示 **193 位在岗**,而同规模的其他诊所都是 32–43。
 *   拆开看:19 个人做了 15,476 条(95.8%),另外 **98 人只有 1 条**、63 人 2–5 条 ——
 *   161 人(83%)全年合计 296 条,占 1.8%。
 *   **160 人的主场在别的诊所**,他们在这儿总共只有 330 条(产品指认的康慧捧:
 *   主场 3,799 条在另一家,这儿 3 条)。医生/护士/前台偶尔被记成一次回访负责人,
 *   就进了名册。
 * ⚠️ 危害不止那张表不好看:`rosterCount` 直接进默认批次估算
 *   (在岗人数 × 每天几通 × 时效),193 会让默认批次比真实规模**大一个数量级**。
 *
 * ⇒ 判据 = **在本诊所的回访量占他个人总量 ≥20%,或在本诊所 ≥20 条**。
 * ⚠️ 两条是**或**,缺一不可:
 *   · 只用占比 → 挡掉 13 个在某诊所做了 50+ 条、但个人总量更大的真客服;
 *   · 只用绝对量 → 对小诊所和新人不公平(那正是"名册不是白名单"要护的人)。
 * ⚠️ 分母是**跨诊所总量**(同一时间窗),⛔ 不是本诊所的 —— 否则占比恒为 100%。
 * ⭐ 实测(测试服,近 12 月):世纪公园 193 → 34,其余诊所各减 1–6 人;
 *   每家诊所留下的名册仍覆盖本诊所 **98% 以上**的回访量。
 * ⚠️ 被闸挡掉的人**照样能被主管点名**(走 `extraUserIds`,姓名由 `namesAnywhere` 兜底,
 *   `inRoster: false`)—— 这道闸只改"建议给谁",⛔ 没改"能分给谁"。
 */
/// 在本诊所的回访量占个人总量的下限(与 `ROSTER_MIN_VISITS` 取或)
const ROSTER_MIN_SHARE = 0.2;
/// 在本诊所的回访绝对条数下限(与 `ROSTER_MIN_SHARE` 取或)
const ROSTER_MIN_VISITS = 20;
@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`
      WITH here AS (
        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
      ),
      -- 归属判据的分母:同一时间窗、**跨诊所**的个人总量(⛔ 不限 clinic_id,见类注释)
      everywhere AS (
        SELECT rv.task_director_id AS "id", COUNT(*)::int AS "total"
        FROM patient_return_visits rv
        WHERE rv.host_id = ${scope.hostId}::uuid
          AND rv.tenant_id = ${scope.tenantId}
          AND rv.task_director_id IN (SELECT "id" FROM here)
          AND rv.source_created_at >= ${since}
        GROUP BY 1
      )
      SELECT h."id", h."name", h."visits", h."lastAt"
      FROM here h
      JOIN everywhere e ON e."id" = h."id"
      -- 「在这儿干活的人」而不是「碰过这儿一次的人」——两条取**或**,理由见类注释
      WHERE h."visits" >= ${ROSTER_MIN_VISITS}
         OR h."visits"::numeric / e."total" >= ${ROSTER_MIN_SHARE}
    `);

    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,也不再返回容量区间**。
        //   "容量/上限"这个概念已经删掉(见 BATCH_SIZE_PER_AGENT_FIRST):负载就是 inHand 本身,
        //   落人走水位法(先给手上最少的)。给一个区间会被读成"合法范围",
        //   把余量减出来会让助手说「李莉还能吃 38 个」—— 拿未经验证的数做完减法当事实说出口。
        //   名册只回**在手**这个客观量;分配后的水位在确认单的 byAgent 里逐人给。
      };
    });

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

    return {
      clinicId,
      rosterMonths: months,
      agents,
      // ⭐ 成品句子,给助手**照抄**用。T14 的落地方式是"提示词 + 工具返回值"双保险:
      //   LLM 做阈值判断和免责声明都不可靠,直接把该说的话给它抄。
      // ⚠️ 措辞跟着判据走:从「有回访记录」改成「常在这个诊所做回访」——
      //   ⛔ 别只改判据不改这句,主管照着旧话去对人数会对不上(名册收窄了)。
      rosterNote:
        `在岗名册按「近 ${months} 个月常在这个诊所做回访」近似判定,不代表系统确认在职;` +
        `偶尔来支援一次的不算在内,名册外的客服也可以指定。`,
    };
  }

  /**
   * 跨诊所、不限时间窗地查姓名 —— 只给「名册外被点名的人」兜底。
   * ⚠️ 同样取**最近一次**(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} 个月无回访记录,名册为空 —— 可能是新开诊所或回访数据未接入;请主管直接指定客服。`;
}
