import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
  AGENT_CAPACITY_DEFAULT,
  AssignStrategy,
  type AgentInfo,
  type AssignmentProposal,
  type ProposedItem,
} from '@pac/types';
import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service';

/**
 * AssignmentProposalService —— 「1,189 人的格子 → 一批 100 人 → 落到 N 个客服头上」。
 *
 * 这是助手出确认单的取数与算法层。**纯只读**(T8:唯一的写动作是主管点确认)。
 *
 * ═══ 两个正交的决策,别混成一件事 ═══════════════════════════
 *   ① 选谁进这批(收敛)   ← 排序键的事
 *   ② 这批怎么落到客服头上 ← 专属优先 / 溢出铺平的事
 * 「优先级排序」只解决 ①,不解决 ②。
 */

/**
 * ① 收敛排序键 —— 三层,每层都能用一句话向主管解释。
 *
 * ```
 * (assignment_id IS NULL) DESC   从没进过任何批次的优先
 * priority_score          DESC   层内沿用现成的分,不自研公式
 * patient_id              ASC    全序兜底
 * ```
 *
 * **首键为什么必须有**:退回的高分单回池后分数几乎不变(freshness 降,但 daysSince 增大
 * 反而可能推高急迫性),它会立刻回到下一批的头部**反复插队**,300 名开外的人永远轮不到。
 * 这不是"还没轮到",是被插队插到死。生产实证:现存 57 个认领单里 79% 已过时限 ——
 * "打了没结果然后挂着"是常态不是例外,这批一旦回池就是稳定的插队源。
 * `assignment_id` 在退回时**不清空**(见 plan.service.recycle),所以它天然就是
 * 「这条从没进过任何批次」的标记 —— **零新列**。
 * 一句话解释:「先给还没联系过的人排队;联系过一轮还没成的,等下一圈。」
 *
 * **第三层不是装饰**:filling 格 1,189 人只有 306 个不同取值(均值 3.9 人同分),
 * 第 100 名撞并列是**必然事件**;而 PG 不承诺没有完整 ORDER BY 时的行序。
 * 被问「她俩分一样,凭什么她进我不进」时必须答得上来。
 * (现有列表排序 `plan.service` 也已经是复合键,同一个问题在分页上踩过。)
 *
 * ⚠️ **退化点**:跑满一整圈后(1,189 / 100 ≈ 12 批)所有人 assignment_id 都非空,
 * 首键恒 false,排序退化成纯 score,插队问题原样回来。届时需要第二圈机制
 * (按 `plan_assignments.created_at` 最早优先);⛔ 不要用 `assignedAt` —— 退回和到期都会清空它。
 * 那是 12 批之后的事,那时已有足够样本用数据决定,现在设计属于过度设计。
 *
 * ⚠️ **不加「专属客服可用性」分层**(重要,且违直觉):85.1% 的池子有在岗专属,
 * 一旦拿它当首键,批次会近乎 100% 是 dedicated,于是 T20 点名要反推的
 * 「专属 vs 铺平完成率差」**永远凑不满样本** —— 用一个未验证的假设去排序,
 * 而这个排序保证了该假设无法被验证。第一批必须混合,才有东西可反推。
 * 这条已经产品确认,不要"顺手优化"掉。
 */
const SELECTION_ORDER = Prisma.sql`
  ORDER BY (fp.assignment_id IS NULL) DESC, fp.priority_score DESC, fp.patient_id ASC
`;

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

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

  async propose(
    scope: TenantScopeContext,
    input: {
      clinicId: string;
      /** 画像「潜在治疗」8 类之一(矩阵 X 轴);不传 = 不按治疗项收窄 */
      potentialTreatment?: string;
      /** 拟分人数;不传 = 按在岗容量总量估 */
      targetCount?: number;
      /** 探索配额占比(0-0.2)。⭐ 唯一的因果抓手,见 selectionMode 注释 */
      exploreRatio?: number;
    },
  ): Promise<AssignmentProposal> {
    const { clinicId, potentialTreatment } = input;
    const roster = await this.roster.list(scope, clinicId);
    const agents = roster.agents;

    // ── 拟分人数 ────────────────────────────────────────────
    // ⚠️⚠️ **「团队还能吃多少」不等于「这批该分多少」** —— 这两件事必须分开。
    //   totalRoom 是技术上限(17 人 × 每人 50 = 850);而 T5 说的是
    //   「宁可只做 100 人做透,不做 1000 人做浅」。默认分满余量正是它反对的那种做法。
    //   ⛔ 但**批次规模上限属于产品决策**(教条七·待确认),工程不该替他定一个数。
    //   折中:默认仍取余量(不凭空造一个没依据的上限),但在 selectionNote 里
    //   **把这个数的来历和风险直说**,让主管一眼看出 850 是"分满"而不是"建议值"。
    const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0);
    const target = Math.max(0, input.targetCount ?? totalRoom);
    const isFullCapacity = input.targetCount == null;
    if (target === 0 || agents.length === 0) {
      return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target);
    }

    // ── ① 收敛:按三层键取前 N ────────────────────────────────
    // ⭐ 多取一些(1.5 倍)给去重和探索配额留余量;不取全量是因为大格子上万条。
    const pool = await this.selectCandidates(scope, clinicId, potentialTreatment, Math.ceil(target * 1.5) + 20);

    // 同患者只留一条(schema 注释承诺的 partial UNIQUE 实际不存在,不能当保障用)
    const seenPatient = new Set<string>();
    const ranked = pool.filter((c) => {
      if (seenPatient.has(c.patientId)) return false;
      seenPatient.add(c.patientId);
      return true;
    });

    // ── 探索配额 ────────────────────────────────────────────
    // 从**排名之外**抽,不是从头部抽 —— 抽头部等于没探索。
    const ratio = Math.min(0.2, Math.max(0, input.exploreRatio ?? 0));
    const exploreN = Math.floor(target * ratio);
    const rankN = Math.max(0, target - exploreN);
    const head = ranked.slice(0, rankN).map((c) => ({ ...c, selectionMode: 'rank' as const }));
    const tailPool = ranked.slice(rankN);
    // ⚠️ 确定性抽样:按 patientId 的字典序等距取,不用随机数 ——
    //   主管微调后会重算,随机会让人数和名单无故跳动,他会以为系统在乱来。
    const explore = pickEvenly(tailPool, exploreN).map((c) => ({
      ...c,
      selectionMode: 'explore' as const,
    }));
    const chosen = [...head, ...explore];

    // ── ② 落人:专属优先 → 溢出均分 ──────────────────────────
    // ⚠️ 专属客服表**作参数传下去**,不挂实例字段:service 是单例,
    //   两个主管同时出确认单会互相冲掉对方的缓存,而且是**静默**串数据。
    //   (本仓已有 ingest-resolver-no-instance-state.spec 在防同一类错。)
    const dedicated = await this.dedicatedCsOf(chosen.map((c) => c.patientId));
    const items = placeAgents(chosen, agents, dedicated);

    const byAgent = new Map<string, ProposedItem[]>();
    for (const it of items.placed) {
      const arr = byAgent.get(it.assigneeUserId) ?? [];
      arr.push(it);
      byAgent.set(it.assigneeUserId, arr);
    }
    const agentById = new Map(agents.map((a) => [a.userId, a]));

    return {
      clinicId,
      potentialTreatment: potentialTreatment ?? null,
      candidateTotal: ranked.length,
      target,
      placed: items.placed.length,
      unplaced: items.unplaced,
      items: items.placed,
      byAgent: [...byAgent].map(([userId, list]) => ({
        userId,
        name: agentById.get(userId)?.name ?? null,
        inHandBefore: agentById.get(userId)?.inHand ?? 0,
        count: list.length,
        dedicated: list.filter((x) => x.assignStrategy === AssignStrategy.DEDICATED).length,
        spread: list.filter((x) => x.assignStrategy !== AssignStrategy.DEDICATED).length,
      })),
      // 已满但仍列出来:主管要看见"这个人不是被漏了,是已经满了"
      skippedAgents: agents
        .filter((a) => a.inHand >= AGENT_CAPACITY_DEFAULT)
        .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
      rosterNote: roster.rosterNote,
      // ⭐ 成品句子,助手照抄(T14 双保险的"工具返回值"那一半)
      capacityNote:
        `容量上限按每人在手 ${AGENT_CAPACITY_DEFAULT} 条计,这是**默认值** ——` +
        `暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
      selectionNote:
        `按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
        (isFullCapacity
          ? `⚠️ ${target} 人 = 在岗 ${agents.length} 位客服的剩余容量之和(**分满**),不是建议规模。` +
            `一批做透通常比做浅有效,如需缩小规模直接说个数字(如"这批只要 100 人")。`
          : '') +
        (exploreN > 0
          ? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
          : '') +
        (items.unplaced > 0
          ? `⚠️ 有 ${items.unplaced} 人按当前容量分不下去,建议本批缩到 ${items.placed.length} 人。`
          : ''),
    };
  }

  /**
   * 候选取数 —— 走原生 SQL 是因为排序键的首层 `(assignment_id IS NULL)` 是**表达式**,
   * Prisma 的 orderBy 表达不了。
   *
   * ⚠️ 过滤条件必须与召回池列表同 scope,否则"矩阵上说有 383 人、点进去列表 350 条"
   * 这种对不上的事一发生,主管对整个功能的信任就没了。
   */
  private async selectCandidates(
    scope: TenantScopeContext,
    clinicId: string,
    potentialTreatment: string | undefined,
    limit: number,
  ): Promise<Array<{ planId: string; patientId: string; priorityScore: number }>> {
    const brandFilter = scope.sourceUnits.length
      ? Prisma.sql`AND p.source_unit IN (${Prisma.join(scope.sourceUnits)})`
      : Prisma.empty;
    // 潜在治疗:画像特征 data->'types' 是字符串数组,用 @> 命中(与 persona-tag-filters 同口径)
    const treatmentFilter = potentialTreatment
      ? Prisma.sql`
          AND EXISTS (
            SELECT 1 FROM personas pe
            JOIN persona_features pf ON pf.persona_id = pe.id AND pf.key = 'potential_treatment'
            WHERE pe.id = fp.persona_id
              AND pe.superseded_at IS NULL
              AND (pf.data #> '{types}') @> ${JSON.stringify([potentialTreatment])}::jsonb
          )`
      : Prisma.empty;

    return this.prisma.$queryRaw<Array<{ planId: string; patientId: string; priorityScore: number }>>(
      Prisma.sql`
        SELECT fp.id AS "planId", fp.patient_id AS "patientId", fp.priority_score AS "priorityScore"
        FROM followup_plans fp
        JOIN patients p ON p.id = fp.patient_id
        WHERE fp.host_id = ${scope.hostId}::uuid
          AND fp.tenant_id = ${scope.tenantId}
          AND fp.target_clinic_id = ${clinicId}
          AND fp.status = 'active'
          AND fp.assignee_user_id IS NULL
          AND fp.superseded_at IS NULL
          -- 冷静期/约定回访未到 → 不该进池,更不该被分
          AND (fp.snoozed_until IS NULL OR fp.snoozed_until <= NOW())
          ${brandFilter}
          ${treatmentFilter}
        ${SELECTION_ORDER}
        LIMIT ${limit}
      `,
    );
  }

  private async dedicatedCsOf(patientIds: string[]): Promise<Map<string, string>> {
    const ids = [...new Set(patientIds)];
    if (ids.length === 0) return new Map();
    const rows = await this.prisma.patient.findMany({
      where: { id: { in: ids } },
      select: { id: true, preferences: true },
    });
    const out = new Map<string, string>();
    for (const r of rows) {
      const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
      if (typeof id === 'string' && id) out.set(r.id, id);
    }
    return out;
  }
}

/**
 * ② 落人:**专属优先 → 溢出均分 + 在手硬护栏**。
 *
 * ⭐ 导出成**纯函数**(不进 class):它是本文件里唯一值得单独测的算法,
 *   而纯函数不需要起 Nest 容器、不需要 mock Prisma,测试成本低一个量级。
 *
 * ── 为什么是均分,不是「水位拉平」 ──────────────────────────
 * 水位拉平(按在手量补到同一水位)看着更聪明,实际站不住,三条:
 *   1. 它的输入是 `remaining = 容量 − 在手`,而**容量是拍的默认值** ——
 *      拿默认值做完减法再当事实说出口("李莉还能吃 38 个"),一句免责声明救不回来。
 *   2. 在手量当前**不可信**:自动回收长期关闭、执行回写率仅 11%、
 *      现存认领单 79% 已过时限。以它为决策依据 = 把噪声固化成政策。
 *   3. **自毁 T20**:要反推的第一项就是"各客服实际能吃多少",那需要负载**方差**作自变量;
 *      而水位拉平把消灭方差当目标函数。
 * 另外它是全局耦合的:主管改一条指定客服,所有人的数字都要跳 —— 违 T13「一次确认」。
 * 均分是局部的:改一条只影响那一条。
 *
 * ⚠️ 切换到贪心「最少在手优先」将来只是一行排序的改动、不需要新列、不影响任何历史归因 ——
 * 所以这个决定**应该等在手量可信之后再用数据做**,现在不预支。
 */
export function placeAgents(
  chosen: Array<{ planId: string; patientId: string; selectionMode: 'rank' | 'explore' }>,
  agents: AgentInfo[],
  dedicatedByPatient: Map<string, string>,
): { placed: ProposedItem[]; unplaced: number } {
    const room = new Map<string, number>();
    for (const a of agents) room.set(a.userId, Math.max(0, AGENT_CAPACITY_DEFAULT - a.inHand));
    const rosterIds = new Set(agents.map((a) => a.userId));

    const placed: ProposedItem[] = [];
    const overflow: typeof chosen = [];

    // ── 第一趟:专属命中 ──────────────────────────────────────
    for (const c of chosen) {
      const dcs = dedicatedByPatient.get(c.patientId);
      if (dcs && rosterIds.has(dcs) && (room.get(dcs) ?? 0) > 0) {
        room.set(dcs, room.get(dcs)! - 1);
        placed.push({
          planId: c.planId,
          patientId: c.patientId,
          assigneeUserId: dcs,
          assignStrategy: AssignStrategy.DEDICATED,
          selectionMode: c.selectionMode,
        });
      } else {
        overflow.push(c);
      }
    }

    // ── 第二趟:溢出均分 ──────────────────────────────────────
    // ⚠️ 剩余容量必须**接着第一趟的结果**算(room 是同一个累加器)——
    //   分开算的话专属大户会被再灌一轮,而这一点在代码上完全不显眼。
    const takers = agents.filter((a) => (room.get(a.userId) ?? 0) > 0).map((a) => a.userId);
    let cursor = 0;
    let unplaced = 0;
    for (const c of overflow) {
      // 找下一个还有余量的人;一圈都没有 → 分不下去
      let hops = 0;
      while (hops < takers.length && (room.get(takers[cursor % takers.length]!) ?? 0) <= 0) {
        cursor++;
        hops++;
      }
      if (takers.length === 0 || hops >= takers.length) {
        // ⛔ **截断不摊派**:硬塞给已经满的人是 T5「宁缺毋滥」的反面,
        //   而且会立刻制造 over_capacity 退回 —— 那正是要反推容量默认值的那个信号,
        //   自己造出来就没法反推了。
        unplaced++;
        continue;
      }
      const who = takers[cursor % takers.length]!;
      cursor++;
      room.set(who, room.get(who)! - 1);
      const dcs = dedicatedByPatient.get(c.patientId);
      placed.push({
        planId: c.planId,
        patientId: c.patientId,
        assigneeUserId: who,
        // ⭐ 两种铺平必须分开记:有专属只是没轮到(关系还在) vs 从头没有专属(关系本就薄)。
        //   混成一个值,T20 算出来的"铺平完成率低"就分不清是策略问题还是人群问题。
        assignStrategy:
          dcs && rosterIds.has(dcs)
            ? AssignStrategy.SPREAD_OVERFLOW
            : AssignStrategy.SPREAD_NO_DEDICATED,
        selectionMode: c.selectionMode,
      });
    }

  return { placed, unplaced };
}

/**
 * 等距抽样 —— **确定性**,不用随机数。
 * 主管微调后会重算,随机会让名单无故跳动;而"同样的输入两次算出同样的分法"
 * 是他敢按确认键的前提。
 */
function pickEvenly<T>(arr: T[], n: number): T[] {
  if (n <= 0 || arr.length === 0) return [];
  if (n >= arr.length) return [...arr];
  const step = arr.length / n;
  return Array.from({ length: n }, (_, i) => arr[Math.floor(i * step)]!);
}

function emptyProposal(
  clinicId: string,
  potentialTreatment: string | undefined,
  agents: AgentInfo[],
  rosterNote: string,
  target: number,
): AssignmentProposal {
  return {
    clinicId,
    potentialTreatment: potentialTreatment ?? null,
    candidateTotal: 0,
    target,
    placed: 0,
    unplaced: 0,
    items: [],
    byAgent: [],
    skippedAgents: agents
      .filter((a) => a.inHand >= AGENT_CAPACITY_DEFAULT)
      .map((a) => ({ userId: a.userId, name: a.name, inHand: a.inHand })),
    rosterNote,
    capacityNote: `容量上限按每人在手 ${AGENT_CAPACITY_DEFAULT} 条计(默认值)。`,
    selectionNote:
      agents.length === 0
        ? '该诊所名册为空,无法出分配方案 —— 请主管直接指定客服。'
        : '在岗客服当前在手量已达容量上限,本批无可分配余量。',
  };
}
